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
+1 -1
View File
@@ -90,7 +90,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `useListNav` + `navItemAttrs(i)` + one `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
+44
View File
@@ -671,3 +671,47 @@ describe("Home", () => {
);
});
});
// E11 S4b, owner decision D4: the rails are one keyboard page — `j` walks a rail's cards and goes on
// into the next rail, whichever order the page stacks them in.
describe("Home · keyboard", () => {
it("walks the rails with j, from one rail on into the next", async () => {
discover.mockResolvedValue({
...payload("v1"),
continue_watching: [
ytCard("v1", "Hero v1"),
ytCard("v2", "Second thing"),
ytCard("v3", "Third thing"),
],
// A pinned channel has no card of its own: the shelf draws it as a ShelfTile.
comfort: [
ytCard("v4", "Comfort thing"),
{
...ytCard("UC9", "Pinned channel"),
kind: "channel",
video: null,
next: null,
unwatched_count: 3,
},
],
});
mount();
await screen.findByText("Comfort thing");
const titles = ["Comfort thing", "Pinned channel", "Second thing", "Third thing"];
const seen: (string | undefined)[] = [];
for (let i = 0; i < titles.length; i++) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
);
});
const item = (document.activeElement as HTMLElement).closest("[data-nav-index]");
seen.push(titles.find((title) => item?.textContent?.includes(title)));
}
// Every card once, across both rails: the shelf tile, and on from one rail into the next.
expect(new Set(seen)).toEqual(new Set(titles));
// Every card of every rail is an item of it — the shelf tile too, not only the native cards.
const rails = [...document.querySelectorAll("[data-nav-list]")];
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
});
});
+9 -2
View File
@@ -40,6 +40,7 @@ import VideoCard from "./VideoCard";
import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./NavigationProvider";
import { usePlexActions } from "./PlexProvider";
import { navItemAttrs } from "../lib/listNav";
// The Home surface (E24) — "Mit nézzek most?".
//
@@ -172,8 +173,9 @@ function Rail({
<Carousel
title={<span className="text-sm font-semibold">{title}</span>}
ariaLabel={ariaLabel}
keyboardNav
>
{cards.map((c) => (
{cards.map((c, i) => (
<div
key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
@@ -184,6 +186,7 @@ function Rail({
video={c.video}
view="cardsSmall"
width={YT_SLOT}
navIndex={i}
onState={onState}
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
@@ -195,13 +198,14 @@ function Rail({
) : c.plex ? (
<PlexPosterCard
card={c.plex}
navIndex={i}
onOpen={() => onPlay(c)}
onInfo={() => onInfo(c)}
onToggleWatched={onToggleWatched}
menuProps={() => menu.triggerProps(c)}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} />
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
)}
</div>
))}
@@ -218,10 +222,12 @@ function ShelfTile({
card,
onPlay,
menuProps,
navIndex,
}: {
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
navIndex?: number;
}) {
const { t } = useTranslation();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
@@ -234,6 +240,7 @@ function ShelfTile({
type="button"
onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
+9 -6
View File
@@ -42,6 +42,7 @@ import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch, useKeyScope } from "../lib/keymap";
import NavGrid from "./ui/NavGrid";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -607,10 +608,11 @@ export default function PlexBrowse() {
{episodes.length > 0 && (
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c, i) => (
<PlexPosterCard
key={c.id}
navIndex={i}
card={c}
onOpen={() => onCard(c)}
onInfo={() => onInfo(c)}
@@ -618,24 +620,25 @@ export default function PlexBrowse() {
menuProps={pinMenu.triggerProps}
/>
))}
</div>
</NavGrid>
</>
)}
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
{episodes.length > 0 && (
<section className="mt-8">
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{episodes.map((ep) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{episodes.map((ep, i) => (
<EpisodeCard
key={ep.id}
navIndex={i}
ep={ep}
withShowTitle
onPlay={() => onCard(ep)}
onToggleWatched={() => toggleWatched(ep)}
/>
))}
</div>
</NavGrid>
</section>
)}
</>
@@ -20,6 +20,7 @@ import { useContextMenu } from "./ui/useContextMenu";
import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
@@ -398,11 +399,12 @@ export default function PlexCollectionView({
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c) => (
<NavGrid className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c, i) => (
<div key={c.id} className="relative">
<PlexPosterCard
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggleWatched}
@@ -421,7 +423,7 @@ export default function PlexCollectionView({
)}
</div>
))}
</div>
</NavGrid>
</>
)}
</>
@@ -6,7 +6,7 @@
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
@@ -263,3 +263,40 @@ describe("PlexDiscover", () => {
expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull();
});
});
// E11 S4b: every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
// from one to the next in page order (owner decision D4).
describe("PlexDiscover · keyboard", () => {
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
/>,
);
await screen.findAllByText("Random Pick");
// Five rails (continue, recently watched, recently added, because-you-watched, surprise) + genres.
const lists = [...document.querySelectorAll("[data-nav-list]")];
expect(lists.length).toBe(6);
// …and each holds its cards as items: the genre tiles too, not only the posters.
expect(lists.every((list) => list.querySelector("[data-nav-index]"))).toBe(true);
const step = () => {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
);
});
return (document.activeElement as HTMLElement).closest("[data-nav-index]")?.textContent;
};
const first = step();
const second = step();
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(second).not.toBe(first); // one card per rail: the second press crossed into the next rail
});
});
+14 -5
View File
@@ -13,6 +13,7 @@ import { PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
@@ -55,11 +56,18 @@ function CardRail({
);
return (
<section className="mb-5">
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
{cards.map((c) => (
<Carousel
title={heading}
ariaLabel={ariaLabel}
onSeeAll={onSeeAll}
seeAllLabel={seeAllLabel}
keyboardNav
>
{cards.map((c, i) => (
<div key={c.id} className="w-[150px] shrink-0">
<PlexPosterCard
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={onToggleWatched}
@@ -222,17 +230,18 @@ export default function PlexDiscover({
{d.genres.length > 0 && (
<section className="mb-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g) => (
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g, i) => (
<PosterTile
key={g.value}
navIndex={i}
title={g.value}
art={g.art}
onClick={() => onGenre(g.value)}
className="h-24"
/>
))}
</div>
</NavGrid>
</section>
)}
+10 -3
View File
@@ -20,6 +20,7 @@ import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexCards";
import { navItemAttrs } from "../lib/listNav";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -649,7 +650,11 @@ export default function PlexInfo({
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
<Carousel ariaLabel={t("plex.info.cast")} gap="gap-3">
<Carousel
ariaLabel={t("plex.info.cast")}
gap="gap-3"
keyboardNav={!!(onOpenPerson || onFilter)}
>
{cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter);
const inner = (
@@ -686,6 +691,7 @@ export default function PlexInfo({
return clickable ? (
<button
key={i}
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
@@ -734,10 +740,11 @@ export default function PlexInfo({
</button>
)}
</div>
<Carousel ariaLabel={col.title} gap="gap-3">
{col.items.map((m) => (
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
{col.items.map((m, i) => (
<button
key={m.id}
{...navItemAttrs(i)}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left"
+4
View File
@@ -1,5 +1,6 @@
import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav";
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
@@ -12,6 +13,7 @@ export default function PosterTile({
art,
onClick,
className,
navIndex,
}: {
title: string;
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m")
@@ -19,6 +21,7 @@ export default function PosterTile({
art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
onClick: () => void;
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
navIndex?: number; // position in a keyboard-walkable grid (lib/listNav); Enter is the button's own
}) {
const posters = (art ?? []).slice(0, 2);
const band = hueTileGradient(hueOf(title));
@@ -27,6 +30,7 @@ export default function PosterTile({
type="button"
onClick={onClick}
title={title}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
>
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
// Every rail and every card of a card list is walkable by the keyboard (E11 S4, lib/listNav). Two
// opt-in halves, both checked here, because either one missing silently leaves the keyboard out —
// and a page where `j` walks two rails and skips the third reads as broken, not as unfinished:
// - a `Carousel` says `keyboardNav`, and a card says `navIndex`;
// - a card that says `navIndex` sits in a list that registers with listNav — a `NavGrid`, a
// `Carousel`, or a `VirtualGrid` (its `renderItem`). In a plain `<div className="grid">` the card
// is marked as an item of a list nobody walks.
// Each exception is listed with its reason.
//
// The assertion pins the SET of offending tags, never a count, and checks the glob matched at all: a
// guard whose glob stops matching is green over nothing (E10 review round 4, round 7).
const RAIL = "Carousel";
const CARDS = [
"VideoCard",
"PlexPosterCard",
"SeasonCard",
"EpisodeCard",
"PosterTile",
"ShelfTile",
];
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
/** `path#tag#n` → why it is not a keyboard item. Empty today: every rail and card list is wired. */
const EXEMPT: Record<string, string> = {};
interface Tag {
start: number;
end: number;
text: string;
}
const stripComments = (source: string) =>
source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
/** Each opening `<Name …>` tag, found by brace depth — a JSX attribute holds its own `>`
* (`title={<span>…</span>}`, an arrow `=>`), so the first `>` is not the end of the tag. Takes
* comment-free code, so the positions line up with `inList`. */
export function openingTags(code: string, name: string): Tag[] {
const tags: Tag[] = [];
const start = new RegExp(`<${name}(?=[\\s/>])`, "g");
for (let m = start.exec(code); m; m = start.exec(code)) {
let depth = 0;
let quote: string | null = null;
let i = m.index + name.length + 1;
for (; i < code.length; i++) {
const c = code[i];
if (quote) {
if (c === quote) quote = null;
} else if (depth === 0 && (c === '"' || c === "'")) quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) break;
}
tags.push({ start: m.index, end: i + 1, text: code.slice(m.index, i + 1) });
}
return tags;
}
/** Whether `pos` lies inside an open list element: between `<List …>` and `</List>`, or inside a
* list's own tag (a `renderItem={…}` attribute). */
export function inList(code: string, pos: number): boolean {
return LISTS.some((name) => {
let open = 0;
for (const tag of openingTags(code, name)) {
if (tag.start > pos) break;
if (tag.end > pos) return true;
if (!tag.text.endsWith("/>")) open++;
}
return open > code.slice(0, pos).split(`</${name}>`).length - 1;
});
}
const has = (tag: Tag, prop: string) => new RegExp(`\\s${prop}\\b`).test(tag.text);
function offenders(): { hits: string[]; tags: number } {
const files = import.meta.glob("./**/*.tsx", { query: "?raw", import: "default", eager: true });
const hits: string[] = [];
let tags = 0;
for (const [rel, source] of Object.entries(files)) {
const path = rel.replace(/^\.\//, "components/");
if (/\.test\.tsx$/.test(path)) continue;
const code = stripComments(String(source));
const report = (id: string, why: string) => {
if (!(id in EXEMPT)) hits.push(`${id} ${why}`);
};
openingTags(code, RAIL).forEach((tag, n) => {
tags++;
if (!has(tag, "keyboardNav")) report(`${path}#${RAIL}#${n + 1}`, "no keyboardNav");
});
for (const name of CARDS) {
openingTags(code, name).forEach((tag, n) => {
tags++;
const id = `${path}#${name}#${n + 1}`;
if (!has(tag, "navIndex")) report(id, "no navIndex");
else if (!inList(code, tag.start)) report(id, "not inside a NavGrid/Carousel/VirtualGrid");
});
}
}
return { hits: hits.sort(), tags };
}
describe("the keyboard-list wiring contract", () => {
it("has no rail or card list the keyboard cannot walk", () => {
const { hits, tags } = offenders();
expect(tags).toBeGreaterThan(15);
expect(hits).toEqual([]);
});
it("reads a tag to its real end, past the > inside its attributes", () => {
const code = stripComments(`
<Carousel
title={<span className="x">{title}</span>}
onSeeAll={() => go(1 > 0)}
keyboardNav
>
// <Carousel keyboardNav> in a comment is no tag
<Carousel ariaLabel="a > b">
`);
const tags = openingTags(code, "Carousel");
expect(tags).toHaveLength(2);
expect(has(tags[0]!, "keyboardNav")).toBe(true);
expect(has(tags[1]!, "keyboardNav")).toBe(false);
});
it("tells a card inside a list from one in a plain grid", () => {
const code = `
<NavGrid className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</NavGrid>
<div className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</div>
<VirtualGrid renderItem={(v, w, i) => <VideoCard navIndex={i} />} />
`;
const at = (n: number) => openingTags(code, "PosterTile")[n]!.start;
expect(inList(code, at(0))).toBe(true);
expect(inList(code, at(1))).toBe(false);
expect(inList(code, openingTags(code, "VideoCard")[0]!.start)).toBe(true);
});
});
+9 -6
View File
@@ -11,6 +11,7 @@ import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./ui/QueryState";
import PosterTile from "./PosterTile";
import PlexLibraryPicker from "./PlexLibraryPicker";
import NavGrid from "./ui/NavGrid";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
@@ -49,10 +50,11 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
return (
<div className={GRID}>
{playlists.map((p) => (
<NavGrid className={GRID}>
{playlists.map((p, i) => (
<PosterTile
key={p.id}
navIndex={i}
title={p.title}
subtitle={t("plex.browse.items", { count: p.item_count })}
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
@@ -60,7 +62,7 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }
className="h-28"
/>
))}
</div>
</NavGrid>
);
}
@@ -215,10 +217,11 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
) : collections.length === 0 ? (
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
) : (
<div className={GRID}>
{collections.map((c) => (
<NavGrid className={GRID}>
{collections.map((c, i) => (
<PosterTile
key={c.id}
navIndex={i}
title={c.title}
art={c.art}
subtitle={collectionMeta(c, t)}
@@ -234,7 +237,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
className="h-28"
/>
))}
</div>
</NavGrid>
)}
</div>
);
+48 -1
View File
@@ -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();
});
});
+19 -5
View File
@@ -4,6 +4,7 @@ import { type CSSProperties } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { navItemAttrs } from "../lib/listNav";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
@@ -52,6 +53,7 @@ export function PlexPosterCard({
onInfo,
onToggleWatched,
menuProps,
navIndex,
}: {
card: PlexCard;
onOpen: () => void;
@@ -61,6 +63,8 @@ export function PlexPosterCard({
* A function of the card rather than a ready-made object, so every card surface in the app takes
* `menuProps={menu.triggerProps}` and reads the same at every call site. */
menuProps?: (c: PlexCard) => Record<string, unknown>;
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
navIndex?: number;
}) {
const { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
@@ -75,6 +79,7 @@ export function PlexPosterCard({
<div
className="group text-left"
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
@@ -82,6 +87,7 @@ export function PlexPosterCard({
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -242,11 +248,13 @@ export function SeasonCard({
onOpen,
onToggleWatched,
onAdd,
navIndex,
}: {
se: PlexSeasonDetail;
onOpen: () => void;
onToggleWatched?: () => void;
onAdd?: () => void;
navIndex?: number;
}) {
const { t } = useTranslation();
const watched = se.status === "watched";
@@ -254,11 +262,12 @@ export function SeasonCard({
? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched");
return (
<div className="group text-left">
<div className="group text-left" {...(navIndex !== undefined && navItemAttrs(navIndex))}>
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -357,7 +366,7 @@ export function CastStrip({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")}>
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")} keyboardNav={clickable}>
{cast.map((c, i) => {
const inner = (
<>
@@ -390,6 +399,7 @@ export function CastStrip({
return clickable ? (
<button
key={i}
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
@@ -427,10 +437,11 @@ export function RelatedStrip({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")}>
{related.map((r) => (
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
{related.map((r, i) => (
<button
key={r.id}
{...navItemAttrs(i)}
onClick={() => onOpen(r.id)}
className="group w-[130px] shrink-0 text-left"
>
@@ -463,12 +474,14 @@ export function EpisodeCard({
onAdd,
onToggleWatched,
withShowTitle,
navIndex,
}: {
ep: PlexCard;
onPlay: () => void;
onAdd?: () => void;
onToggleWatched?: () => void;
withShowTitle?: boolean;
navIndex?: number;
}) {
const { t } = useTranslation();
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
@@ -478,11 +491,12 @@ export function EpisodeCard({
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0;
return (
<div className="group">
<div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex))}>
<div
onClick={onPlay}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
+20 -11
View File
@@ -29,6 +29,7 @@ import { Carousel } from "./ui/Carousel";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -151,10 +152,11 @@ export function PlexPersonView({
cards.length > 0 ? (
<section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c, i) => (
<PlexPosterCard
key={c.id}
navIndex={i}
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
@@ -162,7 +164,7 @@ export function PlexPersonView({
menuProps={pinMenu.triggerProps}
/>
))}
</div>
</NavGrid>
</section>
) : null;
@@ -241,11 +243,16 @@ export function PlexPersonView({
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
{d.known_for.length > 0 && (
<section className="mb-5">
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
{d.known_for.map((c) => (
<Carousel
title={t("plex.person.knownFor")}
ariaLabel={t("plex.person.knownFor")}
keyboardNav
>
{d.known_for.map((c, i) => (
<div key={c.id} className="w-[150px] shrink-0">
<PlexPosterCard
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
@@ -501,10 +508,11 @@ export function PlexShowView({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
{d.seasons.map((se) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
{d.seasons.map((se, i) => (
<SeasonCard
key={se.id}
navIndex={i}
se={se}
onOpen={() => onOpenSeason(se.id)}
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
@@ -520,7 +528,7 @@ export function PlexShowView({
}
/>
))}
</div>
</NavGrid>
</section>
{show.cast.length > 0 && showCast && (
@@ -684,17 +692,18 @@ export function PlexSeasonView({
</div>
</DetailHero>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep, i) => (
<EpisodeCard
key={ep.id}
navIndex={i}
ep={ep}
onPlay={() => onPlay(ep.id, allKeys)}
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
onToggleWatched={() => markEpisode(ep)}
/>
))}
</div>
</NavGrid>
</>
)}
@@ -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` |
+2
View File
@@ -448,6 +448,8 @@ body {
[data-nav-index] {
scroll-margin-top: calc(var(--hdr-h, 0px) + 0.75rem);
scroll-margin-bottom: 0.75rem;
/* Sideways in a rail: clear its edge fade and the paging arrows (Carousel), not just the edge. */
scroll-margin-inline: 2.5rem;
/* A transparent resting ring of the SAME geometry: the video card transitions `all`, so without
it the ring animated in from the initial outline (3px, currentColor) — a white flash on every
`j`, measured in Chrome. Now only the colour fades in. */
+95 -1
View File
@@ -17,6 +17,7 @@ function List({
cols = 1,
mounted,
reveal,
measure,
onOpen = () => {},
}: {
id: string;
@@ -25,10 +26,12 @@ function List({
/** Mount only the first N items, the way a virtualized grid leaves the rest out of the DOM. */
mounted?: number;
reveal?: (index: number) => void;
/** Register without a count or columns — a rail or plain grid, measured from the DOM. */
measure?: boolean;
onOpen?: (id: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true, count, cols, reveal });
useListNav(ref, measure ? { enabled: true } : { enabled: true, count, cols, reveal });
return (
<div ref={ref}>
{Array.from({ length: mounted ?? count }, (_, i) => (
@@ -218,3 +221,94 @@ describe("useListNav", () => {
expect(press("j").defaultPrevented).toBe(false);
});
});
// jsdom lays nothing out, so the tests below give each item a box: `boxes` maps an item's test id
// (`a0`, `b3`) to where it sits. Everything else — the list containers — is a zero box at the origin.
function withLayout(
boxes: (id: string, n: number) => { left: number; top: number } | null,
run: () => void,
) {
const spy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
this: Element,
) {
const m = /^([a-z])(\d+)$/.exec((this as HTMLElement).dataset?.testid ?? "");
const at = m ? boxes(m[1]!, Number(m[2])) : null;
const { left, top } = at ?? { left: 0, top: 0 };
const size = at ? 90 : 0;
return {
left,
top,
right: left + size,
bottom: top + size,
width: size,
height: size,
x: left,
y: top,
} as DOMRect;
});
// Restored in `finally`: a spy left behind by a failing assertion would fail every later test.
try {
run();
} finally {
spy.mockRestore();
}
}
describe("useListNav · measured lists (rails and plain grids)", () => {
it("reads the item count and the columns off the DOM when the list does not give them", () => {
// Three to a row: [0 1 2] [3 4 5] [6]
withLayout(
(_, n) => ({ left: (n % 3) * 100, top: Math.floor(n / 3) * 100 }),
() => {
render(<List id="a" count={7} measure />);
press("j");
press("ArrowDown");
expect(focused()).toBe("a3");
press("ArrowDown");
expect(focused()).toBe("a6");
press("ArrowRight"); // the last item: nothing past it
expect(focused()).toBe("a6");
},
);
});
// Owner decision D4: ↑↓ from a rail go to the rail above or below, not nowhere.
it("goes on from a list's bottom row to the next list's nearest item, and back up", () => {
withLayout(
// Two rails of four; the lower one sits 30px further right.
(list, n) => (list === "a" ? { left: n * 100, top: 0 } : { left: n * 100 + 30, top: 200 }),
() => {
render(
<>
<List id="a" count={4} measure />
<List id="b" count={4} measure />
</>,
);
press("j");
press("ArrowRight");
press("ArrowRight");
expect(focused()).toBe("a2");
press("ArrowDown");
expect(focused()).toBe("b2"); // centre 275 is nearest a2's 45+200
press("ArrowDown"); // the page's last list: stays
expect(focused()).toBe("b2");
press("ArrowUp");
expect(focused()).toBe("a2");
press("ArrowRight");
press("ArrowRight"); // ←→ never leave the rail
expect(focused()).toBe("a3");
},
);
});
it("lands the first j on the first card a rail shows, not one scrolled out to the left", () => {
withLayout(
(_, n) => ({ left: (n - 2) * 100, top: 0 }), // a0 and a1 sit left of the rail's edge
() => {
render(<List id="a" count={4} measure />);
press("j");
expect(focused()).toBe("a2");
},
);
});
});
+68 -15
View File
@@ -20,7 +20,12 @@ import { scrollParent } from "./listPaging";
//
// Every page-level list shares ONE window listener, and the lists are walked in DOCUMENT order: `j`
// at the end of one list continues at the start of the next (owner decision D4, "reading order").
// The arrows stay inside the list they are in.
// ←→ stay inside the list they are in; ↑ on a list's top row and ↓ on its bottom row go on to the
// neighbouring list — the rail above or below on Home and Discover — landing on the item nearest the
// one you left, so a column of rails reads like one grid.
//
// A list that mounts everything (a rail, a plain grid) need not say how many items or columns it has:
// both are read off the DOM when the key is pressed, so an auto-fill grid's columns follow its width.
interface NavList {
readonly el: HTMLElement;
@@ -65,22 +70,47 @@ function current(): { list: NavList; index: number; item: HTMLElement } | null {
return item && list ? { list, index: indexOf(item), item } : null;
}
/** The mounted items that belong to `el` itself (not to a list nested inside it), in index order. */
function ownItems(el: HTMLElement): HTMLElement[] {
return [...el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
.filter((item) => item.parentElement?.closest(`[${LIST}]`) === el)
.sort((a, b) => indexOf(a) - indexOf(b));
}
// Items whose tops are this close share a row (sub-pixel layout, a card a hair taller than the next).
const ROW_SLACK = 4;
const topOf = (el: Element) => el.getBoundingClientRect().top;
const centerX = (el: Element) => {
const r = el.getBoundingClientRect();
return r.left + r.width / 2;
};
/** Items per row, measured: the leading items that share the first item's top. A rail is one row. */
function measuredCols(el: HTMLElement): number {
const items = ownItems(el);
if (!items.length) return 1;
const first = topOf(items[0]!);
const inRow = items.findIndex((item) => Math.abs(topOf(item) - first) > ROW_SLACK);
return inRow < 0 ? items.length : inRow;
}
/** Where `j` lands when nothing in a list has focus yet: the first item the reader can see, not the
* top of the list — after scrolling down a feed, the first press must not throw them back up. "Can
* see" = its bottom edge is below the top of the scroller's visible band, which starts under the
* floating header (the item's own `scroll-margin-top` is exactly that header clearance). */
* floating header (the item's own `scroll-margin-top` is exactly that header clearance), and — in a
* rail scrolled sideways — its right edge is past the rail's left edge. */
function firstVisible(): { list: NavList; index: number } | null {
let fallback: { list: NavList; index: number } | null = null;
for (const list of ordered()) {
const items = [...list.el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
.filter((el) => el.parentElement?.closest(`[${LIST}]`) === list.el)
.sort((a, b) => indexOf(a) - indexOf(b));
const items = ownItems(list.el);
if (!items.length) continue;
fallback ??= { list, index: indexOf(items[0]!) };
const top = scrollParent(list.el)?.getBoundingClientRect().top ?? 0;
const left = list.el.getBoundingClientRect().left;
for (const el of items) {
const band = top + (parseFloat(getComputedStyle(el).scrollMarginTop) || 0);
if (el.getBoundingClientRect().bottom > band) return { list, index: indexOf(el) };
const r = el.getBoundingClientRect();
if (r.bottom > band && r.right > left) return { list, index: indexOf(el) };
}
}
return fallback;
@@ -132,9 +162,10 @@ function step(delta: 1 | -1): void {
}
}
/** Move inside the focused item's list. ←→ step one item (wrapping rows, the reading order); ↑↓ one
* row, landing on the last item when the row below is a short last row. Returns false when focus is
* not in a list, so the arrow keeps its native meaning (scrolling) there. */
/** Move from the focused item. ←→ step one item inside its list (wrapping rows, the reading order);
* ↑↓ one row, landing on the last item when the row below is a short last row, and from the top or
* bottom row on into the neighbouring list. Returns false when focus is not in a list, so the arrow
* keeps its native meaning (scrolling) there. */
function move(dx: -1 | 0 | 1, dy: -1 | 0 | 1): boolean {
const cur = current();
if (!cur) return false;
@@ -146,10 +177,31 @@ function move(dx: -1 | 0 | 1, dy: -1 | 0 | 1): boolean {
else if (dy > 0 && row < Math.floor((count - 1) / cols))
target = Math.min(cur.index + cols, count - 1);
else if (dy < 0 && row > 0) target = cur.index - cols;
else if (dy) crossRow(cur.list, cur.item, dy);
if (target !== cur.index && target >= 0 && target < count) focusIndex(cur.list, target);
return true;
}
/** ↓ past a list's bottom row / ↑ past its top row: the nearest row of the next non-empty list that
* way, on the item horizontally nearest the one being left. Only MOUNTED items are candidates — a
* virtualized neighbour offers the rows it has rendered, which next to the edge you cross from are
* the rows nearest it. At the page's first or last list it stays put, like `j`/`k`. */
function crossRow(from: NavList, item: HTMLElement, dy: -1 | 1): void {
const all = ordered();
const x = centerX(item);
for (let at = all.indexOf(from) + dy; at >= 0 && at < all.length; at += dy) {
const items = ownItems(all[at]!.el);
if (!items.length) continue;
const tops = items.map(topOf);
const edge = dy > 0 ? Math.min(...tops) : Math.max(...tops);
const row = items.filter((_, i) => Math.abs(tops[i]! - edge) <= ROW_SLACK);
const nearest = row.reduce((a, b) =>
Math.abs(centerX(b) - x) < Math.abs(centerX(a) - x) ? b : a,
);
return focusItem(nearest);
}
}
function onKey(e: KeyboardEvent): void {
const arrow = (dx: -1 | 0 | 1, dy: -1 | 0 | 1) => (ev: KeyboardEvent) => {
if (move(dx, dy)) ev.preventDefault();
@@ -181,12 +233,13 @@ function onKey(e: KeyboardEvent): void {
});
}
/** Register `ref` as a keyboard-navigable list while `enabled`. `count` is the number of items (the
* loaded ones, for an infinite list), `cols` the items per row (1 for a column), and `reveal` scrolls
* an item that is not mounted into view — omit it for a list that mounts everything. */
/** Register `ref` as a keyboard-navigable list while `enabled`. A virtualized list must say how many
* items it has (`count` — the loaded ones, for an infinite list), how many per row (`cols`) and how
* to scroll an unmounted item into view (`reveal`). A list that mounts everything omits all three:
* the count and the columns are then measured from the DOM at the moment a key is pressed. */
export function useListNav(
ref: RefObject<HTMLElement | null>,
opts: { enabled: boolean; count: number; cols: number; reveal?: (index: number) => void },
opts: { enabled: boolean; count?: number; cols?: number; reveal?: (index: number) => void },
): void {
const optsRef = useRef(opts);
optsRef.current = opts;
@@ -196,8 +249,8 @@ export function useListNav(
if (!opts.enabled || !el) return;
lists.set(el, {
el,
count: () => optsRef.current.count,
cols: () => optsRef.current.cols,
count: () => optsRef.current.count ?? ownItems(el).length,
cols: () => optsRef.current.cols ?? measuredCols(el),
reveal: (i) => optsRef.current.reveal?.(i),
});
el.setAttribute(LIST, "");