diff --git a/apps/web/components/ChainIcon.tsx b/apps/web/components/ChainIcon.tsx new file mode 100644 index 0000000..282fd0f --- /dev/null +++ b/apps/web/components/ChainIcon.tsx @@ -0,0 +1,94 @@ +import { getChainMeta } from "./chainIcons"; + +export interface ChainIconProps { + chainId: number; + size?: number | string; + showName?: boolean; + showTooltip?: boolean; + className?: string; +} + +/** + * ChainIcon + * + * Renders the icon (and optionally name) for a given chain ID. + * + * @example + * + * + */ +export function ChainIcon({ + chainId, + size = 24, + showName = false, + showTooltip = true, + className = "", +}: ChainIconProps) { + const chain = getChainMeta(chainId); + + return ( + <> + + + + {showName && ( + {chain.name} + )} + + + ); +} + +export default ChainIcon; \ No newline at end of file diff --git a/apps/web/components/CopyTransactionDetails.tsx b/apps/web/components/CopyTransactionDetails.tsx new file mode 100644 index 0000000..b14cfd8 --- /dev/null +++ b/apps/web/components/CopyTransactionDetails.tsx @@ -0,0 +1,359 @@ +import { useState, useCallback, useRef } from "react"; + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export interface UseCopyToClipboardOptions { + /** How long (ms) the "copied" state stays active. Default 2000. */ + resetAfterMs?: number; +} + +export type CopyStatus = "idle" | "copied" | "error"; + +export interface UseCopyToClipboardReturn { + copy: (text: string) => Promise; + status: CopyStatus; + reset: () => void; +} + +/** + * useCopyToClipboard + * + * Wraps the Clipboard API with fallback for older browsers. + * Returns a `copy(text)` function and a `status` state. + */ +export function useCopyToClipboard({ + resetAfterMs = 2000, +}: UseCopyToClipboardOptions = {}): UseCopyToClipboardReturn { + const [status, setStatus] = useState("idle"); + const timerRef = useRef | null>(null); + + const reset = useCallback(() => setStatus("idle"), []); + + const copy = useCallback( + async (text: string): Promise => { + if (timerRef.current) clearTimeout(timerRef.current); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + // Legacy fallback + const el = document.createElement("textarea"); + el.value = text; + el.style.cssText = "position:fixed;opacity:0;pointer-events:none"; + document.body.appendChild(el); + el.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(el); + if (!ok) throw new Error("execCommand copy failed"); + } + setStatus("copied"); + timerRef.current = setTimeout(() => setStatus("idle"), resetAfterMs); + return true; + } catch { + setStatus("error"); + timerRef.current = setTimeout(() => setStatus("idle"), resetAfterMs); + return false; + } + }, + [resetAfterMs] + ); + + return { copy, status, reset }; +} + +// ── CopyButton component ────────────────────────────────────────────────────── + +export interface CopyButtonProps { + text: string; + label?: string; + successLabel?: string; + size?: "sm" | "md" | "lg"; + variant?: "icon" | "text" | "full"; + className?: string; +} + +const SIZE_MAP = { + sm: { padding: "4px 8px", fontSize: "11px", iconSize: "12px" }, + md: { padding: "6px 12px", fontSize: "13px", iconSize: "14px" }, + lg: { padding: "8px 16px", fontSize: "15px", iconSize: "16px" }, +}; + +/** + * CopyButton + * + * A self-contained button that copies `text` to clipboard and shows + * visual feedback. Supports icon-only, text-only, and combined variants. + * + * @example + * + * + */ +export function CopyButton({ + text, + label = "Copy", + successLabel = "Copied!", + size = "md", + variant = "full", + className = "", +}: CopyButtonProps) { + const { copy, status } = useCopyToClipboard(); + const s = SIZE_MAP[size]; + const isCopied = status === "copied"; + const isError = status === "error"; + + return ( + <> + + + + ); +} + +// ── CopyTransactionDetails component ───────────────────────────────────────── + +export interface TransactionDetail { + label: string; + value: string; + /** If true, display truncated with copy button. Default: true when field looks like address/hash */ + copyable?: boolean; +} + +export interface CopyTransactionDetailsProps { + details: TransactionDetail[]; + title?: string; + className?: string; +} + +function isHashOrAddress(value: string) { + return /^0x[0-9a-fA-F]{20,}$/.test(value) || value.length > 30; +} + +function truncate(value: string, chars = 8): string { + if (value.length <= chars * 2 + 3) return value; + return `${value.slice(0, chars)}…${value.slice(-chars)}`; +} + +/** + * CopyTransactionDetails + * + * Renders a structured list of transaction fields, each with an inline + * copy button. Supports one-click "Copy all" for the full set. + * + * @example + * + */ +export function CopyTransactionDetails({ + details, + title, + className = "", +}: CopyTransactionDetailsProps) { + const { copy: copyAll, status: copyAllStatus } = useCopyToClipboard(); + + const allText = details + .map((d) => `${d.label}: ${d.value}`) + .join("\n"); + + return ( + <> + +
+ {(title || details.length > 1) && ( +
+ {title && {title}} + {details.length > 1 && ( + + )} +
+ )} +
    + {details.map((d, i) => { + const shouldCopy = d.copyable ?? isHashOrAddress(d.value); + return ( +
  • + {d.label} + + + {isHashOrAddress(d.value) ? truncate(d.value) : d.value} + + {shouldCopy && ( + + )} + +
  • + ); + })} +
+
+ + ); +} \ No newline at end of file diff --git a/apps/web/components/QuoteExpirationCountdown.tsx b/apps/web/components/QuoteExpirationCountdown.tsx new file mode 100644 index 0000000..994126f --- /dev/null +++ b/apps/web/components/QuoteExpirationCountdown.tsx @@ -0,0 +1,202 @@ +import { useState, useEffect, useCallback } from "react"; + +export interface QuoteExpirationCountdownProps { + expiresAt: string | number | Date; // ISO string, unix ms, or Date + onExpire?: () => void; + onRefresh?: () => void; + className?: string; +} + +type Status = "healthy" | "warning" | "critical" | "expired"; + +function getStatus(secondsLeft: number): Status { + if (secondsLeft <= 0) return "expired"; + if (secondsLeft <= 10) return "critical"; + if (secondsLeft <= 30) return "warning"; + return "healthy"; +} + +function formatTime(seconds: number): string { + if (seconds <= 0) return "00:00"; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +export function QuoteExpirationCountdown({ + expiresAt, + onExpire, + onRefresh, + className = "", +}: QuoteExpirationCountdownProps) { + const getSecondsLeft = useCallback(() => { + const expiry = new Date(expiresAt).getTime(); + return Math.max(0, Math.floor((expiry - Date.now()) / 1000)); + }, [expiresAt]); + + const [secondsLeft, setSecondsLeft] = useState(getSecondsLeft); + const status = getStatus(secondsLeft); + + useEffect(() => { + setSecondsLeft(getSecondsLeft()); + const interval = setInterval(() => { + const s = getSecondsLeft(); + setSecondsLeft(s); + if (s <= 0) { + clearInterval(interval); + onExpire?.(); + } + }, 1000); + return () => clearInterval(interval); + }, [expiresAt, getSecondsLeft, onExpire]); + + const statusConfig = { + healthy: { + label: "Quote valid", + color: "bw-countdown--healthy", + icon: "✓", + }, + warning: { + label: "Expiring soon", + color: "bw-countdown--warning", + icon: "⚠", + }, + critical: { + label: "Expiring now", + color: "bw-countdown--critical", + icon: "!", + }, + expired: { + label: "Quote expired", + color: "bw-countdown--expired", + icon: "✕", + }, + }; + + const cfg = statusConfig[status]; + const progress = + status === "expired" + ? 0 + : Math.min(1, secondsLeft / Math.max(1, getSecondsLeft() + 1)); + + return ( + <> + +
+ + + ); +} + +export default QuoteExpirationCountdown; \ No newline at end of file diff --git a/apps/web/components/TokenSearchAutocomplete.tsx b/apps/web/components/TokenSearchAutocomplete.tsx new file mode 100644 index 0000000..a3ce665 --- /dev/null +++ b/apps/web/components/TokenSearchAutocomplete.tsx @@ -0,0 +1,439 @@ +import { + useState, + useEffect, + useRef, + useCallback, + KeyboardEvent, +} from "react"; + +export interface Token { + address: string; + symbol: string; + name: string; + chainId: number; + decimals: number; + logoURI?: string; +} + +export interface TokenSearchAutocompleteProps { + tokens: Token[]; + value?: Token | null; + onChange?: (token: Token) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + /** Debounce delay in ms. Default 200. */ + debounceMs?: number; +} + +function normalize(str: string) { + return str.toLowerCase().trim(); +} + +function scoreToken(token: Token, query: string): number { + const q = normalize(query); + const sym = normalize(token.symbol); + const name = normalize(token.name); + const addr = normalize(token.address); + + if (sym === q) return 100; + if (sym.startsWith(q)) return 90; + if (name.startsWith(q)) return 80; + if (sym.includes(q)) return 70; + if (name.includes(q)) return 60; + if (addr.startsWith(q)) return 50; + return 0; +} + +function searchTokens(tokens: Token[], query: string): Token[] { + if (!query.trim()) return tokens.slice(0, 8); + return tokens + .map((t) => ({ token: t, score: scoreToken(t, query) })) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 8) + .map((x) => x.token); +} + +function TokenLogo({ token, size = 24 }: { token: Token; size?: number }) { + const [error, setError] = useState(false); + if (token.logoURI && !error) { + return ( + {token.symbol} setError(true)} + /> + ); + } + return ( + + ); +} + +/** + * TokenSearchAutocomplete + * + * Fuzzy-searches a token list with keyboard navigation, accessible ARIA + * listbox, and debounced input. Works with any token list compatible with + * the Token interface (e.g. LiFi, 1inch, or BridgeWise's internal list). + * + * @example + * + */ +export function TokenSearchAutocomplete({ + tokens, + value, + onChange, + placeholder = "Search token…", + disabled = false, + className = "", + debounceMs = 200, +}: TokenSearchAutocompleteProps) { + const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + + const inputRef = useRef(null); + const listRef = useRef(null); + const containerRef = useRef(null); + + const results = searchTokens(tokens, debouncedQuery); + + // Debounce query + useEffect(() => { + const t = setTimeout(() => setDebouncedQuery(query), debounceMs); + return () => clearTimeout(t); + }, [query, debounceMs]); + + // Reset active index when results change + useEffect(() => setActiveIndex(-1), [debouncedQuery]); + + // Close on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (!containerRef.current?.contains(e.target as Node)) setIsOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const select = useCallback( + (token: Token) => { + onChange?.(token); + setQuery(""); + setIsOpen(false); + inputRef.current?.blur(); + }, + [onChange] + ); + + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) { + if (e.key === "ArrowDown" || e.key === "Enter") setIsOpen(true); + return; + } + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, results.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, -1)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && results[activeIndex]) { + select(results[activeIndex]); + } + break; + case "Escape": + setIsOpen(false); + setActiveIndex(-1); + break; + } + }; + + // Scroll active item into view + useEffect(() => { + if (activeIndex >= 0 && listRef.current) { + const item = listRef.current.children[activeIndex] as HTMLElement; + item?.scrollIntoView({ block: "nearest" }); + } + }, [activeIndex]); + + const listId = "bw-token-listbox"; + + return ( + <> + +
+
!disabled && inputRef.current?.focus()} + > + {value && ( + <> + + + + {value.symbol} + + +
+ + )} + { + setQuery(e.target.value); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder={value ? `Change token…` : placeholder} + disabled={disabled} + autoComplete="off" + role="combobox" + aria-expanded={isOpen} + aria-autocomplete="list" + aria-controls={listId} + aria-activedescendant={ + activeIndex >= 0 + ? `bw-token-item-${activeIndex}` + : undefined + } + /> + {query && ( + + )} +
+ + {isOpen && ( +
+
    + {results.length === 0 ? ( +
  • + No tokens found +
  • + ) : ( + results.map((token, i) => { + const isSelected = value?.address === token.address && value?.chainId === token.chainId; + const isActive = i === activeIndex; + return ( +
  • e.preventDefault()} + onClick={() => select(token)} + > + +
    +
    + {token.symbol} +
    +
    + {token.name} +
    +
    + {token.address.slice(0, 6)}…{token.address.slice(-4)} +
    +
    +
  • + ); + }) + )} +
+
+ )} +
+ + ); +} + +export default TokenSearchAutocomplete; \ No newline at end of file diff --git a/apps/web/constants/chainIcons.ts b/apps/web/constants/chainIcons.ts new file mode 100644 index 0000000..d785c52 --- /dev/null +++ b/apps/web/constants/chainIcons.ts @@ -0,0 +1,160 @@ +/** + * chainIcons.ts + * Maps chain IDs to their icon SVG paths and metadata. + * Add new chains here — no other file needs to change. + */ + +export interface ChainMeta { + id: number; + name: string; + symbol: string; + /** Inline SVG string (coloured) */ + svg: string; + /** Tailwind / CSS colour for fallback badge */ + color: string; +} + +// ── SVG icons (simplified, self-contained, accessible) ────────────────────── + +const ETH_SVG = ``; + +const BNB_SVG = ``; + +const POLYGON_SVG = ``; + +const ARBITRUM_SVG = ``; + +const OPTIMISM_SVG = ``; + +const AVALANCHE_SVG = ``; + +const BASE_SVG = ``; + +const SOLANA_SVG = ``; + +const UNKNOWN_SVG = (symbol: string) => + ``; + +// ── Chain registry ──────────────────────────────────────────────────────────── + +export const CHAIN_MAP: Record = { + 1: { + id: 1, + name: "Ethereum", + symbol: "ETH", + svg: ETH_SVG, + color: "#627EEA", + }, + 56: { + id: 56, + name: "BNB Chain", + symbol: "BNB", + svg: BNB_SVG, + color: "#F3BA2F", + }, + 137: { + id: 137, + name: "Polygon", + symbol: "MATIC", + svg: POLYGON_SVG, + color: "#8247E5", + }, + 42161: { + id: 42161, + name: "Arbitrum One", + symbol: "ARB", + svg: ARBITRUM_SVG, + color: "#28A0F0", + }, + 10: { + id: 10, + name: "Optimism", + symbol: "OP", + svg: OPTIMISM_SVG, + color: "#FF0420", + }, + 43114: { + id: 43114, + name: "Avalanche", + symbol: "AVAX", + svg: AVALANCHE_SVG, + color: "#E84142", + }, + 8453: { + id: 8453, + name: "Base", + symbol: "BASE", + svg: BASE_SVG, + color: "#0052FF", + }, + 1399811149: { + id: 1399811149, + name: "Solana", + symbol: "SOL", + svg: SOLANA_SVG, + color: "#9945FF", + }, +}; + +/** + * getChainMeta — returns chain metadata, falling back to an "unknown" entry. + */ +export function getChainMeta(chainId: number): ChainMeta { + return ( + CHAIN_MAP[chainId] ?? { + id: chainId, + name: `Chain ${chainId}`, + symbol: "???", + svg: UNKNOWN_SVG("???"), + color: "#374151", + } + ); +} + +/** + * getAllSupportedChains — returns all registered chains in display order. + */ +export function getAllSupportedChains(): ChainMeta[] { + return Object.values(CHAIN_MAP).sort((a, b) => a.id - b.id); +} + \ No newline at end of file diff --git a/apps/web/hooks/useQuoteExpiration.ts b/apps/web/hooks/useQuoteExpiration.ts new file mode 100644 index 0000000..c8b6b6c --- /dev/null +++ b/apps/web/hooks/useQuoteExpiration.ts @@ -0,0 +1,98 @@ +import { useState, useEffect, useCallback, useRef } from "react"; + +export interface UseQuoteExpirationOptions { + expiresAt: string | number | Date | null; + onExpire?: () => void; + /** Poll interval in ms. Default 1000. */ + interval?: number; +} + +export interface UseQuoteExpirationReturn { + secondsLeft: number; + isExpired: boolean; + isWarning: boolean; // <= 30s + isCritical: boolean; // <= 10s + formattedTime: string; + reset: (newExpiresAt: string | number | Date) => void; +} + +function toMs(val: string | number | Date): number { + return new Date(val).getTime(); +} + +function formatTime(seconds: number): string { + if (seconds <= 0) return "00:00"; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +/** + * useQuoteExpiration + * + * Tracks the remaining time for a quote and fires `onExpire` when time runs out. + * Designed to integrate with BridgeWise quote API responses that include an + * `expiresAt` field. + * + * @example + * const { formattedTime, isCritical, isExpired } = useQuoteExpiration({ + * expiresAt: quote.expiresAt, + * onExpire: () => refetchQuote(), + * }); + */ +export function useQuoteExpiration({ + expiresAt, + onExpire, + interval = 1000, +}: UseQuoteExpirationOptions): UseQuoteExpirationReturn { + const [expiryMs, setExpiryMs] = useState( + expiresAt ? toMs(expiresAt) : null + ); + + const getSecondsLeft = useCallback((): number => { + if (!expiryMs) return 0; + return Math.max(0, Math.floor((expiryMs - Date.now()) / 1000)); + }, [expiryMs]); + + const [secondsLeft, setSecondsLeft] = useState(getSecondsLeft); + const onExpireRef = useRef(onExpire); + onExpireRef.current = onExpire; + + // Sync when expiresAt prop changes (new quote fetched) + useEffect(() => { + if (expiresAt) setExpiryMs(toMs(expiresAt)); + }, [expiresAt]); + + useEffect(() => { + const s = getSecondsLeft(); + setSecondsLeft(s); + if (s <= 0) { + onExpireRef.current?.(); + return; + } + + const id = setInterval(() => { + const remaining = getSecondsLeft(); + setSecondsLeft(remaining); + if (remaining <= 0) { + clearInterval(id); + onExpireRef.current?.(); + } + }, interval); + + return () => clearInterval(id); + }, [expiryMs, getSecondsLeft, interval]); + + const reset = useCallback((newExpiresAt: string | number | Date) => { + setExpiryMs(toMs(newExpiresAt)); + }, []); + + return { + secondsLeft, + isExpired: secondsLeft <= 0, + isWarning: secondsLeft > 0 && secondsLeft <= 30, + isCritical: secondsLeft > 0 && secondsLeft <= 10, + formattedTime: formatTime(secondsLeft), + reset, + }; +} \ No newline at end of file diff --git a/test/bridgewise-issues.test.tsx b/test/bridgewise-issues.test.tsx new file mode 100644 index 0000000..1ae25f7 --- /dev/null +++ b/test/bridgewise-issues.test.tsx @@ -0,0 +1,263 @@ +/** + * BridgeWise — Tests for Issues #144, #145, #146, #147 + * + * Run with: vitest (or jest with ts-jest) + * + * Dependencies: @testing-library/react, @testing-library/user-event, vitest + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// ── #144 ───────────────────────────────────────────────────────────────────── + +describe("#144 QuoteExpirationCountdown", async () => { + const { QuoteExpirationCountdown } = await import("./QuoteExpirationCountdown"); + const { useQuoteExpiration } = await import("./useQuoteExpiration"); + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + function future(s: number) { + return new Date(Date.now() + s * 1000).toISOString(); + } + + it("renders the countdown in MM:SS format", () => { + render(); + expect(screen.getByRole("timer")).toBeTruthy(); + expect(screen.getByText("01:30")).toBeTruthy(); + }); + + it("decrements every second", async () => { + render(); + expect(screen.getByText("00:05")).toBeTruthy(); + act(() => vi.advanceTimersByTime(1000)); + expect(screen.getByText("00:04")).toBeTruthy(); + }); + + it("shows 'expired' state when time runs out", async () => { + render(); + act(() => vi.advanceTimersByTime(2000)); + expect(screen.getByText("00:00")).toBeTruthy(); + expect(screen.getByText(/expired/i)).toBeTruthy(); + }); + + it("calls onExpire callback", async () => { + const onExpire = vi.fn(); + render( + + ); + act(() => vi.advanceTimersByTime(2000)); + expect(onExpire).toHaveBeenCalledOnce(); + }); + + it("shows refresh button on expired state when onRefresh provided", () => { + const onRefresh = vi.fn(); + render( + + ); + const btn = screen.getByRole("button", { name: /refresh/i }); + expect(btn).toBeTruthy(); + btn.click(); + expect(onRefresh).toHaveBeenCalledOnce(); + }); + + it("useQuoteExpiration hook returns correct flags", () => { + let result: ReturnType; + function Wrapper() { + result = useQuoteExpiration({ expiresAt: future(8) }); + return null; + } + render(); + expect(result!.isCritical).toBe(true); + expect(result!.isWarning).toBe(true); + expect(result!.isExpired).toBe(false); + }); +}); + +// ── #145 ───────────────────────────────────────────────────────────────────── + +describe("#145 ChainIcon & chainIcons", async () => { + const { getChainMeta, getAllSupportedChains, CHAIN_MAP } = await import("./chainIcons"); + const { ChainIcon } = await import("./ChainIcon"); + + it("returns correct meta for Ethereum (1)", () => { + const meta = getChainMeta(1); + expect(meta.name).toBe("Ethereum"); + expect(meta.symbol).toBe("ETH"); + expect(meta.color).toBe("#627EEA"); + }); + + it("returns correct meta for Polygon (137)", () => { + const meta = getChainMeta(137); + expect(meta.name).toBe("Polygon"); + expect(meta.symbol).toBe("MATIC"); + }); + + it("returns fallback meta for unknown chain", () => { + const meta = getChainMeta(999999); + expect(meta.name).toMatch(/chain 999999/i); + expect(meta.symbol).toBe("???"); + expect(meta.svg).toBeTruthy(); + }); + + it("getAllSupportedChains returns all registered chains", () => { + const chains = getAllSupportedChains(); + expect(chains.length).toBe(Object.keys(CHAIN_MAP).length); + expect(chains.every((c) => c.svg && c.name && c.color)).toBe(true); + }); + + it("ChainIcon renders an img with aria-label", () => { + render(); + expect(screen.getByRole("img", { name: "Ethereum" })).toBeTruthy(); + }); + + it("ChainIcon renders name when showName=true", () => { + render(); + expect(screen.getByText("BNB Chain")).toBeTruthy(); + }); + + it("ChainIcon renders unknown chain without crashing", () => { + render(); + expect(screen.getByRole("img")).toBeTruthy(); + }); +}); + +// ── #146 ───────────────────────────────────────────────────────────────────── + +describe("#146 TokenSearchAutocomplete", async () => { + const { TokenSearchAutocomplete } = await import("./TokenSearchAutocomplete"); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + const TOKENS = [ + { address: "0xaaa", symbol: "USDC", name: "USD Coin", chainId: 1, decimals: 6 }, + { address: "0xbbb", symbol: "WETH", name: "Wrapped Ether", chainId: 1, decimals: 18 }, + { address: "0xccc", symbol: "DAI", name: "Dai Stablecoin", chainId: 1, decimals: 18 }, + { address: "0xddd", symbol: "USDT", name: "Tether USD", chainId: 1, decimals: 6 }, + ]; + + it("shows suggestions on focus", async () => { + render(); + const input = screen.getByRole("combobox"); + await user.click(input); + act(() => vi.advanceTimersByTime(300)); + expect(screen.getByRole("listbox")).toBeTruthy(); + }); + + it("filters by symbol", async () => { + render(); + const input = screen.getByRole("combobox"); + await user.type(input, "USDC"); + act(() => vi.advanceTimersByTime(300)); + expect(screen.getByText("USDC")).toBeTruthy(); + expect(screen.queryByText("WETH")).toBeNull(); + }); + + it("calls onChange with selected token", async () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole("combobox"); + await user.click(input); + act(() => vi.advanceTimersByTime(300)); + const option = screen.getByText("DAI"); + await user.click(option); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ symbol: "DAI" }) + ); + }); + + it("supports keyboard navigation", async () => { + render(); + const input = screen.getByRole("combobox"); + await user.click(input); + act(() => vi.advanceTimersByTime(300)); + await user.keyboard("{ArrowDown}{ArrowDown}"); + expect(input.getAttribute("aria-activedescendant")).toBe("bw-token-item-1"); + }); + + it("shows 'No tokens found' for unmatched query", async () => { + render(); + const input = screen.getByRole("combobox"); + await user.type(input, "XYZNOTEXIST"); + act(() => vi.advanceTimersByTime(300)); + expect(screen.getByText(/no tokens found/i)).toBeTruthy(); + }); + + it("closes on Escape", async () => { + render(); + const input = screen.getByRole("combobox"); + await user.click(input); + act(() => vi.advanceTimersByTime(300)); + expect(screen.getByRole("listbox")).toBeTruthy(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("listbox")).toBeNull(); + }); +}); + +// ── #147 ───────────────────────────────────────────────────────────────────── + +describe("#147 CopyTransactionDetails", async () => { + const { CopyButton, CopyTransactionDetails } = await import("./CopyTransactionDetails"); + + beforeEach(() => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + const DETAILS = [ + { label: "TX Hash", value: "0x" + "a".repeat(64) }, + { label: "From", value: "0x" + "b".repeat(40) }, + { label: "Amount", value: "1.5 ETH" }, + ]; + + it("renders all detail rows", () => { + render(); + expect(screen.getByText("TX Hash")).toBeTruthy(); + expect(screen.getByText("From")).toBeTruthy(); + expect(screen.getByText("Amount")).toBeTruthy(); + }); + + it("truncates long hash/address values", () => { + render(); + const hashCell = screen.getByTitle("0x" + "a".repeat(64)); + expect(hashCell.textContent).toMatch(/…/); + }); + + it("CopyButton copies text to clipboard", async () => { + const user = userEvent.setup(); + render(); + const btn = screen.getByRole("button"); + await user.click(btn); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world"); + }); + + it("CopyButton shows 'Copied!' after click", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getByText("Copied!")).toBeTruthy()); + }); + + it("shows 'Copy all' button when multiple details present", () => { + render(); + expect(screen.getByRole("button", { name: /copy all/i })).toBeTruthy(); + }); + + it("'Copy all' copies all fields formatted", async () => { + const user = userEvent.setup(); + render(); + const btn = screen.getByRole("button", { name: /copy all/i }); + await user.click(btn); + const written = (navigator.clipboard.writeText as ReturnType).mock.calls[0][0]; + expect(written).toContain("TX Hash:"); + expect(written).toContain("Amount: 1.5 ETH"); + }); +});