From a84b6b5372d8f510c20bf6ec3a543d0148cd1041 Mon Sep 17 00:00:00 2001 From: onyekachi66 Date: Thu, 23 Jul 2026 03:34:51 +0100 Subject: [PATCH] accessibility: add SortAnnouncer for aria-live sorting updates across tables --- src/components/AnchorTable.test.tsx | 21 +++++++++++ src/components/AnchorTable.tsx | 13 ++++++- src/components/PoolTable.test.tsx | 33 +++++++++++++++-- src/components/PoolTable.tsx | 13 ++++++- src/components/SettlementTable.test.tsx | 21 +++++++++++ src/components/SettlementTable.tsx | 13 ++++++- src/components/SortAnnouncer.tsx | 49 +++++++++++++++++++++++++ 7 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/components/SortAnnouncer.tsx diff --git a/src/components/AnchorTable.test.tsx b/src/components/AnchorTable.test.tsx index d4d5200..16c24ae 100644 --- a/src/components/AnchorTable.test.tsx +++ b/src/components/AnchorTable.test.tsx @@ -79,4 +79,25 @@ describe("AnchorTable", () => { ); expect(screen.getAllByText("Deactivate")).toHaveLength(1); }); + + it("announces the new sort key and direction via a live region when clicked", () => { + const { container } = render(); + + // No announcement on initial render + const liveRegion = container.querySelector('[aria-live="polite"].sr-only'); + expect(liveRegion).toBeInTheDocument(); + expect(liveRegion).toHaveTextContent(""); + + // Click to sort by Anchor name + fireEvent.click(screen.getByLabelText("Sort by Anchor")); + expect(liveRegion).toHaveTextContent("Sorted by Anchor, ascending"); + + // Click again to cycle to descending + fireEvent.click(screen.getByLabelText("Sort by Anchor")); + expect(liveRegion).toHaveTextContent("Sorted by Anchor, descending"); + + // Click again to cycle to unsorted + fireEvent.click(screen.getByLabelText("Sort by Anchor")); + expect(liveRegion).toHaveTextContent("Sorting cleared"); + }); }); diff --git a/src/components/AnchorTable.tsx b/src/components/AnchorTable.tsx index 8586e4a..6aafd42 100644 --- a/src/components/AnchorTable.tsx +++ b/src/components/AnchorTable.tsx @@ -6,6 +6,7 @@ import { formatDate } from "@/lib/format"; import { useSortableData } from "@/hooks/useSortableData"; import { EmptyState } from "./EmptyState"; import { SortableHeader } from "./SortableHeader"; +import { SortAnnouncer } from "./SortAnnouncer"; type SortKey = "name" | "registeredAt" | "active"; @@ -35,7 +36,16 @@ export function AnchorTable({ } return ( - + <> + +
+ ); } diff --git a/src/components/PoolTable.test.tsx b/src/components/PoolTable.test.tsx index 8e4a5e2..edae6fe 100644 --- a/src/components/PoolTable.test.tsx +++ b/src/components/PoolTable.test.tsx @@ -5,6 +5,12 @@ import { PoolsPanel } from "./PoolsPanel"; import * as api from "@/lib/api"; import { Pool } from "@/lib/types"; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(""), + usePathname: () => "/dashboard", +})); + const pools: Pool[] = [ { asset: "XLM", total: 300, anchors: 2 }, { asset: "USDC", total: 100, anchors: 5 }, @@ -70,6 +76,27 @@ describe("PoolTable", () => { expect(tfoot).toHaveTextContent("500"); expect(tfoot).toHaveTextContent("3 anchors"); }); + + it("announces the new sort key and direction via a live region when clicked", () => { + const { container } = render(); + + // No announcement on initial render + const liveRegion = container.querySelector('[aria-live="polite"].sr-only'); + expect(liveRegion).toBeInTheDocument(); + expect(liveRegion).toHaveTextContent(""); + + // Click to sort by Total liquidity + fireEvent.click(screen.getByLabelText("Sort by Total liquidity")); + expect(liveRegion).toHaveTextContent("Sorted by Total liquidity, ascending"); + + // Click again to cycle to descending + fireEvent.click(screen.getByLabelText("Sort by Total liquidity")); + expect(liveRegion).toHaveTextContent("Sorted by Total liquidity, descending"); + + // Click again to cycle to unsorted + fireEvent.click(screen.getByLabelText("Sort by Total liquidity")); + expect(liveRegion).toHaveTextContent("Sorting cleared"); + }); }); describe("PoolsPanel", () => { @@ -78,16 +105,16 @@ describe("PoolsPanel", () => { render(); // wait for loading -> ready state await waitFor(() => - expect(screen.getByRole("search", { name: "Pools search and refresh" })).toBeInTheDocument(), + expect(screen.getByRole("search", { name: "Pools search" })).toBeInTheDocument(), ); const searchRegion = screen.getByRole("search", { - name: "Pools search and refresh", + name: "Pools search", }); expect( within(searchRegion).getByRole("textbox", { name: "Search pools" }), ).toBeInTheDocument(); expect( - within(searchRegion).getByRole("button", { name: /refresh/i }), + screen.getByRole("button", { name: /refresh/i }), ).toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/src/components/PoolTable.tsx b/src/components/PoolTable.tsx index aeb7645..5aab3bd 100644 --- a/src/components/PoolTable.tsx +++ b/src/components/PoolTable.tsx @@ -5,6 +5,7 @@ import { formatAmount, pluralize } from "@/lib/format"; import { useSortableData } from "@/hooks/useSortableData"; import { EmptyState } from "./EmptyState"; import { SortableHeader } from "./SortableHeader"; +import { SortAnnouncer } from "./SortAnnouncer"; type SortKey = "asset" | "total" | "anchors"; @@ -26,7 +27,16 @@ export function PoolTable({ pools }: { pools: Pool[] }) { } return ( - + <> + +
+ ); } diff --git a/src/components/SettlementTable.test.tsx b/src/components/SettlementTable.test.tsx index f03fab8..21aed26 100644 --- a/src/components/SettlementTable.test.tsx +++ b/src/components/SettlementTable.test.tsx @@ -125,4 +125,25 @@ describe("SettlementTable sorting", () => { fireEvent.click(screen.getByLabelText("Sort by Amount")); expect(header).toHaveAttribute("aria-sort", "descending"); }); + + it("announces the new sort key and direction via a live region when clicked", () => { + const { container } = render(); + + // No announcement on initial render + const liveRegion = container.querySelector('[aria-live="polite"].sr-only'); + expect(liveRegion).toBeInTheDocument(); + expect(liveRegion).toHaveTextContent(""); + + // Click to sort by Amount + fireEvent.click(screen.getByLabelText("Sort by Amount")); + expect(liveRegion).toHaveTextContent("Sorted by Amount, ascending"); + + // Click again to cycle to descending + fireEvent.click(screen.getByLabelText("Sort by Amount")); + expect(liveRegion).toHaveTextContent("Sorted by Amount, descending"); + + // Click again to cycle to unsorted + fireEvent.click(screen.getByLabelText("Sort by Amount")); + expect(liveRegion).toHaveTextContent("Sorting cleared"); + }); }); diff --git a/src/components/SettlementTable.tsx b/src/components/SettlementTable.tsx index 481a326..2c1e0e5 100644 --- a/src/components/SettlementTable.tsx +++ b/src/components/SettlementTable.tsx @@ -7,6 +7,7 @@ import { useSortableData } from "@/hooks/useSortableData"; import { StatusBadge } from "./StatusBadge"; import { EmptyState } from "./EmptyState"; import { SortableHeader } from "./SortableHeader"; +import { SortAnnouncer } from "./SortAnnouncer"; type SortKey = "anchor" | "amount" | "status"; @@ -46,7 +47,16 @@ export function SettlementTable({ ); return ( - + <> + +
@@ -130,6 +140,7 @@ export function SettlementTable({
#
+ ); } diff --git a/src/components/SortAnnouncer.tsx b/src/components/SortAnnouncer.tsx new file mode 100644 index 0000000..37397c1 --- /dev/null +++ b/src/components/SortAnnouncer.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { SortState } from "@/hooks/useSortableData"; + +export interface SortAnnouncerProps { + sort: SortState | null; + labels: Record; +} + +/** + * A visually hidden component that announces sort changes to screen readers + * via an aria-live region. No announcement is made on initial mount. + */ +export function SortAnnouncer({ + sort, + labels, +}: SortAnnouncerProps) { + const [announcement, setAnnouncement] = useState(""); + const prevSortRef = useRef | null>(sort); + const isFirstRender = useRef(true); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + const prevSort = prevSortRef.current; + if (prevSort?.key === sort?.key && prevSort?.direction === sort?.direction) { + return; + } + prevSortRef.current = sort; + + if (sort) { + const label = labels[sort.key] || String(sort.key); + const direction = sort.direction === "asc" ? "ascending" : "descending"; + setAnnouncement(`Sorted by ${label}, ${direction}`); + } else { + setAnnouncement("Sorting cleared"); + } + }, [sort, labels]); + + return ( +
+ {announcement} +
+ ); +}