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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user