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:
2026-09-13 05:38:36 +02:00
parent 5274216981
commit 6d09b94bbb
21 changed files with 615 additions and 65 deletions
@@ -5,7 +5,7 @@
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and
// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { Carousel } from "./Carousel";
import { navItemAttrs } from "../../lib/listNav";
beforeAll(() => {
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
@@ -91,3 +92,38 @@ describe("Carousel", () => {
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy();
});
});
// E11 S4b: a rail on Home, Discover or an info page is a list the keyboard walks — but only when the
// caller says its cards carry `navItemAttrs`. A carousel of plain content must not claim `j`.
describe("Carousel · keyboard", () => {
const cards = () =>
[0, 1, 2].map((i) => (
<button key={i} {...navItemAttrs(i)}>
{`card ${i}`}
</button>
));
const press = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
it("is walked by j when keyboardNav is on", () => {
render(
<Carousel ariaLabel="Rail" keyboardNav>
{cards()}
</Carousel>,
);
press("j");
expect(document.activeElement?.textContent).toBe("card 0");
press("j");
expect(document.activeElement?.textContent).toBe("card 1");
});
it("leaves j alone without it", () => {
render(<Carousel ariaLabel="Rail">{cards()}</Carousel>);
press("j");
expect(document.activeElement).toBe(document.body);
});
});
+21 -3
View File
@@ -1,8 +1,9 @@
import { useCallback, type ReactNode } from "react";
import { useCallback, useRef, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { useScrollEdges } from "../../lib/useScrollEdges";
import { useListNav } from "../../lib/listNav";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
@@ -43,6 +44,7 @@ export function Carousel({
ariaLabel,
gap = "gap-3",
className,
keyboardNav = false,
children,
}: {
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
@@ -55,10 +57,24 @@ export function Carousel({
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
gap?: string;
className?: string;
/** The cards spread `navItemAttrs(i)` on their roots: `j`/`k`, ←→ and Enter walk the rail, and ↑↓
* leave it for the rail above or below (lib/listNav). */
keyboardNav?: boolean;
children: ReactNode;
}) {
const { t } = useTranslation();
const { ref, el, left, right } = useHScroll();
// The scroller is the list the keyboard walks. useHScroll holds the node through a callback ref,
// listNav reads a ref object — one node, both handed it.
const navRef = useRef<HTMLDivElement | null>(null);
const setScroller = useCallback(
(node: HTMLDivElement | null) => {
ref(node);
navRef.current = node;
},
[ref],
);
useListNav(navRef, { enabled: keyboardNav });
const page = useCallback(
(dir: -1 | 1) => {
@@ -112,9 +128,11 @@ export function Carousel({
<ChevronLeft className="h-5 w-5" />
</button>
)}
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
<div
ref={ref}
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
ref={setScroller}
className={clsx("no-scrollbar -mx-1 -mt-1 flex overflow-x-auto px-1 pt-1 pb-1", gap)}
style={{ maskImage: mask, WebkitMaskImage: mask }}
role="group"
aria-label={label}
+23
View File
@@ -0,0 +1,23 @@
import { useRef, type ReactNode } from "react";
import { useListNav } from "../../lib/listNav";
// A plain grid of cards the keyboard can walk — `j`/`k`, the arrows, Enter (lib/listNav) — for the
// grids that mount every item (the Plex title, season and episode grids). Nothing to count: the items
// and the columns per row are measured when a key is pressed, so an auto-fill grid's columns follow
// its width. Each child spreads `navItemAttrs(index)` on its root; a virtualized grid is VirtualGrid's
// `keyboardNav` instead.
export default function NavGrid({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ domain-free.
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster`, `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader`, `Pager`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| data display | `DataTable`, `tableHeader`, `Pager`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid the keyboard walks), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |