From 75d89613d0c88162855620d2045fe1b7f83dda7d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 13 Sep 2026 05:03:35 +0200 Subject: [PATCH] feat(palette): PageUp/PageDown move a page of results at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jump-to palette lists every module, channel, playlist, view and action — 461 rows on the owner's account, ~11 of them in view — and the arrows were the only way through it: forty presses to cross the channel group. - PageDown/PageUp step by the rows that fit in the list right now, minus one, so the row you paged from stays in sight at the new page's edge. Measured per press (the list is 50vh and group headings eat into it); a fixed 10 when there is no layout to measure. - Unlike the arrows they stop at the ends instead of wrapping: a page-sized jump that lands back at the top reads as having lost your place. - Home/End stay the text field's own. Measured in the browser: 0 → 10 → 20 rows, stops on row 461, PageUp from the end steps 9 (a group heading is in view), the selected row stays visible throughout. --- .../components/ui/CommandPalette.dom.test.tsx | 71 +++++++++++++++++++ frontend/src/components/ui/CommandPalette.tsx | 29 ++++++++ 2 files changed, 100 insertions(+) diff --git a/frontend/src/components/ui/CommandPalette.dom.test.tsx b/frontend/src/components/ui/CommandPalette.dom.test.tsx index 3a3abaca..f27e85aa 100644 --- a/frontend/src/components/ui/CommandPalette.dom.test.tsx +++ b/frontend/src/components/ui/CommandPalette.dom.test.tsx @@ -262,3 +262,74 @@ describe("CommandPalette · accessibility", () => { expect(status.textContent).toBe(""); }); }); + +// Owner request (E11): with 461 rows and ~11 in view, the arrows alone were a slog. PageUp/PageDown +// move by what fits in the list, and — unlike the arrows — stop at the ends instead of wrapping. +describe("CommandPalette · paging", () => { + const many = (): CommandGroup[] => [ + { + id: "rows", + label: "Rows", + items: Array.from({ length: 12 }, (_, i) => ({ + id: `r${i}`, + label: `Row ${i}`, + run: () => {}, + })), + }, + ]; + const openMany = () => render( {}} groups={many()} />); + const rect = (top: number, height: number) => + ({ + top, + bottom: top + height, + height, + left: 0, + right: 100, + width: 100, + x: 0, + y: top, + }) as DOMRect; + + it("pages by the rows in view minus one, and stops at both ends", () => { + // The list shows 100px and the rows are 30px tall: 3 fit, so a page is 2 rows. + const spy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + if (this.getAttribute("role") === "listbox") return rect(0, 100); + const all = [...document.querySelectorAll('[role="option"]')]; + return rect(all.indexOf(this) * 30, 30); + }); + // Restored in `finally`: a spy left behind by a failing assertion fails every later test too. + try { + openMany(); + press("PageDown"); + expect(selected()).toBe("r2"); + press("PageDown"); + expect(selected()).toBe("r4"); + for (let i = 0; i < 10; i++) press("PageDown"); + expect(selected()).toBe("r11"); // no wrap to the top + press("PageUp"); + expect(selected()).toBe("r9"); + for (let i = 0; i < 10; i++) press("PageUp"); + expect(selected()).toBe("r0"); + } finally { + spy.mockRestore(); + } + }); + + it("steps a fixed page when there is no layout to measure", () => { + openMany(); + press("PageDown"); + expect(selected()).toBe("r10"); + press("PageDown"); + expect(selected()).toBe("r11"); + }); + + it("leaves Home and End to the text field", () => { + openMany(); + press("PageDown"); + expect(fireEvent.keyDown(field(), { key: "Home" })).toBe(true); // not preventDefault-ed + expect(fireEvent.keyDown(field(), { key: "End" })).toBe(true); + expect(selected()).toBe("r10"); + }); +}); diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx index 0102f038..1caea4db 100644 --- a/frontend/src/components/ui/CommandPalette.tsx +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -71,6 +71,24 @@ 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, @@ -91,6 +109,7 @@ export default function CommandPalette({ // Ref on the highlighted row rather than a selector lookup: item ids come from domain data // (playlist names, channel handles) and would need escaping to be safe in a query string. const activeRef = useRef(null); + const listRef = useRef(null); // A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover // query from last time hides everything you might want now. @@ -166,6 +185,15 @@ export default function CommandPalette({ const step = e.key === "ArrowDown" ? 1 : -1; 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). + 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); } else if (e.key === "Enter") { const item = flat[activeIndex]; if (item) { @@ -208,6 +236,7 @@ export default function CommandPalette({ element; the "nothing matches" line sits OUTSIDE it, since a listbox may hold only options and groups. */}