Files
siftlode/frontend/src/lib/listPaging.ts
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

52 lines
2.4 KiB
TypeScript

// ── PageUp/PageDown over a list the keyboard otherwise walks row by row (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;
}