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