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
18 changes: 17 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
describe("App", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
resetSearchRequestCount();
});

Expand Down Expand Up @@ -121,9 +122,24 @@ describe("App", () => {
expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("fantasy");
});

it("saves, restores, and removes a favorite", async () => {
const firstRender = withProviders(<App />);
await screen.findByRole("heading", { name: "The Hobbit" });

fireEvent.click(screen.getByRole("button", { name: "Add The Hobbit to favorites" }));
expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify(["/works/OL1"]));

firstRender.unmount();
withProviders(<App />);
await screen.findByRole("button", { name: "Remove The Hobbit from favorites list" });

fireEvent.click(screen.getByRole("button", { name: "Remove The Hobbit from favorites list" }));
expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify([]));
});

it("matches the snapshot", async () => {
const { container } = withProviders(<App />);
await screen.findByRole("heading", { level: 2 });
await screen.findByRole("heading", { level: 2, name: "The Hobbit" });
expect(container).toMatchSnapshot();
});
});
13 changes: 11 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@ import { useState } from "react";
import { BookCard } from "./components/BookCard";
import { BookJumpList } from "./components/BookJumpList";
import { NavigationControls } from "./components/NavigationControls";
import { FavoritesView } from "./components/FavoritesView";
import { SubjectFilter } from "./components/SubjectFilter";
import { useBooks } from "./hooks/useBooks";
import { useFavorites } from "./hooks/useFavorites";
import { usePreferences } from "./hooks/usePreferences";
import "./App.css";

function App() {
const { subject, setSubject } = usePreferences();
const { books, isLoading, isError } = useBooks(subject);

const [index, setIndex] = useState(0);
const safeIndex = books.length > 0 ? Math.min(index, books.length - 1) : 0;
const current = books[safeIndex];
const { favoriteKeys, toggleFavorite, removeFavorite } = useFavorites();

return (
<div className="app">
Expand All @@ -30,7 +32,11 @@ function App() {
)}
{current && (
<section className="book-viewer" aria-label="Book viewer">
<BookCard book={current} />
<BookCard
book={current}
isFavorite={favoriteKeys.includes(current.key)}
onFavoriteToggle={() => toggleFavorite(current.key)}
/>
<NavigationControls
currentIndex={safeIndex}
totalCount={books.length}
Expand All @@ -44,6 +50,9 @@ function App() {
/>
</section>
)}
{!isLoading && !isError && (
<FavoritesView books={books} favoriteKeys={favoriteKeys} onRemove={removeFavorite} />
)}
</main>
<footer className="app-footer">
<p>Data from the OpenLibrary API.</p>
Expand Down
21 changes: 21 additions & 0 deletions src/__snapshots__/App.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ exports[`App > matches the snapshot 1`] = `
dragons
</li>
</ul>
<button
aria-label="Add The Hobbit to favorites"
aria-pressed="false"
class="favorite-button"
type="button"
>
☆ Add to favorites
</button>
</div>
</article>
<nav
Expand Down Expand Up @@ -153,6 +161,19 @@ exports[`App > matches the snapshot 1`] = `
</select>
</label>
</section>
<section
aria-labelledby="favorites-heading"
class="favorites-view"
>
<h2
id="favorites-heading"
>
Favorites
</h2>
<p>
No favorite books yet.
</p>
</section>
</main>
<footer
class="app-footer"
Expand Down
12 changes: 11 additions & 1 deletion src/components/BookCard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { coverUrl } from "../api/openLibrary";
import type { Book } from "../types";
import { FavoriteButton } from "./FavoriteButton";
import "./BookCard.css";

interface BookCardProps {
book: Book;
isFavorite?: boolean;
onFavoriteToggle?: () => void;
}

export function BookCard({ book }: BookCardProps) {
export function BookCard({ book, isFavorite = false, onFavoriteToggle }: BookCardProps) {
const imageUrl = coverUrl(book.coverId);
const authors = book.authors.length > 0 ? book.authors.join(", ") : "Unknown author";

Expand All @@ -30,6 +33,13 @@ export function BookCard({ book }: BookCardProps) {
))}
</ul>
)}
{onFavoriteToggle && (
<FavoriteButton
bookTitle={book.title}
isFavorite={isFavorite}
onToggle={onFavoriteToggle}
/>
)}
</div>
</article>
);
Expand Down
18 changes: 18 additions & 0 deletions src/components/FavoriteButton.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.favorite-button {
align-self: flex-start;
padding: 0.5rem 0.75rem;
border: 1px solid #cbd2d9;
border-radius: 0.375rem;
background-color: #ffffff;
color: #1f2933;
font: inherit;
}

.favorite-button:hover {
background-color: #f5f7fa;
}

.favorite-button:focus-visible {
outline: 0.1875rem solid #2c3e50;
outline-offset: 0.125rem;
}
25 changes: 25 additions & 0 deletions src/components/FavoriteButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FavoriteButton } from "./FavoriteButton";

