Files
siftlode/frontend/src/lib/listPaging.test.ts
T
peter 5274216981 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.
2026-09-13 05:20:53 +02:00

44 lines
1.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});