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
+81
View File
@@ -137,10 +137,82 @@ test.describe('the album dropdown', () => {
await app.setViewportSize({ width: 1440, height: 900 });
}
});
test('the arrow keys still move by a row across the split', async ({
app,
}) => {
// The dropdown draws two virtualizers where there was one, and the
// roving tab stop indexes the whole album list rather than either
// half. Home and End have to cross the dropdown, and ArrowDown has
// to move by a row rather than to the end — which it did not, in
// any of these grids, because `offsetTop` inside a virtualizer is
// always 0 and every rendered card counted as one row.
await app.setViewportSize({ width: 700, height: 700 });
try {
await expandCard(app, 1);
await expect.poll(() => dropdownState(app)).toMatchObject({
present: true,
split: true,
});
const moves = await app.evaluate(async () => {
const grid = document.querySelector('cover-grid');
const root = grid?.shadowRoot;
const container = root?.querySelector('.grid-scroll-container');
const cards = () =>
[...(root?.querySelectorAll<HTMLElement>('.album-card') ?? [])];
const at = () => {
const active = root?.activeElement as HTMLElement | null;
return active ? Number(active.dataset['index']) : null;
};
const press = async (key: string) => {
container?.dispatchEvent(
new KeyboardEvent('keydown', {
key,
bubbles: true,
composed: true,
}),
);
await new Promise((r) => setTimeout(r, 400));
return at();
};
cards()[0]?.focus();
const columns = cards().filter(
(c) =>
Math.round(c.getBoundingClientRect().top) ===
Math.round(cards()[0]!.getBoundingClientRect().top),
).length;
const down = await press('ArrowDown');
const end = await press('End');
const home = await press('Home');
return { columns, down, end, home, last: cards().length - 1 };
});
expect(moves.columns).toBeGreaterThan(1);
expect(moves.down).toBe(moves.columns);
expect(moves.end).toBe(moves.last);
expect(moves.home).toBe(0);
} finally {
await app.setViewportSize({ width: 1440, height: 900 });
}
});
});
/** Focus a card and press Enter, which is the only thing that expands one. */
async function expandCard(app: Page, index: number): Promise<void> {
// The cards come from a virtualizer, so they are not there when the
// view is: a keydown dispatched into an empty grid hits nothing, and
// what fails is the *poll* several lines later, which reads as the
// dropdown being broken rather than as a race. This spec flaked on
// roughly one run in two on main without this wait.
await expect.poll(() => cardCount(app)).toBeGreaterThan(index);
await app.evaluate((i) => {
const card = document
.querySelector('cover-grid')
@@ -157,6 +229,15 @@ async function expandCard(app: Page, index: number): Promise<void> {
}, index);
}
async function cardCount(app: Page): Promise<number> {
return app.evaluate(
() =>
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card').length ?? 0,
);
}
async function closeDropdown(app: Page): Promise<void> {
await app.evaluate(() => {
const grid = document.querySelector('cover-grid') as