fix(a11y,layout): deep-review fixes — menu roles, sort aria-pressed, rem rail threshold

Epic-end deep-review of the branch surfaced six verified findings; fix them.

a11y (medium): FilterSelect and SortControl opened their AnchoredPopover in
mode:"listbox" but never passed a role, so the surface fell through to
role="menu" while the rows rendered role="option"+aria-selected — options
orphaned outside a listbox, so AT could not announce the selected value.
Switch both to the house menu pattern (as ViewSwitcher already uses): a menu
surface with menuitemradio (single) / menuitemcheckbox (multi) rows carrying
aria-checked, scoped by a role=group. Trigger stays a button+haspopup=menu.

a11y (low): the SortControl asc/desc direction segment signalled the active
direction by colour alone. Add aria-pressed so the current direction is
programmatically exposed, matching SegmentedToggle.

correctness (low): PageShell converted a rem maxWidth to px with a hardcoded
16 to decide the pinned-rail overlay threshold, but index.css scales the root
font by --font-scale (default 1.06 ~ 17px, slider 0.9-1.3), so the threshold
was wrong at every text size on the rem pages (playlists is the only one with
a pinned rail). Use the live root font-size, scale the 36rem slack the same
way, and recompute when --font-scale changes (a guarded MutationObserver — the
slider fires no resize).

tests: cover the two gaps the review flagged — ItemCount (plural, 1M+ overflow,
undefined icon-only, tooltip) and the PageShell rail-pinned-w/content-mw
threshold math incl. the font-scale scaling. Updated the FilterSelect/
SortControl role queries to the new menuitemradio/checkbox roles.

Verified in the running app: menu surfaces carry role=group + menuitemradio/
checkbox with 0 orphaned options, and the sort direction segment exposes
aria-pressed. Dropped two review findings (max-width duplication, pill-class
reuse) as refuted on adversarial verification. Gate green: tsc, 372 vitest,
prettier, eslint (0 err).
This commit is contained in:
2026-08-21 22:41:57 +02:00
parent edbc9dede5
commit 4ee452b854
7 changed files with 214 additions and 97 deletions
+17 -3
View File
@@ -75,20 +75,34 @@ export default function PageShell({
const root = document.documentElement;
root.style.setProperty("--content-mw", maxWidth ?? "none");
// Room the pinned rail needs: its own width (~16rem) on each side of the centred content, plus the
// nav rail (~4rem). Below that, or on a full-width page (no maxWidth), pin in-flow (w-67).
// nav rail (~4rem) = ~36rem. Below that, or on a full-width page (no maxWidth), pin in-flow (w-67).
let lastPxPerRem = 0;
const apply = () => {
// Convert the rem maxWidth (and the 36rem slack) against the LIVE root font-size, not a hardcoded
// 16: index.css scales the root by --font-scale (default 1.06 ≈ 17px, slider 0.9–1.3), and the page's
// own content wrapper is a rem `max-w-*`, so a fixed 16 mis-sizes the threshold at every text size.
const pxPerRem = parseFloat(getComputedStyle(root).fontSize) || 16;
lastPxPerRem = pxPerRem;
const mwPx = maxWidth
? maxWidth.endsWith("rem")
? parseFloat(maxWidth) * 16
? parseFloat(maxWidth) * pxPerRem
: parseFloat(maxWidth)
: null;
const roomy = mwPx != null && window.innerWidth >= mwPx + 576;
const roomy = mwPx != null && window.innerWidth >= mwPx + 36 * pxPerRem;
root.style.setProperty("--rail-pinned-w", roomy ? "2.25rem" : "16.75rem");
};
apply();
window.addEventListener("resize", apply);
// The text-size slider changes --font-scale (hence the root font-size) WITHOUT firing resize, so the
// rem→px threshold would go stale. Re-run when the root font actually changes; the pxPerRem guard keeps
// our own --rail-pinned-w write (a rem string, no font change) from re-triggering the observer.
const fontObserver = new MutationObserver(() => {
if ((parseFloat(getComputedStyle(root).fontSize) || 16) !== lastPxPerRem) apply();
});
fontObserver.observe(root, { attributes: true, attributeFilter: ["style"] });
return () => {
window.removeEventListener("resize", apply);
fontObserver.disconnect();
root.style.removeProperty("--content-mw");
root.style.removeProperty("--rail-pinned-w");
};