From 34b0c207f67c7065330534928291c06541216f63 Mon Sep 17 00:00:00 2001 From: Primex-Tech Date: Mon, 20 Jul 2026 13:15:32 -0700 Subject: [PATCH 1/4] The change is purely visual: the existing sort click and keyboard activation behavior remains unchanged. --- src/components/AnchorTable.test.tsx | 6 ++++++ src/components/AnchorTable.tsx | 2 +- src/components/PoolTable.test.tsx | 6 ++++++ src/components/PoolTable.tsx | 2 +- src/components/SettlementTable.test.tsx | 6 ++++++ src/components/SettlementTable.tsx | 2 +- 6 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/AnchorTable.test.tsx b/src/components/AnchorTable.test.tsx index 2144224..d4d5200 100644 --- a/src/components/AnchorTable.test.tsx +++ b/src/components/AnchorTable.test.tsx @@ -52,6 +52,12 @@ describe("AnchorTable", () => { expect(nameCells()).toEqual(["Charliec", "Bravob", "Alphaa"]); }); + it("applies a visible focus style to sortable header buttons", () => { + render(); + const sortButton = screen.getByLabelText("Sort by Anchor"); + expect(sortButton).toHaveClass("focus-visible:border", "focus-visible:border-zinc-600"); + }); + it("exposes the current sort direction via aria-sort", () => { render(); const header = screen.getByLabelText("Sort by Anchor").closest("th"); diff --git a/src/components/AnchorTable.tsx b/src/components/AnchorTable.tsx index 559e0b9..c165a6f 100644 --- a/src/components/AnchorTable.tsx +++ b/src/components/AnchorTable.tsx @@ -119,7 +119,7 @@ function SortableHeader({ type="button" onClick={() => onSort(sortKey)} aria-label={`Sort by ${label}`} - className="flex items-center gap-1 hover:text-zinc-200" + className="flex items-center gap-1 rounded-sm px-1 py-0.5 hover:text-zinc-200 focus-visible:border focus-visible:border-zinc-600 focus-visible:outline-none" > {label} {indicator} diff --git a/src/components/PoolTable.test.tsx b/src/components/PoolTable.test.tsx index 5a084a2..77c12fd 100644 --- a/src/components/PoolTable.test.tsx +++ b/src/components/PoolTable.test.tsx @@ -46,6 +46,12 @@ describe("PoolTable", () => { expect(assetCells()).toEqual(["EURC", "USDC", "XLM"]); }); + it("applies a visible focus style to sortable header buttons", () => { + render(); + const sortButton = screen.getByLabelText("Sort by Asset"); + expect(sortButton).toHaveClass("focus-visible:border", "focus-visible:border-zinc-600"); + }); + it("exposes the current sort direction via aria-sort", () => { render(); const header = screen.getByLabelText("Sort by Asset").closest("th"); diff --git a/src/components/PoolTable.tsx b/src/components/PoolTable.tsx index 5b02a1e..284881d 100644 --- a/src/components/PoolTable.tsx +++ b/src/components/PoolTable.tsx @@ -88,7 +88,7 @@ function SortableHeader({ type="button" onClick={() => onSort(sortKey)} aria-label={`Sort by ${label}`} - className="flex items-center gap-1 hover:text-zinc-200" + className="flex items-center gap-1 rounded-sm px-1 py-0.5 hover:text-zinc-200 focus-visible:border focus-visible:border-zinc-600 focus-visible:outline-none" > {label} {indicator} diff --git a/src/components/SettlementTable.test.tsx b/src/components/SettlementTable.test.tsx index 445c4b0..b3ebea7 100644 --- a/src/components/SettlementTable.test.tsx +++ b/src/components/SettlementTable.test.tsx @@ -46,6 +46,12 @@ describe("SettlementTable sorting", () => { expect(amountCells()).toEqual(["300", "200", "100"]); }); + it("applies a visible focus style to sortable header buttons", () => { + render(); + const sortButton = screen.getByLabelText("Sort by Amount"); + expect(sortButton).toHaveClass("focus-visible:border", "focus-visible:border-zinc-600"); + }); + it("exposes the current sort direction via aria-sort", () => { render(); const header = screen.getByLabelText("Sort by Amount").closest("th"); diff --git a/src/components/SettlementTable.tsx b/src/components/SettlementTable.tsx index 83bd619..970c94b 100644 --- a/src/components/SettlementTable.tsx +++ b/src/components/SettlementTable.tsx @@ -133,7 +133,7 @@ function SortableHeader({ type="button" onClick={() => onSort(sortKey)} aria-label={`Sort by ${label}`} - className="flex items-center gap-1 hover:text-zinc-200" + className="flex items-center gap-1 rounded-sm px-1 py-0.5 hover:text-zinc-200 focus-visible:border focus-visible:border-zinc-600 focus-visible:outline-none" > {label} {indicator} From 796da3740e1820f20e6a65b0f92f4857506dd94a Mon Sep 17 00:00:00 2001 From: Primex-Tech Date: Tue, 21 Jul 2026 08:39:34 -0700 Subject: [PATCH 2/4] Add SettlementForm asset autocomplete via availableLiquidity datalist --- src/components/SettlementForm.test.tsx | 33 ++++++++++++++++++++++++++ src/components/SettlementForm.tsx | 13 ++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/components/SettlementForm.test.tsx b/src/components/SettlementForm.test.tsx index 77218e8..4094d05 100644 --- a/src/components/SettlementForm.test.tsx +++ b/src/components/SettlementForm.test.tsx @@ -125,4 +125,37 @@ describe("SettlementForm", () => { expect(onSubmit).not.toHaveBeenCalled(); }); + + it("renders asset suggestions from availableLiquidity", () => { + const onSubmit = vi.fn(); + render( + , + ); + + const input = screen.getByPlaceholderText("Asset"); + expect(input).toHaveAttribute("list", "settlement-form-asset-list"); + expect(screen.getByRole("listbox", { name: "settlement-form-asset-list" })).toBeInTheDocument(); + expect(screen.getByDisplayValue("USDC")).toBeInTheDocument(); + + const datalist = document.getElementById("settlement-form-asset-list"); + expect(datalist).toBeInTheDocument(); + expect(datalist?.querySelectorAll("option")).toHaveLength(3); + expect(Array.from(datalist?.querySelectorAll("option") ?? []).map((option) => option.value)).toEqual([ + "USDC", + "BTC", + "EURT", + ]); + }); + + it("does not render a datalist when availableLiquidity is absent", () => { + const onSubmit = vi.fn(); + render(); + + const input = screen.getByPlaceholderText("Asset"); + expect(input).not.toHaveAttribute("list"); + expect(document.getElementById("settlement-form-asset-list")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/SettlementForm.tsx b/src/components/SettlementForm.tsx index 0542a29..8ad13b4 100644 --- a/src/components/SettlementForm.tsx +++ b/src/components/SettlementForm.tsx @@ -8,6 +8,7 @@ const inputClass = const invalidInputClass = "w-full rounded-lg border border-red-500/60 bg-zinc-950 px-3 py-2 text-sm " + "text-zinc-100 outline-none focus:border-red-500"; +const ASSET_DATALIST_ID = "settlement-form-asset-list"; interface FormErrors { anchor?: string; @@ -34,9 +35,11 @@ function validate(anchor: string, asset: string, amount: string): FormErrors { export function SettlementForm({ onSubmit, pending, + availableLiquidity, }: { onSubmit: (input: { anchor: string; asset: string; amount: number }) => void; pending?: boolean; + availableLiquidity?: Record; }) { const [anchor, setAnchor] = useState(""); const [asset, setAsset] = useState("USDC"); @@ -44,6 +47,8 @@ export function SettlementForm({ const [errors, setErrors] = useState({}); const anchorRef = useRef(null); + const assetOptions = Object.keys(availableLiquidity ?? {}); + const assetListId = assetOptions.length > 0 ? ASSET_DATALIST_ID : undefined; function submit(event: FormEvent) { event.preventDefault(); @@ -99,9 +104,17 @@ export function SettlementForm({ if (errors.asset) setErrors((prev) => ({ ...prev, asset: undefined })); }} placeholder="Asset" + list={assetListId} aria-invalid={Boolean(errors.asset)} className={errors.asset ? invalidInputClass : inputClass} /> + {assetOptions.length > 0 ? ( + + {assetOptions.map((assetOption) => ( + + ) : null} {errors.asset ? (

{errors.asset}

) : null} From 23776e47242bbb3c9c6843cee554bda8f1379a99 Mon Sep 17 00:00:00 2001 From: Primex-Tech Date: Tue, 21 Jul 2026 11:58:35 -0700 Subject: [PATCH 3/4] Debounce PoolsPanel search filtering and add regression test --- src/components/PoolsPanel.test.tsx | 36 +++++++++++++++++++++++++++++- src/components/PoolsPanel.tsx | 6 ++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/components/PoolsPanel.test.tsx b/src/components/PoolsPanel.test.tsx index 896f16c..0a34e2d 100644 --- a/src/components/PoolsPanel.test.tsx +++ b/src/components/PoolsPanel.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { PoolsPanel } from "./PoolsPanel"; import { fetchPools } from "@/lib/api"; @@ -144,6 +144,40 @@ describe("PoolsPanel", () => { expect(screen.getByLabelText("Search pools")).toHaveValue(""); }); + it("debounces the search filter, updating the list only after the delay", async () => { + vi.useFakeTimers(); + try { + vi.mocked(fetchPools).mockResolvedValue([ + { asset: "USDC", total: 1000, anchors: 2 }, + { asset: "EURC", total: 500, anchors: 1 }, + ]); + + render(); + await screen.findByText("USDC"); + await screen.findByText("EURC"); + + fireEvent.change(screen.getByLabelText("Search pools"), { + target: { value: "usdc" }, + }); + + expect(screen.getByLabelText("Search pools")).toHaveValue("usdc"); + expect(screen.getByText("EURC")).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(199); + }); + expect(screen.getByText("EURC")).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.queryByText("EURC")).not.toBeInTheDocument(); + expect(screen.getByText("USDC")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it("shows an error message and retries on demand", async () => { vi.mocked(fetchPools).mockRejectedValueOnce(new Error("network down")); diff --git a/src/components/PoolsPanel.tsx b/src/components/PoolsPanel.tsx index 232327d..9383c6f 100644 --- a/src/components/PoolsPanel.tsx +++ b/src/components/PoolsPanel.tsx @@ -5,6 +5,7 @@ import { fetchPools } from "@/lib/api"; import { formatAmount } from "@/lib/format"; import { matchesQuery } from "@/lib/search"; import { useAsync } from "@/hooks/useAsync"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useFocusShortcut } from "@/hooks/useFocusShortcut"; import { useQueryState } from "@/hooks/useQueryState"; import { Card } from "./Card"; @@ -14,11 +15,14 @@ import { PoolDistributionBar } from "./PoolDistributionBar"; import { TableSkeleton } from "./TableSkeleton"; import { EmptyState } from "./EmptyState"; +const SEARCH_DEBOUNCE_MS = 200; + /** Client panel that loads liquidity pools and renders summary stats. */ export function PoolsPanel() { const load = useCallback((signal: AbortSignal) => fetchPools(signal), []); const { state, reload } = useAsync(load); const [query, setQuery] = useQueryState("q", ""); + const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS); const searchRef = useRef(null); useFocusShortcut("/", searchRef); @@ -50,7 +54,7 @@ export function PoolsPanel() { const totalLiquidity = state.data.reduce((sum, p) => sum + p.total, 0); const positions = state.data.reduce((sum, p) => sum + p.anchors, 0); const filteredPools = state.data.filter((pool) => - matchesQuery([pool.asset], query), + matchesQuery([pool.asset], debouncedQuery), ); return ( From 82e2df6b30106d7ebe4c98fdd7e4a77b669e779f Mon Sep 17 00:00:00 2001 From: Primex-Tech Date: Wed, 22 Jul 2026 12:45:49 -0700 Subject: [PATCH 4/4] Add accessible landmark to anchors toolbar --- src/components/AnchorsPanel.test.tsx | 11 +++++++++++ src/components/AnchorsPanel.tsx | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/AnchorsPanel.test.tsx b/src/components/AnchorsPanel.test.tsx index ce7f0a4..2bf4dc9 100644 --- a/src/components/AnchorsPanel.test.tsx +++ b/src/components/AnchorsPanel.test.tsx @@ -75,6 +75,17 @@ describe("AnchorsPanel", () => { expect(screen.queryByText("Anchor B")).not.toBeInTheDocument(); }); + it("exposes the filter and search toolbar as a labelled search landmark", async () => { + vi.mocked(fetchAnchors).mockResolvedValue([ + { id: "a", name: "Anchor A", registeredAt: "", active: true }, + ]); + + renderPanel(); + await screen.findByText("Anchor A"); + + expect(screen.getByRole("search", { name: /anchors/i })).toBeInTheDocument(); + }); + it("focuses the search box when / is pressed", async () => { vi.mocked(fetchAnchors).mockResolvedValue([ { id: "a", name: "Anchor A", registeredAt: "", active: true }, diff --git a/src/components/AnchorsPanel.tsx b/src/components/AnchorsPanel.tsx index 9d5679e..f0f47dd 100644 --- a/src/components/AnchorsPanel.tsx +++ b/src/components/AnchorsPanel.tsx @@ -147,7 +147,11 @@ export function AnchorsPanel() { ) : ( <> {state.data.length > 0 ? ( -
+
{FILTERS.map((f, i) => (