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:
@@ -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 |
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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, "");
|
||||
|
||||
Reference in New Issue
Block a user