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
+39 -33
View File
@@ -41,7 +41,7 @@ export default function SortControl({
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "listbox",
mode: "menu",
placement: "bottom-end",
});
@@ -89,43 +89,47 @@ export default function SortControl({
ariaLabel={label}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{defaultLabel !== undefined && (
<PopoverItem
role="option"
selected={active === null}
onSelect={() => {
onChange(null);
setOpen(false); // "no sort" is a final choice — nothing left to direct
}}
label={defaultLabel}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
active === null ? "text-fg" : "text-muted"
}`}
>
<Check
className={`h-3.5 w-3.5 shrink-0 ${active === null ? "text-accent" : "opacity-0"}`}
/>
<span className="flex-1 truncate">{defaultLabel}</span>
</PopoverItem>
)}
{options.map((o) => {
const on = active?.key === o.key;
return (
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
<div role="group" aria-label={label}>
{defaultLabel !== undefined && (
<PopoverItem
key={o.key}
role="option"
selected={on}
onSelect={() => onChange(pickSort(options, active, o.key))} // keep open to set direction
label={o.label}
role="menuitemradio"
checked={active === null}
onSelect={() => {
onChange(null);
setOpen(false); // "no sort" is a final choice — nothing left to direct
}}
label={defaultLabel}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
active === null ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<span className="flex-1 truncate">{o.label}</span>
<Check
className={`h-3.5 w-3.5 shrink-0 ${active === null ? "text-accent" : "opacity-0"}`}
/>
<span className="flex-1 truncate">{defaultLabel}</span>
</PopoverItem>
);
})}
)}
{options.map((o) => {
const on = active?.key === o.key;
return (
<PopoverItem
key={o.key}
role="menuitemradio"
checked={on}
onSelect={() => onChange(pickSort(options, active, o.key))} // keep open to set direction
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
})}
</div>
{((showArrow && active) || trailing) && (
<div
role="group"
@@ -136,6 +140,7 @@ export default function SortControl({
<div className="flex flex-1 gap-1">
<button
type="button"
aria-pressed={active.dir === "asc"}
onClick={() => onChange({ key: active.key, dir: "asc" })}
className={segCls(active.dir === "asc")}
>
@@ -144,6 +149,7 @@ export default function SortControl({
</button>
<button
type="button"
aria-pressed={active.dir === "desc"}
onClick={() => onChange({ key: active.key, dir: "desc" })}
className={segCls(active.dir === "desc")}
>