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.
This commit is contained in:
2026-09-13 05:20:53 +02:00
parent 75d89613d0
commit 5274216981
7 changed files with 255 additions and 35 deletions
@@ -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);
});
});
+46 -2
View File
@@ -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.
@@ -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(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
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(<Combobox label="Channel" options={rows([10])} selected={[]} onChange={onChange} />);
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(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
expect(fireEvent.keyDown(screen.getByRole("combobox"), { key: "PageDown" })).toBe(true);
});
});
+11 -25
View File
@@ -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) {
+1 -8
View File
@@ -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
+43
View File
@@ -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);
});
});
+49
View File
@@ -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<Element>): 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;
}