From ea61304784b77fd38b52200fd5e67f7bef20886a Mon Sep 17 00:00:00 2001 From: Even Date: Wed, 16 Sep 2026 20:56:11 +0200 Subject: [PATCH] feat: improve accessibility of library controls and book viewer (#31) - Announce loading, empty-result and changed-book feedback through one persistent role="status" region in App; errors keep role="alert" - Keep Previous/Next focusable at the boundaries with aria-disabled instead of disabled - Move focus to the Favorites heading after removing a favorite - Include the book title in the no-cover fallback to match the alt text - Add tests for the announcements, focus handling and keyboard-only use - Update App, BookCard, FavoritesView and NavigationControls snapshots deliberately for the markup changes above Closes #31 Co-Authored-By: Claude Opus 5 --- src/App.css | 9 ++++ src/App.test.tsx | 47 ++++++++++++++++++- src/App.tsx | 15 ++++-- src/__snapshots__/App.test.tsx.snap | 22 +++++++-- src/components/BookCard.test.tsx | 2 +- src/components/BookCard.tsx | 2 +- src/components/FavoritesView.test.tsx | 8 ++++ src/components/FavoritesView.tsx | 13 ++++- src/components/NavigationControls.css | 4 +- src/components/NavigationControls.test.tsx | 34 ++++++++++++-- src/components/NavigationControls.tsx | 13 +++-- .../__snapshots__/BookCard.test.tsx.snap | 3 +- .../__snapshots__/FavoritesView.test.tsx.snap | 1 + .../NavigationControls.test.tsx.snap | 3 +- 14 files changed, 151 insertions(+), 25 deletions(-) diff --git a/src/App.css b/src/App.css index 7188ed3..f85c675 100644 --- a/src/App.css +++ b/src/App.css @@ -34,6 +34,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 de5f00a..c0d6da8 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -46,7 +46,52 @@ 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("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(localStorage.getItem("t19.favorites")).toBe(JSON.stringify(["/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(); + + await user.tab(); + expect( + screen.getByRole("button", { name: "Remove A Wizard of Earthsea from favorites list" }), + ).toHaveFocus(); + await user.keyboard("{Enter}"); + expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify([])); }); it("navigates to the next and previous book with the controls", async () => { diff --git a/src/App.tsx b/src/App.tsx index 25a4c54..bd05b5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,11 +43,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 d983911..131be14 100644 --- a/src/components/FavoritesView.test.tsx +++ b/src/components/FavoritesView.test.tsx @@ -27,6 +27,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 64e4e5f..9ca8d51 100644 --- a/src/components/FavoritesView.tsx +++ b/src/components/FavoritesView.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import type { Book } from "../types"; import "./FavoritesView.css"; @@ -9,10 +10,18 @@ interface FavoritesViewProps { export function FavoritesView({ books, favoriteKeys, onRemove }: FavoritesViewProps) { const favorites = books.filter((book) => favoriteKeys.includes(book.key)); + const headingRef = useRef(null); + + function handleRemove(bookKey: string) { + onRemove(bookKey); + headingRef.current?.focus(); + } return (
-

Favorites

+

+ Favorites +

{favorites.length === 0 ? (

No favorite books yet.

) : ( @@ -26,7 +35,7 @@ export function FavoritesView({ books, favoriteKeys, onRemove }: FavoritesViewPr 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" >