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
59 changes: 57 additions & 2 deletions components/transactions/sort.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ describe("SortControl", () => {
);
// The secondary chip text includes "then Amount ↓"
expect(screen.getByText(/then Amount/i)).toBeInTheDocument();
expect(screen.getByText(/↓/)).toBeInTheDocument();
// Use getAllByText because the secondary chip and the dropdown item
// both render the direction arrow; at least one is in the chip.
expect(screen.getAllByText(/↓/).length).toBeGreaterThanOrEqual(1);
});

it("does NOT show secondary sort chip when only primary is set", () => {
Expand Down Expand Up @@ -187,7 +189,7 @@ describe("SortControl", () => {
onClearSecondarySort={vi.fn()}
/>,
);
const trigger = screen.getByLabelText("Sort transactions");
const trigger = screen.getByLabelText(/Sort transactions/i);
expect(trigger).toBeInTheDocument();
});

Expand All @@ -202,4 +204,57 @@ describe("SortControl", () => {
const closeBtn = screen.getByLabelText("Clear secondary sort");
expect(closeBtn).toBeInTheDocument();
});

it("includes current sort info in the trigger aria-label", () => {
render(
<SortControl
sortConfigs={defaultSortConfigs}
onSort={vi.fn()}
onClearSecondarySort={vi.fn()}
/>,
);
const trigger = screen.getByLabelText(/Sorted by Date descending/i);
expect(trigger).toBeInTheDocument();
});

it("renders a visually-hidden aria-live sort announcement region", () => {
render(
<SortControl
sortConfigs={defaultSortConfigs}
onSort={vi.fn()}
onClearSecondarySort={vi.fn()}
/>,
);
const region = screen.getByTestId("sort-announcement");
expect(region).toBeInTheDocument();
expect(region).toHaveAttribute("aria-live", "polite");
expect(region).toHaveAttribute("role", "status");
expect(region).toHaveTextContent("Sorted by Date descending.");
});

it("announces multi-column sort in the aria-live region", () => {
render(
<SortControl
sortConfigs={multiSortConfigs}
onSort={vi.fn()}
onClearSecondarySort={vi.fn()}
/>,
);
const region = screen.getByTestId("sort-announcement");
expect(region).toHaveTextContent(
/Sorted by Status ascending, then by Amount descending/i,
);
});

it("announces 'No sort applied' when sortConfigs is empty", () => {
render(
<SortControl
sortConfigs={[]}
onSort={vi.fn()}
onClearSecondarySort={vi.fn()}
/>,
);
const region = screen.getByTestId("sort-announcement");
expect(region).toHaveTextContent("No sort applied.");
});
});
29 changes: 28 additions & 1 deletion components/transactions/sort.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,42 @@ const SortControl = ({
return idx >= 0 ? idx + 1 : null;
};

/** Build a human-readable description of the active sort(s) for screen readers. */
function renderSortDescription(configs: SortConfig[]): string {
if (configs.length === 0) return "No sort applied.";
const parts = configs.map((c, i) => {
const dir = c.direction === "asc" ? "ascending" : "descending";
return i === 0
? `Sorted by ${SORT_LABELS[c.field]} ${dir}`
: `then by ${SORT_LABELS[c.field]} ${dir}`;
});
return parts.join(", ") + ".";
}

/** Human-readable description of the active sort(s) for screen readers. */
const liveSortDescription = renderSortDescription(sortConfigs);

return (
<div className="flex items-center gap-1">
{/* Visually-hidden live region that announces sort changes to screen
readers. aria-live="polite" keeps announcements from interrupting
the user's current task. */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
data-testid="sort-announcement"
>
{liveSortDescription}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="default"
className="text-gray-400 hover:text-white hover:bg-[#1a0c1d]"
aria-label="Sort transactions"
aria-label={`Sort transactions. ${liveSortDescription}`}
>
<ChevronsUpDown
size={20}
Expand Down
1 change: 1 addition & 0 deletions components/transactions/transactions-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,7 @@ export default function TransactionsContent() {
<>
<TransactionsTable
transactions={paginatedTransactions}
sortConfigs={filters.sortConfigs}
selectedIds={selectedIds}
onSelectRow={handleSelectRow}
onSelectAll={handleSelectAllForPage}
Expand Down
50 changes: 50 additions & 0 deletions components/transactions/transactions-table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,56 @@ describe("TransactionsTable — ARIA roles", () => {
document.querySelector("caption")?.textContent,
).toMatch(/transaction history/i);
});

it("sets aria-sort='descending' on the Date header when sorted by date desc", () => {
render(
<TransactionsTable
transactions={THREE_ROWS}
sortConfigs={[{ field: "date", direction: "desc" }]}
/>,
);
const dateHeader = screen.getByRole("columnheader", { name: /date/i });
expect(dateHeader).toHaveAttribute("aria-sort", "descending");
});

it("sets aria-sort='ascending' on the Amount header when sorted by amount asc", () => {
render(
<TransactionsTable
transactions={THREE_ROWS}
sortConfigs={[{ field: "amount", direction: "asc" }]}
/>,
);
const amountHeader = screen.getByRole("columnheader", { name: /amount/i });
expect(amountHeader).toHaveAttribute("aria-sort", "ascending");
});

it("sets aria-sort='none' on non-sorted column headers", () => {
render(
<TransactionsTable
transactions={THREE_ROWS}
sortConfigs={[{ field: "date", direction: "desc" }]}
/>,
);
const amountHeader = screen.getByRole("columnheader", { name: /amount/i });
expect(amountHeader).toHaveAttribute("aria-sort", "none");
});

it("sets aria-sort='none' on all headers when no sort configs provided", () => {
render(<TransactionsTable transactions={THREE_ROWS} />);
// Only sortable columns (Date, Amount, Status) get aria-sort="none".
// Non-sortable columns (Transaction Type, Address, Token) do not set aria-sort.
const headers = screen.getAllByRole("columnheader");
const sortableHeaders = headers.filter(
(h) =>
h.textContent?.trim() === "Date" ||
h.textContent?.trim() === "Amount" ||
h.textContent?.trim() === "Status",
);
expect(sortableHeaders.length).toBe(3);
sortableHeaders.forEach((h) => {
expect(h).toHaveAttribute("aria-sort", "none");
});
});
});

describe("TransactionsTable — tooltip and truncation", () => {
Expand Down
148 changes: 83 additions & 65 deletions components/transactions/transactions-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TransactionsTableProps, TransactionProps, Tag } from "@/types/transaction";
import { TransactionsTableProps, TransactionProps, Tag, SortConfig, SortField } from "@/types/transaction";
import { Badge } from "@/components/ui/badge";
import TokenIcon from "@/components/transactions/token-icon";
import { getStatusColor } from "@/utils/transactionUtils";
Expand All @@ -76,6 +76,8 @@ import { TransactionTableSkeleton } from "@/components/ui/table-skeleton";

interface TransactionsTablePropsExtended extends TransactionsTableProps {
isLoading?: boolean;
/** Ordered sort criteria for the transactions table. */
sortConfigs?: SortConfig[];
/** Set of transaction ids that are currently selected. */
selectedIds?: Set<string>;
/**
Expand Down Expand Up @@ -288,6 +290,7 @@ function TransactionQuickViewDialog({
export function TransactionsTable({
transactions,
isLoading = false,
sortConfigs = [],
selectedIds = new Set(),
onSelectRow,
onSelectAll,
Expand All @@ -298,6 +301,14 @@ export function TransactionsTable({
onCreateTag,
}: TransactionsTablePropsExtended) {
const isEmpty = !isLoading && transactions.length === 0;

/** Map a table column to the aria-sort value for its header. */
const getAriaSort = (field: SortField): "ascending" | "descending" | "none" => {
if (sortConfigs.length === 0) return "none";
const config = sortConfigs[0];
if (config.field !== field) return "none";
return config.direction === "asc" ? "ascending" : "descending";
};

// State for quick-view dialog
const [selectedTransaction, setSelectedTransaction] = React.useState<TransactionProps | null>(null);
Expand Down Expand Up @@ -405,74 +416,81 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
)}
{...props}
/>
<>
{/* Desktop Table */}
<div
ref={tableWrapperRef}
className="hidden md:block w-full rounded-[12px] overflow-auto border border-[#2D2D2D]"
>
<Table>
{/* caption is visually hidden but announced by screen readers */}
<caption className="sr-only">Transaction history. Click a row to view transaction details.</caption>
<TableHeader>
<TableRow className="bg-[#191919]">
{isSelectable && (
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-4 w-12"
>
<Checkbox
aria-label={
allSelected
? "Deselect all transactions on this page"
: "Select all transactions on this page"
}
checked={headerCheckedState}
onCheckedChange={(checked) =>
onSelectAll?.(checked === true)
}
className="border-[#555] data-[state=checked]:border-white data-[state=indeterminate]:border-white"
/>
</TableHead>
)}
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6"
>
Transaction Type
</TableHead>
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6 w-[200px]"
>
Address
</TableHead>
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6"
>
Date
</TableHead>
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6"
>
Token
</TableHead>
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6 w-[140px]"
>
Amount
</TableHead>
);
}

