diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 5798f0c1..6f4a308a 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -238,6 +238,7 @@ export default function ChannelDiscovery({ sort={sort} onSortChange={setSort} emptyText={t("channels.discovery.empty")} + keyboardNav /> ) : ( { + const actual = await importOriginal(); + 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) { + const ref = useRef(null); + useListNav(ref, { enabled: !!keyboardNav }); + return ( +
+ {items.map((item, i) => ( + {renderItem(item, 0, i)} + ))} +
+ ); + }, + }; +}); + +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[] = [ + { + key: "channel", + header: "Channel", + cardPrimary: true, + render: (c) => ( + opened.push(c.id)} + /> + ), + }, + ]; + render( c.id} />); + press("j"); + expect(focusedTitle()).toBe("Alpha"); + press("j"); + expect(focusedTitle()).toBe("Beta"); + press("Enter"); + expect(opened).toEqual(["UCb"]); + }); +}); diff --git a/frontend/src/components/ChannelLayoutGrid.tsx b/frontend/src/components/ChannelLayoutGrid.tsx index aa44ddd3..20c3510a 100644 --- a/frontend/src/components/ChannelLayoutGrid.tsx +++ b/frontend/src/components/ChannelLayoutGrid.tsx @@ -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` 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 { rows: T[]; @@ -52,9 +56,10 @@ export default function ChannelLayoutGrid({ // 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) => (
@@ -91,7 +96,8 @@ export default function ChannelLayoutGrid({ minCol={spec.minCol} colMaxWidth={null} rowEst={spec.rowEst} - renderItem={renderCard} + keyboardNav + renderItem={(row, _itemWidth, index) => renderCard(row, index)} /> ); } diff --git a/frontend/src/components/ChannelLink.tsx b/frontend/src/components/ChannelLink.tsx index a422dbea..e944ab41 100644 --- a/frontend/src/components/ChannelLink.tsx +++ b/frontend/src/components/ChannelLink.tsx @@ -30,6 +30,9 @@ export default function ChannelLink({ {onView ? (
-
- {queue.map((job) => ( - + + {queue.map((job, i) => ( + {(job.status === "queued" || job.status === "running") && ( act.mutate({ id: job.id, op: "pause" })} @@ -275,21 +276,25 @@ export default function DownloadCenter() { {t("downloads.empty.queue")}
))} -
+ )} {tab === "done" && ( <> -
- {library.map((job) => { + + {library.map((job, i) => { // Row actions gated on the stored media_kind + job_kind (see downloadActions). const a = libraryRowActions(job); return ( - + {a.play && ( - setPlayJob(job)} title={t("downloads.actions.play")}> + setPlayJob(job)} + title={t("downloads.actions.play")} + > )} @@ -358,19 +363,23 @@ export default function DownloadCenter() { {t("downloads.empty.done")}
))} - + )} {tab === "shared" && ( -
- {(sharedQ.data ?? []).map((job) => { + + {(sharedQ.data ?? []).map((job, i) => { // Same media_kind + can_download gate as the library, via the shared helper (S5b). const a = sharedRowActions(job); return ( - + {a.play && ( - setPlayJob(job)} title={t("downloads.actions.play")}> + setPlayJob(job)} + title={t("downloads.actions.play")} + > )} @@ -408,7 +417,7 @@ export default function DownloadCenter() { {t("downloads.empty.shared")}
))} - + )} {tab === "system" && me.role === "admin" && } diff --git a/frontend/src/components/ExcludedChannelList.dom.test.tsx b/frontend/src/components/ExcludedChannelList.dom.test.tsx new file mode 100644 index 00000000..567f373b --- /dev/null +++ b/frontend/src/components/ExcludedChannelList.dom.test.tsx @@ -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(); + 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( + {}} + 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); + }); +}); diff --git a/frontend/src/components/ExcludedChannelList.tsx b/frontend/src/components/ExcludedChannelList.tsx index 5caf33dc..811bfd2e 100644 --- a/frontend/src/components/ExcludedChannelList.tsx +++ b/frontend/src/components/ExcludedChannelList.tsx @@ -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} {title} -
- {channels.map((c) => ( -
+ + {channels.map((c, i) => ( +
))} -
+

{hint}

); diff --git a/frontend/src/components/Playlists.dom.test.tsx b/frontend/src/components/Playlists.dom.test.tsx index d786233d..bd3f67b3 100644 --- a/frontend/src/components/Playlists.dom.test.tsx +++ b/frontend/src/components/Playlists.dom.test.tsx @@ -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")); + }); +}); diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 5962d177..4d787d7a 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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
diff --git a/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx new file mode 100644 index 00000000..9e540c45 --- /dev/null +++ b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx @@ -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(); + 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[] = [ + { + key: "name", + header: "Name", + cardPrimary: true, + render: (r) => ( + + ), + }, + { key: "act", header: "", render: (r) => }, + ]; + return render( + 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); + }); +}); diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index 36658622..04d0fa4a 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -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({ filters: filtersProp, onFiltersChange, fixedLayout = false, + keyboardNav = false, }: { rows: T[]; columns: Column[]; @@ -111,6 +113,10 @@ export default function DataTable({ // 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({ const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters; const [page, setPage] = useState(initial.page); const [size, setSize] = useState(initial.size ?? pageSize); + const bodyRef = useRef(null); + const cardsRef = useRef(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({ filters={filters} onFilter={applyFilter} /> - - {paged.map((row) => ( + + {paged.map((row, i) => ( ({ {/* 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). */} -
- {paged.map((row) => ( +
+ {paged.map((row, i) => (
{columns diff --git a/frontend/src/components/ui/NavGrid.tsx b/frontend/src/components/ui/NavGrid.tsx index 00509243..d3449174 100644 --- a/frontend/src/components/ui/NavGrid.tsx +++ b/frontend/src/components/ui/NavGrid.tsx @@ -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(null); + const ref = useRef(null); useListNav(ref, { enabled: true }); return ( -
+ {children} -
+ ); } diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 7b3716c7..3427f93d 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -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) diff --git a/frontend/src/lib/listNav.dom.test.tsx b/frontend/src/lib/listNav.dom.test.tsx index 876a78be..34ea2e0d 100644 --- a/frontend/src/lib/listNav.dom.test.tsx +++ b/frontend/src/lib/listNav.dom.test.tsx @@ -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( + <> + + + , + ); + 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(); + 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"); + }); +}); diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts index a14e9134..72e32850 100644 --- a/frontend/src/lib/listNav.ts +++ b/frontend/src/lib/listNav.ts @@ -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(); };