Files
yellowjacket/frontend/src/utils/roving-grid.ts
T
logan 65c1b4fd53
Build & publish Arch package / arch-package (push) Successful in 2m6s
CI / check (push) Successful in 2m28s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m6s
fix(a11y): move the card grids by a row, not to the end
`RovingGridController.measureColumns` read `offsetTop`, and every card
in these grids is drawn by a `lit-virtualizer`, which positions its
children with a transform — which `offsetTop` does not see. So all of
them reported 0, every rendered card counted as one row, and ArrowDown
was `min(i + everything, last)` while ArrowUp was `max(i - everything,
0)`: the vertical arrows have been End and Home in the albums, artists
and genres grids since the day this was written. At 700x700 with three
real rows of 3/3/2, ArrowDown from card 0 landed on card 7.

Two things behind it, both only visible once the grid splits:

`cover-grid`'s scrollToIndex was `querySelector('lit-virtualizer')` —
always `#grid-before` — while the roving index spans the whole album
list, so with a dropdown open End scrolled the wrong half to an index
it does not contain. It now picks the half that holds the index and
rebases it.

And the focus is retried on a deadline rather than taken once at the
host's `updateComplete`: a scroll of 5 000 rows produces the card a few
hundred ms later, so the tab stop moved and nothing took focus, which
looks exactly like the key not being handled.

Also waits for the virtualizer in album-dropdown.spec's expandCard,
which flaked on roughly one run in two on main.
2026-08-12 17:44:22 -04:00

191 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A roving tab stop for a grid of cards.
*
* Every card in the albums, artists and genres grids is
* `role="button" tabindex="0"`, which is reachable but unusable: the tab
* sequence is as long as the library, so getting past the grid means
* pressing Tab a thousand times and getting *into* it lands on card one
* with no way to move but Tab. The convention for a grid is one tab
* stop that the arrow keys move — which is what this is.
*
* The column count is measured from the rendered cards rather than
* computed from the layout config, because all three grids are
* virtualized with a centring `justify` and the arithmetic would be a
* second description of a layout the DOM already knows.
*
* It has to be measured with `getBoundingClientRect()`, though, and
* not with `offsetTop`: `lit-virtualizer` positions its children with
* a `transform`, which `offsetTop` does not see, so **every** card in
* every one of these grids reported `offsetTop === 0`. That made the
* measured column count the number of rendered cards, which made
* ArrowDown `min(i + everything, last)` and ArrowUp `max(i - everything,
* 0)` — the vertical arrows were End and Home, in all three grids,
* from the day this was written. Reproduced at 700×700 with three real
* rows of 3/3/2: ArrowDown from card 0 landed on card 7.
*/
import type { ReactiveController, ReactiveControllerHost } from 'lit';
/**
* How long to keep retrying the focus while a virtualizer catches up.
*
* A deadline rather than a frame count because the wait is a scroll and
* a re-render, not a fixed number of paints: at 5 000 albums, End from
* the top produced the card in under 500 ms and a ten-frame budget
* (~160 ms) expired first — the index moved and nothing took focus,
* which is indistinguishable from the key not being handled at all.
*/
const focusRetryBudgetMs = 1000;
export interface RovingGridHost extends ReactiveControllerHost {
shadowRoot: ShadowRoot | null;
}
export interface RovingGridOptions {
/** CSS selector matching one card, inside the host's shadow root. */
cardSelector: string;
/** How many cards there are right now. */
count: () => number;
/** Bring an index into view before focusing it — the grids are
* virtualized, so a card outside the window does not exist yet. */
scrollToIndex?: (index: number) => void;
/** Called when the user activates the focused card. */
activate?: (index: number) => void;
}
export class RovingGridController implements ReactiveController {
private host: RovingGridHost;
private opts: RovingGridOptions;
/** The card holding the tab stop. */
private focusedIndex = 0;
constructor(host: RovingGridHost, opts: RovingGridOptions) {
this.host = host;
this.opts = opts;
host.addController(this);
}
hostConnected(): void {}
/** `tabindex` for the card at `index`. */
tabIndexFor(index: number): number {
return index === this.focusedIndex ? 0 : -1;
}
/** Remember where the user is, so tabbing back returns there. */
noteFocus(index: number): void {
if (index === this.focusedIndex) return;
this.focusedIndex = index;
this.host.requestUpdate();
}
/** Keydown handler for the scroll container. */
handleKeydown = (e: KeyboardEvent): void => {
const last = this.opts.count() - 1;
if (last < 0) return;
const columns = this.measureColumns();
let next = this.focusedIndex;
switch (e.key) {
case 'ArrowRight':
next = Math.min(this.focusedIndex + 1, last);
break;
case 'ArrowLeft':
next = Math.max(this.focusedIndex - 1, 0);
break;
case 'ArrowDown':
next = Math.min(this.focusedIndex + columns, last);
break;
case 'ArrowUp':
next = Math.max(this.focusedIndex - columns, 0);
break;
case 'Home':
next = 0;
break;
case 'End':
next = last;
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
this.focus(next);
};
/**
* Move the tab stop, scrolling and focusing the card.
*
* The focus is retried across a few frames because the host
* finishing its update is not the virtualizer finishing its own: a
* scroll of several thousand rows produces the card a frame or two
* later, and a single query at `updateComplete` finds nothing and
* silently leaves focus where it was. Measured at 5 000 albums with
* a dropdown open, where End moved the index and focused nothing.
*/
focus(index: number): void {
this.focusedIndex = index;
this.opts.scrollToIndex?.(index);
this.host.requestUpdate();
const deadline = performance.now() + focusRetryBudgetMs;
void this.host.updateComplete.then(() => this.focusCard(index, deadline));
}
private focusCard(index: number, deadline: number): void {
const card = this.cards().find(
(c) => Number(c.dataset['index']) === index,
);
if (card) {
card.focus();
return;
}
// Give up rather than spin: the index can also simply be gone,
// if a rescan shortened the list mid-keypress.
if (performance.now() >= deadline) return;
// Stop chasing an index the user has already moved away from.
if (this.focusedIndex !== index) return;
requestAnimationFrame(() => this.focusCard(index, deadline));
}
private cards(): HTMLElement[] {
return [
...(this.host.shadowRoot?.querySelectorAll<HTMLElement>(
this.opts.cardSelector,
) ?? []),
];
}
/**
* Cards sharing a top edge are one row.
*
* Rounded because a virtualizer's transforms are fractional and two
* cards in the same row routinely differ in the third decimal, and
* measured against the *first* card's row because that row is the
* only one guaranteed to be full — a short last row would
* under-count the columns.
*/
private measureColumns(): number {
const cards = this.cards();
if (cards.length === 0) return 1;
const top = Math.round(cards[0]!.getBoundingClientRect().top);
const inRow = cards.filter(
(card) => Math.round(card.getBoundingClientRect().top) === top,
).length;
return Math.max(1, inRow);
}
}