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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -247,3 +247,44 @@ describe("Combobox", () => {
|
||||
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
|
||||
});
|
||||
});
|
||||
|
||||
// PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to
|
||||
// hundreds of rows, and floating-ui's list navigation has only the arrows. jsdom has no layout, so a
|
||||
// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging.
|
||||
describe("Combobox · paging", () => {
|
||||
const rows = (disabled: number[] = []) =>
|
||||
Array.from({ length: 25 }, (_, i) => ({ value: `Row ${i}`, disabled: disabled.includes(i) }));
|
||||
const activeValue = (input: HTMLElement) =>
|
||||
document.getElementById(input.getAttribute("aria-activedescendant") ?? "")?.textContent;
|
||||
|
||||
it("moves the active option a page at a time, stopping at both ends", () => {
|
||||
wrap(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
expect(fireEvent.keyDown(input, { key: "PageDown" })).toBe(false); // the page does not scroll
|
||||
expect(activeValue(input)).toBe("Row 10");
|
||||
expect(document.activeElement).toBe(input);
|
||||
fireEvent.keyDown(input, { key: "PageDown" });
|
||||
fireEvent.keyDown(input, { key: "PageDown" });
|
||||
expect(activeValue(input)).toBe("Row 24"); // no wrap to the top
|
||||
fireEvent.keyDown(input, { key: "PageUp" });
|
||||
expect(activeValue(input)).toBe("Row 14");
|
||||
});
|
||||
|
||||
it("never lands on a disabled option, and Enter commits where it landed", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Channel" options={rows([10])} selected={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: "PageDown" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["Row 11"]);
|
||||
});
|
||||
|
||||
it("leaves the key alone while the list is closed", () => {
|
||||
wrap(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
|
||||
expect(fireEvent.keyDown(screen.getByRole("combobox"), { key: "PageDown" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { closeTopOverlayThenRun } from "../../lib/history";
|
||||
import { pageStep, pageTarget } from "../../lib/listPaging";
|
||||
|
||||
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
|
||||
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
|
||||
@@ -71,24 +72,6 @@ function rankItem(item: CommandItem, groupLabel: string, words: string[]): Rank
|
||||
* every keystroke queues one announcement per letter. */
|
||||
const ANNOUNCE_DELAY_MS = 400;
|
||||
|
||||
/** PageUp/PageDown step without layout to measure (jsdom, or a list not painted yet). */
|
||||
const PAGE_FALLBACK = 10;
|
||||
|
||||
/** How far PageUp/PageDown move: the rows that fit in the scrolling list right now, minus one, so the
|
||||
* row you paged from stays in sight as the new page's edge (a native listbox does the same). Measured
|
||||
* rather than fixed: the list is 50vh tall and group headings eat into it. With 461 rows and ~11 in
|
||||
* view, the arrows alone meant forty presses to cross the channel group (E11, owner request). */
|
||||
function pageStep(list: HTMLElement | null): number {
|
||||
const box = list?.getBoundingClientRect();
|
||||
if (!list || !box || box.height === 0) return PAGE_FALLBACK;
|
||||
let fits = 0;
|
||||
for (const option of list.querySelectorAll('[role="option"]')) {
|
||||
const r = option.getBoundingClientRect();
|
||||
if (r.top >= box.top - 0.5 && r.bottom <= box.bottom + 0.5) fits++;
|
||||
}
|
||||
return Math.max(1, fits - 1);
|
||||
}
|
||||
|
||||
export default function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
@@ -186,14 +169,17 @@ export default function CommandPalette({
|
||||
const from = Math.max(activeIndex, 0);
|
||||
setPicked(flat[(from + step + flat.length) % flat.length]!.id);
|
||||
} else if (e.key === "PageDown" || e.key === "PageUp") {
|
||||
// A page at a time, and NO wrap: the arrows wrap because one step past the end is a small,
|
||||
// obvious move, but a page-sized jump that lands back at the top reads as "it lost my place".
|
||||
// Home/End stay the field's own (caret to the start/end of the query).
|
||||
// A page at a time, stopping at the ends (lib/listPaging). The list is 50vh and group headings
|
||||
// eat into it, so the page is measured. Home/End stay the field's own (caret to the query's ends).
|
||||
e.preventDefault();
|
||||
if (flat.length === 0) return;
|
||||
const step = pageStep(listRef.current) * (e.key === "PageDown" ? 1 : -1);
|
||||
const to = Math.min(flat.length - 1, Math.max(0, Math.max(activeIndex, 0) + step));
|
||||
setPicked(flat[to]!.id);
|
||||
const list = listRef.current;
|
||||
const step = pageStep(list, list?.querySelectorAll('[role="option"]') ?? []);
|
||||
const to = pageTarget(
|
||||
Math.max(activeIndex, 0),
|
||||
e.key === "PageDown" ? step : -step,
|
||||
flat.length,
|
||||
);
|
||||
if (to !== null) setPicked(flat[to]!.id);
|
||||
} else if (e.key === "Enter") {
|
||||
const item = flat[activeIndex];
|
||||
if (item) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { dispatch, useKeyScope } from "./keymap";
|
||||
import { scrollParent } from "./listPaging";
|
||||
|
||||
// ── Keyboard navigation over lists and grids (E11 S4, UX-6) ──────────────────────────────────────
|
||||
// Before this, a feed card cost ~10 Tab stops (thumb, play, info, title, channel, five actions), so
|
||||
@@ -64,14 +65,6 @@ function current(): { list: NavList; index: number; item: HTMLElement } | null {
|
||||
return item && list ? { list, index: indexOf(item), item } : null;
|
||||
}
|
||||
|
||||
function scrollParent(node: HTMLElement): HTMLElement | null {
|
||||
for (let el = node.parentElement; el; el = el.parentElement) {
|
||||
const oy = getComputedStyle(el).overflowY;
|
||||
if (oy === "auto" || oy === "scroll") return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Where `j` lands when nothing in a list has focus yet: the first item the reader can see, not the
|
||||
* top of the list — after scrolling down a feed, the first press must not throw them back up. "Can
|
||||
* see" = its bottom edge is below the top of the scroller's visible band, which starts under the
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// ── PageUp/PageDown over a list the keyboard otherwise walks row by row (E11, owner request) ─────
|
||||
// The jump-to palette (461 rows, ~11 in view) and every popover list — the sidebar's channel filter
|
||||
// over hundreds of channels, the Plex pickers, the filter and sort menus — had only the arrows. One
|
||||
// definition of "a page" here, so the palette and the dropdowns cannot drift apart.
|
||||
|
||||
/** A page when there is no layout to measure (jsdom, or a list not painted yet). */
|
||||
export const PAGE_FALLBACK = 10;
|
||||
|
||||
/** The nearest ancestor that scrolls vertically. */
|
||||
export function scrollParent(node: Element): HTMLElement | null {
|
||||
for (let el = node.parentElement; el; el = el.parentElement) {
|
||||
const oy = getComputedStyle(el).overflowY;
|
||||
if (oy === "auto" || oy === "scroll") return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** How far one PageUp/PageDown moves: the rows that fit wholly inside `scroller` right now, minus one,
|
||||
* so the row you paged from stays in sight at the new page's edge (a native listbox does the same).
|
||||
* Measured rather than fixed: a list's height follows the viewport, and group headings eat into it. */
|
||||
export function pageStep(scroller: Element | null, rows: Iterable<Element>): number {
|
||||
const box = scroller?.getBoundingClientRect();
|
||||
if (!box || box.height === 0) return PAGE_FALLBACK;
|
||||
let fits = 0;
|
||||
for (const row of rows) {
|
||||
const r = row.getBoundingClientRect();
|
||||
if (r.top >= box.top - 0.5 && r.bottom <= box.bottom + 0.5) fits++;
|
||||
}
|
||||
return Math.max(1, fits - 1);
|
||||
}
|
||||
|
||||
/** Where a page move of `delta` from row `from` lands among `count` rows. It stops at both ends — no
|
||||
* wrap, unlike the arrows: one step past the end is a small, obvious move, but a page-sized jump that
|
||||
* lands back at the top reads as having lost your place. A row `usable` refuses (a disabled menu row)
|
||||
* is never the target: the search goes on past the landing row first, then back toward the start,
|
||||
* never beyond it. `null` = nowhere to go. */
|
||||
export function pageTarget(
|
||||
from: number,
|
||||
delta: number,
|
||||
count: number,
|
||||
usable: (index: number) => boolean = () => true,
|
||||
): number | null {
|
||||
if (count <= 0) return null;
|
||||
const to = Math.min(count - 1, Math.max(0, from + delta));
|
||||
const dir = delta < 0 ? -1 : 1;
|
||||
for (let i = to; i >= 0 && i < count; i += dir) if (usable(i)) return i;
|
||||
for (let i = to - dir; (i - from) * dir > 0; i -= dir) if (usable(i)) return i;
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user