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
+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.