From 5274216981f48fcbda4912c4435ddd1a91ba5042 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 13 Sep 2026 05:20:53 +0200 Subject: [PATCH] feat(ui): PageUp/PageDown page through every dropdown list, not just the palette The palette got PageUp/PageDown, and the same crawl stayed everywhere else a list opens under a control: the sidebar's channel filter, the Plex genre/studio/collection pickers, the filter and sort menus, submenus of every playlist you own. floating-ui's list navigation has only the arrows and Home/End. - `lib/listPaging`: the one definition of a page (the rows wholly in view minus one, a fixed 10 with no layout) and of where it lands (stops at both ends, never on a row it may not take, looking back toward the start rather than past it). The palette now uses it instead of its own copy, and `listNav` takes its scroll-parent helper from here. - `AnchoredPopover`: one keydown handler on the reference AND the surface, so a combobox (keys on its input, `aria-activedescendant`) and a roving menu (keys on the focused row) both page. It runs after floating-ui's own, which has by then switched to keyboard modality and scrolls the new row in. A disabled row is never the target; a submenu's key does not also page the menu around it; with no mounted rows (closed, a hover card, a dialog) the key keeps its native meaning. Measured in the browser: the channel filter shows 8 rows and pages by 7 with the active row in view after the scroll; a menu pages to its last row and back, the page behind it does not scroll; the palette still pages by 11 of its 12 visible rows. --- .../src/components/ui/ActionMenu.dom.test.tsx | 64 +++++++++++++++++++ .../src/components/ui/AnchoredPopover.tsx | 48 +++++++++++++- .../src/components/ui/Combobox.dom.test.tsx | 41 ++++++++++++ frontend/src/components/ui/CommandPalette.tsx | 36 ++++------- frontend/src/lib/listNav.ts | 9 +-- frontend/src/lib/listPaging.test.ts | 43 +++++++++++++ frontend/src/lib/listPaging.ts | 49 ++++++++++++++ 7 files changed, 255 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/listPaging.test.ts create mode 100644 frontend/src/lib/listPaging.ts diff --git a/frontend/src/components/ui/ActionMenu.dom.test.tsx b/frontend/src/components/ui/ActionMenu.dom.test.tsx index 732f0454..b5b54ace 100644 --- a/frontend/src/components/ui/ActionMenu.dom.test.tsx +++ b/frontend/src/components/ui/ActionMenu.dom.test.tsx @@ -130,3 +130,67 @@ describe("ActionMenu", () => { expect(screen.getByTestId("custom-trigger")).toBeTruthy(); }); }); + +// PageUp/PageDown in a roving menu (E11, owner request) — the same page as the palette's and the +// combobox's (lib/listPaging); here the keyboard's row holds real DOM focus (the `tabindex=0` rover). +describe("ActionMenu · paging", () => { + it("moves the roving focus a page at a time, over disabled rows, stopping at the ends", () => { + renderMenu( + Array.from({ length: 25 }, (_, i) => ({ + id: `r${i}`, + label: `Row ${i}`, + onSelect: vi.fn(), + disabled: i === 20, + })), + ); + fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); + const menu = screen.getByRole("menu", { hidden: true }); + const rover = () => + screen + .getAllByRole("menuitem", { hidden: true }) + .find((el) => el.getAttribute("tabindex") === "0")?.textContent; + + expect(fireEvent.keyDown(menu, { key: "PageDown" })).toBe(false); + expect(rover()).toBe("Row 10"); + fireEvent.keyDown(menu, { key: "PageDown" }); + expect(rover()).toBe("Row 21"); // Row 20 is disabled + fireEvent.keyDown(menu, { key: "PageDown" }); + expect(rover()).toBe("Row 24"); + fireEvent.keyDown(menu, { key: "PageUp" }); + expect(rover()).toBe("Row 14"); + }); + + it("pages only the submenu the key was pressed in, not the menu around it", () => { + // A submenu's surface sits inside its parent's in the React tree, so the key bubbles through the + // parent's handler too; paging both would drag the focus back out to the parent's row. + renderMenu([ + { + id: "move", + label: "Move to", + items: Array.from({ length: 25 }, (_, i) => ({ + id: `s${i}`, + label: `Sub ${i}`, + onSelect: vi.fn(), + })), + }, + ...Array.from({ length: 12 }, (_, i) => ({ + id: `p${i}`, + label: `Top ${i}`, + onSelect: vi.fn(), + })), + ]); + fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); + fireEvent.click(screen.getByText("Move to")); + const rovers = () => + screen + .getAllByRole("menuitem", { hidden: true }) + .filter((el) => el.getAttribute("tabindex") === "0") + .map((el) => el.textContent ?? ""); + const outer = () => rovers().filter((label) => !label.startsWith("Sub")); + const before = outer(); + + fireEvent.keyDown(screen.getByText("Sub 0").closest("button")!, { key: "PageDown" }); + expect(rovers()).toContain("Sub 10"); + expect(outer()).toEqual(before); + }); +}); diff --git a/frontend/src/components/ui/AnchoredPopover.tsx b/frontend/src/components/ui/AnchoredPopover.tsx index 05323d9d..1b87842f 100644 --- a/frontend/src/components/ui/AnchoredPopover.tsx +++ b/frontend/src/components/ui/AnchoredPopover.tsx @@ -3,6 +3,7 @@ import { useContext, useEffect, useId, + useMemo, useRef, useState, type ButtonHTMLAttributes, @@ -37,6 +38,7 @@ import { } from "@floating-ui/react"; import { useLayer } from "../../lib/layerStack"; import { useFullscreenPortalHost } from "../../lib/fullscreen"; +import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging"; // R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9 // hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative @@ -52,7 +54,8 @@ import { useFullscreenPortalHost } from "../../lib/fullscreen"; // // S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's // textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine: -// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only +// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only; +// PageUp/PageDown (a page, no loop) are ours, for every list surface incl. the combobox // - useTypeahead → type-to-focus a matching item by its label // - FloatingFocusManager → focus moves into the surface on open and RETURNS to the trigger on close // (a body-portaled popover sits at the end of the document; without this, Tab can't reach it and @@ -272,7 +275,48 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { }, }); - const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]); + // PageUp/PageDown: a page of rows at a time (lib/listPaging, shared with the palette). floating-ui's + // list navigation knows only the arrows and Home/End, and the arrows alone crawl through the sidebar's + // channel filter or a Plex studio list. On BOTH elements: a combobox's keys arrive on its input, a + // menu's on the focused row inside the surface. Last in the list, so floating-ui's own keydown has + // already switched to keyboard modality — which is what makes it scroll the new row into view. A + // nested menu's surface bubbles through its parent's in the React tree; the inner one handles the + // key first and marks it, so the outer one stands down. + const onPageKey = (e: React.KeyboardEvent) => { + if ((e.key !== "PageDown" && e.key !== "PageUp") || e.defaultPrevented) return; + const rows = elementsRef.current; + const live = rows.filter((el): el is HTMLElement => el != null); + // No mounted rows — a closed surface, a hover card, a dialog — and the key keeps its own meaning. + if (!live.length) return; + e.preventDefault(); + const step = pageStep(scrollParent(live[0]!), live); + const to = pageTarget( + activeIndex ?? 0, + e.key === "PageDown" ? step : -step, + rows.length, + (i) => !!rows[i] && !rows[i]!.hasAttribute("disabled"), + ); + if (to === null) return; + setActiveIndex(to); + setHoverIndex(null); + }; + const pageKeyRef = useRef(onPageKey); + pageKeyRef.current = onPageKey; + const paging = useMemo(() => { + const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e); + return { reference: { onKeyDown }, floating: { onKeyDown } }; + }, []); + + const interactions = useInteractions([ + click, + hover, + focus, + dismiss, + role, + listNav, + typeahead, + paging, + ]); // `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is // then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back. diff --git a/frontend/src/components/ui/Combobox.dom.test.tsx b/frontend/src/components/ui/Combobox.dom.test.tsx index 19401cc5..30dab7a6 100644 --- a/frontend/src/components/ui/Combobox.dom.test.tsx +++ b/frontend/src/components/ui/Combobox.dom.test.tsx @@ -247,3 +247,44 @@ describe("Combobox", () => { expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed }); }); + +// PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to +// hundreds of rows, and floating-ui's list navigation has only the arrows. jsdom has no layout, so a +// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging. +describe("Combobox · paging", () => { + const rows = (disabled: number[] = []) => + Array.from({ length: 25 }, (_, i) => ({ value: `Row ${i}`, disabled: disabled.includes(i) })); + const activeValue = (input: HTMLElement) => + document.getElementById(input.getAttribute("aria-activedescendant") ?? "")?.textContent; + + it("moves the active option a page at a time, stopping at both ends", () => { + wrap(); + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.focus(input); + expect(fireEvent.keyDown(input, { key: "PageDown" })).toBe(false); // the page does not scroll + expect(activeValue(input)).toBe("Row 10"); + expect(document.activeElement).toBe(input); + fireEvent.keyDown(input, { key: "PageDown" }); + fireEvent.keyDown(input, { key: "PageDown" }); + expect(activeValue(input)).toBe("Row 24"); // no wrap to the top + fireEvent.keyDown(input, { key: "PageUp" }); + expect(activeValue(input)).toBe("Row 14"); + }); + + it("never lands on a disabled option, and Enter commits where it landed", () => { + const onChange = vi.fn(); + wrap(); + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "PageDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith(["Row 11"]); + }); + + it("leaves the key alone while the list is closed", () => { + wrap(); + expect(fireEvent.keyDown(screen.getByRole("combobox"), { key: "PageDown" })).toBe(true); + }); +}); diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx index 1caea4db..d21fddbc 100644 --- a/frontend/src/components/ui/CommandPalette.tsx +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Search } from "lucide-react"; import Modal from "./Modal"; import { closeTopOverlayThenRun } from "../../lib/history"; +import { pageStep, pageTarget } from "../../lib/listPaging"; // The app's jump-to bar: one text field over every place you can go and every app-wide thing you can // do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three @@ -71,24 +72,6 @@ function rankItem(item: CommandItem, groupLabel: string, words: string[]): Rank * every keystroke queues one announcement per letter. */ const ANNOUNCE_DELAY_MS = 400; -/** PageUp/PageDown step without layout to measure (jsdom, or a list not painted yet). */ -const PAGE_FALLBACK = 10; - -/** How far PageUp/PageDown move: the rows that fit in the scrolling list right now, minus one, so the - * row you paged from stays in sight as the new page's edge (a native listbox does the same). Measured - * rather than fixed: the list is 50vh tall and group headings eat into it. With 461 rows and ~11 in - * view, the arrows alone meant forty presses to cross the channel group (E11, owner request). */ -function pageStep(list: HTMLElement | null): number { - const box = list?.getBoundingClientRect(); - if (!list || !box || box.height === 0) return PAGE_FALLBACK; - let fits = 0; - for (const option of list.querySelectorAll('[role="option"]')) { - const r = option.getBoundingClientRect(); - if (r.top >= box.top - 0.5 && r.bottom <= box.bottom + 0.5) fits++; - } - return Math.max(1, fits - 1); -} - export default function CommandPalette({ open, onClose, @@ -186,14 +169,17 @@ export default function CommandPalette({ const from = Math.max(activeIndex, 0); setPicked(flat[(from + step + flat.length) % flat.length]!.id); } else if (e.key === "PageDown" || e.key === "PageUp") { - // A page at a time, and NO wrap: the arrows wrap because one step past the end is a small, - // obvious move, but a page-sized jump that lands back at the top reads as "it lost my place". - // Home/End stay the field's own (caret to the start/end of the query). + // A page at a time, stopping at the ends (lib/listPaging). The list is 50vh and group headings + // eat into it, so the page is measured. Home/End stay the field's own (caret to the query's ends). e.preventDefault(); - if (flat.length === 0) return; - const step = pageStep(listRef.current) * (e.key === "PageDown" ? 1 : -1); - const to = Math.min(flat.length - 1, Math.max(0, Math.max(activeIndex, 0) + step)); - setPicked(flat[to]!.id); + const list = listRef.current; + const step = pageStep(list, list?.querySelectorAll('[role="option"]') ?? []); + const to = pageTarget( + Math.max(activeIndex, 0), + e.key === "PageDown" ? step : -step, + flat.length, + ); + if (to !== null) setPicked(flat[to]!.id); } else if (e.key === "Enter") { const item = flat[activeIndex]; if (item) { diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts index d8be132a..369ef486 100644 --- a/frontend/src/lib/listNav.ts +++ b/frontend/src/lib/listNav.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, type RefObject } from "react"; import { dispatch, useKeyScope } from "./keymap"; +import { scrollParent } from "./listPaging"; // ── Keyboard navigation over lists and grids (E11 S4, UX-6) ────────────────────────────────────── // Before this, a feed card cost ~10 Tab stops (thumb, play, info, title, channel, five actions), so @@ -64,14 +65,6 @@ function current(): { list: NavList; index: number; item: HTMLElement } | null { return item && list ? { list, index: indexOf(item), item } : null; } -function scrollParent(node: HTMLElement): HTMLElement | null { - for (let el = node.parentElement; el; el = el.parentElement) { - const oy = getComputedStyle(el).overflowY; - if (oy === "auto" || oy === "scroll") return el; - } - return null; -} - /** Where `j` lands when nothing in a list has focus yet: the first item the reader can see, not the * top of the list — after scrolling down a feed, the first press must not throw them back up. "Can * see" = its bottom edge is below the top of the scroller's visible band, which starts under the diff --git a/frontend/src/lib/listPaging.test.ts b/frontend/src/lib/listPaging.test.ts new file mode 100644 index 00000000..f8900af5 --- /dev/null +++ b/frontend/src/lib/listPaging.test.ts @@ -0,0 +1,43 @@ +// One definition of a PageUp/PageDown page for the palette and every popover list (E11, owner +// request). The DOM-bound halves are covered where they are wired (CommandPalette, Combobox, +// ActionMenu); this pins the arithmetic both lean on. +import { describe, expect, it } from "vitest"; +import { PAGE_FALLBACK, pageStep, pageTarget } from "./listPaging"; + +const box = (top: number, height: number) => + ({ getBoundingClientRect: () => ({ top, bottom: top + height, height }) }) as unknown as Element; + +describe("pageStep", () => { + it("is the rows wholly in view minus one", () => { + // A 100px list with 30px rows: 0–30, 30–60, 60–90 fit; 90–120 is cut off. + const rows = [0, 30, 60, 90, 120].map((top) => box(top, 30)); + expect(pageStep(box(0, 100), rows)).toBe(2); + }); + + it("never stands still, and falls back without layout", () => { + expect(pageStep(box(0, 20), [box(0, 30)])).toBe(1); + expect(pageStep(box(0, 0), [box(0, 30)])).toBe(PAGE_FALLBACK); + expect(pageStep(null, [])).toBe(PAGE_FALLBACK); + }); +}); + +describe("pageTarget", () => { + it("moves a page and stops at both ends instead of wrapping", () => { + expect(pageTarget(0, 10, 25)).toBe(10); + expect(pageTarget(20, 10, 25)).toBe(24); + expect(pageTarget(24, 10, 25)).toBe(24); + expect(pageTarget(3, -10, 25)).toBe(0); + expect(pageTarget(0, 10, 0)).toBeNull(); + }); + + it("passes over a row it may not land on, then looks back — never past the start", () => { + const off = new Set([10, 11]); + const usable = (i: number) => !off.has(i); + expect(pageTarget(0, 10, 25, usable)).toBe(12); // on past the landing row first + off.add(24); + off.add(23); + expect(pageTarget(15, 10, 25, usable)).toBe(22); // at the end: back toward the start + expect(pageTarget(22, 10, 25, usable)).toBeNull(); // nothing between the start and the end + expect(pageTarget(15, -10, 25, (i) => i > 12)).toBe(13); + }); +}); diff --git a/frontend/src/lib/listPaging.ts b/frontend/src/lib/listPaging.ts new file mode 100644 index 00000000..78f9c033 --- /dev/null +++ b/frontend/src/lib/listPaging.ts @@ -0,0 +1,49 @@ +// ── PageUp/PageDown over a list the keyboard otherwise walks row by row (E11, owner request) ───── +// The jump-to palette (461 rows, ~11 in view) and every popover list — the sidebar's channel filter +// over hundreds of channels, the Plex pickers, the filter and sort menus — had only the arrows. One +// definition of "a page" here, so the palette and the dropdowns cannot drift apart. + +/** A page when there is no layout to measure (jsdom, or a list not painted yet). */ +export const PAGE_FALLBACK = 10; + +/** The nearest ancestor that scrolls vertically. */ +export function scrollParent(node: Element): HTMLElement | null { + for (let el = node.parentElement; el; el = el.parentElement) { + const oy = getComputedStyle(el).overflowY; + if (oy === "auto" || oy === "scroll") return el; + } + return null; +} + +/** How far one PageUp/PageDown moves: the rows that fit wholly inside `scroller` right now, minus one, + * so the row you paged from stays in sight at the new page's edge (a native listbox does the same). + * Measured rather than fixed: a list's height follows the viewport, and group headings eat into it. */ +export function pageStep(scroller: Element | null, rows: Iterable): number { + const box = scroller?.getBoundingClientRect(); + if (!box || box.height === 0) return PAGE_FALLBACK; + let fits = 0; + for (const row of rows) { + const r = row.getBoundingClientRect(); + if (r.top >= box.top - 0.5 && r.bottom <= box.bottom + 0.5) fits++; + } + return Math.max(1, fits - 1); +} + +/** Where a page move of `delta` from row `from` lands among `count` rows. It stops at both ends — no + * wrap, unlike the arrows: one step past the end is a small, obvious move, but a page-sized jump that + * lands back at the top reads as having lost your place. A row `usable` refuses (a disabled menu row) + * is never the target: the search goes on past the landing row first, then back toward the start, + * never beyond it. `null` = nowhere to go. */ +export function pageTarget( + from: number, + delta: number, + count: number, + usable: (index: number) => boolean = () => true, +): number | null { + if (count <= 0) return null; + const to = Math.min(count - 1, Math.max(0, from + delta)); + const dir = delta < 0 ? -1 : 1; + for (let i = to; i >= 0 && i < count; i += dir) if (usable(i)) return i; + for (let i = to - dir; (i - from) * dir > 0; i -= dir) if (usable(i)) return i; + return null; +}