diff --git a/src/App.css b/src/App.css index 570ace9..c16d5f4 100644 --- a/src/App.css +++ b/src/App.css @@ -81,6 +81,15 @@ margin: 0 auto; } +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + .app-footer { padding: 0.75rem; text-align: center; diff --git a/src/App.test.tsx b/src/App.test.tsx index 31b8555..53cb043 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -46,7 +46,51 @@ describe("App", () => { it("shows an empty message when no books match the subject", async () => { server.use(emptySearchHandler); withProviders(); - expect(await screen.findByText(/No books found/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("No books found")); + }); + + it("announces the book shown after navigating", async () => { + const user = userEvent.setup(); + withProviders(); + await screen.findByRole("heading", { name: "A Wizard of Earthsea" }); + + await user.click(screen.getByRole("button", { name: "Show next book" })); + + expect(screen.getByRole("status")).toHaveTextContent("Showing book 2 of 3: The Hobbit"); + }); + + it("operates the library controls with the keyboard only", async () => { + const user = userEvent.setup(); + withProviders(); + await screen.findByRole("heading", { name: "A Wizard of Earthsea" }); + + await user.tab(); + expect(screen.getByRole("button", { name: "Library" })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole("button", { name: "Favorites" })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole("combobox", { name: "Subject" })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole("combobox", { name: "Sort by" })).toHaveFocus(); + + await user.tab(); + expect( + screen.getByRole("button", { name: "Add A Wizard of Earthsea to favorites" }), + ).toHaveFocus(); + await user.keyboard("{Enter}"); + expect(JSON.parse(localStorage.getItem("t19.favorites") ?? "[]")).toEqual([ + expect.objectContaining({ key: "/works/OL2" }), + ]); + + await user.tab(); + expect(screen.getByRole("button", { name: "Show previous book" })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole("button", { name: "Show next book" })).toHaveFocus(); + await user.keyboard("{Enter}"); + expect(await screen.findByRole("heading", { name: "The Hobbit" })).toBeInTheDocument(); + + await user.tab(); + expect(screen.getByRole("combobox", { name: "Jump to book" })).toHaveFocus(); }); it("switches between the library and favorites views", async () => { diff --git a/src/App.tsx b/src/App.tsx index e8b520d..525f148 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,11 +73,18 @@ function App() { - {isLoading &&

Loading books…

} +
+ {isLoading &&

Loading books…

} + {!isLoading && !isError && books.length === 0 && ( +

No books found for the subject “{subject}”.

+ )} + {current && ( +

+ Showing book {safeIndex + 1} of {sortedBooks.length}: {current.title} +

+ )} +
{isError &&

Could not load books. Please try refreshing the page.

} - {!isLoading && !isError && books.length === 0 && ( -

No books found for the subject “{subject}”.

- )} {current && (
matches the snapshot 1`] = ` +
+

+ Showing book + 1 + of + 3 + : + A Wizard of Earthsea +

+
matches the snapshot 1`] = ` class="book-card-cover" >

- No cover available + No cover available for + A Wizard of Earthsea

matches the snapshot 1`] = ` class="navigation-controls" >
diff --git a/src/components/FavoritesView.test.tsx b/src/components/FavoritesView.test.tsx index fb8e7d6..733b9d9 100644 --- a/src/components/FavoritesView.test.tsx +++ b/src/components/FavoritesView.test.tsx @@ -26,6 +26,14 @@ describe("FavoritesView", () => { expect(onRemove).toHaveBeenCalledWith("/works/OL1"); }); + it("moves focus to the favorites heading after a removal", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove The Hobbit from favorites list" })); + + expect(screen.getByRole("heading", { name: "Favorites" })).toHaveFocus(); + }); + it("shows an empty state", () => { render(); diff --git a/src/components/FavoritesView.tsx b/src/components/FavoritesView.tsx index 5d4f6b7..34ee843 100644 --- a/src/components/FavoritesView.tsx +++ b/src/components/FavoritesView.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import type { FavoriteBook } from "../types"; import "./FavoritesView.css"; @@ -10,10 +11,18 @@ export function FavoritesView({ favorites, onRemove }: FavoritesViewProps) { const sortedFavorites = [...favorites].sort((left, right) => (left.title ?? "Saved book").localeCompare(right.title ?? "Saved book"), ); + const headingRef = useRef(null); + + function handleRemove(bookKey: string) { + onRemove(bookKey); + headingRef.current?.focus(); + } return (
-

Favorites

+

+ Favorites +

{sortedFavorites.length === 0 ? (

No favorite books yet.

) : ( @@ -31,7 +40,7 @@ export function FavoritesView({ favorites, onRemove }: FavoritesViewProps) { diff --git a/src/components/NavigationControls.css b/src/components/NavigationControls.css index 1b3019d..c8e73b1 100644 --- a/src/components/NavigationControls.css +++ b/src/components/NavigationControls.css @@ -20,7 +20,7 @@ color: #1f2933; } -.navigation-controls button:hover:not(:disabled) { +.navigation-controls button:hover:not([aria-disabled="true"]) { background-color: #f5f7fa; } @@ -29,7 +29,7 @@ outline-offset: 2px; } -.navigation-controls button:disabled { +.navigation-controls button[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; } diff --git a/src/components/NavigationControls.test.tsx b/src/components/NavigationControls.test.tsx index b37a86e..cee42c1 100644 --- a/src/components/NavigationControls.test.tsx +++ b/src/components/NavigationControls.test.tsx @@ -34,7 +34,31 @@ describe("NavigationControls", () => { expect(onNext).toHaveBeenCalledTimes(1); }); - it("disables the buttons at the boundaries", async () => { + it("activates the buttons with the keyboard", async () => { + const user = userEvent.setup(); + const onPrevious = vi.fn(); + const onNext = vi.fn(); + render( + , + ); + + await user.tab(); + expect(screen.getByRole("button", { name: "Show previous book" })).toHaveFocus(); + await user.keyboard("{Enter}"); + await user.tab(); + expect(screen.getByRole("button", { name: "Show next book" })).toHaveFocus(); + await user.keyboard(" "); + + expect(onPrevious).toHaveBeenCalledTimes(1); + expect(onNext).toHaveBeenCalledTimes(1); + }); + + it("marks the buttons as disabled at the boundaries but keeps them focusable", async () => { const user = userEvent.setup(); const onPrevious = vi.fn(); const onNext = vi.fn(); @@ -47,8 +71,10 @@ describe("NavigationControls", () => { />, ); const previous = screen.getByRole("button", { name: "Show previous book" }); - expect(previous).toBeDisabled(); - await user.click(previous); + expect(previous).toHaveAttribute("aria-disabled", "true"); + await user.tab(); + expect(previous).toHaveFocus(); + await user.keyboard("{Enter}"); expect(onPrevious).not.toHaveBeenCalled(); rerender( @@ -60,7 +86,7 @@ describe("NavigationControls", () => { />, ); const next = screen.getByRole("button", { name: "Show next book" }); - expect(next).toBeDisabled(); + expect(next).toHaveAttribute("aria-disabled", "true"); await user.click(next); expect(onNext).not.toHaveBeenCalled(); expect(screen.getByText("10 / 10")).toBeInTheDocument(); diff --git a/src/components/NavigationControls.tsx b/src/components/NavigationControls.tsx index 58f9b25..ca7731e 100644 --- a/src/components/NavigationControls.tsx +++ b/src/components/NavigationControls.tsx @@ -13,23 +13,26 @@ export function NavigationControls({ onPrevious, onNext, }: NavigationControlsProps) { + const isFirst = currentIndex === 0; + const isLast = currentIndex >= totalCount - 1; + return (
matches the rendered snapshot 1`] = ` >

Favorites

diff --git a/src/components/__snapshots__/NavigationControls.test.tsx.snap b/src/components/__snapshots__/NavigationControls.test.tsx.snap index 8ec68b3..8a8b8be 100644 --- a/src/components/__snapshots__/NavigationControls.test.tsx.snap +++ b/src/components/__snapshots__/NavigationControls.test.tsx.snap @@ -7,13 +7,13 @@ exports[`NavigationControls > matches the snapshot 1`] = ` class="navigation-controls" >