Files
siftlode/frontend/src/lib/listPaging.ts
T
peter 5274216981 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.
2026-09-13 05:20:53 +02:00

50 lines
2.4 KiB
TypeScript

// ── 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;
}