Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
42 changes: 31 additions & 11 deletions docs/accessibility.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/choices-and-reasoning.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ better-auth stores users and sessions in our own PostgreSQL database through its

## The URL holds the search state

The search text, filters, sorting and page number are stored in the URL (`?q=…&genres=…&sort=…&page=…`) instead of in component state. This means a search can be shared as a link, survives a page reload, and the browser's back button works as expected. Together with Apollo Client's cache for data from the server, this covers our need for state management without a separate global store.
The search text, filters, sorting and page number are stored in the URL (`?q=...&genres=...&sort=...&page=...`) instead of in component state. This means a search can be shared as a link, survives a page reload, and the browser's back button works as expected. Together with Apollo Client's cache for data from the server, this covers our need for state management without a separate global store.

## Pagination with page numbers

Expand Down
2 changes: 1 addition & 1 deletion frontend/app/components/BookCard/BookCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe("BookCard", () => {
renderRoutes(routes, { initialEntries: ["/?q=fantasy"] });

expect(
screen.getByRole("link", { name: "View details for The Blade Itself" }),
screen.getByRole("link", { name: /^The Blade Itself/ }),
).toHaveAttribute("href", `/book/${book.id}?q=fantasy`);
});

Expand Down
3 changes: 1 addition & 2 deletions frontend/app/components/BookCard/BookCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ function BookCard({ book, searchListParams }: BookCardProps) {
search: nextSearchParams.toString(),
}}
className="flex h-full flex-col cursor-pointer p-6 rounded-xl"
aria-label={`View details for ${book.title}`}
>
<BookCover book={book} />
<BookCover book={book} decorative />

