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:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -623,6 +623,7 @@ export default function Channels() {
|
||||
fixedLayout
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={pinMenu.triggerProps}
|
||||
keyboardNav
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user