diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index 2a0a4204..0b1b3c6c 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -36,12 +36,13 @@ export async function clickVisibleChannelLink(page: Page): Promise { /** * Assert a content-type (e.g. "includeLive") is on/off via the Content-types FilterSelect dropdown. * The options only exist while the dropdown is open, so this opens it, checks the option's - * `aria-selected`, then closes it (Escape) so the floating panel can't block a later click. + * `aria-checked` (the multi FilterSelect renders its rows as menuitemcheckbox), then closes it + * (Escape) so the floating panel can't block a later click. */ export async function expectContentType(page: Page, value: string, on: boolean): Promise { await page.getByTestId("feed-content").click(); await expect(page.getByTestId(`feed-content-${value}`)).toHaveAttribute( - "aria-selected", + "aria-checked", on ? "true" : "false", ); await page.keyboard.press("Escape"); diff --git a/frontend/src/components/FilterSelect.dom.test.tsx b/frontend/src/components/FilterSelect.dom.test.tsx index 6ffe8210..7bcd451b 100644 --- a/frontend/src/components/FilterSelect.dom.test.tsx +++ b/frontend/src/components/FilterSelect.dom.test.tsx @@ -38,7 +38,7 @@ const tree = (ui: React.ReactNode) => render({ui}); describe("FilterSelect", () => { it("renders a labeled trigger `Label: value` and keeps options unmounted until opened", () => { tree(); - const trigger = screen.getByRole("button", { name: "Status" }); + const trigger = screen.getByRole("button", { name: /^Status/ }); expect(trigger.textContent).toContain("Status:"); expect(trigger.textContent).toContain("All"); expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull(); @@ -47,7 +47,7 @@ describe("FilterSelect", () => { it("single: picking a row replaces the selection and closes", () => { const onChange = vi.fn(); tree(); - fireEvent.click(screen.getByRole("button", { name: "Status" })); + fireEvent.click(screen.getByRole("button", { name: /^Status/ })); const rows = screen.getAllByRole("menuitemradio", { hidden: true }); expect(rows.map((o) => o.textContent)).toEqual(["All", "New"]); expect(rows[0]!.getAttribute("aria-checked")).toBe("true"); // "All" is the current selection @@ -61,7 +61,7 @@ describe("FilterSelect", () => { tree( , ); - fireEvent.click(screen.getByRole("button", { name: "Type" })); + fireEvent.click(screen.getByRole("button", { name: /^Type/ })); fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[1]!); // Shorts expect(onChange).toHaveBeenCalledWith(["n", "s"]); expect(screen.queryAllByRole("menuitemcheckbox", { hidden: true }).length).toBeGreaterThan(0); // still open @@ -72,7 +72,7 @@ describe("FilterSelect", () => { tree( , ); - fireEvent.click(screen.getByRole("button", { name: "Type" })); + fireEvent.click(screen.getByRole("button", { name: /^Type/ })); fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[0]!); // Normal — the only one selected expect(onChange).not.toHaveBeenCalled(); }); @@ -87,6 +87,6 @@ describe("FilterSelect", () => { onChange={vi.fn()} />, ); - expect(screen.getByRole("button", { name: "Type" }).textContent).toContain("Normal +1"); + expect(screen.getByRole("button", { name: /^Type/ }).textContent).toContain("Normal +1"); }); }); diff --git a/frontend/src/components/FilterSelect.tsx b/frontend/src/components/FilterSelect.tsx index 8759ad06..329e2491 100644 --- a/frontend/src/components/FilterSelect.tsx +++ b/frontend/src/components/FilterSelect.tsx @@ -81,7 +81,9 @@ export default function FilterSelect({ ref={pop.refs.setReference} {...pop.getReferenceProps()} data-testid={testId} - aria-label={label} + // Carry the current value in the accessible name (not just the group label), matching + // ViewSwitcher — a screen reader announces "Watch state: Unwatched", not a bare "Watch state". + aria-label={`${label}: ${summary}`} className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${ open ? "border-accent" : "border-border" }`} diff --git a/frontend/src/components/PageShell.dom.test.tsx b/frontend/src/components/PageShell.dom.test.tsx index 3ac8da84..40bb35e4 100644 --- a/frontend/src/components/PageShell.dom.test.tsx +++ b/frontend/src/components/PageShell.dom.test.tsx @@ -124,4 +124,16 @@ describe("PageShell --rail-pinned-w / --content-mw", () => { render(content); expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem"); }); + + it("recomputes at runtime when --font-scale changes the root font, without looping", async () => { + setWidth(1650); // roomy at 16px/rem (1650 >= 1600) → overlay + render(content); + expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem"); + // The text-size slider changes the root font-size with NO resize event — the MutationObserver + // must recompute. At 17px/rem the threshold is 100rem*17 = 1700 > 1650, so the rail goes in-flow. + // (If the observer's self-write guard were broken this would loop forever and time out.) + root.style.fontSize = "17px"; + await new Promise((r) => setTimeout(r, 0)); // flush the MutationObserver microtask + expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem"); + }); }); diff --git a/frontend/src/components/SortControl.dom.test.tsx b/frontend/src/components/SortControl.dom.test.tsx index 84c39dc1..03f517eb 100644 --- a/frontend/src/components/SortControl.dom.test.tsx +++ b/frontend/src/components/SortControl.dom.test.tsx @@ -48,7 +48,7 @@ function renderControl(sort: SortState, extra: Record = {}) { return { onChange }; } -const openPanel = () => fireEvent.click(screen.getByRole("button", { name: "Sort" })); +const openPanel = () => fireEvent.click(screen.getByRole("button", { name: /^Sort/ })); afterEach(cleanup); @@ -71,7 +71,7 @@ describe("SortControl", () => { it("summarises the trigger with the first option (not blank) for an unavailable key without defaultLabel", () => { renderControl({ key: "ghost", dir: "asc" }); - expect(screen.getByRole("button", { name: "Sort" }).textContent).toContain("Date"); + expect(screen.getByRole("button", { name: /^Sort/ }).textContent).toContain("Date"); }); it("picking a new option fires onChange at that option's defaultDir", () => { diff --git a/frontend/src/components/SortControl.tsx b/frontend/src/components/SortControl.tsx index 7bde36cb..817b0dc7 100644 --- a/frontend/src/components/SortControl.tsx +++ b/frontend/src/components/SortControl.tsx @@ -67,7 +67,12 @@ export default function SortControl({ type="button" ref={pop.refs.setReference} {...pop.getReferenceProps()} - aria-label={label} + // Carry the active sort key AND direction in the accessible name (the visible arrow is + // aria-hidden), matching ViewSwitcher — a screen reader announces "Sort: Date, Descending", + // not a bare "Sort". + aria-label={`${label}: ${summary}${ + showArrow && active ? `, ${active.dir === "asc" ? ascLabel : descLabel}` : "" + }`} className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${ open ? "border-accent" : "border-border" }`}