<CardHeader className="mt-4">
<CardTitle className="font-bold">{book.title}</CardTitle>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ exports[`BookCard > matches the snapshot 1`] = `
data-slot="card"
>
<a
aria-label="View details for The Blade Itself"
class="flex h-full flex-col cursor-pointer p-6 rounded-xl"
data-discover="true"
href="/book/1"
Expand All @@ -20,8 +19,8 @@ exports[`BookCard > matches the snapshot 1`] = `
class="block h-full overflow-hidden"
>
<img
alt="Book cover of The Blade Itself"
class="size-full object-cover transition-transform duration-200 group-hover:scale-103"
alt=""
class="size-full object-cover transition-transform duration-200 motion-safe:group-hover:scale-103"
loading="lazy"
src="https://covers.openlibrary.org/b/id/14543422-M.jpg?default=false"
/>
Expand Down Expand Up @@ -56,7 +55,8 @@ exports[`BookCard > matches the snapshot 1`] = `
</div>
</a>
<button
aria-label="Add The Blade Itself to favorites"
aria-disabled="false"
aria-label="Favorite The Blade Itself"
aria-pressed="false"
class="group/button inline-flex shrink-0 items-center justify-center rounded-lg border bg-clip-padding font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 not-disabled:cursor-pointer border-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 size-8 absolute right-2 top-2 z-10 bg-favorite-button text-xl"
data-slot="button"
Expand Down
18 changes: 14 additions & 4 deletions frontend/app/components/BookCover/BookCover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,32 @@ interface BookCoverProps {
// "lazy" lets the browser skip covers far outside the screen until the user
// scrolls near them. Use "eager" for a cover that is visible right away.
loading?: "lazy" | "eager";
// Hides the cover from screen readers when the title is next to it in the same
// link, so the link's name starts with the title instead of repeating it.
decorative?: boolean;
}

function BookCover({ book, loading = "lazy" }: BookCoverProps) {
function BookCover({
book,
loading = "lazy",
decorative = false,
}: BookCoverProps) {
return book.coverImgUrl ? (
<figure className="mx-6 my-3 aspect-2/3 contain-content">
<picture className="block h-full overflow-hidden">
<img
src={book.coverImgUrl}
alt={`Book cover of ${book.title}`}
alt={decorative ? "" : `Book cover of ${book.title}`}
loading={loading}
className="size-full object-cover transition-transform duration-200 group-hover:scale-103"
className="size-full object-cover transition-transform duration-200 motion-safe:group-hover:scale-103"
/>
</picture>
</figure>
) : (
<p className="flex items-center justify-center mx-6 my-3 aspect-2/3 rounded-md bg-background text-center text-sm">
<p
aria-hidden={decorative || undefined}
className="flex items-center justify-center mx-6 my-3 aspect-2/3 rounded-md bg-background text-center text-sm"
>
No cover available
</p>
);
Expand Down
27 changes: 26 additions & 1 deletion frontend/app/components/BookPagination/BookPagination.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BookPagination } from "~/components/BookPagination/BookPagination";
Expand All @@ -15,7 +15,19 @@ const Stub = createRoutesStub([
const currentPage = () =>
screen.getByRole("link", { current: "page" }).textContent;

function prefersReducedMotion(reduce: boolean) {
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({ matches: reduce })),
);
}

describe("BookPagination", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it("disables Previous on the first page and Next on the last page", () => {
const { unmount } = render(<Stub />);

Expand All @@ -35,6 +47,7 @@ describe("BookPagination", () => {
});

it("moves to the next or previous page and scrolls to the top", async () => {
prefersReducedMotion(false);
// jsdom cannot scroll; replace window.scrollTo so it does not warn.
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(vi.fn());
const user = userEvent.setup();
Expand All @@ -47,6 +60,18 @@ describe("BookPagination", () => {
await waitFor(() => expect(currentPage()).toBe("3"));

expect(scrollTo).toHaveBeenCalledTimes(2);
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: "smooth" });
});

it("scrolls to the top without animation when the user prefers reduced motion", async () => {
prefersReducedMotion(true);
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(vi.fn());
const user = userEvent.setup();
render(<Stub initialEntries={["/?page=3"]} />);

await user.click(screen.getByRole("link", { name: "Go to next page" }));

expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: "instant" });
});

it("keeps the search and filters when changing page", () => {
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/components/BookPagination/BookPagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ function BookPagination({ totalPages }: { totalPages: number }) {
};

const handlePageChange = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
window.scrollTo({ top: 0, behavior: reduceMotion ? "instant" : "smooth" });
};

const renderPageLink = (page: number) => (
Expand Down
29 changes: 22 additions & 7 deletions frontend/app/components/FavoriteButton/FavoriteButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ describe("FavoriteButton", () => {
renderRoutes(routes);

await user.click(
screen.getByRole("button", { name: "Add The Blade Itself to favorites" }),
screen.getByRole("button", {
name: "Favorite The Blade Itself",
pressed: false,
}),
);

expect(
Expand Down Expand Up @@ -73,12 +76,16 @@ describe("FavoriteButton", () => {
});

await user.click(
screen.getByRole("button", { name: "Add The Blade Itself to favorites" }),
screen.getByRole("button", {
name: "Favorite The Blade Itself",
pressed: false,
}),
);

expect(
await screen.findByRole("button", {
name: "Remove The Blade Itself from favorites",
name: "Favorite The Blade Itself",
pressed: true,
}),
).toHaveAttribute("aria-pressed", "true");
});
Expand All @@ -100,13 +107,15 @@ describe("FavoriteButton", () => {
// Shown as a favorite once the user's favorites have loaded.
await user.click(
await screen.findByRole("button", {
name: "Remove The Blade Itself from favorites",
name: "Favorite The Blade Itself",
pressed: true,
}),
);

expect(
await screen.findByRole("button", {
name: "Add The Blade Itself to favorites",
name: "Favorite The Blade Itself",
pressed: false,
}),
).toHaveAttribute("aria-pressed", "false");
});
Expand All @@ -125,7 +134,10 @@ describe("FavoriteButton", () => {
});

await user.click(
screen.getByRole("button", { name: "Add The Blade Itself to favorites" }),
screen.getByRole("button", {
name: "Favorite The Blade Itself",
pressed: false,
}),
);

expect(
Expand All @@ -134,7 +146,10 @@ describe("FavoriteButton", () => {
),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add The Blade Itself to favorites" }),
screen.getByRole("button", {
name: "Favorite The Blade Itself",
pressed: false,
}),
).toHaveAttribute("aria-pressed", "false");
});
});
10 changes: 4 additions & 6 deletions frontend/app/components/FavoriteButton/FavoriteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,11 @@ function FavoriteButton({ book }: FavoriteButtonProps) {
favorite && "text-primary",
)}
onClick={() => void toggleFavorite()}
// Keeps keyboard focus on the button while saving
focusableWhenDisabled
disabled={saving}
aria-label={
// Toggle the accessibility label based on the current state for screen readers
favorite
? `Remove ${book.title} from favorites`
: `Add ${book.title} to favorites`
}
// The label stays the same, aria-pressed tells whether it is a favorite
aria-label={`Favorite ${book.title}`}
aria-pressed={favorite}
>
<HeartIcon
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function Header() {
<header className="flex items-center justify-between border-b-2 border-zinc-800 not-dark:bg-white px-10 py-3.75">
<section className="flex items-center gap-10">
<Link to="/">
<h1 className="m-0 text-2xl">Readr</h1>
<span className="text-2xl">Readr</span>
</Link>

<nav aria-label="Main" className="hidden md:block">
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/components/books/BookFilters/BookFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ function BookFilters() {
return (
<section className="flex flex-col gap-4">
<header className="flex items-center justify-between">
<h2 className="text-lg font-bold">Filter Books</h2>
<h2 id="filter-books-heading" className="text-lg font-bold">
Filter Books
</h2>
<Button
type="button"
variant="link"
Expand Down
7 changes: 6 additions & 1 deletion frontend/app/components/books/SearchBar/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function SearchBar({ resultCount }: SearchBarProps) {

return (
<section className="flex items-center justify-center gap-4 mx-auto mt-10 w-full px-10 sm:mt-20">
<h1 className="sr-only">Browse books</h1>
<InputGroup className="h-10 max-w-150 bg-card">
<InputGroupAddon align="inline-start">
<SearchIcon />
Expand Down Expand Up @@ -115,7 +116,11 @@ function SearchBar({ resultCount }: SearchBarProps) {
</Badge>
)}
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<PopoverContent
align="end"
className="w-80"
aria-labelledby="filter-books-heading"
>
<BookFilters />
</PopoverContent>
</Popover>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function GenresSection() {
))}
</ul>
) : error || genres.length === 0 ? (
<p role="status">Could not load the genres right now.</p>
<p role="alert">Could not load the genres right now.</p>
) : (
<ul className="flex flex-wrap gap-3">
{genres.slice(0, GENRE_LIMIT).map((genre) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function TopRatedSection({
))}
</ul>
) : (
<p role="status">Could not load the top rated books right now.</p>
<p role="alert">Could not load the top rated books right now.</p>
)}
</RevealSection>
);
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/components/ui/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function ComboboxInput({
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
render={<ComboboxTrigger aria-label="Show options" />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
Expand Down
9 changes: 5 additions & 4 deletions frontend/app/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,13 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary: oklch(0.424 0.2 265.65);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
/* Darker than the shadcn default (0.556), which was below 4.5:1 contrast on our background. */
--muted-foreground: oklch(0.52 0 0);
/* Darker than the shadcn default (0.556) to reach 7:1 contrast (WCAG AAA) on our backgrounds. */
--muted-foreground: oklch(0.435 0 0);
--accent: oklch(93.7% 0.03 260.75);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
Expand Down Expand Up @@ -137,7 +137,8 @@
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
/* Lighter than the shadcn default (0.708) to reach 7:1 contrast (WCAG AAA) on cards. */
--muted-foreground: oklch(0.74 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
Expand Down
20 changes: 19 additions & 1 deletion frontend/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { MainLoading } from "~/components/MainLoading/MainLoading";
import type { Route } from "./+types/root";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { ApolloProvider } from "@apollo/client/react";
import { buttonVariants } from "~/components/ui/Button";
import { Toaster } from "~/components/ui/Toast";
import { cn } from "~/lib/utils";
import { TooltipProvider } from "~/components/ui/Tooltip";
import { ThemeProvider, ThemeScript } from "~/lib/themeProvider";

Expand Down Expand Up @@ -67,8 +69,24 @@ export function Layout({ children }: { children: React.ReactNode }) {
<ThemeProvider>
<ApolloProvider client={apolloClient}>
<TooltipProvider>
<a
href="#main-content"
className={cn(
buttonVariants(),
"sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50",
)}
>
Skip to main content
</a>
<Header />
{children}
{/* One target for the skip link, since each route uses its own <main> */}
<div
id="main-content"
tabIndex={-1}
className="flex flex-1 flex-col outline-none"
>
{children}
</div>
<Footer />
<ScrollRestoration />
<Scripts />
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/routes/book/selectedBook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) {
<section className="flex flex-col gap-2">
<Card className="p-7.5">
<CardHeader className="px-0">
<CardTitle className="text-2xl font-bold">
{book.title}
<CardTitle>
<h1 className="text-2xl font-bold">{book.title}</h1>
</CardTitle>
</CardHeader>
<CardContent className="px-0 text-md">
Expand Down
Loading