fix(a11y): move the card grids by a row, not to the end
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

`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.
This commit is contained in:
2026-08-12 17:44:22 -04:00
parent dddf54ba0c
commit 65c1b4fd53
4 changed files with 319 additions and 15 deletions
+67 -10
View File
@@ -12,9 +12,30 @@
* 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;
}
@@ -96,19 +117,45 @@ export class RovingGridController implements ReactiveController {
this.focus(next);
};
/** Move the tab stop, scrolling and focusing the card. */
/**
* 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();
void this.host.updateComplete.then(() => {
const cards = this.cards();
const deadline = performance.now() + focusRetryBudgetMs;
cards
.find((card) => Number(card.dataset['index']) === index)
?.focus();
});
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[] {
@@ -119,14 +166,24 @@ export class RovingGridController implements ReactiveController {
];
}
/** Cards sharing a top offset are one row. */
/**
* 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 = cards[0]!.offsetTop;
const inRow = cards.filter((card) => card.offsetTop === top).length;
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);
}