From 4bbdd61118e881048506d15cca50f6b70c08a166 Mon Sep 17 00:00:00 2001 From: Enochflames Date: Fri, 16 Oct 2026 06:47:53 -0400 Subject: [PATCH] fix: keyboard-navigable dropdowns, double-submit guard, storage key hardening, offline indicator Keyboard navigation (a11y) - Fix CategoryFilter render (mismatched JSX tags from a bad merge) and add ArrowUp/ArrowDown alongside Left/Right/Home/End. - Add a reusable Listbox (WAI-ARIA select-only combobox) and use it for the policies sort selector: Arrow/Home/End, Enter/Space, Escape, typeahead. - Claims export menu: focus moves into the menu, Arrow/Home/End navigation, Escape returns focus to the trigger. Double-submit - Add useSubmitGuard: ref-based in-flight lock plus a 500ms leading-edge cooldown. Fixes double-clicking "Confirm details" in BuyPolicyModal also triggering signing. Applied to BuyPolicyModal and DepositModal, with a button spinner and aria-busy. Storage - Centralize every key in a storageKeys registry under the ps_ prefix and document them in docs/STORAGE.md; a test audits src for raw keys. - Move wallet id/address/network from localStorage to sessionStorage. - Fix useExpiryWarnings writing the raw wallet address into a key name. - migrateLegacyStorage() cleans up older builds' keys without logging active sessions out; clear the wallet kit's key on logout. Offline - networkStatus store combines navigator.onLine with API requests that get no response, probes the origin to auto-recover. - OfflineBanner in the layout; network actions (connect, buy/sign, deposit, withdraw) are disabled while offline. Also fix mismatched ErrorBoundaryWrapper tags in the root layout. Co-Authored-By: Claude Opus 5.5 --- docs/STORAGE.md | 54 +++++ src/__tests__/CategoryFilter.test.tsx | 31 +++ src/__tests__/ClaimsPage.test.tsx | 38 ++++ src/__tests__/Listbox.test.tsx | 108 ++++++++++ src/__tests__/WalletContext.test.tsx | 2 +- src/__tests__/doubleSubmit.test.tsx | 120 +++++++++++ src/__tests__/networkStatus.test.tsx | 113 ++++++++++ .../purchase-flow.integration.test.tsx | 7 +- src/__tests__/stellar.test.ts | 28 +-- src/__tests__/storageKeys.test.ts | 94 ++++++++- src/__tests__/useExpiryWarnings.test.tsx | 3 +- src/app/claims/page.tsx | 63 +++++- src/app/layout.tsx | 8 +- src/app/page.tsx | 5 +- src/app/policies/page.tsx | 38 ++-- src/components/BuyPolicyModal.tsx | 23 +- src/components/CategoryFilter.tsx | 29 +-- src/components/DepositModal.tsx | 15 +- src/components/Listbox.tsx | 196 ++++++++++++++++++ src/components/LoadingSpinner.tsx | 14 ++ src/components/NetworkBanner.tsx | 3 +- src/components/OfflineBanner.tsx | 45 ++++ src/components/WalletButton.tsx | 5 +- src/components/WithdrawModal.tsx | 6 +- src/context/ThemeContext.tsx | 3 +- src/context/WalletContext.tsx | 4 +- src/hooks/useExpiryWarnings.ts | 4 +- src/hooks/useOnlineStatus.ts | 13 ++ src/hooks/useSubmitGuard.ts | 45 ++++ src/lib/api.ts | 8 +- src/lib/constants.ts | 11 +- src/lib/networkStatus.ts | 114 ++++++++++ src/lib/stellar.ts | 27 +-- src/lib/storageKeys.ts | 76 ++++++- 34 files changed, 1245 insertions(+), 108 deletions(-) create mode 100644 docs/STORAGE.md create mode 100644 src/__tests__/Listbox.test.tsx create mode 100644 src/__tests__/doubleSubmit.test.tsx create mode 100644 src/__tests__/networkStatus.test.tsx create mode 100644 src/components/Listbox.tsx create mode 100644 src/components/OfflineBanner.tsx create mode 100644 src/hooks/useOnlineStatus.ts create mode 100644 src/hooks/useSubmitGuard.ts create mode 100644 src/lib/networkStatus.ts diff --git a/docs/STORAGE.md b/docs/STORAGE.md new file mode 100644 index 0000000..3a8fe51 --- /dev/null +++ b/docs/STORAGE.md @@ -0,0 +1,54 @@ +# Browser storage + +Everything Parashield keeps in `localStorage` / `sessionStorage`. All key names +are defined in [`src/lib/storageKeys.ts`](../src/lib/storageKeys.ts); nothing +else in `src/` may use a raw string key (enforced by +`src/__tests__/storageKeys.test.ts`). + +## Rules + +1. **One namespace.** Every first-party key starts with `ps_`, so the app's + whole footprint can be audited and cleared by prefix. +2. **Wallet and session data never outlives the tab.** Anything that + identifies the user or wallet goes in `sessionStorage` and is also wiped on + logout (`WalletProvider.disconnect` → `storage.clearSession`). +3. **`localStorage` is for UI preferences only.** Nothing identifying. +4. **No raw identifiers in key names.** Per-wallet keys use a one-way + fingerprint of the address (`fingerprint()` in `storageKeys.ts`), never the + address itself. +5. **New key?** Add it to `STORAGE_KEYS` and to the table below. + +## Inventory + +| Key | Area | Contents | Cleared | +| --- | --- | --- | --- | +| `ps_auth_token` | session | JWT from the sign-in challenge | tab close, logout, 401 | +| `ps_wallet_id` | session | Selected wallet module id (e.g. `freighter`) | tab close, logout | +| `ps_wallet_address` | session | Connected Stellar public key, used to re-check the wallet on reload | tab close, logout | +| `ps_wallet_network` | session | Network passphrase the wallet last reported | tab close, logout | +| `ps_expiry_notified_` | session | Policy ids already shown an expiry warning | tab close, logout | +| `ps_testnet_banner_dismissed` | session | `"1"` once the testnet banner is dismissed | tab close | +| `ps_theme` | local | `"dark"` or `"light"` | never (preference) | +| `ps_category_filter` | local | Last product category filter, e.g. `"crop"` | never (preference) | +| `@StellarWalletsKit/usedWalletsIds` | local | Written by `@creit.tech/stellar-wallets-kit`: wallet module ids used in this browser, for ordering its picker. Third-party, can't be renamed. | logout | + +## Migration from older builds + +`migrateLegacyStorage()` runs when `WalletProvider` mounts and is a no-op once +there's nothing left to migrate: + +- `ps_wallet_id`, `ps_wallet_address` and `ps_wallet_network` used to be in + `localStorage`, so the raw address stayed in the browser indefinitely. They + are removed from there; if the tab still has a live session they are copied + into `sessionStorage` first, so the upgrade doesn't log anyone out. +- `parashield_testnet_banner_dismissed` is renamed to + `ps_testnet_banner_dismissed`. + +## What this does and doesn't protect against + +Browser storage is per-origin: only scripts running on the Parashield origin +can read it, and any such script can read every value whatever the key is +called. Consistent, non-identifying key names and short retention reduce what +is left on a shared device and what a casual look at DevTools reveals. They +don't defend against a malicious script on the page; the Content Security +Policy in `netlify.toml` is what limits that. diff --git a/src/__tests__/CategoryFilter.test.tsx b/src/__tests__/CategoryFilter.test.tsx index fb836dc..5d07429 100644 --- a/src/__tests__/CategoryFilter.test.tsx +++ b/src/__tests__/CategoryFilter.test.tsx @@ -74,6 +74,37 @@ describe('CategoryFilter', () => { expect(html).toContain('custom'); }); + describe('keyboard navigation', () => { + function KeyboardHarness() { + const [value, setValue] = useState<'all' | 'crop' | 'flight' | 'disaster' | 'health' | 'defi'>('all'); + return ; + } + + it('moves selection and focus with ArrowDown/ArrowUp as well as Left/Right, wrapping', () => { + render(); + const all = screen.getByRole('tab', { name: /all policies/i }); + + fireEvent.keyDown(all, { key: 'ArrowDown' }); + const crop = screen.getByRole('tab', { name: /crop/i }); + expect(crop).toHaveAttribute('aria-selected', 'true'); + expect(crop).toHaveFocus(); + + fireEvent.keyDown(crop, { key: 'ArrowUp' }); + expect(all).toHaveFocus(); + fireEvent.keyDown(all, { key: 'ArrowUp' }); + expect(screen.getByRole('tab', { name: /defi/i })).toHaveFocus(); + }); + + it('jumps to the ends with Home/End', () => { + render(); + fireEvent.keyDown(screen.getByRole('tab', { name: /all policies/i }), { key: 'End' }); + const defi = screen.getByRole('tab', { name: /defi/i }); + expect(defi).toHaveFocus(); + fireEvent.keyDown(defi, { key: 'Home' }); + expect(screen.getByRole('tab', { name: /all policies/i })).toHaveFocus(); + }); + }); + describe('re-rendering (#590)', () => { beforeEach(() => labelReads.clear()); diff --git a/src/__tests__/ClaimsPage.test.tsx b/src/__tests__/ClaimsPage.test.tsx index 81d9c1b..e112f4a 100644 --- a/src/__tests__/ClaimsPage.test.tsx +++ b/src/__tests__/ClaimsPage.test.tsx @@ -53,4 +53,42 @@ describe('ClaimsPage export dropdown (#546)', () => { expect(screen.queryByRole('menu')).toBeNull(); }); + + it('opens from the keyboard and moves focus with arrow keys, Home and End', () => { + render(); + const trigger = screen.getByRole('button', { name: /Export/ }); + + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + const [csv, json] = screen.getAllByRole('menuitem'); + expect(csv).toHaveFocus(); + + const menu = screen.getByRole('menu'); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(json).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); // wraps + expect(csv).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'End' }); + expect(json).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'Home' }); + expect(csv).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'ArrowUp' }); // wraps + expect(json).toHaveFocus(); + }); + + it('ArrowUp on the trigger opens on the last item', () => { + render(); + fireEvent.keyDown(screen.getByRole('button', { name: /Export/ }), { key: 'ArrowUp' }); + expect(screen.getByRole('menuitem', { name: /JSON/ })).toHaveFocus(); + }); + + it('Escape closes the menu and returns focus to the trigger', () => { + render(); + const trigger = screen.getByRole('button', { name: /Export/ }); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + + expect(screen.queryByRole('menu')).toBeNull(); + expect(trigger).toHaveFocus(); + }); }); diff --git a/src/__tests__/Listbox.test.tsx b/src/__tests__/Listbox.test.tsx new file mode 100644 index 0000000..d1bb1fa --- /dev/null +++ b/src/__tests__/Listbox.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; +import { useState } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Listbox } from '../components/Listbox'; + +const OPTIONS = [ + { value: 'newest', label: 'Newest First' }, + { value: 'oldest', label: 'Oldest First' }, + { value: 'amount-high', label: 'Highest Amount' }, + { value: 'expiry', label: 'Expiring Soon' }, +] as const; +type Value = (typeof OPTIONS)[number]['value']; + +function Harness({ onChange = vi.fn() }: { onChange?: (v: Value) => void }) { + const [value, setValue] = useState('newest'); + return ( + { setValue(v); onChange(v); }} + label="Sort policies" + /> + ); +} + +function setup() { + const onChange = vi.fn(); + render(); + const combobox = screen.getByRole('combobox', { name: 'Sort policies' }); + const key = (k: string) => fireEvent.keyDown(combobox, { key: k }); + const active = () => { + const id = combobox.getAttribute('aria-activedescendant'); + return id ? document.getElementById(id)?.textContent : null; + }; + return { combobox, key, active, onChange }; +} + +describe('Listbox keyboard pattern', () => { + it('opens with ArrowDown on the selected option and exposes listbox semantics', () => { + const { combobox, key, active } = setup(); + expect(combobox).toHaveAttribute('aria-expanded', 'false'); + expect(combobox).toHaveAttribute('aria-haspopup', 'listbox'); + + key('ArrowDown'); + expect(combobox).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('listbox')).toBeVisible(); + expect(active()).toBe('Newest First'); + expect(screen.getByRole('option', { name: 'Newest First' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('moves with ArrowUp/ArrowDown and jumps with Home/End', () => { + const { key, active } = setup(); + key('ArrowDown'); + key('ArrowDown'); + expect(active()).toBe('Oldest First'); + key('End'); + expect(active()).toBe('Expiring Soon'); + key('ArrowDown'); // clamps at the end + expect(active()).toBe('Expiring Soon'); + key('ArrowUp'); + expect(active()).toBe('Highest Amount'); + key('Home'); + expect(active()).toBe('Newest First'); + }); + + it('opens on the first / last option with Home / End while closed', () => { + const { key, active } = setup(); + key('End'); + expect(active()).toBe('Expiring Soon'); + key('Escape'); + key('Home'); + expect(active()).toBe('Newest First'); + }); + + it('selects with Enter and closes', () => { + const { combobox, key, onChange } = setup(); + key('ArrowDown'); + key('ArrowDown'); + key('Enter'); + expect(onChange).toHaveBeenCalledWith('oldest'); + expect(combobox).toHaveAttribute('aria-expanded', 'false'); + expect(combobox).toHaveTextContent('Oldest First'); + }); + + it('Escape closes without changing the value', () => { + const { combobox, key, onChange } = setup(); + key('ArrowDown'); + key('End'); + key('Escape'); + expect(combobox).toHaveAttribute('aria-expanded', 'false'); + expect(onChange).not.toHaveBeenCalled(); + expect(combobox).toHaveTextContent('Newest First'); + }); + + it('jumps to an option by typing its first letter', () => { + const { key, active } = setup(); + key('ArrowDown'); + key('h'); + expect(active()).toBe('Highest Amount'); + }); + + it('still works with the mouse', () => { + const { combobox, onChange } = setup(); + fireEvent.click(combobox); + fireEvent.click(screen.getByRole('option', { name: 'Expiring Soon' })); + expect(onChange).toHaveBeenCalledWith('expiry'); + }); +}); diff --git a/src/__tests__/WalletContext.test.tsx b/src/__tests__/WalletContext.test.tsx index 2a9bc36..51ebfb2 100644 --- a/src/__tests__/WalletContext.test.tsx +++ b/src/__tests__/WalletContext.test.tsx @@ -93,7 +93,7 @@ describe('WalletProvider logout cleanup (#687)', () => { act(() => wallet.ctx.disconnect()); expect(storage.clearSession).toHaveBeenCalledWith( - ['ps_auth_token', 'ps_wallet_id', 'ps_wallet_address', 'ps_wallet_network'], + ['ps_auth_token', 'ps_wallet_id', 'ps_wallet_address', 'ps_wallet_network', '@StellarWalletsKit/usedWalletsIds'], ['ps_expiry_notified_'], ); }); diff --git a/src/__tests__/doubleSubmit.test.tsx b/src/__tests__/doubleSubmit.test.tsx new file mode 100644 index 0000000..4b1a303 --- /dev/null +++ b/src/__tests__/doubleSubmit.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { BuyPolicyModal } from '../components/BuyPolicyModal'; +import { DepositModal } from '../components/DepositModal'; +import { invokeBuyPolicy, buildDepositTx, submitSignedTransaction } from '@/lib/contract'; +import { SUBMIT_COOLDOWN_MS } from '@/hooks/useSubmitGuard'; +import type { PoolStats, Product } from '../types'; + +vi.mock('@/hooks/useWallet', () => ({ + useWallet: vi.fn(() => ({ address: 'GABC123', connect: vi.fn(), connecting: false, error: null })), +})); +vi.mock('@/context/ToastContext', () => ({ useToast: vi.fn(() => ({ show: vi.fn() })) })); +vi.mock('@/lib/stellar', () => ({ signTransaction: vi.fn(async () => 'signed-xdr') })); +vi.mock('@/lib/api', () => ({ + buyPolicy: vi.fn(async () => ({})), + fetchPoolShares: vi.fn(async () => ({ shareSupply: '10000000', totalLiquidity: '100000000', paused: false })), +})); + +// A request that stays in flight until the test resolves it. +let resolveRequest: () => void = () => {}; +vi.mock('@/lib/contract', () => ({ + invokeBuyPolicy: vi.fn( + () => new Promise((resolve) => { resolveRequest = () => resolve({ txHash: 'abc123def456', signedXdr: 'x' }); }), + ), + buildDepositTx: vi.fn(async () => 'unsigned-xdr'), + submitSignedTransaction: vi.fn( + () => new Promise((resolve) => { resolveRequest = () => resolve(); }), + ), + withStateRestore: vi.fn((fn: () => Promise) => fn()), +})); + +const product: Product = { + id: 'product-1', name: 'DeFi Cover', category: 'defi', triggerType: 'Threshold', threshold: '30', + comparison: 'LessThan', coverageMin: '1000000', coverageMax: '100000000', premiumRate: 500, + maxDuration: 30, status: 'Active', +} as Product; + +const pool: PoolStats = { + poolId: 'pool-1', category: 'crop', totalLiquidity: '100000000', activePolicies: 3, utilizationRate: 0.4, apy: 0.12, +}; + +/** Let the user "think" between deliberate clicks so the cooldown has passed. */ +function pause() { + act(() => { vi.advanceTimersByTime(SUBMIT_COOLDOWN_MS + 1); }); +} + +async function flush() { + await act(async () => { await Promise.resolve(); }); +} + +describe('double-click protection', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.clearAllMocks(); + }); + afterEach(() => vi.useRealTimers()); + + it('BuyPolicyModal: double-click on "Confirm details" does not also sign', async () => { + render(); + fireEvent.change(screen.getByPlaceholderText(/–/), { target: { value: '5' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await flush(); + pause(); + + const confirm = screen.getByRole('button', { name: 'Confirm details' }); + fireEvent.click(confirm); + fireEvent.click(confirm); + await flush(); + + expect(screen.getByRole('button', { name: /sign & confirm/i })).toBeInTheDocument(); + expect(invokeBuyPolicy).not.toHaveBeenCalled(); + }); + + it('BuyPolicyModal: rapid clicks on "Sign & Confirm" fire one request and show a loading state', async () => { + render(); + fireEvent.change(screen.getByPlaceholderText(/–/), { target: { value: '5' } }); + for (const name of ['Next', 'Confirm details']) { + fireEvent.click(screen.getByRole('button', { name })); + await flush(); + pause(); + } + + const sign = screen.getByRole('button', { name: /sign & confirm/i }); + fireEvent.click(sign); + fireEvent.click(sign); + await flush(); + // Even after the cooldown, a click while the request is in flight is dropped. + pause(); + fireEvent.click(sign); + await flush(); + + expect(invokeBuyPolicy).toHaveBeenCalledTimes(1); + const busyButton = screen.getByRole('button', { name: /submitting/i }); + expect(busyButton).toBeDisabled(); + expect(busyButton).toHaveAttribute('aria-busy', 'true'); + + await act(async () => { resolveRequest(); }); + }); + + it('DepositModal: double-click submits one deposit and disables the button in flight', async () => { + render(); + await flush(); + fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: '10' } }); + + const submit = screen.getByRole('button', { name: /confirm deposit/i }); + fireEvent.click(submit); + fireEvent.click(submit); + await flush(); + await flush(); + + expect(buildDepositTx).toHaveBeenCalledTimes(1); + expect(submitSignedTransaction).toHaveBeenCalledTimes(1); + const busyButton = screen.getByRole('button', { name: /confirming on chain/i }); + expect(busyButton).toBeDisabled(); + expect(busyButton).toHaveAttribute('aria-busy', 'true'); + + await act(async () => { resolveRequest(); }); + }); +}); diff --git a/src/__tests__/networkStatus.test.tsx b/src/__tests__/networkStatus.test.tsx new file mode 100644 index 0000000..050d25f --- /dev/null +++ b/src/__tests__/networkStatus.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { + PROBE_INTERVAL_MS, + isOnline, + reportRequestFailure, + reportRequestSuccess, + resetNetworkStatus, + subscribe, +} from '@/lib/networkStatus'; +import { OfflineBanner, BACK_ONLINE_DISPLAY_MS } from '@/components/OfflineBanner'; + +function setNavigatorOnline(value: boolean) { + Object.defineProperty(window.navigator, 'onLine', { configurable: true, get: () => value }); +} + +function goOffline() { + setNavigatorOnline(false); + act(() => { window.dispatchEvent(new Event('offline')); }); +} + +function goOnline() { + setNavigatorOnline(true); + act(() => { window.dispatchEvent(new Event('online')); }); +} + +beforeEach(() => { + resetNetworkStatus(); + setNavigatorOnline(true); +}); + +describe('networkStatus', () => { + it('follows the browser online/offline events', () => { + const unsubscribe = subscribe(() => {}); + expect(isOnline()).toBe(true); + goOffline(); + expect(isOnline()).toBe(false); + goOnline(); + expect(isOnline()).toBe(true); + unsubscribe(); + }); + + it('starts offline when the browser already is', () => { + setNavigatorOnline(false); + const unsubscribe = subscribe(() => {}); + expect(isOnline()).toBe(false); + unsubscribe(); + }); + + it('goes offline on request failures and recovers on the next success', () => { + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + reportRequestFailure(); + expect(isOnline()).toBe(false); + reportRequestSuccess(); + expect(isOnline()).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + describe('recovery probe', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); + + it('probes the origin while requests fail and recovers once it answers', async () => { + const fetchMock = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValue(new Response()); + vi.stubGlobal('fetch', fetchMock); + const unsubscribe = subscribe(() => {}); + + reportRequestFailure(); + await vi.advanceTimersByTimeAsync(PROBE_INTERVAL_MS); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(isOnline()).toBe(false); + + await vi.advanceTimersByTimeAsync(PROBE_INTERVAL_MS); + expect(isOnline()).toBe(true); + + // Stops probing once recovered. + await vi.advanceTimersByTimeAsync(PROBE_INTERVAL_MS * 3); + expect(fetchMock).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + }); +}); + +describe('OfflineBanner', () => { + afterEach(() => vi.useRealTimers()); + + it('shows nothing while online', () => { + render(); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + }); + + it('shows the offline banner, then a brief "back online" notice', () => { + vi.useFakeTimers(); + render(); + + goOffline(); + expect(screen.getByRole('status')).toHaveTextContent(/you're offline/i); + + goOnline(); + expect(screen.getByRole('status')).toHaveTextContent(/back online/i); + + act(() => { vi.advanceTimersByTime(BACK_ONLINE_DISPLAY_MS); }); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + }); + + it('shows the banner when API requests fail even though the browser reports online', () => { + render(); + act(() => reportRequestFailure()); + expect(screen.getByRole('status')).toHaveTextContent(/you're offline/i); + }); +}); diff --git a/src/__tests__/purchase-flow.integration.test.tsx b/src/__tests__/purchase-flow.integration.test.tsx index ab0d6ac..63d1964 100644 --- a/src/__tests__/purchase-flow.integration.test.tsx +++ b/src/__tests__/purchase-flow.integration.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import { ProductCard } from '../components/ProductCard'; import { BuyPolicyModal } from '../components/BuyPolicyModal'; import { useWallet } from '@/hooks/useWallet'; +import { SUBMIT_COOLDOWN_MS } from '@/hooks/useSubmitGuard'; import type { Product } from '../types'; // ── Mocks ──────────────────────────────────────────────────────────────────── @@ -180,10 +181,14 @@ describe('Purchase flow integration', () => { expect(screen.getAllByText('Crop Insurance').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('5.00 USDC')).toBeInTheDocument(); - // Step 1: Confirm details → Sign + // Step 1: Confirm details → Sign. A real user reads the review first; + // clicks within the double-click cooldown of the last one are dropped. + const later = Date.now() + SUBMIT_COOLDOWN_MS + 1; + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(later); await act(async () => { fireEvent.click(screen.getByText('Confirm details')); }); + nowSpy.mockRestore(); expect(screen.getByText('Sign & Confirm')).toBeInTheDocument(); expect(screen.getByText(/Your Stellar wallet will prompt you/)).toBeInTheDocument(); diff --git a/src/__tests__/stellar.test.ts b/src/__tests__/stellar.test.ts index d46adcb..e7ff6a3 100644 --- a/src/__tests__/stellar.test.ts +++ b/src/__tests__/stellar.test.ts @@ -36,9 +36,9 @@ import { describe('stellar helpers', () => { beforeEach(() => { - storage.remove(WALLET_STORAGE_KEY); - storage.remove(ADDRESS_STORAGE_KEY); - storage.remove(NETWORK_STORAGE_KEY); + storage.removeSession(WALLET_STORAGE_KEY); + storage.removeSession(ADDRESS_STORAGE_KEY); + storage.removeSession(NETWORK_STORAGE_KEY); vi.clearAllMocks(); }); @@ -61,22 +61,22 @@ describe('stellar helpers', () => { }); it('returns the address written by the connect flow', () => { - storage.set(ADDRESS_STORAGE_KEY, 'GABCDEF1234567890'); + storage.setSession(ADDRESS_STORAGE_KEY, 'GABCDEF1234567890'); expect(getStoredAddress()).toBe('GABCDEF1234567890'); }); }); describe('disconnectWallet', () => { it('removes the wallet-id, address, and network keys from storage', () => { - storage.set(WALLET_STORAGE_KEY, 'freighter'); - storage.set(ADDRESS_STORAGE_KEY, 'GTEST'); - storage.set(NETWORK_STORAGE_KEY, 'testnet'); + storage.setSession(WALLET_STORAGE_KEY, 'freighter'); + storage.setSession(ADDRESS_STORAGE_KEY, 'GTEST'); + storage.setSession(NETWORK_STORAGE_KEY, 'testnet'); disconnectWallet(); - expect(storage.get(WALLET_STORAGE_KEY)).toBeNull(); - expect(storage.get(ADDRESS_STORAGE_KEY)).toBeNull(); - expect(storage.get(NETWORK_STORAGE_KEY)).toBeNull(); + expect(storage.getSession(WALLET_STORAGE_KEY)).toBeNull(); + expect(storage.getSession(ADDRESS_STORAGE_KEY)).toBeNull(); + expect(storage.getSession(NETWORK_STORAGE_KEY)).toBeNull(); }); }); @@ -86,8 +86,8 @@ describe('stellar helpers', () => { }); it('passes through the signedMessage returned by kit.signMessage', async () => { - storage.set(ADDRESS_STORAGE_KEY, 'GTEST123'); - storage.set(WALLET_STORAGE_KEY, 'freighter'); + storage.setSession(ADDRESS_STORAGE_KEY, 'GTEST123'); + storage.setSession(WALLET_STORAGE_KEY, 'freighter'); mockSignMessage.mockResolvedValueOnce({ signedMessage: 'base64sig==' }); const result = await signAuthMessage('my-challenge-nonce'); @@ -102,8 +102,8 @@ describe('stellar helpers', () => { it('returns the raw base64 value without any encoding transformation', async () => { // signAuthMessage must return exactly what the wallet kit provides. // SEP-43 wallets return base64, not hex. The backend owns decoding. - storage.set(ADDRESS_STORAGE_KEY, 'GTEST123'); - storage.set(WALLET_STORAGE_KEY, 'freighter'); + storage.setSession(ADDRESS_STORAGE_KEY, 'GTEST123'); + storage.setSession(WALLET_STORAGE_KEY, 'freighter'); const b64 = 'aGVsbG8gd29ybGQ='; mockSignMessage.mockResolvedValueOnce({ signedMessage: b64 }); diff --git a/src/__tests__/storageKeys.test.ts b/src/__tests__/storageKeys.test.ts index 4cd01fe..467769c 100644 --- a/src/__tests__/storageKeys.test.ts +++ b/src/__tests__/storageKeys.test.ts @@ -1,4 +1,16 @@ -import { fingerprint, expiryNotifiedStorageKey } from '../lib/storageKeys'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + fingerprint, + expiryNotifiedStorageKey, + migrateLegacyStorage, + EXPIRY_NOTIFIED_PREFIX, + STORAGE_KEYS, + STORAGE_PREFIX, + WALLET_KIT_STORAGE_KEY, +} from '../lib/storageKeys'; + +const ROOT = resolve(__dirname, '../..'); const WALLET_A = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; const WALLET_B = 'GCKFBEIYTKP5RDBQMTVVALONAOPBXICILMAFL6ANEHMIYX7XFBZ7ZMGT'; @@ -20,3 +32,83 @@ describe('storageKeys (#548)', () => { expect(fingerprint('a')).not.toBe(fingerprint('b')); }); }); + +describe('storage key registry', () => { + it('namespaces every first-party key under the shared prefix, with no duplicates', () => { + const keys = Object.values(STORAGE_KEYS); + for (const key of keys) expect(key.startsWith(STORAGE_PREFIX)).toBe(true); + expect(EXPIRY_NOTIFIED_PREFIX.startsWith(STORAGE_PREFIX)).toBe(true); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('documents every key in docs/STORAGE.md', () => { + const doc = readFileSync(join(ROOT, 'docs/STORAGE.md'), 'utf8'); + for (const key of [...Object.values(STORAGE_KEYS), WALLET_KIT_STORAGE_KEY]) { + expect(doc, `${key} missing from docs/STORAGE.md`).toContain(`\`${key}`); + } + expect(doc).toContain(`\`${EXPIRY_NOTIFIED_PREFIX}`); + }); + + // Audit: no raw storage key string may appear outside the registry, so a + // new key can't be added without going through storageKeys.ts. + it('no source file outside the registry uses a raw storage key', () => { + const offenders: string[] = []; + const rawKey = /['"`](ps_|parashield_|eduvault_)\w*/; + const literalAccess = /(localStorage|sessionStorage)\.(get|set|remove)Item\(\s*['"`]/; + for (const file of sourceFiles(join(ROOT, 'src'))) { + if (file.endsWith('lib/storageKeys.ts')) continue; + readFileSync(file, 'utf8').split('\n').forEach((line, i) => { + if (/^\s*(\*|\/\/)/.test(line)) return; // comments + if (rawKey.test(line) || literalAccess.test(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`); + }); + } + expect(offenders).toEqual([]); + }); +}); + +describe('migrateLegacyStorage', () => { + beforeEach(() => { localStorage.clear(); sessionStorage.clear(); }); + + it('removes wallet data from localStorage when there is no live session', () => { + localStorage.setItem('ps_wallet_address', WALLET_A); + localStorage.setItem('ps_wallet_id', 'freighter'); + localStorage.setItem('ps_theme', 'light'); + + migrateLegacyStorage(); + + expect(localStorage.getItem('ps_wallet_address')).toBeNull(); + expect(localStorage.getItem('ps_wallet_id')).toBeNull(); + expect(sessionStorage.getItem('ps_wallet_address')).toBeNull(); + // Preferences are left alone. + expect(localStorage.getItem('ps_theme')).toBe('light'); + }); + + it('carries wallet data into sessionStorage when the tab still has a session', () => { + sessionStorage.setItem(STORAGE_KEYS.authToken, 'jwt'); + localStorage.setItem('ps_wallet_address', WALLET_A); + localStorage.setItem('ps_wallet_network', 'Test SDF Network ; September 2015'); + + migrateLegacyStorage(); + + expect(localStorage.getItem('ps_wallet_address')).toBeNull(); + expect(sessionStorage.getItem(STORAGE_KEYS.walletAddress)).toBe(WALLET_A); + expect(sessionStorage.getItem(STORAGE_KEYS.walletNetwork)).toBe('Test SDF Network ; September 2015'); + }); + + it('renames the pre-namespace testnet banner key', () => { + sessionStorage.setItem('parashield_testnet_banner_dismissed', '1'); + + migrateLegacyStorage(); + + expect(sessionStorage.getItem('parashield_testnet_banner_dismissed')).toBeNull(); + expect(sessionStorage.getItem(STORAGE_KEYS.testnetBannerDismissed)).toBe('1'); + }); +}); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return entry.name === '__tests__' ? [] : sourceFiles(path); + return /\.(ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) ? [path] : []; + }); +} diff --git a/src/__tests__/useExpiryWarnings.test.tsx b/src/__tests__/useExpiryWarnings.test.tsx index 3fc5772..a03a13a 100644 --- a/src/__tests__/useExpiryWarnings.test.tsx +++ b/src/__tests__/useExpiryWarnings.test.tsx @@ -1,4 +1,5 @@ import { useExpiryWarnings } from '../hooks/useExpiryWarnings'; +import { expiryNotifiedStorageKey } from '../lib/storageKeys'; import { renderHook } from './renderHook'; import type { Policy } from '../types'; @@ -56,7 +57,7 @@ describe('useExpiryWarnings (#532)', () => { }); it('skips policies already recorded in sessionStorage', () => { - sessionStorage.setItem('ps_expiry_notified_GWALLET', JSON.stringify(['policy-1'])); + sessionStorage.setItem(expiryNotifiedStorageKey('GWALLET'), JSON.stringify(['policy-1'])); const toasts: string[] = []; renderHook(() => useExpiryWarnings([makePolicy()], true, 'GWALLET', (m) => { toasts.push(m); })); diff --git a/src/app/claims/page.tsx b/src/app/claims/page.tsx index cc261d3..daa2c48 100644 --- a/src/app/claims/page.tsx +++ b/src/app/claims/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { useWallet } from '@/hooks/useWallet'; import { useClaims } from '@/hooks/useClaims'; import { ConnectWalletPrompt } from '@/components/ConnectWalletPrompt'; @@ -26,14 +26,56 @@ export default function ClaimsPage() { const [refreshing, setRefreshing] = useState(false); const [exportOpen, setExportOpen] = useState(false); + const exportButtonRef = useRef(null); + const exportMenuRef = useRef(null); + // Which item to focus when the menu opens: ArrowUp on the trigger opens on + // the last item, everything else on the first (WAI-ARIA menu button). + const exportFocusRef = useRef<'first' | 'last'>('first'); + + const closeExport = useCallback((restoreFocus: boolean) => { + setExportOpen(false); + if (restoreFocus) exportButtonRef.current?.focus(); + }, []); + useEffect(() => { if (!exportOpen) return; + const items = exportMenuRef.current?.querySelectorAll('[role="menuitem"]'); + items?.[exportFocusRef.current === 'last' ? items.length - 1 : 0]?.focus(); + exportFocusRef.current = 'first'; + const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setExportOpen(false); + if (e.key === 'Escape') closeExport(true); }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); - }, [exportOpen]); + }, [exportOpen, closeExport]); + + const handleExportTriggerKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + exportFocusRef.current = e.key === 'ArrowUp' ? 'last' : 'first'; + setExportOpen(true); + } + }; + + const handleExportMenuKeyDown = (e: React.KeyboardEvent) => { + const items = Array.from( + exportMenuRef.current?.querySelectorAll('[role="menuitem"]') ?? [], + ); + if (items.length === 0) return; + const current = items.indexOf(document.activeElement as HTMLElement); + let next: number | null = null; + if (e.key === 'ArrowDown') next = (current + 1) % items.length; + else if (e.key === 'ArrowUp') next = (current - 1 + items.length) % items.length; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = items.length - 1; + else if (e.key === 'Tab') closeExport(false); + + if (next !== null) { + e.preventDefault(); + items[next].focus(); + } + }; const handleRefresh = useCallback(async () => { setRefreshing(true); @@ -69,7 +111,9 @@ export default function ClaimsPage() { {claims.length > 0 && (
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9f2c4c9..be08f1d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import './globals.css'; import { NavBar } from '@/components/NavBar'; import { ToastContainer } from '@/components/Toast'; import { NetworkBanner } from '@/components/NetworkBanner'; +import { OfflineBanner } from '@/components/OfflineBanner'; import { WalletProvider } from '@/context/WalletContext'; import { ToastProvider } from '@/context/ToastContext'; import { ThemeProvider } from '@/context/ThemeContext'; @@ -82,10 +83,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + - +
{children} @@ -93,7 +95,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
- +
@@ -101,7 +103,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
© {CURRENT_YEAR} Parashield · Built on Stellar · Powered by Soroban
- + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index a04211c..9a1732b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -14,6 +14,7 @@ import { CategoryFilter } from '@/components/CategoryFilter'; import { EmptyState } from '@/components/EmptyState'; import { CompareModal } from '@/components/CompareModal'; import { CATEGORY_LABELS } from '@/lib/constants'; +import { STORAGE_KEYS } from '@/lib/storageKeys'; import type { Category, Product } from '@/types'; type CategoryFilterValue = Category | 'all'; @@ -66,7 +67,7 @@ export default function HomePage() { useEffect(() => { try { - const stored = localStorage.getItem('ps_category_filter'); + const stored = localStorage.getItem(STORAGE_KEYS.categoryFilter); if (stored) { setCategory(JSON.parse(stored) as CategoryFilterValue); } @@ -77,7 +78,7 @@ export default function HomePage() { useEffect(() => { try { - localStorage.setItem('ps_category_filter', JSON.stringify(category)); + localStorage.setItem(STORAGE_KEYS.categoryFilter, JSON.stringify(category)); } catch { // ignore } diff --git a/src/app/policies/page.tsx b/src/app/policies/page.tsx index 28ff725..37e227f 100644 --- a/src/app/policies/page.tsx +++ b/src/app/policies/page.tsx @@ -9,6 +9,7 @@ import { ConnectWalletPrompt } from '@/components/ConnectWalletPrompt'; import { SkeletonCard } from '@/components/Skeleton'; import { EmptyState } from '@/components/EmptyState'; import { Badge } from '@/components/Badge'; +import { Listbox, type ListboxOption } from '@/components/Listbox'; import Link from 'next/link'; type Filter = 'All' | 'Active' | 'Pending' | 'Processing' | 'Expired' | 'Claimed' | 'Cancelled'; @@ -21,7 +22,7 @@ const FILTERS: Filter[] = ['All', 'Active', 'Pending', 'Processing', 'Expired', const SKELETON_COUNT = 6; // Sort options for policy display -const SORT_OPTIONS: { value: SortOption; label: string }[] = [ +const SORT_OPTIONS: ListboxOption[] = [ { value: 'newest', label: 'Newest First' }, { value: 'oldest', label: 'Oldest First' }, { value: 'amount-high', label: 'Highest Amount' }, @@ -176,8 +177,8 @@ export default function PoliciesPage() { setPage(0); }, []); - const handleSortChange = useCallback((e: React.ChangeEvent) => { - setSortBy(e.target.value as SortOption); + const handleSortChange = useCallback((value: SortOption) => { + setSortBy(value); setPage(0); }, []); @@ -321,29 +322,14 @@ export default function PoliciesPage() { )}
- {/* Sort Dropdown */} -
- - - - -
+ {/* Sort Dropdown: keyboard-navigable listbox */} + )} diff --git a/src/components/BuyPolicyModal.tsx b/src/components/BuyPolicyModal.tsx index b6fcf34..7469a5b 100644 --- a/src/components/BuyPolicyModal.tsx +++ b/src/components/BuyPolicyModal.tsx @@ -12,6 +12,9 @@ import { buildRainfallKey, buildFlightKey } from '@/lib/oracle'; import { Modal } from './Modal'; import { StepProgress } from './ProgressBar'; import { useToast } from '@/context/ToastContext'; +import { useSubmitGuard } from '@/hooks/useSubmitGuard'; +import { useOnlineStatus } from '@/hooks/useOnlineStatus'; +import { ButtonSpinner } from './LoadingSpinner'; import { INPUT_CLASS, SELECT_CLASS, LABEL_CLASS, ERROR_TEXT, INFO_TEXT, CARD_PANEL } from '@/lib/styles'; import { MAX_INPUT_LENGTHS } from '@/lib/constants'; @@ -50,6 +53,13 @@ export function BuyPolicyModal({ product, onClose }: Props) { }, [product.id]); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + // Drops repeat clicks while a request is in flight and right after a step + // advance, so a double-click on "Confirm details" can't also sign. + const { guard, pending } = useSubmitGuard(); + const online = useOnlineStatus(); + // Configure/Review are local; connecting the wallet and signing are not. + const needsNetwork = !address || step === 2; + const blockedOffline = !online && needsNetwork; const [coverageError, setCoverageError] = useState(''); const [durationError, setDurationError] = useState(''); @@ -469,12 +479,15 @@ export function BuyPolicyModal({ product, onClose }: Props) { )} + cat={cat} + index={i} + selected={value === cat} + onSelect={handleSelect} + onKeyDown={handleKeyDown} + setRef={setRef} + /> ))} ); diff --git a/src/components/DepositModal.tsx b/src/components/DepositModal.tsx index 53e396a..f7eab66 100644 --- a/src/components/DepositModal.tsx +++ b/src/components/DepositModal.tsx @@ -12,6 +12,9 @@ import { ContractError, toUserMessage } from '@/lib/errors'; import { CATEGORY_LABELS, MIN_DEPOSIT_STROOPS } from '@/lib/constants'; import { Modal } from './Modal'; import { useToast } from '@/context/ToastContext'; +import { useSubmitGuard } from '@/hooks/useSubmitGuard'; +import { useOnlineStatus } from '@/hooks/useOnlineStatus'; +import { ButtonSpinner } from './LoadingSpinner'; import { INPUT_CLASS, LABEL_CLASS, ERROR_TEXT, WARNING_TEXT, CARD_PANEL } from '@/lib/styles'; interface Props { @@ -53,6 +56,8 @@ export function DepositModal({ pool, onClose }: Props) { const [sharesFetchError, setSharesFetchError] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + const { guard, pending } = useSubmitGuard(); + const online = useOnlineStatus(); const loadShares = useCallback( (signal: { cancelled: boolean }) => { @@ -208,11 +213,13 @@ export function DepositModal({ pool, onClose }: Props) { ); diff --git a/src/components/Listbox.tsx b/src/components/Listbox.tsx new file mode 100644 index 0000000..847182c --- /dev/null +++ b/src/components/Listbox.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { useCallback, useEffect, useId, useRef, useState } from 'react'; + +export interface ListboxOption { + value: T; + label: string; +} + +interface ListboxProps { + value: T; + options: readonly ListboxOption[]; + onChange: (value: T) => void; + /** Accessible name for the control (there is no visible label). */ + label: string; + className?: string; +} + +/** + * Custom dropdown following the WAI-ARIA "select-only combobox" pattern. + * Focus stays on the trigger button the whole time; the highlighted option is + * announced through aria-activedescendant. + * + * Closed: ArrowDown / ArrowUp / Enter / Space open it, Home / End open it on + * the first / last option. + * Open: ArrowDown / ArrowUp move, Home / End jump, Enter / Space select, + * Escape closes without changing the value, Tab closes, and typing a + * character jumps to the next option starting with it. + */ +export function Listbox({ value, options, onChange, label, className }: ListboxProps) { + const id = useId(); + const listId = `${id}-listbox`; + const optionId = (index: number) => `${id}-option-${index}`; + + const rootRef = useRef(null); + const listRef = useRef(null); + const [open, setOpen] = useState(false); + const selectedIndex = Math.max(0, options.findIndex((o) => o.value === value)); + const [activeIndex, setActiveIndex] = useState(selectedIndex); + + const openAt = useCallback((index: number) => { + setActiveIndex(index); + setOpen(true); + }, []); + + const commit = useCallback((index: number) => { + setOpen(false); + const option = options[index]; + if (option && option.value !== value) onChange(option.value); + }, [options, value, onChange]); + + // Close on a click anywhere outside the control. + useEffect(() => { + if (!open) return; + const onPointerDown = (e: PointerEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('pointerdown', onPointerDown); + return () => document.removeEventListener('pointerdown', onPointerDown); + }, [open]); + + // Keep the highlighted option visible in a scrolled list. + useEffect(() => { + if (!open) return; + const el = listRef.current?.querySelector(`#${CSS.escape(optionId(activeIndex))}`); + el?.scrollIntoView?.({ block: 'nearest' }); + // optionId is derived from the stable useId value. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, activeIndex]); + + function typeahead(char: string) { + const lower = char.toLowerCase(); + for (let step = 1; step <= options.length; step++) { + const index = (activeIndex + step) % options.length; + if (options[index].label.toLowerCase().startsWith(lower)) return index; + } + return null; + } + + function handleKeyDown(e: React.KeyboardEvent) { + const last = options.length - 1; + + if (!open) { + switch (e.key) { + case 'ArrowDown': + case 'ArrowUp': + case 'Enter': + case ' ': + e.preventDefault(); + openAt(selectedIndex); + return; + case 'Home': + e.preventDefault(); + openAt(0); + return; + case 'End': + e.preventDefault(); + openAt(last); + return; + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setActiveIndex((i) => Math.min(last, i + 1)); + return; + case 'ArrowUp': + e.preventDefault(); + setActiveIndex((i) => Math.max(0, i - 1)); + return; + case 'Home': + e.preventDefault(); + setActiveIndex(0); + return; + case 'End': + e.preventDefault(); + setActiveIndex(last); + return; + case 'Enter': + case ' ': + e.preventDefault(); + commit(activeIndex); + return; + case 'Escape': + // Stop it reaching document-level Escape handlers (e.g. a parent modal). + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + return; + case 'Tab': + setOpen(false); + return; + } + + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + const match = typeahead(e.key); + if (match !== null) setActiveIndex(match); + } + } + + return ( +
+ + +
+ ); +} diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx index 1379435..2c18a0b 100644 --- a/src/components/LoadingSpinner.tsx +++ b/src/components/LoadingSpinner.tsx @@ -26,3 +26,17 @@ export function FullPageSpinner() { ); } + +/** + * Decorative spinner for inside a button. Hidden from assistive tech so it + * doesn't change the button's accessible name; mark the button `aria-busy` + * and keep its text describing the state ("Submitting…") instead. + */ +export function ButtonSpinner({ className }: { className?: string }) { + return ( +