return (
<>
{/* Desktop Table */}
<div
ref={tableWrapperRef}
className="hidden md:block w-full rounded-[12px] overflow-auto border border-[#2D2D2D]"
>
<Table>
{/* caption is visually hidden but announced by screen readers */}
<caption className="sr-only">Transaction history. Click a row to view transaction details.</caption>
<TableHeader>
<TableRow className="bg-[#191919]">
{isSelectable && (
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6 w-[120px]"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-4 w-12"
>
Status
<Checkbox
aria-label={
allSelected
? "Deselect all transactions on this page"
: "Select all transactions on this page"
}
checked={headerCheckedState}
onCheckedChange={(checked) =>
onSelectAll?.(checked === true)
}
className="border-[#555] data-[state=checked]:border-white data-[state=indeterminate]:border-white"
/>
</TableHead>
</TableRow>
</TableHeader>
)}
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6"
>
Transaction Type
</TableHead>
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6 w-[200px]"
>
Address
</TableHead>
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6"
aria-sort={getAriaSort("date")}
>
Date
</TableHead>
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6"
>
Token
</TableHead>
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6 w-[140px]"
aria-sort={getAriaSort("amount")}
>
Amount
</TableHead>
<TableHead
scope="col"
className="text-white font-bold border-[#2D2D2D] border-y-2 border-t-0 py-4 px-6 w-[120px]"
aria-sort={getAriaSort("status")}
>
Status
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: TRANSACTIONS_PAGE_SIZE }).map((_, index) => (
Expand Down