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
@@ -2,8 +2,9 @@
//
// The rendering integration of SortControl (its pickSort/heal decision logic is covered in
// columnSort.test.ts). Labels are plain props here — no i18n needed. It's a floating-ui popover now
// (a labeled pill + a listbox with a footer direction segment), so we stub the two autoUpdate
// observers and wrap in a FloatingTree; options only exist while open → role queries pass { hidden: true }.
// (a labeled pill + a menu of `menuitemradio` sort keys with a footer direction segment), so we stub
// the two autoUpdate observers and wrap in a FloatingTree; rows only exist while open → role queries
// pass { hidden: true }.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
@@ -55,22 +56,17 @@ describe("SortControl", () => {
it("renders one option per SortOption, no no-sort row without defaultLabel", () => {
renderControl({ key: "title", dir: "asc" });
openPanel();
expect(screen.getAllByRole("option", { hidden: true }).map((o) => o.textContent)).toEqual([
"Date",
"Title",
"Shuffle",
]);
expect(
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
).toEqual(["Date", "Title", "Shuffle"]);
});
it("adds a leading no-sort option when defaultLabel is set (tables)", () => {
renderControl(null, { defaultLabel: "None" });
openPanel();
expect(screen.getAllByRole("option", { hidden: true }).map((o) => o.textContent)).toEqual([
"None",
"Date",
"Title",
"Shuffle",
]);
expect(
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
).toEqual(["None", "Date", "Title", "Shuffle"]);
});
it("summarises the trigger with the first option (not blank) for an unavailable key without defaultLabel", () => {
@@ -81,7 +77,7 @@ describe("SortControl", () => {
it("picking a new option fires onChange at that option's defaultDir", () => {
const { onChange } = renderControl({ key: "title", dir: "asc" });
openPanel();
fireEvent.click(screen.getAllByRole("option", { hidden: true })[0]!); // Date
fireEvent.click(screen.getAllByRole("menuitemradio", { hidden: true })[0]!); // Date
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "desc" });
});