feat(keys): j/k and the arrows walk the channel, playlist and download lists

Third and last slice of the E11 keyboard layer (S4c). S4a/S4b covered every card grid and rail; the
table-shaped pages — the channel manager, a playlist, the download center — were still Tab-stop
country: a channel row carries a priority stepper, four action buttons and a tag picker before the next
row's name.

- `ui/DataTable keyboardNav`: the rows are the items, Enter clicks the element a column marks
  `data-nav-open`. DataTable renders its rows twice (a table, and a card list below `md`); both register
  and `lib/listNav` now walks only lists on screen (`checkVisibility`), so `j` past the last row never
  carries on into the hidden copy.
- `lib/listNav` leaves a key something before it already handled (`defaultPrevented`): during a keyboard
  drag a playlist row's grip moves the row with the arrows, and dnd-kit takes the key on the document
  before listNav's window listener. Without the guard ↓ moved the focus off the grip and dropped the drag.
- `ui/NavGrid as="tbody"` for a table whose body is the list (the playlist, under dnd-kit's
  SortableContext).
- Wired: Subscriptions and Discovery tables, the card layout (`ChannelLayoutGrid` → VirtualGrid
  `keyboardNav`), Hidden/Blocked cards (the ChannelLink name is a channel's open element); a playlist's
  rows (the title plays; the grip keeps Space/Enter); the download queue, library and shared lists (the
  play button opens, `IconBtn open`; a row without one opens nothing).
- `listNav.wiring.contract.test`: `VirtualGrid` and `DataTable` must say `keyboardNav` like `Carousel`,
  `DownloadRow` is a card; the audit log's table is the one exemption (its rows open nothing), and an
  exemption that no longer matches a tag fails the test.

Measured in Chrome: Channels table j → row, ring round the whole row; a row walked down keeps 12px above
the viewport edge, walked up clears the sticky header by 35px; Enter opens the channel. Cards 3 columns:
→ 0→1, ↓ 1→4. Hidden and Discovery: j + Enter open the channel. Playlist: j + Enter plays, Escape returns
focus to the row; a keyboard drag (Enter on the grip, ↓) moves the row with focus kept on the grip, and
Escape cancels with the order unchanged. Downloads library: Enter plays a video row, does nothing on a
plain file row.
This commit is contained in:
2026-09-13 06:08:05 +02:00
parent eb0507f942
commit b5d37ca142
20 changed files with 456 additions and 62 deletions
@@ -238,6 +238,7 @@ export default function ChannelDiscovery({
sort={sort}
onSortChange={setSort}
emptyText={t("channels.discovery.empty")}
keyboardNav
/>
) : (
<ChannelLayoutGrid
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
//
// The channel manager's card layout as a keyboard list (E11 S4c): each card is an item, and Enter opens
// the channel through the name ChannelLink renders. VirtualGrid is replaced by a stand-in that mounts
// every item — jsdom has no layout for the virtualizer to measure — and registers with listNav exactly
// when the real one would: when it is asked to (`keyboardNav`).
import { Fragment, useRef } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import type { VirtualGridProps } from "./ui/VirtualGrid";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./ui/VirtualGrid", async () => {
const { useListNav } = await import("../lib/listNav");
return {
default: function GridStandIn({
items,
getKey,
renderItem,
keyboardNav,
}: VirtualGridProps<unknown>) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: !!keyboardNav });
return (
<div ref={ref}>
{items.map((item, i) => (
<Fragment key={getKey(item)}>{renderItem(item, 0, i)}</Fragment>
))}
</div>
);
},
};
});
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import ChannelLink from "./ChannelLink";
import { type Column } from "./ui/DataTable";
afterEach(cleanup);
type Ch = { id: string; title: string };
const rows: Ch[] = [
{ id: "UCa", title: "Alpha" },
{ id: "UCb", title: "Beta" },
];
function press(key: string) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
const focusedTitle = () =>
(document.activeElement as HTMLElement).querySelector("[data-nav-open]")?.textContent;
describe("ChannelLayoutGrid · keyboard", () => {
it("walks the cards with j/k and opens the channel on Enter", () => {
const opened: string[] = [];
const columns: Column<Ch>[] = [
{
key: "channel",
header: "Channel",
cardPrimary: true,
render: (c) => (
<ChannelLink
id={c.id}
title={c.title}
thumbnailUrl={null}
onView={() => opened.push(c.id)}
/>
),
},
];
render(<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} />);
press("j");
expect(focusedTitle()).toBe("Alpha");
press("j");
expect(focusedTitle()).toBe("Beta");
press("Enter");
expect(opened).toEqual(["UCb"]);
});
});
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { type Column } from "./ui/DataTable";
import VirtualGrid from "./ui/VirtualGrid";
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
import { navItemAttrs } from "../lib/listNav";
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
@@ -12,6 +13,9 @@ import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
//
// Each card is a keyboard item (lib/listNav): `j`/`k` and the arrows walk the cards, and Enter opens
// the channel — the name ChannelLink renders is the card's open element.
interface ChannelLayoutGridProps<T> {
rows: T[];
@@ -52,9 +56,10 @@ export default function ChannelLayoutGrid<T>({
// Everything else flows in the meta line: not the heading, not a slotted column.
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
const renderCard = (row: T) => (
const renderCard = (row: T, index: number) => (
<div
{...rowProps?.(row)}
{...navItemAttrs(index)}
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
>
<div className="flex items-start justify-between gap-2">
@@ -91,7 +96,8 @@ export default function ChannelLayoutGrid<T>({
minCol={spec.minCol}
colMaxWidth={null}
rowEst={spec.rowEst}
renderItem={renderCard}
keyboardNav
renderItem={(row, _itemWidth, index) => renderCard(row, index)}
/>
);
}
+3
View File
@@ -30,6 +30,9 @@ export default function ChannelLink({
{onView ? (
<button
onClick={onView}
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
// card clicks this.
data-nav-open=""
onAuxClick={(e) => {
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
// opening the in-app channel detail.
+1
View File
@@ -623,6 +623,7 @@ export default function Channels() {
fixedLayout
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
keyboardNav
/>
) : (
<ChannelLayoutGrid
+23 -14
View File
@@ -42,6 +42,7 @@ import { Button } from "./ui/Button";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
export default function DownloadCenter() {
const me = useMe();
@@ -225,9 +226,9 @@ export default function DownloadCenter() {
{t("downloads.page.upload")}
</Button>
</div>
<div className="space-y-2">
{queue.map((job) => (
<DownloadRow key={job.id} job={job} onShowError={setErrorJob}>
<NavGrid className="space-y-2">
{queue.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
{(job.status === "queued" || job.status === "running") && (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "pause" })}
@@ -275,21 +276,25 @@ export default function DownloadCenter() {
{t("downloads.empty.queue")}
</div>
))}
</div>
</NavGrid>
</>
)}
{tab === "done" && (
<>
<UsageBar />
<div className="space-y-2">
{library.map((job) => {
<NavGrid className="space-y-2">
{library.map((job, i) => {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return (
<DownloadRow key={job.id} job={job}>
<DownloadRow key={job.id} job={job} navIndex={i}>
{a.play && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
@@ -358,19 +363,23 @@ export default function DownloadCenter() {
{t("downloads.empty.done")}
</div>
))}
</div>
</NavGrid>
</>
)}
{tab === "shared" && (
<div className="space-y-2">
{(sharedQ.data ?? []).map((job) => {
<NavGrid className="space-y-2">
{(sharedQ.data ?? []).map((job, i) => {
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
const a = sharedRowActions(job);
return (
<DownloadRow key={job.id} job={job}>
<DownloadRow key={job.id} job={job} navIndex={i}>
{a.play && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
@@ -408,7 +417,7 @@ export default function DownloadCenter() {
{t("downloads.empty.shared")}
</div>
))}
</div>
</NavGrid>
)}
{tab === "system" && me.role === "admin" && <AdminSystem />}
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
//
// The Hidden / Blocked channel cards as a keyboard list (E11 S4c): `j`/`k` walk the cards, Enter opens
// the channel — the ChannelLink name is the card's open element — and the card's own action button
// keeps its Enter.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import ExcludedChannelList from "./ExcludedChannelList";
afterEach(cleanup);
function press(key: string) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
const channels = [
{ id: "UCa", title: "Alpha", thumbnail_url: null },
{ id: "UCb", title: "Beta", thumbnail_url: null },
];
describe("ExcludedChannelList · keyboard", () => {
it("walks the cards with j/k and opens the channel on Enter", () => {
const onView = vi.fn();
render(
<ExcludedChannelList
channels={channels}
icon={null}
title="Hidden"
hint=""
emptyText=""
actionLabel="Unhide"
actionIcon={null}
onAction={() => {}}
onView={onView}
/>,
);
press("j");
press("j");
expect(document.activeElement?.textContent).toContain("Beta");
press("k");
expect(document.activeElement?.textContent).toContain("Alpha");
press("Enter");
expect(onView).toHaveBeenCalledWith(channels[0]);
expect(screen.getAllByRole("button", { name: "Unhide" })).toHaveLength(2);
});
});
@@ -1,5 +1,7 @@
import { type ReactNode } from "react";
import ChannelLink from "./ChannelLink";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
@@ -40,9 +42,13 @@ export default function ExcludedChannelList({
{icon}
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c) => (
<div key={c.id} className="glass-card flex items-center gap-2 rounded-xl px-3 py-2">
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c, i) => (
<div
key={c.id}
{...navItemAttrs(i)}
className="glass-card flex items-center gap-2 rounded-xl px-3 py-2"
>
<div className="min-w-0 flex-1">
<ChannelLink
id={c.id}
@@ -63,7 +69,7 @@ export default function ExcludedChannelList({
</button>
</div>
))}
</div>
</NavGrid>
<p className="text-2xs text-muted mt-3">{hint}</p>
</>
);
@@ -384,3 +384,22 @@ describe("Playlists — a change the server refused is taken back on screen", ()
);
});
});
describe("Playlists — the rows are a keyboard list", () => {
it("walks the rows with j and plays the focused one on Enter", async () => {
mount({ 1: [vid("A", 0), vid("B", 0)] });
await screen.findByRole("button", { name: "card.play — Video B" });
const press = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
press("j");
press("j");
expect(document.activeElement?.tagName).toBe("TR");
expect(document.activeElement?.textContent).toContain("Video B");
press("Enter");
await waitFor(() => expect(h.player?.video.id).toBe("B"));
});
});
+13 -3
View File
@@ -56,12 +56,14 @@ import { useComfortPin, pinVideo } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import UndoToolbar from "./ui/UndoToolbar";
import NavGrid from "./ui/NavGrid";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider";
import { usePlaylistActions } from "./usePlaylistActions";
import { usePlaylists } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { usePref } from "../lib/usePref";
import { navItemAttrs } from "../lib/listNav";
import { ColumnHead, alignClass } from "./ui/tableHeader";
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
@@ -93,12 +95,15 @@ const idsKey = (items: Video[]) =>
function Row({
video,
index,
columns,
dragDisabled,
dragHint,
menuProps,
}: {
video: Video;
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
index: number;
columns: Column<Video>[];
dragDisabled: boolean;
dragHint: string;
@@ -125,6 +130,7 @@ function Row({
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
// the same lag by another route (150ms behind the pointer, on every row).
className="border-b border-border/50 hover:bg-card/40 transition-colors"
{...navItemAttrs(index)}
{...menuProps}
>
{columns.map((col) => (
@@ -1087,10 +1093,14 @@ export default function Playlists() {
items={shown.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<tbody>
{shown.map((v) => (
{/* The rows are a keyboard list: `j`/`k` and the arrows walk them, Enter plays. The grip
keeps Space/Enter, and during a keyboard drag the arrows too (listNav leaves a key
dnd-kit has handled). */}
<NavGrid as="tbody">
{shown.map((v, i) => (
<Row
key={v.id}
index={i}
video={v}
columns={columns}
dragDisabled={!canEdit || viewActive}
@@ -1098,7 +1108,7 @@ export default function Playlists() {
menuProps={rowMenu.triggerProps(v)}
/>
))}
</tbody>
</NavGrid>
</SortableContext>
</table>
{shown.length === 0 && (
@@ -12,7 +12,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import { DownloadRow } from "./downloadRow";
import { DownloadRow, IconBtn } from "./downloadRow";
import type { DownloadJob } from "../lib/api";
const baseJob: DownloadJob = {
@@ -116,3 +116,27 @@ describe("DownloadRow file kind", () => {
expect(container.querySelector("img")).not.toBeNull();
});
});
describe("DownloadRow · keyboard", () => {
it("is a list item only when it is given a position", () => {
const { container, rerender } = render(<DownloadRow job={baseJob} navIndex={3} />);
expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3");
rerender(<DownloadRow job={baseJob} />);
expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false);
});
it("marks the action that opens the row, and only that one", () => {
render(
<DownloadRow job={baseJob} navIndex={0}>
<IconBtn open onClick={() => {}} title="play">
p
</IconBtn>
<IconBtn onClick={() => {}} title="delete">
d
</IconBtn>
</DownloadRow>,
);
expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true);
expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false);
});
});
+12 -1
View File
@@ -8,6 +8,7 @@ import { api, type DownloadJob } from "../lib/api";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
import { navItemAttrs } from "../lib/listNav";
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
@@ -112,16 +113,20 @@ export function IconBtn({
onClick,
title,
danger,
open,
children,
}: {
onClick: () => void;
title: string;
danger?: boolean;
/** The row's "open" action (play): Enter on a keyboard-walked row (lib/listNav) clicks it. */
open?: boolean;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
data-nav-open={open ? "" : undefined}
title={title}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition",
@@ -201,16 +206,22 @@ function ChannelLine({ job }: { job: DownloadJob }) {
export function DownloadRow({
job,
onShowError,
navIndex,
children,
}: {
job: DownloadJob;
onShowError?: (job: DownloadJob) => void;
/** Its position in a keyboard-walkable list (lib/listNav). */
navIndex?: number;
children?: ReactNode;
}) {
const { t } = useTranslation();
const running = job.status === "running";
return (
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
<div
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition"
>
<Thumb job={job} />
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
@@ -3,7 +3,7 @@ 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 `Carousel`, a `VirtualGrid` and a `DataTable` say `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.
@@ -11,7 +11,7 @@ import { describe, expect, it } from "vitest";
//
// 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 WALKABLE = ["Carousel", "VirtualGrid", "DataTable"];
const CARDS = [
"VideoCard",
"PlexPosterCard",
@@ -19,11 +19,15 @@ const CARDS = [
"EpisodeCard",
"PosterTile",
"ShelfTile",
"DownloadRow",
];
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> = {};
/** `path#tag#n` → why it is not a keyboard list. An entry that no longer matches a tag fails the test. */
const EXEMPT: Record<string, string> = {
"components/AuditLog.tsx#DataTable#1":
"an admin log read in place: its rows open nothing, so stepping onto them would only move a ring",
};
interface Tag {
start: number;
@@ -74,21 +78,25 @@ export function inList(code: string, pos: number): boolean {
const has = (tag: Tag, prop: string) => new RegExp(`\\s${prop}\\b`).test(tag.text);
function offenders(): { hits: string[]; tags: number } {
function offenders(): { hits: string[]; tags: number; exempted: Set<string> } {
const files = import.meta.glob("./**/*.tsx", { query: "?raw", import: "default", eager: true });
const hits: string[] = [];
const exempted = new Set<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}`);
if (id in EXEMPT) exempted.add(id);
else 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 WALKABLE) {
openingTags(code, name).forEach((tag, n) => {
tags++;
if (!has(tag, "keyboardNav")) report(`${path}#${name}#${n + 1}`, "no keyboardNav");
});
}
for (const name of CARDS) {
openingTags(code, name).forEach((tag, n) => {
tags++;
@@ -98,14 +106,15 @@ function offenders(): { hits: string[]; tags: number } {
});
}
}
return { hits: hits.sort(), tags };
return { hits: hits.sort(), tags, exempted };
}
describe("the keyboard-list wiring contract", () => {
it("has no rail or card list the keyboard cannot walk", () => {
const { hits, tags } = offenders();
const { hits, tags, exempted } = offenders();
expect(tags).toBeGreaterThan(15);
expect(hits).toEqual([]);
expect(Object.keys(EXEMPT).filter((id) => !exempted.has(id))).toEqual([]);
});
it("reads a tag to its real end, past the > inside its attributes", () => {
+6 -1
View File
@@ -68,7 +68,12 @@ export function playlistColumns({
<Play className="w-4 h-4 text-white fill-current" />
</span>
</button>
<button onClick={() => onPlay(v)} className="min-w-0 flex-1 text-left">
<button
onClick={() => onPlay(v)}
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
data-nav-open=""
className="min-w-0 flex-1 text-left"
>
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
</button>
</div>
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
//
// DataTable's `keyboardNav` (E11 S4c): the rows are a keyboard list — `j`/`k` walk them and Enter
// clicks the element a column marks `data-nav-open`. The table renders its rows twice (a table and,
// below `md`, a card list); only the copy on screen may be walked, or `j` past the last row would carry
// on into the hidden one.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(cleanup);
type Row = { id: string };
const rows: Row[] = [{ id: "a" }, { id: "b" }];
function table(onOpen: (id: string) => void, keyboardNav = true) {
const columns: Column<Row>[] = [
{
key: "name",
header: "Name",
cardPrimary: true,
render: (r) => (
<button data-nav-open="" onClick={() => onOpen(r.id)}>
{r.id}
</button>
),
},
{ key: "act", header: "", render: (r) => <button>act {r.id}</button> },
];
return render(
<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} keyboardNav={keyboardNav} />,
);
}
function press(key: string) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
/** What a display:none copy answers in a browser; jsdom lays nothing out, so every copy is "visible". */
const offScreen = (el: Element | null) =>
Object.defineProperty(el!, "checkVisibility", { value: () => false, configurable: true });
/** The focused row: which copy (TR / DIV) and which row. */
const focused = () => {
const el = document.activeElement as HTMLElement;
return `${el.tagName} ${el.querySelector("[data-nav-open]")?.textContent ?? ""}`;
};
describe("DataTable · keyboardNav", () => {
it("walks the table's rows, stops at the last one, and opens a row on Enter", () => {
const opened: string[] = [];
const { container } = table((id) => opened.push(id));
offScreen(container.querySelector(".md\\:hidden"));
press("j");
expect(focused()).toBe("TR a");
press("j");
expect(focused()).toBe("TR b");
// The hidden card copy follows the table in the DOM — it must not be the next stop.
press("j");
expect(focused()).toBe("TR b");
press("Enter");
expect(opened).toEqual(["b"]);
});
it("walks the card copy when that is the one on screen", () => {
const { container } = table(() => {});
offScreen(container.querySelector("tbody"));
press("j");
expect(focused()).toBe("DIV a");
});
it("leaves the rows out of the keyboard without keyboardNav", () => {
const { container } = table(() => {}, false);
expect(container.querySelector("[data-nav-index]")).toBeNull();
press("j");
expect(document.activeElement).toBe(document.body);
});
});
+16 -4
View File
@@ -5,6 +5,7 @@ import Pager, { hasPagerContent } from "./Pager";
import { PageToolbar } from "./PageShell";
import { ColumnHead, alignClass } from "./tableHeader";
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -63,6 +64,7 @@ export default function DataTable<T>({
filters: filtersProp,
onFiltersChange,
fixedLayout = false,
keyboardNav = false,
}: {
rows: T[];
columns: Column<T>[];
@@ -111,6 +113,10 @@ export default function DataTable<T>({
// the viewport. Off by default so existing content-sized tables (playlists, discovery, Plex) are
// untouched; the channel manager turns it on so its row never scrolls horizontally out of view.
fixedLayout?: boolean;
// Take part in page keyboard navigation (`lib/listNav`): `j`/`k` and the arrows walk the rows, and
// Enter clicks the element a column's render marks `data-nav-open` (the channel name). Both row
// shapes register; listNav walks only the one on screen, so the hidden copy is never a stop.
keyboardNav?: boolean;
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
@@ -126,6 +132,10 @@ export default function DataTable<T>({
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
const bodyRef = useRef<HTMLTableSectionElement>(null);
const cardsRef = useRef<HTMLDivElement>(null);
useListNav(bodyRef, { enabled: keyboardNav });
useListNav(cardsRef, { enabled: keyboardNav });
// Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
// latest resolved map so the externalFilter/reset effects can derive from it without taking
@@ -257,11 +267,12 @@ export default function DataTable<T>({
filters={filters}
onFilter={applyFilter}
/>
<tbody>
{paged.map((row) => (
<tbody ref={bodyRef}>
{paged.map((row, i) => (
<tr
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`border-b border-border/50 hover:bg-(--hover) transition ${
rowClassName?.(row) ?? ""
}`}
@@ -283,11 +294,12 @@ export default function DataTable<T>({
{/* Narrow screens: a compact card per row — the primary column as the heading, the
rest flowing in a single wrapping meta line (no full-width label/value gaps). */}
<div className="md:hidden flex flex-col gap-2">
{paged.map((row) => (
<div ref={cardsRef} className="md:hidden flex flex-col gap-2">
{paged.map((row, i) => (
<div
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
+11 -8
View File
@@ -1,23 +1,26 @@
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.
// A plain list of cards or rows the keyboard can walk — `j`/`k`, the arrows, Enter (lib/listNav) — for
// the lists that mount every item (the Plex title, season and episode grids, the download lists, a
// playlist's table body). 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 and a column of rows is one per row.
// Each child spreads `navItemAttrs(index)` on its root; a virtualized grid is VirtualGrid's
// `keyboardNav` instead. `as="tbody"` for a table whose rows are the items.
export default function NavGrid({
as: Tag = "div",
className,
children,
}: {
as?: "div" | "tbody";
className?: string;
children: ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLDivElement & HTMLTableSectionElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref} className={className}>
<Tag ref={ref} className={className}>
{children}
</div>
</Tag>
);
}
+7 -7
View File
@@ -33,13 +33,13 @@ domain-free.
## What lives here
| area | components |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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`, `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` |
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
## The size contract (E10 S1c)
+37
View File
@@ -312,3 +312,40 @@ describe("useListNav · measured lists (rails and plain grids)", () => {
);
});
});
describe("useListNav · keys and lists it must leave alone", () => {
// DataTable renders its rows twice — a table and a card list, one of them display:none at any width.
it("skips a list that is not on screen", () => {
render(
<>
<List id="a" count={2} />
<List id="b" count={2} />
</>,
);
Object.defineProperty(screen.getByTestId("a0").parentElement!, "checkVisibility", {
value: () => false,
});
press("j");
expect(focused()).toBe("b0");
press("k");
expect(focused()).toBe("b0");
});
// A playlist row's drag handle moves the row with the arrows during a keyboard drag; dnd-kit handles
// the key on the document first and prevents its default. Moving focus as well would drop the drag.
it("leaves a key that something before it already handled", () => {
render(<List id="a" count={3} />);
press("j");
const handled = (e: KeyboardEvent) => e.preventDefault();
document.addEventListener("keydown", handled);
try {
press("ArrowDown");
press("j");
expect(focused()).toBe("a0");
} finally {
document.removeEventListener("keydown", handled);
}
press("ArrowDown");
expect(focused()).toBe("a1");
});
});
+13 -5
View File
@@ -55,11 +55,15 @@ function itemAt(list: NavList, index: number): HTMLElement | null {
}
function ordered(): NavList[] {
return [...lists.values()]
.filter((l) => l.el.isConnected)
.sort((a, b) =>
a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
);
return (
[...lists.values()]
// Only lists on screen: a responsive table renders its rows twice — a table and a card list, one
// of them display:none at any width (ui/DataTable) — and `j` must not walk into the hidden copy.
.filter((l) => l.el.isConnected && l.el.checkVisibility?.() !== false)
.sort((a, b) =>
a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
)
);
}
/** The item holding focus (on its root or on a control inside it), and the list it belongs to. */
@@ -203,6 +207,10 @@ function crossRow(from: NavList, item: HTMLElement, dy: -1 | 1): void {
}
function onKey(e: KeyboardEvent): void {
// A key a widget inside the item already handled is not ours: during a keyboard drag a playlist
// row's grip moves the row with the arrows (dnd-kit listens on the document, before this window
// listener, and prevents the default of every key it acts on).
if (e.defaultPrevented) return;
const arrow = (dx: -1 | 0 | 1, dy: -1 | 0 | 1) => (ev: KeyboardEvent) => {
if (move(dx, dy)) ev.preventDefault();
};