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
+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);
});
});