Skip to content
Open
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ accessible: it autofocuses the (non-destructive) cancel button, closes on
**Escape**, and traps Tab focus between its two buttons. Tables show an
animated skeleton while their first page of data is loading, instead of a
bare "Loading…" line. Sortable column headers expose their current direction
via `aria-sort` for assistive technology.
via `aria-sort` for assistive technology. While a table is sorted, a **Reset sort**
control appears in the active column header and restores the original row order in
one action.

A mock **wallet connect** lives in the header (a stand-in for a real Stellar
wallet integration); the connected account is persisted to `localStorage` so
Expand Down
13 changes: 13 additions & 0 deletions src/components/AnchorTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ describe("AnchorTable", () => {
expect(nameCells()).toEqual(["Alphaa", "Bravob", "Charliec"]);
});

it("resets an active sort directly to the original row order", () => {
render(<AnchorTable anchors={anchors} />);
const header = screen.getByLabelText("Sort by Anchor").closest("th");

expect(screen.queryByRole("button", { name: "Reset sort" })).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Sort by Anchor"));
expect(nameCells()).toEqual(["Alphaa", "Bravob", "Charliec"]);

fireEvent.click(screen.getByRole("button", { name: "Reset sort" }));
expect(nameCells()).toEqual(["Charliec", "Alphaa", "Bravob"]);
expect(header).toHaveAttribute("aria-sort", "none");
});