describe("FavoriteButton", () => {
it("reports a toggle with the book title in its accessible name", () => {
const onToggle = vi.fn();
render(<FavoriteButton bookTitle="The Hobbit" isFavorite={false} onToggle={onToggle} />);

const button = screen.getByRole("button", { name: "Add The Hobbit to favorites" });
expect(button).toHaveAttribute("aria-pressed", "false");

fireEvent.click(button);

expect(onToggle).toHaveBeenCalledOnce();
});

it("matches the rendered snapshot", () => {
const { container } = render(
<FavoriteButton bookTitle="The Hobbit" isFavorite={true} onToggle={vi.fn()} />,
);

expect(container).toMatchSnapshot();
});
});
23 changes: 23 additions & 0 deletions src/components/FavoriteButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import "./FavoriteButton.css";

interface FavoriteButtonProps {
bookTitle: string;
isFavorite: boolean;
onToggle: () => void;
}

export function FavoriteButton({ bookTitle, isFavorite, onToggle }: FavoriteButtonProps) {
const action = isFavorite ? "Remove" : "Add";

return (
<button
className="favorite-button"
type="button"
aria-pressed={isFavorite}
aria-label={`${action} ${bookTitle} ${isFavorite ? "from" : "to"} favorites`}
onClick={onToggle}
>
{isFavorite ? "★ Favorited" : "☆ Add to favorites"}
</button>
);
}
63 changes: 63 additions & 0 deletions src/components/FavoritesView.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
.favorites-view {
max-width: 32rem;
margin: 2rem auto 0;
padding-top: 1.5rem;
border-top: 1px solid #d5dde5;
}

.favorites-view h2 {
margin: 0;
color: #243b53;
}

.favorites-view ul {
display: grid;
gap: 0.75rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}

.favorites-view li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
border: 1px solid #d5dde5;
border-radius: 0.5rem;
}

.favorites-view h3,
.favorites-view p {
margin: 0;
}

.favorites-view p {
color: #616e7c;
}

.favorites-view button {
padding: 0.5rem 0.75rem;
border: 1px solid #cbd2d9;
border-radius: 0.375rem;
background-color: #ffffff;
color: #1f2933;
font: inherit;
}

.favorites-view button:hover {
background-color: #f5f7fa;
}

.favorites-view button:focus-visible {
outline: 0.1875rem solid #2c3e50;
outline-offset: 0.125rem;
}

@media (max-width: 30rem) {
.favorites-view li {
align-items: flex-start;
flex-direction: column;
}
}
43 changes: 43 additions & 0 deletions src/components/FavoritesView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Book } from "../types";
import { FavoritesView } from "./FavoritesView";

const books: Book[] = [
{
key: "/works/OL1",
title: "The Hobbit",
authors: ["J. R. R. Tolkien"],
firstPublishYear: 1937,
coverId: 123,
subjects: ["fantasy"],
},
];

describe("FavoritesView", () => {
it("shows a favorite with its author and remove control", () => {
const onRemove = vi.fn();
render(<FavoritesView books={books} favoriteKeys={["/works/OL1"]} onRemove={onRemove} />);

expect(screen.getByRole("heading", { name: "The Hobbit" })).toBeInTheDocument();
expect(screen.getByText("J. R. R. Tolkien")).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Remove The Hobbit from favorites list" }));

expect(onRemove).toHaveBeenCalledWith("/works/OL1");
});

it("shows an empty state", () => {
render(<FavoritesView books={books} favoriteKeys={[]} onRemove={vi.fn()} />);

expect(screen.getByText("No favorite books yet.")).toBeInTheDocument();
});

it("matches the rendered snapshot", () => {
const { container } = render(
<FavoritesView books={books} favoriteKeys={["/works/OL1"]} onRemove={vi.fn()} />,
);

expect(container).toMatchSnapshot();
});
});
39 changes: 39 additions & 0 deletions src/components/FavoritesView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Book } from "../types";
import "./FavoritesView.css";

interface FavoritesViewProps {
books: Book[];
favoriteKeys: string[];
onRemove: (bookKey: string) => void;
}

export function FavoritesView({ books, favoriteKeys, onRemove }: FavoritesViewProps) {
const favorites = books.filter((book) => favoriteKeys.includes(book.key));

return (
<section className="favorites-view" aria-labelledby="favorites-heading">
<h2 id="favorites-heading">Favorites</h2>
{favorites.length === 0 ? (
<p>No favorite books yet.</p>
) : (
<ul>
{favorites.map((book) => (
<li key={book.key}>
<div>
<h3>{book.title}</h3>
<p>{book.authors.length > 0 ? book.authors.join(", ") : "Unknown author"}</p>
</div>
<button
type="button"
aria-label={`Remove ${book.title} from favorites list`}
onClick={() => onRemove(book.key)}
>
Remove
</button>
</li>
))}
</ul>
)}
</section>
);
}
14 changes: 14 additions & 0 deletions src/components/__snapshots__/FavoriteButton.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`FavoriteButton > matches the rendered snapshot 1`] = `
<div>
<button
aria-label="Remove The Hobbit from favorites"
aria-pressed="true"
class="favorite-button"
type="button"
>
★ Favorited
</button>
</div>
`;
Loading