Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/components/AnchorTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AnchorTable anchors={anchors} />);

// 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");
});
});
13 changes: 12 additions & 1 deletion src/components/AnchorTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -35,7 +36,16 @@ export function AnchorTable({
}

return (
<table className="w-full text-left text-sm">
<>
<SortAnnouncer
sort={sort}
labels={{
name: "Anchor",
registeredAt: "Registered",
active: "Status",
}}
/>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-400">
<SortableHeader
Expand Down Expand Up @@ -101,6 +111,7 @@ export function AnchorTable({
))}
</tbody>
</table>
</>
);
}

33 changes: 30 additions & 3 deletions src/components/PoolTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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(<PoolTable pools={pools} />);

// 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", () => {
Expand All @@ -78,16 +105,16 @@ describe("PoolsPanel", () => {
render(<PoolsPanel />);
// 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();
});
});
13 changes: 12 additions & 1 deletion src/components/PoolTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -26,7 +27,16 @@ export function PoolTable({ pools }: { pools: Pool[] }) {
}

return (
<table className="w-full text-left text-sm">
<>
<SortAnnouncer
sort={sort}
labels={{
asset: "Asset",
total: "Total liquidity",
anchors: "Anchors",
}}
/>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-400">
<SortableHeader
Expand Down Expand Up @@ -75,6 +85,7 @@ export function PoolTable({ pools }: { pools: Pool[] }) {
</tr>
</tfoot>
</table>
</>
);
}

21 changes: 21 additions & 0 deletions src/components/SettlementTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SettlementTable settlements={settlements} />);

// 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");
});
});
13 changes: 12 additions & 1 deletion src/components/SettlementTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -46,7 +47,16 @@ export function SettlementTable({
);

return (
<table className="w-full text-left text-sm">
<>
<SortAnnouncer
sort={sort}
labels={{
anchor: "Anchor",
amount: "Amount",
status: "Status",
}}
/>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-400">
<th className="py-2 font-medium">#</th>
Expand Down Expand Up @@ -130,6 +140,7 @@ export function SettlementTable({
</tr>
</tfoot>
</table>
</>
);
}

49 changes: 49 additions & 0 deletions src/components/SortAnnouncer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { SortState } from "@/hooks/useSortableData";

export interface SortAnnouncerProps<K extends string> {
sort: SortState<K> | null;
labels: Record<K, string>;
}

/**
* 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<K extends string>({
sort,
labels,
}: SortAnnouncerProps<K>) {
const [announcement, setAnnouncement] = useState("");
const prevSortRef = useRef<SortState<K> | 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 (
<div className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</div>
);
}