it("sorts descending by registered date on a second click", () => {
render(<AnchorTable anchors={anchors} />);
fireEvent.click(screen.getByLabelText("Sort by Registered"));
Expand Down
5 changes: 4 additions & 1 deletion src/components/AnchorTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function AnchorTable({
/** Ids of anchors with a deactivation currently in flight. */
deregisteringIds?: Set<string>;
}) {
const { sorted, sort, requestSort } = useSortableData<Anchor, SortKey>(
const { sorted, sort, requestSort, clearSort } = useSortableData<Anchor, SortKey>(
anchors,
getSortValue,
);
Expand All @@ -53,18 +53,21 @@ export function AnchorTable({
sortKey="name"
sort={sort}
onSort={requestSort}
onClearSort={clearSort}
/>
<SortableHeader
label="Registered"
sortKey="registeredAt"
sort={sort}
onSort={requestSort}
onClearSort={clearSort}
/>
<SortableHeader
label="Status"
sortKey="active"
sort={sort}
onSort={requestSort}
onClearSort={clearSort}
/>
{onDeregister ? <th className="py-2" /> : null}
</tr>
Expand Down
15 changes: 15 additions & 0 deletions src/components/PoolTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ describe("PoolTable", () => {
expect(assetCells()).toEqual(["USDC", "EURC", "XLM"]);
});

it("resets an active sort directly to the original row order", () => {
render(<PoolTable pools={pools} />);
const header = screen
.getByLabelText("Sort by Total liquidity")
.closest("th");

expect(screen.queryByRole("button", { name: "Reset sort" })).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Sort by Total liquidity"));
expect(assetCells()).toEqual(["USDC", "EURC", "XLM"]);

fireEvent.click(screen.getByRole("button", { name: "Reset sort" }));
expect(assetCells()).toEqual(["XLM", "USDC", "EURC"]);
expect(header).toHaveAttribute("aria-sort", "none");
});

it("sorts descending by total liquidity on a second click", () => {
render(<PoolTable pools={pools} />);
fireEvent.click(screen.getByLabelText("Sort by Total liquidity"));
Expand Down
5 changes: 4 additions & 1 deletion src/components/PoolTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function getSortValue(pool: Pool, key: SortKey): string | number {

/** Renders aggregated liquidity pools as a sortable table. */
export function PoolTable({ pools }: { pools: Pool[] }) {
const { sorted, sort, requestSort } = useSortableData<Pool, SortKey>(
const { sorted, sort, requestSort, clearSort } = useSortableData<Pool, SortKey>(
pools,
getSortValue,
);
Expand Down Expand Up @@ -44,18 +44,21 @@ export function PoolTable({ pools }: { pools: Pool[] }) {
sortKey="asset"
sort={sort}
onSort={requestSort}
onClearSort={clearSort}
/>
<SortableHeader
label="Total liquidity"
sortKey="total"
sort={sort}
onSort={requestSort}
onClearSort={clearSort}
/>
<SortableHeader
label="Anchors"
sortKey="anchors"
sort={sort}
onSort={requestSort}
onClearSort={clearSort}
/>
</tr>
</thead>
Expand Down
13 changes: 13 additions & 0 deletions src/components/SettlementTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ describe("SettlementTable sorting", () => {
expect(amountCells()).toEqual(["100", "200", "300"]);
});

it("resets an active sort directly to the original row order", () => {
render(<SettlementTable settlements={settlements} />);
const header = screen.getByLabelText("Sort by Amount").closest("th");

expect(screen.queryByRole("button", { name: "Reset sort" })).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Sort by Amount"));
expect(amountCells()).toEqual(["100", "200", "300"]);

fireEvent.click(screen.getByRole("button", { name: "Reset sort" }));
expect(amountCells()).toEqual(["300", "100", "200"]);
expect(header).toHaveAttribute("aria-sort", "none");
});

it("sorts descending by amount on a second click", () => {
render(<SettlementTable settlements={settlements} />);
fireEvent.click(screen.getByLabelText("Sort by Amount"));
Expand Down
5 changes: 4 additions & 1 deletion src/components/SettlementTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function SettlementTable({
onCancel?: (id: number) => void;
pendingIds?: Set<number>;
}) {
const { sorted, sort, requestSort } = useSortableData<Settlement, SortKey>(
const { sorted, sort, requestSort, clearSort } = useSortableData<Settlement, SortKey>(
settlements,
getSortValue,
);
Expand Down Expand Up @@ -68,20 +68,23 @@ export function SettlementTable({
sortKey="anchor"
sort={sort}
onSort={requestSort}
onClearSort={clearSort}
/>
<th className="py-2 font-medium">Asset</th>
<SortableHeader
label="Amount"
sortKey="amount"
sort={sort}
onSort={requestSort}
onClearSort={clearSort}
/>
<th className="py-2 font-medium">Fee</th>
<SortableHeader
label="Status"
sortKey="status"
sort={sort}
onSort={requestSort}
onClearSort={clearSort}
/>
{actionable ? <th className="py-2" /> : null}
</tr>
Expand Down
23 changes: 23 additions & 0 deletions src/components/SortableHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,27 @@ describe("SortableHeader", () => {
expect(onSort).toHaveBeenCalledOnce();
expect(onSort).toHaveBeenCalledWith("asset");
});

it("shows a reset control only for the active sort and calls its callback", () => {
const onClearSort = vi.fn();
render(
<table>
<thead>
<tr>
<SortableHeader
label="Asset"
sortKey="asset"
sort={{ key: "asset", direction: "asc" }}
onSort={vi.fn()}
onClearSort={onClearSort}
/>
</tr>
</thead>
</table>,
);

fireEvent.click(screen.getByRole("button", { name: "Reset sort" }));

expect(onClearSort).toHaveBeenCalledOnce();
});
});
16 changes: 15 additions & 1 deletion src/components/SortableHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

import { SortState } from "@/hooks/useSortableData";

/** A sortable `<th>` with aria-sort and a click-to-sort button, shared across tables. */
/**
* A sortable `<th>` with aria-sort, a click-to-sort button, and a reset control
* for the active sort. The reset is only shown when this column is sorted.
*/
export function SortableHeader<K extends string>({
label,
sortKey,
sort,
onSort,
onClearSort,
}: {
label: string;
sortKey: K;
sort: SortState<K> | null;
onSort: (key: K) => void;
onClearSort?: () => void;
}) {
const active = sort?.key === sortKey;
const indicator = active ? (sort?.direction === "asc" ? "▲" : "▼") : "";
Expand All @@ -33,6 +38,15 @@ export function SortableHeader<K extends string>({
{label}
<span className="w-2 text-[10px] text-zinc-500">{indicator}</span>
</button>
{active && onClearSort ? (
<button
type="button"
onClick={onClearSort}
className="ml-1 rounded-sm px-1 py-0.5 text-xs text-zinc-500 hover:text-zinc-200 focus-visible:border focus-visible:border-zinc-600 focus-visible:outline-none"
>
Reset sort
</button>
) : null}
</th>
);
}