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. */}