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:
@@ -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")}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user