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:
@@ -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