Files
siftlode/frontend/src/components/plexCards.dom.test.tsx
T
peter 6d09b94bbb feat(keys): j/k and the arrows walk Home's rails and every Plex grid and rail
Second slice of the E11 keyboard layer (S4b). S4a gave the feed, the library and the channel page
`j`/`k`, the arrows and Enter; Home's shelves and the whole Plex module were still Tab-stop country —
a Plex poster is its own Tab stop, and a rail's cards sat behind every control of the cards before.

- `lib/listNav`: a list that mounts every item no longer declares a count or columns — both are
  measured off the DOM when a key is pressed, so an auto-fill grid's columns follow its width and a
  rail is one row. ↑ on a list's top row and ↓ on its bottom row go on to the neighbouring list, onto
  the item horizontally nearest the one left (owner decision D4: ←→ within a rail, ↑↓ to the rail
  above or below). The first `j` skips cards a rail has scrolled out to the left.
- `Carousel keyboardNav` registers the scroller; the scroller gets a 4px inset (against a -4px margin,
  layout unchanged) so the focus ring is not clipped, and `[data-nav-index]` a 2.5rem inline scroll
  margin so a card stepped into view clears the rail's edge fade and paging arrow.
- `ui/NavGrid`: the plain, fully mounted grid that registers itself.
- `navIndex` on PlexPosterCard / SeasonCard / EpisodeCard (the card root is the item, the poster the
  open element — the ring goes round poster and title, D2), PosterTile and Home's ShelfTile (the button
  is the item); cast, related and collection-strip buttons carry the attrs directly.
- Wired: Home's comfort and resume rails; Plex Browse titles + episodes, Discover rails + genres,
  collection view, playlists/collections browse, person known-for + filmography, show seasons, season
  episodes, cast/related strips, info-page cast + collection strips.
- `listNav.wiring.contract.test`: every `Carousel` says `keyboardNav`, every card says `navIndex`, and a
  card with `navIndex` sits inside a NavGrid / Carousel / VirtualGrid — both halves are opt-in, and
  either one missing leaves a list the keyboard silently skips.
2026-09-13 05:38:36 +02:00

150 lines
5.1 KiB
TypeScript

// @vitest-environment jsdom
//
// Plex card a11y: locks the accessible names on the icon-only card controls (the E4 a11y sweep). These
// buttons are icon-only (WatchedIcon / Info / ListPlus, no text), so their name comes solely from the
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
// key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
const movie = (status?: string): PlexCard => ({
id: "1",
type: "movie",
title: "M",
thumb: "",
status,
});
const episode = (status?: string): PlexCard => ({
id: "e1",
type: "episode",
title: "E1",
thumb: "",
status,
});
const season = (status = "new"): PlexSeasonDetail => ({
id: "s1",
season_number: 1,
title: "S1",
thumb: "",
episode_count: 3,
status,
episodes: [],
});
afterEach(cleanup);
describe("PlexPosterCard a11y", () => {
it("names the watched-toggle (mark-watched when unwatched) and the info button", () => {
render(
<PlexPosterCard card={movie()} onOpen={vi.fn()} onInfo={vi.fn()} onToggleWatched={vi.fn()} />,
);
expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.info.title" })).toBeTruthy();
});
it("names the watched-toggle as mark-unwatched when the movie is watched", () => {
render(
<PlexPosterCard
card={movie("watched")}
onOpen={vi.fn()}
onInfo={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
});
it("reveals the synopsis + rating on the hover scrim when the card carries them", () => {
render(
<PlexPosterCard
card={{ ...movie(), year: 2023, rating: 7.5, summary: "A quiet synopsis." }}
onOpen={vi.fn()}
onInfo={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
});
it("omits the scrim when the card has no synopsis or rating", () => {
render(
<PlexPosterCard card={movie()} onOpen={vi.fn()} onInfo={vi.fn()} onToggleWatched={vi.fn()} />,
);
expect(screen.queryByText(/synopsis/i)).toBeNull();
});
});
describe("SeasonCard a11y", () => {
it("names the watched-toggle and the add-season button", () => {
render(<SeasonCard se={season()} onOpen={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />);
expect(screen.getByRole("button", { name: "plex.series.markSeasonWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlist.addSeason" })).toBeTruthy();
});
});
describe("EpisodeCard a11y", () => {
it("names the watched-toggle and the add-episode button", () => {
render(
<EpisodeCard ep={episode()} onPlay={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />,
);
expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy();
});
});
// E11 S4b: in a keyboard-walkable grid or rail the WHOLE card is the item (the ring goes round the
// poster and its title, D2) and the poster — the card's own role=button — is what Enter opens.
describe("Plex cards in a keyboard-walkable list", () => {
const cases = [
[
"PlexPosterCard",
(onOpen: () => void, navIndex?: number) => (
<PlexPosterCard
card={movie()}
onOpen={onOpen}
onInfo={vi.fn()}
onToggleWatched={vi.fn()}
navIndex={navIndex}
/>
),
],
[
"SeasonCard",
(onOpen: () => void, navIndex?: number) => (
<SeasonCard se={season()} onOpen={onOpen} navIndex={navIndex} />
),
],
[
"EpisodeCard",
(onOpen: () => void, navIndex?: number) => (
<EpisodeCard ep={episode()} onPlay={onOpen} navIndex={navIndex} />
),
],
] as const;
it.each(cases)("%s: the root is the item and the poster is its open element", (_, card) => {
const onOpen = vi.fn();
const { container } = render(card(onOpen, 3));
const root = container.firstElementChild as HTMLElement;
expect(root.getAttribute("data-nav-index")).toBe("3");
const open = root.querySelector<HTMLElement>("[data-nav-open]")!;
expect(open.getAttribute("role")).toBe("button");
fireEvent.click(open);
expect(onOpen).toHaveBeenCalledTimes(1);
});
it.each(cases)("%s: outside a list it is no item", (_, card) => {
const { container } = render(card(vi.fn()));
expect(container.querySelector("[data-nav-index]")).toBeNull();
});
});