Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 45 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,51 @@ describe("App", () => {
it("shows an empty message when no books match the subject", async () => {
server.use(emptySearchHandler);
withProviders(<App />);
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(<App />);
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(<App />);
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 () => {
Expand Down
15 changes: 11 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,18 @@ function App() {
<SubjectFilter subject={subject} onSubjectChange={handleSubjectChange} />
<SortSelect sort={sort} onSortChange={handleSortChange} />
</div>
{isLoading && <p role="status">Loading books…</p>}
<div role="status">
{isLoading && <p>Loading books…</p>}
{!isLoading && !isError && books.length === 0 && (
<p>No books found for the subject “{subject}”.</p>
)}
{current && (
<p className="visually-hidden">
Showing book {safeIndex + 1} of {sortedBooks.length}: {current.title}
</p>
)}
</div>
{isError && <p role="alert">Could not load books. Please try refreshing the page.</p>}
{!isLoading && !isError && books.length === 0 && (
<p>No books found for the subject “{subject}”.</p>
)}
{current && (
<section className="book-viewer" aria-label="Book viewer">
<BookCard
Expand Down
21 changes: 18 additions & 3 deletions src/__snapshots__/App.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ exports[`App > matches the snapshot 1`] = `
</select>
</label>
</div>
<div
role="status"
>
<p
class="visually-hidden"
>
Showing book
1
of
3
:
A Wizard of Earthsea
</p>
</div>
<section
aria-label="Book viewer"
class="book-viewer"
Expand All @@ -130,7 +144,8 @@ exports[`App > matches the snapshot 1`] = `
class="book-card-cover"
>
<p>
No cover available
No cover available for
A Wizard of Earthsea
</p>
</div>
<div
Expand Down Expand Up @@ -195,21 +210,21 @@ exports[`App > matches the snapshot 1`] = `
class="navigation-controls"
>
<button
aria-disabled="true"
aria-label="Show previous book"
disabled=""
type="button"
>
Previous
</button>
<p
aria-live="polite"
class="navigation-progress"
>
1
/
3
</p>
<button
aria-disabled="false"
aria-label="Show next book"
type="button"
>
Expand Down
8 changes: 5 additions & 3 deletions src/components/BookCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ describe("BookCard", () => {
expect(screen.getByRole("heading", { name: minimalBook.title })).toBeInTheDocument();
expect(screen.getByText("Unknown author")).toBeInTheDocument();
expect(screen.queryByText(/^Rating:/)).not.toBeInTheDocument();
expect(screen.getByText("No cover available")).toBeInTheDocument();
expect(screen.getByText(`No cover available for ${minimalBook.title}`)).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByText("No cover available").closest(".book-card-cover")).toBeInTheDocument();
expect(
screen.getByText(`No cover available for ${minimalBook.title}`).closest(".book-card-cover"),
).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

Expand All @@ -74,7 +76,7 @@ describe("BookCard", () => {
fireEvent.error(image);

expect(screen.queryByRole("img")).not.toBeInTheDocument();
const fallback = screen.getByText("No cover available");
const fallback = screen.getByText(`No cover available for ${fullBook.title}`);
expect(fallback).toBeInTheDocument();
expect(fallback.closest(".book-card-cover")).toBeInTheDocument();
});
Expand Down
2 changes: 1 addition & 1 deletion src/components/BookCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function BookCard({ book, isFavorite = false, onFavoriteToggle }: BookCar
onError={() => setCoverError({ bookKey: book.key, failed: true })}
/>
) : (
<p>No cover available</p>
<p>No cover available for {book.title}</p>
)}
</div>
<div className="book-card-content">
Expand Down
8 changes: 8 additions & 0 deletions src/components/FavoritesView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ describe("FavoritesView", () => {
expect(onRemove).toHaveBeenCalledWith("/works/OL1");
});

it("moves focus to the favorites heading after a removal", () => {
render(<FavoritesView favorites={favorites} onRemove={vi.fn()} />);

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(<FavoritesView favorites={[]} onRemove={vi.fn()} />);

Expand Down
13 changes: 11 additions & 2 deletions src/components/FavoritesView.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useRef } from "react";
import type { FavoriteBook } from "../types";
import "./FavoritesView.css";

Expand All @@ -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<HTMLHeadingElement>(null);

function handleRemove(bookKey: string) {
onRemove(bookKey);
headingRef.current?.focus();
}

return (
<section className="favorites-view" aria-labelledby="favorites-heading">
<h2 id="favorites-heading">Favorites</h2>
<h2 id="favorites-heading" ref={headingRef} tabIndex={-1}>
Favorites
</h2>
{sortedFavorites.length === 0 ? (
<p>No favorite books yet.</p>
) : (
Expand All @@ -31,7 +40,7 @@ export function FavoritesView({ favorites, onRemove }: FavoritesViewProps) {
<button
type="button"
aria-label={`Remove ${book.title ?? "saved book"} from favorites list`}
onClick={() => onRemove(book.key)}
onClick={() => handleRemove(book.key)}
>
Remove
</button>
Expand Down
4 changes: 2 additions & 2 deletions src/components/NavigationControls.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
color: #1f2933;
}

.navigation-controls button:hover:not(:disabled) {
.navigation-controls button:hover:not([aria-disabled="true"]) {
background-color: #f5f7fa;
}

Expand All @@ -29,7 +29,7 @@
outline-offset: 2px;
}

.navigation-controls button:disabled {
.navigation-controls button[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
34 changes: 30 additions & 4 deletions src/components/NavigationControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<NavigationControls
currentIndex={2}
totalCount={10}
onPrevious={onPrevious}
onNext={onNext}
/>,
);

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();
Expand All @@ -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(
Expand All @@ -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();
Expand Down
13 changes: 8 additions & 5 deletions src/components/NavigationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,26 @@ export function NavigationControls({
onPrevious,
onNext,
}: NavigationControlsProps) {
const isFirst = currentIndex === 0;
const isLast = currentIndex >= totalCount - 1;

return (
<nav className="navigation-controls" aria-label="Book navigation">
<button
type="button"
onClick={onPrevious}
disabled={currentIndex === 0}
onClick={isFirst ? undefined : onPrevious}
aria-disabled={isFirst}
aria-label="Show previous book"
>
Previous
</button>
<p className="navigation-progress" aria-live="polite">
<p className="navigation-progress">
{currentIndex + 1} / {totalCount}
</p>
<button
type="button"
onClick={onNext}
disabled={currentIndex >= totalCount - 1}
onClick={isLast ? undefined : onNext}
aria-disabled={isLast}
aria-label="Show next book"
>
Next
Expand Down
3 changes: 2 additions & 1 deletion src/components/__snapshots__/BookCard.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ exports[`BookCard > renders a placeholder for a minimal book 1`] = `
class="book-card-cover"
>
<p>
No cover available
No cover available for
Untitled Book
</p>
</div>
<div
Expand Down
1 change: 1 addition & 0 deletions src/components/__snapshots__/FavoritesView.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ exports[`FavoritesView > matches the rendered snapshot 1`] = `
>
<h2
id="favorites-heading"
tabindex="-1"
>
Favorites
</h2>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,21 @@ exports[`NavigationControls > matches the snapshot 1`] = `
class="navigation-controls"
>
<button
aria-disabled="false"
aria-label="Show previous book"
type="button"
>
Previous
</button>
<p
aria-live="polite"
class="navigation-progress"
>
3
/
10
</p>
<button
aria-disabled="false"
aria-label="Show next book"
type="button"
>
Expand Down