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.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
// 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 } from "@testing-library/react";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -100,3 +100,50 @@ describe("EpisodeCard a11y", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user