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}
+
+ );
+}