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"
>
1
@@ -210,6 +224,7 @@ exports[`App > matches the snapshot 1`] = `
3
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"
>
3
@@ -21,6 +21,7 @@ exports[`NavigationControls > matches the snapshot 1`] = `
10