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}
|
sort={sort}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
emptyText={t("channels.discovery.empty")}
|
emptyText={t("channels.discovery.empty")}
|
||||||
|
keyboardNav
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChannelLayoutGrid
|
<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 { type Column } from "./ui/DataTable";
|
||||||
import VirtualGrid from "./ui/VirtualGrid";
|
import VirtualGrid from "./ui/VirtualGrid";
|
||||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
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 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
|
// 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.
|
// 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
|
// 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.
|
// `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> {
|
interface ChannelLayoutGridProps<T> {
|
||||||
rows: 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.
|
// 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 meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
||||||
|
|
||||||
const renderCard = (row: T) => (
|
const renderCard = (row: T, index: number) => (
|
||||||
<div
|
<div
|
||||||
{...rowProps?.(row)}
|
{...rowProps?.(row)}
|
||||||
|
{...navItemAttrs(index)}
|
||||||
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
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">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -91,7 +96,8 @@ export default function ChannelLayoutGrid<T>({
|
|||||||
minCol={spec.minCol}
|
minCol={spec.minCol}
|
||||||
colMaxWidth={null}
|
colMaxWidth={null}
|
||||||
rowEst={spec.rowEst}
|
rowEst={spec.rowEst}
|
||||||
renderItem={renderCard}
|
keyboardNav
|
||||||
|
renderItem={(row, _itemWidth, index) => renderCard(row, index)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export default function ChannelLink({
|
|||||||
{onView ? (
|
{onView ? (
|
||||||
<button
|
<button
|
||||||
onClick={onView}
|
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) => {
|
onAuxClick={(e) => {
|
||||||
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
|
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
|
||||||
// opening the in-app channel detail.
|
// opening the in-app channel detail.
|
||||||
|
|||||||
@@ -623,6 +623,7 @@ export default function Channels() {
|
|||||||
fixedLayout
|
fixedLayout
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
rowProps={pinMenu.triggerProps}
|
rowProps={pinMenu.triggerProps}
|
||||||
|
keyboardNav
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChannelLayoutGrid
|
<ChannelLayoutGrid
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { Button } from "./ui/Button";
|
|||||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||||
import { AdminSystem } from "./downloadAdmin";
|
import { AdminSystem } from "./downloadAdmin";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
|
|
||||||
export default function DownloadCenter() {
|
export default function DownloadCenter() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
@@ -225,9 +226,9 @@ export default function DownloadCenter() {
|
|||||||
{t("downloads.page.upload")}
|
{t("downloads.page.upload")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<NavGrid className="space-y-2">
|
||||||
{queue.map((job) => (
|
{queue.map((job, i) => (
|
||||||
<DownloadRow key={job.id} job={job} onShowError={setErrorJob}>
|
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
|
||||||
{(job.status === "queued" || job.status === "running") && (
|
{(job.status === "queued" || job.status === "running") && (
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() => act.mutate({ id: job.id, op: "pause" })}
|
onClick={() => act.mutate({ id: job.id, op: "pause" })}
|
||||||
@@ -275,21 +276,25 @@ export default function DownloadCenter() {
|
|||||||
{t("downloads.empty.queue")}
|
{t("downloads.empty.queue")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "done" && (
|
{tab === "done" && (
|
||||||
<>
|
<>
|
||||||
<UsageBar />
|
<UsageBar />
|
||||||
<div className="space-y-2">
|
<NavGrid className="space-y-2">
|
||||||
{library.map((job) => {
|
{library.map((job, i) => {
|
||||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||||
const a = libraryRowActions(job);
|
const a = libraryRowActions(job);
|
||||||
return (
|
return (
|
||||||
<DownloadRow key={job.id} job={job}>
|
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||||
{a.play && (
|
{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" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
@@ -358,19 +363,23 @@ export default function DownloadCenter() {
|
|||||||
{t("downloads.empty.done")}
|
{t("downloads.empty.done")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "shared" && (
|
{tab === "shared" && (
|
||||||
<div className="space-y-2">
|
<NavGrid className="space-y-2">
|
||||||
{(sharedQ.data ?? []).map((job) => {
|
{(sharedQ.data ?? []).map((job, i) => {
|
||||||
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||||
const a = sharedRowActions(job);
|
const a = sharedRowActions(job);
|
||||||
return (
|
return (
|
||||||
<DownloadRow key={job.id} job={job}>
|
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||||
{a.play && (
|
{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" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
@@ -408,7 +417,7 @@ export default function DownloadCenter() {
|
|||||||
{t("downloads.empty.shared")}
|
{t("downloads.empty.shared")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
{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 { type ReactNode } from "react";
|
||||||
import ChannelLink from "./ChannelLink";
|
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.
|
// 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
|
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
|
||||||
@@ -40,9 +42,13 @@ export default function ExcludedChannelList({
|
|||||||
{icon}
|
{icon}
|
||||||
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
|
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{channels.map((c) => (
|
{channels.map((c, i) => (
|
||||||
<div key={c.id} className="glass-card flex items-center gap-2 rounded-xl px-3 py-2">
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
<ChannelLink
|
<ChannelLink
|
||||||
id={c.id}
|
id={c.id}
|
||||||
@@ -63,7 +69,7 @@ export default function ExcludedChannelList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
<p className="text-2xs text-muted mt-3">{hint}</p>
|
<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 { ContextMenu } from "./ui/ContextMenu";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import UndoToolbar from "./ui/UndoToolbar";
|
import UndoToolbar from "./ui/UndoToolbar";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { usePlaylistActions } from "./usePlaylistActions";
|
import { usePlaylistActions } from "./usePlaylistActions";
|
||||||
import { usePlaylists } from "./PlaylistsProvider";
|
import { usePlaylists } from "./PlaylistsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { usePref } from "../lib/usePref";
|
import { usePref } from "../lib/usePref";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { ColumnHead, alignClass } from "./ui/tableHeader";
|
import { ColumnHead, alignClass } from "./ui/tableHeader";
|
||||||
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
|
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
|
||||||
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||||||
@@ -93,12 +95,15 @@ const idsKey = (items: Video[]) =>
|
|||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
video,
|
video,
|
||||||
|
index,
|
||||||
columns,
|
columns,
|
||||||
dragDisabled,
|
dragDisabled,
|
||||||
dragHint,
|
dragHint,
|
||||||
menuProps,
|
menuProps,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
|
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
|
||||||
|
index: number;
|
||||||
columns: Column<Video>[];
|
columns: Column<Video>[];
|
||||||
dragDisabled: boolean;
|
dragDisabled: boolean;
|
||||||
dragHint: string;
|
dragHint: string;
|
||||||
@@ -125,6 +130,7 @@ function Row({
|
|||||||
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
// 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).
|
// 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"
|
className="border-b border-border/50 hover:bg-card/40 transition-colors"
|
||||||
|
{...navItemAttrs(index)}
|
||||||
{...menuProps}
|
{...menuProps}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
@@ -1087,10 +1093,14 @@ export default function Playlists() {
|
|||||||
items={shown.map((v) => v.id)}
|
items={shown.map((v) => v.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<tbody>
|
{/* The rows are a keyboard list: `j`/`k` and the arrows walk them, Enter plays. The grip
|
||||||
{shown.map((v) => (
|
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
|
<Row
|
||||||
key={v.id}
|
key={v.id}
|
||||||
|
index={i}
|
||||||
video={v}
|
video={v}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dragDisabled={!canEdit || viewActive}
|
dragDisabled={!canEdit || viewActive}
|
||||||
@@ -1098,7 +1108,7 @@ export default function Playlists() {
|
|||||||
menuProps={rowMenu.triggerProps(v)}
|
menuProps={rowMenu.triggerProps(v)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</NavGrid>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</table>
|
</table>
|
||||||
{shown.length === 0 && (
|
{shown.length === 0 && (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { DownloadRow } from "./downloadRow";
|
import { DownloadRow, IconBtn } from "./downloadRow";
|
||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../lib/api";
|
||||||
|
|
||||||
const baseJob: DownloadJob = {
|
const baseJob: DownloadJob = {
|
||||||
@@ -116,3 +116,27 @@ describe("DownloadRow file kind", () => {
|
|||||||
expect(container.querySelector("img")).not.toBeNull();
|
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 { notify } from "../lib/notifications";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
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),
|
// 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,
|
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
||||||
@@ -112,16 +113,20 @@ export function IconBtn({
|
|||||||
onClick,
|
onClick,
|
||||||
title,
|
title,
|
||||||
danger,
|
danger,
|
||||||
|
open,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
/** The row's "open" action (play): Enter on a keyboard-walked row (lib/listNav) clicks it. */
|
||||||
|
open?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
data-nav-open={open ? "" : undefined}
|
||||||
title={title}
|
title={title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||||
@@ -201,16 +206,22 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
|||||||
export function DownloadRow({
|
export function DownloadRow({
|
||||||
job,
|
job,
|
||||||
onShowError,
|
onShowError,
|
||||||
|
navIndex,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
job: DownloadJob;
|
job: DownloadJob;
|
||||||
onShowError?: (job: DownloadJob) => void;
|
onShowError?: (job: DownloadJob) => void;
|
||||||
|
/** Its position in a keyboard-walkable list (lib/listNav). */
|
||||||
|
navIndex?: number;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const running = job.status === "running";
|
const running = job.status === "running";
|
||||||
return (
|
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} />
|
<Thumb job={job} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
<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
|
// 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 —
|
// 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:
|
// 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
|
// - 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
|
// `Carousel`, or a `VirtualGrid` (its `renderItem`). In a plain `<div className="grid">` the card
|
||||||
// is marked as an item of a list nobody walks.
|
// 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
|
// 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).
|
// 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 = [
|
const CARDS = [
|
||||||
"VideoCard",
|
"VideoCard",
|
||||||
"PlexPosterCard",
|
"PlexPosterCard",
|
||||||
@@ -19,11 +19,15 @@ const CARDS = [
|
|||||||
"EpisodeCard",
|
"EpisodeCard",
|
||||||
"PosterTile",
|
"PosterTile",
|
||||||
"ShelfTile",
|
"ShelfTile",
|
||||||
|
"DownloadRow",
|
||||||
];
|
];
|
||||||
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
|
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
|
||||||
|
|
||||||
/** `path#tag#n` → why it is not a keyboard item. Empty today: every rail and card list is wired. */
|
/** `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> = {};
|
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 {
|
interface Tag {
|
||||||
start: number;
|
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);
|
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 files = import.meta.glob("./**/*.tsx", { query: "?raw", import: "default", eager: true });
|
||||||
const hits: string[] = [];
|
const hits: string[] = [];
|
||||||
|
const exempted = new Set<string>();
|
||||||
let tags = 0;
|
let tags = 0;
|
||||||
for (const [rel, source] of Object.entries(files)) {
|
for (const [rel, source] of Object.entries(files)) {
|
||||||
const path = rel.replace(/^\.\//, "components/");
|
const path = rel.replace(/^\.\//, "components/");
|
||||||
if (/\.test\.tsx$/.test(path)) continue;
|
if (/\.test\.tsx$/.test(path)) continue;
|
||||||
const code = stripComments(String(source));
|
const code = stripComments(String(source));
|
||||||
const report = (id: string, why: string) => {
|
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) => {
|
for (const name of WALKABLE) {
|
||||||
tags++;
|
openingTags(code, name).forEach((tag, n) => {
|
||||||
if (!has(tag, "keyboardNav")) report(`${path}#${RAIL}#${n + 1}`, "no keyboardNav");
|
tags++;
|
||||||
});
|
if (!has(tag, "keyboardNav")) report(`${path}#${name}#${n + 1}`, "no keyboardNav");
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const name of CARDS) {
|
for (const name of CARDS) {
|
||||||
openingTags(code, name).forEach((tag, n) => {
|
openingTags(code, name).forEach((tag, n) => {
|
||||||
tags++;
|
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", () => {
|
describe("the keyboard-list wiring contract", () => {
|
||||||
it("has no rail or card list the keyboard cannot walk", () => {
|
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(tags).toBeGreaterThan(15);
|
||||||
expect(hits).toEqual([]);
|
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", () => {
|
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" />
|
<Play className="w-4 h-4 text-white fill-current" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 { PageToolbar } from "./PageShell";
|
||||||
import { ColumnHead, alignClass } from "./tableHeader";
|
import { ColumnHead, alignClass } from "./tableHeader";
|
||||||
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
|
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,
|
// 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
|
// 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,
|
filters: filtersProp,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
fixedLayout = false,
|
fixedLayout = false,
|
||||||
|
keyboardNav = false,
|
||||||
}: {
|
}: {
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: Column<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
|
// 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.
|
// untouched; the channel manager turns it on so its row never scrolls horizontally out of view.
|
||||||
fixedLayout?: boolean;
|
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 { t } = useTranslation();
|
||||||
const initial = loadPersist(persistKey);
|
const initial = loadPersist(persistKey);
|
||||||
@@ -126,6 +132,10 @@ export default function DataTable<T>({
|
|||||||
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
|
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
|
||||||
const [page, setPage] = useState(initial.page);
|
const [page, setPage] = useState(initial.page);
|
||||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
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
|
// 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
|
// 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}
|
filters={filters}
|
||||||
onFilter={applyFilter}
|
onFilter={applyFilter}
|
||||||
/>
|
/>
|
||||||
<tbody>
|
<tbody ref={bodyRef}>
|
||||||
{paged.map((row) => (
|
{paged.map((row, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
{...rowProps?.(row)}
|
{...rowProps?.(row)}
|
||||||
|
{...(keyboardNav && navItemAttrs(i))}
|
||||||
className={`border-b border-border/50 hover:bg-(--hover) transition ${
|
className={`border-b border-border/50 hover:bg-(--hover) transition ${
|
||||||
rowClassName?.(row) ?? ""
|
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
|
{/* 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). */}
|
rest flowing in a single wrapping meta line (no full-width label/value gaps). */}
|
||||||
<div className="md:hidden flex flex-col gap-2">
|
<div ref={cardsRef} className="md:hidden flex flex-col gap-2">
|
||||||
{paged.map((row) => (
|
{paged.map((row, i) => (
|
||||||
<div
|
<div
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
{...rowProps?.(row)}
|
{...rowProps?.(row)}
|
||||||
|
{...(keyboardNav && navItemAttrs(i))}
|
||||||
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
|
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
|
||||||
>
|
>
|
||||||
{columns
|
{columns
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { useRef, type ReactNode } from "react";
|
import { useRef, type ReactNode } from "react";
|
||||||
import { useListNav } from "../../lib/listNav";
|
import { useListNav } from "../../lib/listNav";
|
||||||
|
|
||||||
// A plain grid of cards the keyboard can walk — `j`/`k`, the arrows, Enter (lib/listNav) — for the
|
// A plain list of cards or rows the keyboard can walk — `j`/`k`, the arrows, Enter (lib/listNav) — for
|
||||||
// grids that mount every item (the Plex title, season and episode grids). Nothing to count: the items
|
// the lists that mount every item (the Plex title, season and episode grids, the download lists, a
|
||||||
// and the columns per row are measured when a key is pressed, so an auto-fill grid's columns follow
|
// playlist's table body). Nothing to count: the items and the columns per row are measured when a key
|
||||||
// its width. Each child spreads `navItemAttrs(index)` on its root; a virtualized grid is VirtualGrid's
|
// is pressed, so an auto-fill grid's columns follow its width and a column of rows is one per row.
|
||||||
// `keyboardNav` instead.
|
// 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({
|
export default function NavGrid({
|
||||||
|
as: Tag = "div",
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
as?: "div" | "tbody";
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement & HTMLTableSectionElement>(null);
|
||||||
useListNav(ref, { enabled: true });
|
useListNav(ref, { enabled: true });
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={className}>
|
<Tag ref={ref} className={className}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Tag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ domain-free.
|
|||||||
|
|
||||||
## What lives here
|
## What lives here
|
||||||
|
|
||||||
| area | components |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| page & panel structure | `PageShell`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||||
|
|
||||||
## The size contract (E10 S1c)
|
## 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[] {
|
function ordered(): NavList[] {
|
||||||
return [...lists.values()]
|
return (
|
||||||
.filter((l) => l.el.isConnected)
|
[...lists.values()]
|
||||||
.sort((a, b) =>
|
// Only lists on screen: a responsive table renders its rows twice — a table and a card list, one
|
||||||
a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
|
// 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. */
|
/** 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 {
|
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) => {
|
const arrow = (dx: -1 | 0 | 1, dy: -1 | 0 | 1) => (ev: KeyboardEvent) => {
|
||||||
if (move(dx, dy)) ev.preventDefault();
|
if (move(dx, dy)) ev.preventDefault();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user