diff --git a/frontend/src/components/TransactionFilterSidebar.test.tsx b/frontend/src/components/TransactionFilterSidebar.test.tsx index 535b49cc..46f0dca8 100644 --- a/frontend/src/components/TransactionFilterSidebar.test.tsx +++ b/frontend/src/components/TransactionFilterSidebar.test.tsx @@ -702,4 +702,142 @@ describe("TransactionFilterSidebar", () => { expect(btn).toBeDisabled(); }); }); + + // ── 7. Screen reader & optimistic updates (#939, #940, #941) ───────────── + + describe("7 · Screen reader & optimistic updates (#939 #940 #941)", () => { + // #940 — desktop landmark role + it("desktop panel has role='complementary' with accessible label", () => { + const { container } = render(); + const panel = getDesktopPanel(container); + expect(panel).toHaveAttribute("role", "complementary"); + expect(panel).toHaveAttribute("aria-label", "Transaction filters"); + }); + + // #940 — SyncSpinner inside asset buttons must be decorative so it doesn't + // corrupt the button's accessible name with "Syncing…" + it("SyncSpinner wrapper inside active asset button has aria-hidden='true'", () => { + const { container } = render( + , + ); + const panel = getDesktopPanel(container); + const usdc = within(panel).getByRole("button", { name: /USDC/i }); + // The span wrapping SyncSpinner must hide spinner text from the button label. + const hiddenSpan = usdc.querySelector("span[aria-hidden='true']"); + expect(hiddenSpan).toBeInTheDocument(); + }); + + // #941 — optimistic active-filter count badge + it("shows no count badge when no filters are active", () => { + const { container } = render(); + const panel = getDesktopPanel(container); + // Badge is the aria-hidden span next to the heading. + const badge = within(panel).queryByText(/^\d+$/); + expect(badge).not.toBeInTheDocument(); + }); + + it("shows count badge of 1 when only search is active", () => { + const { container } = render( + , + ); + const panel = getDesktopPanel(container); + // aria-hidden badge with the count number + expect(within(panel).getByText("1")).toBeInTheDocument(); + }); + + it("shows correct count when multiple filters are active", () => { + const { container } = render( + , + ); + const panel = getDesktopPanel(container); + expect(within(panel).getByText("5")).toBeInTheDocument(); + }); + + // #940 — live region for screen-reader filter count announcements + it("renders a polite live region for active filter count", () => { + const { container } = render(); + const panel = getDesktopPanel(container); + const live = within(panel).getByRole("status"); + expect(live).toBeInTheDocument(); + expect(live).toHaveAttribute("aria-live", "polite"); + }); + + it("live region is empty when no filters are active", () => { + const { container } = render(); + const panel = getDesktopPanel(container); + const live = within(panel).getByRole("status"); + expect(live.textContent).toBe(""); + }); + + it("live region announces singular count text when one filter is active", () => { + const { container } = render( + , + ); + const panel = getDesktopPanel(container); + const live = within(panel).getByRole("status"); + expect(live).toHaveTextContent("1 filter active"); + }); + + it("live region announces plural count text when multiple filters are active", () => { + const { container } = render( + , + ); + const panel = getDesktopPanel(container); + const live = within(panel).getByRole("status"); + expect(live).toHaveTextContent("2 filters active"); + }); + + // #941 — Clear All accessible label reflects the active filter count + it("Clear All button aria-label includes the active count", () => { + const { container } = render( + , + ); + const panel = getDesktopPanel(container); + const btn = within(panel).getByRole("button", { name: /clear all 2 active filters/i }); + expect(btn).toBeInTheDocument(); + }); + + it("Clear All button aria-label is generic when no filters are active", () => { + const { container } = render(); + const panel = getDesktopPanel(container); + const btn = within(panel).getByRole("button", { name: /clear all filters/i }); + expect(btn).toBeInTheDocument(); + expect(btn).toHaveAttribute("aria-label", "Clear all filters"); + }); + + // #940 — mobile dialog still has its landmark attributes + it("mobile dialog retains role='dialog' and aria-modal when open", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAttribute("aria-label", "Filter sidebar"); + }); + }); }); \ No newline at end of file diff --git a/frontend/src/components/TransactionFilterSidebar.tsx b/frontend/src/components/TransactionFilterSidebar.tsx index 0062eaff..f5e1f6f9 100644 --- a/frontend/src/components/TransactionFilterSidebar.tsx +++ b/frontend/src/components/TransactionFilterSidebar.tsx @@ -153,6 +153,14 @@ export default function TransactionFilterSidebar({ const uid = useId(); const anyPending = searchSyncPending || isFilterPending; + const activeFilterCount = [ + filters.search !== "", + filters.status !== "all", + filters.asset !== "all", + filters.dateFrom !== "", + filters.dateTo !== "", + ].filter(Boolean).length; + const renderContent = (isMobile: boolean) => { const suffix = isMobile ? `-${uid}-mobile` : `-${uid}-desktop`; @@ -165,7 +173,27 @@ export default function TransactionFilterSidebar({

Filters

+ {/* Optimistic active-filter count badge — updates before URL sync */} + {activeFilterCount > 0 && ( + + )} + {/* Screen-reader live region for filter count announcements */} + + {activeFilterCount > 0 + ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} active` + : ""} +
{onClose && isMobile && (