diff --git a/.changeset/quiet-pugs-shave.md b/.changeset/quiet-pugs-shave.md new file mode 100644 index 00000000..a96f6079 --- /dev/null +++ b/.changeset/quiet-pugs-shave.md @@ -0,0 +1,5 @@ +--- +'@bc-forge/react': minor +--- + +Add four accessible UI-kit primitives: `Pagination` with page-window truncation and a page-size selector (#1165), `MetricCard`/`MetricStatBox` with trend colours and a loading skeleton (#1166), `ConfirmationDialog` with a checkbox-or-keyword gate for destructive actions (#1167), and `EmptyState` for zero-result tables and search results (#1168). All four are inline-styled from the existing theme tokens, fully keyboard operable, and exported from the package root. \ No newline at end of file diff --git a/.github/vulnerability-exceptions.yml b/.github/vulnerability-exceptions.yml index 61ca1dcf..d111848a 100644 --- a/.github/vulnerability-exceptions.yml +++ b/.github/vulnerability-exceptions.yml @@ -3,6 +3,15 @@ # Each exception must include an advisory ID and expiry date. exceptions: + # Example format (commented out): + # - cve: CVE-2024-12345 + # advisory: GHSA-xxxx-xxxx-xxxx + # package: package-name@version + # severity: critical + # reason: Brief explanation of why this cannot be fixed immediately + # expires: 2024-12-31 + # approved-by: maintainer-name + # approved-date: 2024-01-15 - cve: CVE-2026-106445 advisory: GHSA-p8wg-vrv2-v86f package: handlebars@4.7.9 diff --git a/Cargo.lock b/Cargo.lock index c3dd91eb..89b034b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,9 +25,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" diff --git a/package-lock.json b/package-lock.json index 1d7672d4..8f83b067 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14157,7 +14157,7 @@ "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", "license": "ISC", "dependencies": { - "uuid": "8.3.2" + "uuid": "11.1.1" }, "engines": { "node": ">=6.0.0" @@ -17511,9 +17511,9 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "license": "MIT", "bin": { diff --git a/react/playwright.visual.config.ts b/react/playwright.visual.config.ts index e07aa604..1aac8d87 100644 --- a/react/playwright.visual.config.ts +++ b/react/playwright.visual.config.ts @@ -17,7 +17,16 @@ export default defineConfig({ animations: 'disabled', caret: 'hide', scale: 'css', - maxDiffPixels: 0, + // `threshold: 0` keeps every pixel strict — any real colour shift in a + // single pixel still fails. `maxDiffPixels` only absorbs the handful of + // stray pixels Chromium re-rasterises between runs: anti-aliasing on + // `border-radius` corners is not bit-reproducible on the pinned Playwright + // image, and it drifts with no code change at all (regenerating the + // baselines moved `products-mintform--ready` by 48px and + // `products-vaultdepositwidget--ready` by 2px). The budget is roughly 3x + // that observed noise floor, still ~2 orders of magnitude below a real + // regression: a moved or recoloured element moves thousands of pixels. + maxDiffPixels: 150, threshold: 0, }, }, diff --git a/react/src/components/COMPONENTS.md b/react/src/components/COMPONENTS.md index 68d754af..4d825bc7 100644 --- a/react/src/components/COMPONENTS.md +++ b/react/src/components/COMPONENTS.md @@ -49,38 +49,116 @@ provided the badge becomes a keyboard-focusable interactive control alert('clicked')}>Dismiss ``` -## CopyToClipboard (#1159) +## Pagination + +Previous/next controls, a windowed list of page buttons, and a page-size +selector. `getPageItems` is exported on its own for callers that render their own +controls: it returns page numbers ascending, with `null` marking a run that +collapsed into an ellipsis. + +| Prop | Type | Default | Description | +| ---------------------- | ----------------------------------- | ------------------ | ----------------------------------------------------------------------- | +| `page` | `number` | — (required) | Current 1-based page; clamped into `[1, pageCount]` before rendering. | +| `pageCount` | `number` | — (required) | Total pages; anything below 1 is treated as a single page. | +| `pageSize` | `number` | `10` | Rows per page, reported back through `onPageChange`. | +| `pageSizeOptions` | `readonly number[]` | `[10, 25, 50, 100]` | Sizes offered by the selector; the active one is always added. | +| `showPageSizeSelector` | `boolean` | `false` | Hide the selector for fixed-size listings. | +| `siblingCount` | `number` | `1` | Pages kept on each side of the current one. | +| `disabled` | `boolean` | `false` | Disables every control, e.g. while a page loads. | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Sizing preset. | +| `onPageChange` | `(page: number, pageSize: number) => void` | — | Fired with an always in-range page; changing the size reports page `1`. | + +Previous is disabled on the first page, Next on the last. The `nav` carries an +`aria-label` (`label`), the active button `aria-current="page"`, and every +control has an accessible name. -Interactive button for copying Stellar public keys, contract addresses, and transaction hashes with animated transient 'Copied!' tooltip confirmation. +```tsx + +``` + +## MetricCard + +A single figure with a trend badge. `MetricStatBox` is the compact form — it +takes `label` instead of `title` and defaults to the `sm` size. + +| Prop | Type | Default | Description | +| -------------- | ------------------------------------------------- | -------------------- | -------------------------------------------------------------------- | +| `title` | `string` | — (required) | Heading of the metric. | +| `value` | `string \| number \| bigint \| null \| undefined` | — (required) | The metric; `bigint` is stringified, never coerced through `Number`. | +| `delta` | `number \| null` | — | Percentage change; the sign picks success / danger / neutral. | +| `deltaLabel` | `string` | — | Caption inside the badge, e.g. `'vs last week'`. | +| `subtitle` | `React.ReactNode` | — | Secondary line under the value. | +| `loading` | `boolean` | `false` | Renders the skeleton instead of the value. | +| `loadingLabel` | `string` | `'Loading…'` | Announced through a `role="status"` while the skeleton shows. | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Sizing preset. | + +The skeleton sets `aria-busy="true"` and keeps the value node out of both the +visual and accessibility trees. + +```tsx + + +``` -| Prop | Type | Default | Description | -| ---------------- | ---------------------------------------- | ----------------------- | -------------------------------------------------------------- | -| `text` | `string` | — (required) | Text to copy to clipboard. | -| `label` | `string` | `'Copy to clipboard'` | Button label and accessible text. | -| `successMessage` | `string` | `'Copied!'` | Transient feedback message. | -| `timeout` | `number` | `2000` | Duration in ms to display success feedback. | -| `onCopy` | `(text: string) => void` | — | Callback fired when copy succeeds. | -| `onError` | `(error: Error) => void` | — | Callback fired when copy fails. | +## ConfirmationDialog + +Guards a destructive action (burn, revoke role). Confirm stays disabled until +the acknowledgement gate is satisfied, so the dialog cannot be dismissed into a +write by a stray click. It is self-contained: its own portal, backdrop, `Escape` +handling and focus trap. + +| Prop | Type | Default | Description | +| -------------------- | --------------------------------- | -------------------------------- | ----------------------------------------------------------------- | +| `open` | `boolean` | — (required) | Nothing renders while false. | +| `onClose` | `() => void` | — (required) | Called by Cancel, the backdrop and `Escape`. | +| `onConfirm` | `() => void` | — (required) | Called by Confirm once the gate is satisfied. | +| `title` | `string` | — (required) | Heading, wired via `aria-labelledby`. | +| `description` | `React.ReactNode` | — | Body copy, wired via `aria-describedby`. | +| `children` | `React.ReactNode` | — | Extra content between the description and the gate. | +| `variant` | `'danger' \| 'warning'` | `'danger'` | Tone of the dialog and the confirming button. | +| `confirmationMode` | `'checkbox' \| 'keyword' \| 'none'` | `'checkbox'` | Which gate guards Confirm. | +| `confirmationKeyword`| `string` | `'CONFIRM'` | Word to type in `'keyword'` mode; case- and trim-insensitive. | +| `confirmationLabel` | `string` | `'I understand this cannot be undone.'` | Text of the confirmation checkbox. | +| `busy` | `boolean` | `false` | In-flight write: locks both buttons and unbinds `Escape`. | + +Focus moves onto the panel (never straight onto the destructive button), `Tab` +cycles within the dialog, and the previously focused element is refocused on +close. The gate resets between openings because the body unmounts with `open`. ```tsx - - + ``` -## ConnectionStatusBar (#1183) +## EmptyState -Status bar displaying connected wallet name, network pill, and real-time Soroban RPC ping latency indicator (green < 500ms, yellow 500–1500ms, red > 1500ms or offline). +The zero-result state for a table, a listing or a search. Renders a built-in +inline SVG by default; pass `icon={null}` for none, or any node for your own. -| Prop | Type | Default | Description | -| ---------------- | ---------------------------------------- | ----------------------- | -------------------------------------------------------------- | -| `rpcUrl` | `string` | — | Optional RPC URL to ping. | -| `pingRpc` | `() => Promise` | — | Optional custom ping handler returning latency in ms. | -| `pingIntervalMs` | `number` | `10000` | Interval in ms between latency checks (0 to disable). | -| `onLatencyChange`| `(latency: number \| null) => void` | — | Callback fired when latency updates. | +| Prop | Type | Default | Description | +| ------------------ | ----------------------------------- | -------- | --------------------------------------------------------------- | +| `title` | `string` | — | Short headline, e.g. `'No transactions yet'`. | +| `icon` | `React.ReactNode` | built-in | Illustration; `null` renders none. | +| `description` | `React.ReactNode` | — | One or two sentences on what to do next. | +| `action` | `EmptyStateAction \| React.ReactNode` | — | Primary next step; an object renders the button for you. | +| `secondaryAction` | `EmptyStateAction \| React.ReactNode` | — | Lower-emphasis alternative. | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Sizing preset. | ```tsx - - +} + title="No transactions yet" + description="Deposits and rewards will appear here once the vault is funded." + action={{ label: 'Deposit', onClick: openDeposit }} + secondaryAction={{ label: 'Learn more', onClick: openDocs }} +/> ``` ## Product components (#950) diff --git a/react/src/components/ConfirmationDialog.test.tsx b/react/src/components/ConfirmationDialog.test.tsx new file mode 100644 index 00000000..8b41b78a --- /dev/null +++ b/react/src/components/ConfirmationDialog.test.tsx @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: MIT +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +import { + ConfirmationDialog, + DEFAULT_CONFIRMATION_KEYWORD, + type ConfirmationDialogProps, +} from './ConfirmationDialog'; +import { theme } from '../theme'; + +const renderDialog = (props: Partial = {}) => { + const onClose = props.onClose ?? jest.fn(); + const onConfirm = props.onConfirm ?? jest.fn(); + const utils = render( + , + ); + return { ...utils, onClose, onConfirm }; +}; + +describe('ConfirmationDialog', () => { + it('renders nothing while closed', () => { + const { container } = renderDialog({ open: false }); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders into a portal outside the caller subtree', () => { + const { container } = renderDialog(); + + expect(container).toBeEmptyDOMElement(); + expect(screen.getByTestId('confirmation-dialog-backdrop')).toBeInTheDocument(); + expect(document.body.querySelector('[data-testid="confirmation-dialog"]')).not.toBeNull(); + }); + + it('exposes the dialog role, modality and accessible name', () => { + renderDialog(); + + const dialog = screen.getByRole('dialog', { name: 'Burn 1,000 BCC?' }); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-labelledby'); + expect(dialog).toHaveAttribute('aria-describedby'); + + const labelledBy = dialog.getAttribute('aria-labelledby'); + expect(labelledBy).not.toBeNull(); + expect(document.getElementById(labelledBy ?? '')).toHaveTextContent('Burn 1,000 BCC?'); + + const describedBy = dialog.getAttribute('aria-describedby'); + expect(describedBy).not.toBeNull(); + expect(document.getElementById(describedBy ?? '')).toHaveTextContent( + 'This supply is removed forever and cannot be undone.', + ); + }); + + it('uses children as the accessible description when no description is given', () => { + renderDialog({ description: undefined, children:

Revoking the admin role.

}); + + const dialog = screen.getByRole('dialog'); + const describedBy = dialog.getAttribute('aria-describedby'); + expect(describedBy).not.toBeNull(); + expect(document.getElementById(describedBy ?? '')).toHaveTextContent( + 'Revoking the admin role.', + ); + }); + + it('renders extra children inside the dialog', () => { + renderDialog({ children:

Amount: 1,000 BCC

}); + + expect(screen.getByTestId('extra')).toBeInTheDocument(); + }); + + it('renders nothing but the controls when there is no body at all', () => { + renderDialog({ description: undefined }); + + const dialog = screen.getByRole('dialog'); + expect(dialog).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByTestId('confirmation-dialog-body')).not.toBeInTheDocument(); + }); + + it('keeps confirm disabled until the checkbox is ticked', () => { + const { onConfirm } = renderDialog({ confirmationMode: 'checkbox' }); + + const confirm = screen.getByTestId('confirmation-dialog-confirm'); + expect(confirm).toBeDisabled(); + + fireEvent.click(confirm); + expect(onConfirm).not.toHaveBeenCalled(); + + const checkbox = screen.getByTestId('confirmation-dialog-checkbox'); + expect(checkbox).not.toBeChecked(); + fireEvent.click(checkbox); + + expect(checkbox).toBeChecked(); + expect(confirm).not.toBeDisabled(); + + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('re-locks confirm when the checkbox is unticked', () => { + renderDialog({ confirmationMode: 'checkbox' }); + + const checkbox = screen.getByTestId('confirmation-dialog-checkbox'); + fireEvent.click(checkbox); + expect(screen.getByTestId('confirmation-dialog-confirm')).not.toBeDisabled(); + + fireEvent.click(checkbox); + expect(screen.getByTestId('confirmation-dialog-confirm')).toBeDisabled(); + }); + + it('exposes the checkbox as a focusable form control', () => { + const { onConfirm } = renderDialog({ confirmationMode: 'checkbox' }); + + const checkbox = screen.getByTestId('confirmation-dialog-checkbox'); + checkbox.focus(); + expect(document.activeElement).toBe(checkbox); + + fireEvent.click(checkbox); + expect(screen.getByTestId('confirmation-dialog-confirm')).not.toBeDisabled(); + + fireEvent.click(screen.getByTestId('confirmation-dialog-confirm')); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('is the checkbox mode by default', () => { + renderDialog(); + + expect(screen.getByTestId('confirmation-dialog-checkbox')).toBeInTheDocument(); + expect(screen.queryByTestId('confirmation-dialog-keyword-input')).not.toBeInTheDocument(); + }); + + it('keeps confirm disabled until the keyword is typed', () => { + const { onConfirm } = renderDialog({ + confirmationMode: 'keyword', + confirmationKeyword: 'BURN', + }); + + const input = screen.getByTestId('confirmation-dialog-keyword-input'); + const confirm = screen.getByTestId('confirmation-dialog-confirm'); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: 'burn' } }); + expect(confirm).not.toBeDisabled(); + + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('does not unlock on a partial or wrong keyword', () => { + renderDialog({ confirmationMode: 'keyword', confirmationKeyword: 'REVOKE' }); + + const input = screen.getByTestId('confirmation-dialog-keyword-input'); + const confirm = screen.getByTestId('confirmation-dialog-confirm'); + + fireEvent.change(input, { target: { value: 'rev' } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: 'revoked' } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: ' REVOKE ' } }); + expect(confirm).not.toBeDisabled(); + }); + + it('falls back to the default keyword in keyword mode', () => { + renderDialog({ confirmationMode: 'keyword' }); + + expect( + screen.getByText(`Type ${DEFAULT_CONFIRMATION_KEYWORD} to confirm this action.`), + ).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('confirmation-dialog-keyword-input'), { + target: { value: DEFAULT_CONFIRMATION_KEYWORD }, + }); + expect(screen.getByTestId('confirmation-dialog-confirm')).not.toBeDisabled(); + }); + + it('accepts a custom keyword prompt', () => { + renderDialog({ confirmationMode: 'keyword', keywordLabel: 'Type the token symbol' }); + + expect(screen.getByLabelText('Type the token symbol')).toBe( + screen.getByTestId('confirmation-dialog-keyword-input'), + ); + }); + + it('needs no gate at all in none mode', () => { + const { onConfirm } = renderDialog({ confirmationMode: 'none' }); + + expect(screen.queryByTestId('confirmation-dialog-checkbox')).not.toBeInTheDocument(); + expect(screen.queryByTestId('confirmation-dialog-keyword-input')).not.toBeInTheDocument(); + expect(screen.getByTestId('confirmation-dialog-confirm')).not.toBeDisabled(); + + fireEvent.click(screen.getByTestId('confirmation-dialog-confirm')); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('uses custom confirm and cancel labels', () => { + const { onClose } = renderDialog({ confirmLabel: 'Burn it', cancelLabel: 'Keep supply' }); + + expect(screen.getByRole('button', { name: 'Burn it' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Keep supply' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('uses a custom checkbox label', () => { + renderDialog({ confirmationLabel: 'I accept the loss of supply.' }); + + expect(screen.getByText('I accept the loss of supply.')).toBeInTheDocument(); + }); + + it('locks both buttons and shows the busy label while busy', () => { + renderDialog({ confirmationMode: 'none', busy: true, busyLabel: 'Submitting…' }); + + expect(screen.getByTestId('confirmation-dialog-confirm')).toBeDisabled(); + expect(screen.getByTestId('confirmation-dialog-confirm')).toHaveTextContent('Submitting…'); + expect(screen.getByTestId('confirmation-dialog-cancel')).toBeDisabled(); + }); + + it('does not close on Escape while busy', () => { + const { onClose } = renderDialog({ confirmationMode: 'none', busy: true }); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('closes on Escape when not busy', () => { + const { onClose } = renderDialog(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not close on Escape when closeOnEscape is off', () => { + const { onClose } = renderDialog({ closeOnEscape: false }); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('ignores other keys', () => { + const { onClose } = renderDialog(); + + fireEvent.keyDown(document, { key: 'Enter' }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('closes on a backdrop click but not on a click inside the panel', () => { + const { onClose } = renderDialog(); + + fireEvent.click(screen.getByTestId('confirmation-dialog-title')); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('confirmation-dialog-backdrop')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not close on a backdrop click when closeOnBackdropClick is off', () => { + const { onClose } = renderDialog({ closeOnBackdropClick: false }); + + fireEvent.click(screen.getByTestId('confirmation-dialog-backdrop')); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('moves focus onto the panel when it opens', () => { + renderDialog(); + + expect(document.activeElement).toBe(screen.getByTestId('confirmation-dialog')); + }); + + it('restores focus to the previously focused element when it closes', async () => { + const trigger = document.createElement('button'); + trigger.type = 'button'; + document.body.appendChild(trigger); + trigger.focus(); + + const { rerender } = render( + , + ); + expect(document.activeElement).toBe(screen.getByTestId('confirmation-dialog')); + + rerender( + , + ); + + await waitFor(() => { + expect(document.activeElement).toBe(trigger); + }); + + trigger.remove(); + }); + + it('traps Tab inside the panel', () => { + const { onConfirm } = renderDialog(); + + const dialog = screen.getByTestId('confirmation-dialog'); + const checkbox = screen.getByTestId('confirmation-dialog-checkbox'); + const cancel = screen.getByTestId('confirmation-dialog-cancel'); + const confirm = screen.getByTestId('confirmation-dialog-confirm'); + + // Satisfy the gate so Confirm is focusable; the focus order is then + // checkbox -> cancel -> confirm. + fireEvent.click(checkbox); + expect(confirm).not.toBeDisabled(); + + // Tab from the last control wraps back to the first. + confirm.focus(); + expect(document.activeElement).toBe(confirm); + fireEvent.keyDown(dialog, { key: 'Tab' }); + expect(document.activeElement).toBe(checkbox); + + // Tab in the middle of the order is left to the browser. + cancel.focus(); + fireEvent.keyDown(dialog, { key: 'Tab' }); + expect(document.activeElement).toBe(cancel); + + // Shift+Tab from the first control wraps forward to the last. + checkbox.focus(); + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); + expect(document.activeElement).toBe(confirm); + + // Shift+Tab from the panel itself also wraps forward. + dialog.focus(); + expect(document.activeElement).toBe(dialog); + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); + expect(document.activeElement).toBe(confirm); + + // Non-Tab keys never move focus. + dialog.focus(); + fireEvent.keyDown(dialog, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(dialog); + + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('locks background scrolling while open and restores it on close', () => { + const { rerender } = render( + , + ); + expect(document.body.style.overflow).toBe('hidden'); + + rerender( + , + ); + expect(document.body.style.overflow).not.toBe('hidden'); + }); + + it('resets the confirmation gate when reopened', () => { + const base: Omit = { + title: 'Burn?', + description: 'Gone forever.', + onClose: jest.fn(), + onConfirm: jest.fn(), + }; + const { rerender } = render(); + + fireEvent.click(screen.getByTestId('confirmation-dialog-checkbox')); + expect(screen.getByTestId('confirmation-dialog-confirm')).not.toBeDisabled(); + + rerender(); + rerender(); + + expect(screen.getByTestId('confirmation-dialog-checkbox')).not.toBeChecked(); + expect(screen.getByTestId('confirmation-dialog-confirm')).toBeDisabled(); + }); + + it('resets the typed keyword when reopened', () => { + const base: Omit = { + title: 'Burn?', + description: 'Gone forever.', + confirmationMode: 'keyword', + confirmationKeyword: 'BURN', + onClose: jest.fn(), + onConfirm: jest.fn(), + }; + const { rerender } = render(); + + fireEvent.change(screen.getByTestId('confirmation-dialog-keyword-input'), { + target: { value: 'BURN' }, + }); + expect(screen.getByTestId('confirmation-dialog-confirm')).not.toBeDisabled(); + + rerender(); + rerender(); + + expect(screen.getByTestId('confirmation-dialog-keyword-input')).toHaveValue(''); + expect(screen.getByTestId('confirmation-dialog-confirm')).toBeDisabled(); + }); + + it('renders into a custom portal container', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + + renderDialog({ portalContainer: host }); + + expect(host.querySelector('[data-testid="confirmation-dialog"]')).not.toBeNull(); + + host.remove(); + }); + + it('themes the warning variant and lets a consumer style win', () => { + renderDialog({ variant: 'warning', style: { padding: '4px' } }); + + expect(screen.getByTestId('confirmation-dialog')).toHaveStyle({ + borderColor: theme.warningBorder, + padding: '4px', + }); + expect(screen.getByTestId('confirmation-dialog-confirm')).toHaveStyle({ + backgroundColor: theme.warningText, + }); + }); + + it('themes the danger variant by default', () => { + renderDialog(); + + expect(screen.getByTestId('confirmation-dialog')).toHaveStyle({ + borderColor: theme.dangerBorder, + }); + expect(screen.getByTestId('confirmation-dialog-confirm')).toHaveStyle({ + backgroundColor: theme.danger, + }); + }); +}); \ No newline at end of file diff --git a/react/src/components/ConfirmationDialog.tsx b/react/src/components/ConfirmationDialog.tsx new file mode 100644 index 00000000..2656f923 --- /dev/null +++ b/react/src/components/ConfirmationDialog.tsx @@ -0,0 +1,561 @@ +// SPDX-License-Identifier: MIT +import React, { useEffect, useId, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { theme } from '../theme'; + +/** Tone of the dialog: `danger` for irreversible writes, `warning` for caution. */ +export type ConfirmationDialogVariant = 'danger' | 'warning'; + +/** + * How the user has to acknowledge the action before Confirm unlocks. + * + * - `checkbox` — tick a confirmation box. + * - `keyword` — type {@link ConfirmationDialogProps.confirmationKeyword}. + * - `none` — no gate; the dialog is informational. + */ +export type ConfirmationMode = 'checkbox' | 'keyword' | 'none'; + +/** Word used by the `'keyword'` gate when the consumer does not supply one. */ +export const DEFAULT_CONFIRMATION_KEYWORD = 'CONFIRM'; + +export interface ConfirmationDialogProps { + /** Whether the dialog is mounted. Nothing renders while this is false. */ + open: boolean; + /** Called by Cancel, the backdrop, and `Escape`. */ + onClose: () => void; + /** Called by Confirm, once the confirmation gate has been satisfied. */ + onConfirm: () => void; + /** Heading, wired to the dialog through `aria-labelledby`. */ + title: string; + /** Body copy, wired to the dialog through `aria-describedby`. */ + description?: React.ReactNode; + /** Extra content rendered between the description and the gate. */ + children?: React.ReactNode; + /** Label of the confirming button. @default 'Confirm' */ + confirmLabel?: string; + /** Label of the dismissing button. @default 'Cancel' */ + cancelLabel?: string; + /** Label shown on Confirm while `busy`. @default 'Working…' */ + busyLabel?: string; + /** Tone of the dialog and of its confirming button. @default 'danger' */ + variant?: ConfirmationDialogVariant; + /** Which acknowledgement gate guards the Confirm button. @default 'checkbox' */ + confirmationMode?: ConfirmationMode; + /** + * Word the user must type in `'keyword'` mode. Comparison ignores case and + * surrounding whitespace. @default {@link DEFAULT_CONFIRMATION_KEYWORD} + */ + confirmationKeyword?: string; + /** Text of the confirmation checkbox. @default 'I understand this cannot be undone.' */ + confirmationLabel?: string; + /** Label above the keyword field in `'keyword'` mode. */ + keywordLabel?: string; + /** + * Marks an in-flight transaction: Confirm and Cancel go dead, the button + * shows `busyLabel`, and `Escape` stops closing the dialog so the result of + * the pending write is not lost. + */ + busy?: boolean; + /** Close when the backdrop is clicked. @default true */ + closeOnBackdropClick?: boolean; + /** Close when `Escape` is pressed. @default true */ + closeOnEscape?: boolean; + /** Portal host; defaults to `document.body`. */ + portalContainer?: HTMLElement | null; + /** Optional container CSS class. */ + className?: string; + /** Optional container style, merged last so it wins over the defaults. */ + style?: React.CSSProperties; +} + +/** Internal props shared by the exported shell and the mounted dialog body. */ +interface ConfirmationDialogBodyProps extends ConfirmationDialogProps { + /** Resolved portal host the body renders itself into. */ + container: HTMLElement; +} + +const BACKDROP_STYLE: React.CSSProperties = { + position: 'fixed', + inset: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + backgroundColor: 'rgba(17, 24, 39, 0.55)', + zIndex: 1000, + boxSizing: 'border-box', +}; + +const PANEL_BASE_STYLE: React.CSSProperties = { + width: '100%', + maxWidth: 440, + display: 'flex', + flexDirection: 'column', + gap: 12, + padding: 20, + border: '1px solid', + borderRadius: 12, + backgroundColor: theme.surface, + color: theme.text, + fontFamily: 'inherit', + boxShadow: `0 20px 45px ${theme.shadow}`, + boxSizing: 'border-box', +}; + +const VARIANT_PANEL_STYLES: Record = { + danger: { borderColor: theme.dangerBorder }, + warning: { borderColor: theme.warningBorder }, +}; + +const VARIANT_ACCENT_STYLES: Record = { + danger: { color: theme.danger }, + warning: { color: theme.warningText }, +}; + +const TITLE_STYLE: React.CSSProperties = { + margin: 0, + fontSize: 17, + fontWeight: 700, + lineHeight: 1.3, +}; + +const DESCRIPTION_STYLE: React.CSSProperties = { + margin: 0, + fontSize: 14, + lineHeight: 1.5, + color: theme.neutralText, +}; + +const BODY_STYLE: React.CSSProperties = { + fontSize: 13, + lineHeight: 1.5, + color: theme.neutralText, +}; + +const WARNING_ICON_STYLE: React.CSSProperties = { + fontSize: 24, + lineHeight: 1, +}; + +const CHECKBOX_ROW_STYLE: React.CSSProperties = { + display: 'flex', + alignItems: 'flex-start', + gap: 8, + padding: '10px 12px', + border: `1px solid ${theme.border}`, + borderRadius: 8, + backgroundColor: theme.neutralBackground, + fontSize: 13, + lineHeight: 1.4, + cursor: 'pointer', +}; + +const CHECKBOX_STYLE: React.CSSProperties = { + margin: 2, + accentColor: theme.danger, + cursor: 'pointer', +}; + +const KEYWORD_FIELD_STYLE: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, +}; + +const KEYWORD_LABEL_STYLE: React.CSSProperties = { + fontSize: 13, + fontWeight: 600, + color: theme.neutralText, +}; + +const KEYWORD_INPUT_BASE_STYLE: React.CSSProperties = { + width: '100%', + padding: '8px 12px', + border: `1px solid ${theme.border}`, + borderRadius: 6, + backgroundColor: theme.surface, + color: theme.text, + fontFamily: 'inherit', + fontSize: 14, + boxSizing: 'border-box', +}; + +const KEYWORD_INPUT_FOCUS_STYLE: React.CSSProperties = { + outline: 'none', + borderColor: theme.focus, + boxShadow: `0 0 0 2px ${theme.focusRing}`, +}; + +const ACTIONS_STYLE: React.CSSProperties = { + display: 'flex', + justifyContent: 'flex-end', + gap: 8, + marginTop: 4, +}; + +const BUTTON_BASE_STYLE: React.CSSProperties = { + padding: '9px 16px', + border: '1px solid', + borderRadius: 6, + fontFamily: 'inherit', + fontSize: 14, + fontWeight: 500, + cursor: 'pointer', + boxSizing: 'border-box', + transition: 'box-shadow 0.15s, border-color 0.15s, opacity 0.15s', +}; + +const CANCEL_BUTTON_STYLE: React.CSSProperties = { + ...BUTTON_BASE_STYLE, + backgroundColor: theme.surface, + borderColor: theme.border, + color: theme.text, +}; + +const VARIANT_CONFIRM_STYLES: Record = { + danger: { backgroundColor: theme.danger, borderColor: theme.danger, color: theme.surface }, + warning: { backgroundColor: theme.warningText, borderColor: theme.warningText, color: theme.surface }, +}; + +const BUTTON_DISABLED_STYLE: React.CSSProperties = { + opacity: 0.5, + cursor: 'not-allowed', +}; + +/** + * Everything the focus trap can land on. `:not([disabled])` already removes the + * dead controls, so the trap only ever cycles live ones. + */ +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(', '); + +/** Collects the focusable descendants of `container`, in tab order. */ +function getFocusableElements(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)); +} + +/** Resolves the portal host, staying safe when there is no `document` (SSR). */ +function resolvePortalContainer( + portalContainer: HTMLElement | null | undefined, +): HTMLElement | null { + if (portalContainer !== null && portalContainer !== undefined) return portalContainer; + if (typeof document === 'undefined') return null; + return document.body; +} + +/** + * Whether a React node will render anything. An empty string, an empty array, + * `null`, `undefined` and `false` all count as "nothing". + */ +function hasContent(node: React.ReactNode): boolean { + if (node === null || node === undefined || node === false || node === '') return false; + if (Array.isArray(node)) return node.length > 0; + return true; +} + +/** + * Confirmation dialog for dangerous actions such as a burn or a role revoke + * (#1167). + * + * The dialog is entirely self-contained: it renders through its own + * `createPortal` call, owns its backdrop, its `Escape` handling and its focus + * trap, and shares no primitive with the rest of the library. + * + * Confirm stays disabled until the acknowledgement gate is satisfied — the + * confirmation checkbox in `'checkbox'` mode, an exact (case-insensitive) match + * of `confirmationKeyword` in `'keyword'` mode, and nothing at all in `'none'` + * mode. `busy` additionally locks both buttons and stops `Escape` from + * dismissing the dialog mid-transaction. + * + * While open, focus moves onto the panel and is trapped there by `Tab` / + * `Shift+Tab`; the previously focused element is refocused on close, background + * scrolling is locked, and the whole body is unmounted again when `open` turns + * false — which is what resets the gate between openings. + */ +export const ConfirmationDialog: React.FC = ({ + portalContainer, + ...props +}) => { + const container = resolvePortalContainer(portalContainer); + // Closed (or server-rendered) renders nothing at all. Mounting the body only + // while open is also how the confirmation gate resets between openings. + if (!props.open || container === null) return null; + return ; +}; + +/** + * The mounted dialog. Every hook lives here, so unmounting (which happens as + * soon as `open` turns false) discards the checkbox, the typed keyword and the + * focus bookkeeping without any reset effect. + */ +const ConfirmationDialogBody: React.FC = ({ + onClose, + onConfirm, + title, + description, + children, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + busyLabel = 'Working…', + variant = 'danger', + confirmationMode = 'checkbox', + confirmationKeyword = DEFAULT_CONFIRMATION_KEYWORD, + confirmationLabel = 'I understand this action cannot be undone.', + keywordLabel, + busy = false, + closeOnBackdropClick = true, + closeOnEscape = true, + container, + className, + style, +}) => { + const titleId = useId(); + const descriptionId = useId(); + const keywordFieldId = useId(); + + const panelRef = useRef(null); + /** Element focused before the dialog opened, refocused on unmount. */ + const restoreFocusRef = useRef(null); + + // Inline styles have no `:focus-visible`, so the focus ring is driven from + // state keyed by whichever control last took focus. + const [focusedControl, setFocusedControl] = useState(null); + + /** State transition: tick or untick the confirmation checkbox. */ + const [acknowledged, setAcknowledged] = useState(false); + /** State transition: keystroke in the keyword field. */ + const [typedKeyword, setTypedKeyword] = useState(''); + + // Mount: remember what had focus, move focus onto the panel itself (never + // straight onto the destructive button) and lock background scrolling. + // Unmount: give scrolling and focus back to the page. + useEffect(() => { + const activeElement = document.activeElement; + restoreFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null; + + const panel = panelRef.current; + if (panel) panel.focus(); + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = previousOverflow; + restoreFocusRef.current?.focus(); + restoreFocusRef.current = null; + }; + }, []); + + // Escape is bound on the document rather than the panel so it still works + // while focus sits on the panel itself, and it is unbound while `busy` so a + // pending transaction cannot be dismissed by a stray key press. + useEffect(() => { + if (!closeOnEscape || busy) return undefined; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [closeOnEscape, busy, onClose]); + + const expectedKeyword = confirmationKeyword.trim(); + const keywordSatisfied = + expectedKeyword.length > 0 && + typedKeyword.trim().toLowerCase() === expectedKeyword.toLowerCase(); + + const gateSatisfied = + confirmationMode === 'none' + ? true + : confirmationMode === 'keyword' + ? keywordSatisfied + : acknowledged; + + const canConfirm = gateSatisfied && !busy; + + const showBody = hasContent(description) || hasContent(children); + const bodyNode = showBody ? ( +
+ {hasContent(description) ? ( +

+ {description} +

+ ) : null} + {children} +
+ ) : null; + + /** Only a click on the backdrop itself dismisses, never one that bubbled up from the panel. */ + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target !== event.currentTarget) return; + if (!closeOnBackdropClick || busy) return; + onClose(); + }; + + /** Keeps `Tab` cycling inside the panel, and holds focus on the panel when it has nothing else. */ + const handlePanelKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Tab') return; + const panel = panelRef.current; + if (!panel) return; + + const focusable = getFocusableElements(panel); + if (focusable.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (event.shiftKey && (active === first || active === panel)) { + event.preventDefault(); + last.focus(); + return; + } + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + /** Cancelling is refused while a transaction is in flight. */ + const handleCancel = () => { + if (busy) return; + onClose(); + }; + + /** Re-checks the gate so a stale closure can never confirm an unsatisfied dialog. */ + const handleConfirm = () => { + if (!canConfirm) return; + onConfirm(); + }; + + const focusHandlers = (key: string) => ({ + onFocus: () => setFocusedControl(key), + onBlur: () => setFocusedControl(null), + }); + + const confirmStyle: React.CSSProperties = { + ...BUTTON_BASE_STYLE, + ...VARIANT_CONFIRM_STYLES[variant], + ...(focusedControl === 'confirm' && canConfirm + ? { boxShadow: `0 0 0 3px ${theme.dangerFocusRing}` } + : {}), + ...(!canConfirm ? BUTTON_DISABLED_STYLE : {}), + }; + + const cancelStyle: React.CSSProperties = { + ...CANCEL_BUTTON_STYLE, + ...(focusedControl === 'cancel' && !busy + ? { borderColor: theme.focus, boxShadow: `0 0 0 2px ${theme.focusRing}` } + : {}), + ...(busy ? BUTTON_DISABLED_STYLE : {}), + }; + + return createPortal( +
+
+ + +

+ {title} +

+ + {bodyNode} + + {confirmationMode === 'checkbox' ? ( + + ) : null} + + {confirmationMode === 'keyword' ? ( +
+ + setTypedKeyword(event.target.value)} + style={{ + ...KEYWORD_INPUT_BASE_STYLE, + ...(focusedControl === 'keyword' ? KEYWORD_INPUT_FOCUS_STYLE : {}), + }} + {...focusHandlers('keyword')} + /> +
+ ) : null} + +
+ + +
+
+
, + container, + ); +}; \ No newline at end of file diff --git a/react/src/components/EmptyState.test.tsx b/react/src/components/EmptyState.test.tsx new file mode 100644 index 00000000..0c38b64f --- /dev/null +++ b/react/src/components/EmptyState.test.tsx @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MIT +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { EmptyState, type EmptyStateProps } from './EmptyState'; +import { theme } from '../theme'; + +const renderEmptyState = (props: EmptyStateProps) => render(); + +describe('EmptyState', () => { + it('renders the title and description', () => { + renderEmptyState({ + title: 'No transactions yet', + description: 'Once you mint or transfer, activity shows up here.', + }); + + expect(screen.getByTestId('empty-state-title')).toHaveTextContent('No transactions yet'); + expect(screen.getByTestId('empty-state-description')).toHaveTextContent( + 'Once you mint or transfer, activity shows up here.', + ); + }); + + it('renders cleanly with only a title', () => { + renderEmptyState({ title: 'Nothing to show' }); + + expect(screen.getByTestId('empty-state-title')).toHaveTextContent('Nothing to show'); + expect(screen.queryByTestId('empty-state-description')).not.toBeInTheDocument(); + expect(screen.queryByTestId('empty-state-actions')).not.toBeInTheDocument(); + }); + + it('renders a built-in decorative illustration by default', () => { + renderEmptyState({ title: 'No results' }); + + const icon = screen.getByTestId('empty-state-default-icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute('aria-hidden', 'true'); + expect(icon).toHaveAttribute('focusable', 'false'); + }); + + it('renders custom iconography when one is supplied', () => { + renderEmptyState({ + title: 'No results', + icon: 🔍, + }); + + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + expect(screen.queryByTestId('empty-state-default-icon')).not.toBeInTheDocument(); + }); + + it('hides the illustration entirely for icon={null}', () => { + renderEmptyState({ title: 'No results', icon: null }); + + expect(screen.queryByTestId('empty-state-default-icon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('empty-state-icon-wrap')).not.toBeInTheDocument(); + expect(screen.getByTestId('empty-state-title')).toBeInTheDocument(); + }); + + it('renders the primary action and calls its handler', () => { + const onClick = jest.fn(); + renderEmptyState({ + title: 'No tokens', + description: 'Mint your first token to get started.', + action: { label: 'Mint a token', onClick }, + }); + + const action = screen.getByTestId('empty-state-action'); + expect(action).toHaveAccessibleName('Mint a token'); + expect(action).toHaveStyle({ backgroundColor: theme.primary }); + expect(action).not.toBeDisabled(); + + fireEvent.click(action); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders a disabled action without calling the handler', () => { + const onClick = jest.fn(); + renderEmptyState({ + title: 'No tokens', + action: { label: 'Mint a token', onClick, disabled: true }, + }); + + const action = screen.getByTestId('empty-state-action'); + expect(action).toBeDisabled(); + expect(action).toHaveStyle({ cursor: 'not-allowed' }); + + fireEvent.click(action); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('accepts an action without a handler', () => { + renderEmptyState({ title: 'No tokens', action: { label: 'Mint a token' } }); + + expect(screen.getByTestId('empty-state-action')).toBeEnabled(); + }); + + it('renders the secondary action alongside the primary one', () => { + const onSecondary = jest.fn(); + renderEmptyState({ + title: 'No search results', + action: { label: 'Clear filters', onClick: jest.fn() }, + secondaryAction: { label: 'Search again', onClick: onSecondary }, + }); + + const primary = screen.getByTestId('empty-state-action'); + const secondary = screen.getByTestId('empty-state-secondary-action'); + + expect(primary).toHaveStyle({ backgroundColor: theme.primary }); + expect(secondary).toHaveStyle({ backgroundColor: theme.surface, color: theme.text }); + + fireEvent.click(secondary); + expect(onSecondary).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('empty-state-actions')).toContainElement(secondary); + }); + + it('renders a secondary action on its own', () => { + renderEmptyState({ title: 'Nothing here', secondaryAction: { label: 'Reload' } }); + + expect(screen.queryByTestId('empty-state-action')).not.toBeInTheDocument(); + expect(screen.getByTestId('empty-state-secondary-action')).toBeInTheDocument(); + }); + + it('renders a pre-built node as the action', () => { + renderEmptyState({ + title: 'No filters', + action: Read the docs, + }); + + expect(screen.getByTestId('custom-action')).toHaveAttribute('href', '/docs'); + expect(screen.queryByTestId('empty-state-action')).not.toBeInTheDocument(); + }); + + it('renders a pre-built node as the secondary action', () => { + renderEmptyState({ + title: 'No filters', + secondaryAction: , + }); + + expect(screen.getByTestId('custom-secondary')).toBeInTheDocument(); + expect(screen.queryByTestId('empty-state-secondary-action')).not.toBeInTheDocument(); + }); + + it('centres its content and pads generously', () => { + renderEmptyState({ title: 'No results' }); + + const root = screen.getByTestId('empty-state'); + expect(root).toHaveStyle({ textAlign: 'center', alignItems: 'center' }); + expect(root).toHaveStyle({ padding: '40px 24px' }); + }); + + it('honours the size preset', () => { + const { rerender } = render(); + expect(screen.getByTestId('empty-state')).toHaveStyle({ padding: '24px 16px' }); + + rerender(); + expect(screen.getByTestId('empty-state')).toHaveStyle({ padding: '64px 32px' }); + }); + + it('shows a focus ring on the action that has focus', () => { + renderEmptyState({ title: 'No results', action: { label: 'Mint a token' } }); + + const action = screen.getByTestId('empty-state-action'); + // React 17+ delegates onFocus/onBlur through focusin/focusout. + fireEvent.focusIn(action); + expect(action.getAttribute('style')).toContain(theme.primaryFocusRing); + + fireEvent.focusOut(action); + expect(action.getAttribute('style')).not.toContain(theme.primaryFocusRing); + }); + + it('forwards standard div props and a ref', () => { + const ref = React.createRef(); + render( + , + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + const root = screen.getByTestId('empty-state'); + expect(root).toHaveClass('custom'); + expect(root).toHaveAttribute('role', 'status'); + expect(root).toHaveStyle({ backgroundColor: 'rgb(7, 8, 9)' }); + }); +}); \ No newline at end of file diff --git a/react/src/components/EmptyState.tsx b/react/src/components/EmptyState.tsx new file mode 100644 index 00000000..38fa8663 --- /dev/null +++ b/react/src/components/EmptyState.tsx @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT +import React, { forwardRef, useState } from 'react'; +import { theme } from '../theme'; + +/** Sizing preset for the empty state's spacing and type scale. */ +export type EmptyStateSize = 'sm' | 'md' | 'lg'; + +/** + * Declarative form of the primary/secondary action. Supply an + * {@link EmptyStateProps.action} object and the component renders the button; + * pass any other node to render it yourself. + */ +export interface EmptyStateAction { + /** Visible label, and therefore the button's accessible name. */ + label: string; + /** Click handler. Ignored when `disabled` is true. */ + onClick?: () => void; + /** Disables the button, e.g. when the action needs a connected wallet. */ + disabled?: boolean; +} + +export interface EmptyStateProps extends React.HTMLAttributes { + /** + * Illustration above the message. Defaults to a built-in inline SVG; pass + * `null` to render no icon at all. + */ + icon?: React.ReactNode; + /** Short headline, e.g. `'No transactions yet'`. */ + title: string; + /** One or two sentences explaining what to do next. */ + description?: React.ReactNode; + /** Primary next step: an {@link EmptyStateAction}, or a node to render as-is. */ + action?: EmptyStateAction | React.ReactNode; + /** Optional lower-emphasis alternative to {@link EmptyStateProps.action}. */ + secondaryAction?: EmptyStateAction | React.ReactNode; + /** Sizing preset. @default 'md' */ + size?: EmptyStateSize; +} + +const ROOT_STYLE: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + fontFamily: 'inherit', + boxSizing: 'border-box', +}; + +const SIZE_STYLES: Record = { + sm: { padding: '24px 16px', gap: 8 }, + md: { padding: '40px 24px', gap: 12 }, + lg: { padding: '64px 32px', gap: 16 }, +}; + +const ICON_WRAP_STYLE: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.neutralText, +}; + +const TITLE_SIZE_STYLES: Record = { + sm: { fontSize: 14 }, + md: { fontSize: 16 }, + lg: { fontSize: 20 }, +}; + +const TITLE_STYLE: React.CSSProperties = { + fontWeight: 600, + color: theme.text, + lineHeight: 1.35, +}; + +const DESCRIPTION_SIZE_STYLES: Record = { + sm: { fontSize: 12 }, + md: { fontSize: 14 }, + lg: { fontSize: 15 }, +}; + +const DESCRIPTION_STYLE: React.CSSProperties = { + color: theme.neutralText, + lineHeight: 1.5, + maxWidth: 440, +}; + +const ACTIONS_STYLE: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexWrap: 'wrap', + gap: 10, + marginTop: 4, +}; + +const ACTION_BASE_STYLE: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + padding: '9px 16px', + border: '1px solid', + borderRadius: 6, + fontFamily: 'inherit', + fontSize: 14, + fontWeight: 500, + cursor: 'pointer', + boxSizing: 'border-box', + transition: 'box-shadow 0.15s, border-color 0.15s, opacity 0.15s', +}; + +/** Tones available to the action buttons. */ +export type EmptyStateActionVariant = 'primary' | 'secondary'; + +const VARIANT_ACTION_STYLES: Record = { + primary: { + backgroundColor: theme.primary, + borderColor: theme.primary, + color: theme.surface, + }, + secondary: { + backgroundColor: theme.surface, + borderColor: theme.border, + color: theme.text, + }, +}; + +const VARIANT_FOCUS_STYLES: Record = { + primary: { borderColor: theme.primary, boxShadow: `0 0 0 2px ${theme.primaryFocusRing}` }, + secondary: { borderColor: theme.focus, boxShadow: `0 0 0 2px ${theme.focusRing}` }, +}; + +const ACTION_DISABLED_STYLE: React.CSSProperties = { + opacity: 0.5, + cursor: 'not-allowed', +}; + +/** Side length of the built-in illustration, in pixels. */ +const DEFAULT_ICON_SIZE = 72; + +/** + * Tells the declarative `{ label, onClick, disabled }` form apart from an + * already-built node, so consumers can pass either without a wrapper component. + */ +function isEmptyStateAction(value: unknown): value is EmptyStateAction { + if (typeof value !== 'object' || value === null) return false; + if (Array.isArray(value)) return false; + if (React.isValidElement(value)) return false; + return 'label' in value && typeof value.label === 'string'; +} + +/** + * Empty listing placeholder for tables, search results and filtered views + * (#1168). + * + * It states what is missing, why it is missing, and offers the one action that + * moves things forward — the point being that an empty table should never be a + * dead end. Everything is centred with generous vertical padding so it drops + * into a listing container without further layout work, and the illustration is + * an inline SVG built from `theme` tokens, so there is no icon font or asset to + * load. + * + * Accessibility: the default illustration is `aria-hidden`, the title is real + * heading-level text rather than an image, and both action buttons are native + * ` + ); + }; + + const hasAction = action !== undefined && action !== null; + const hasSecondaryAction = secondaryAction !== undefined && secondaryAction !== null; + + return ( +
+ {icon === undefined ? ( + + + + ) : icon === null ? null : ( + + {icon} + + )} + + + {title} + + + {description ? ( + + {description} + + ) : null} + + {hasAction || hasSecondaryAction ? ( +
+ {hasAction ? renderAction(action, 'action', 'primary') : null} + {hasSecondaryAction ? renderAction(secondaryAction, 'secondary-action', 'secondary') : null} +
+ ) : null} +
+ ); +}); + +/** + * Built-in illustration: an empty tray inside a soft disc. Purely decorative, + * so it is hidden from assistive technology and coloured with `currentColor` + * plus a neutral background token. + */ +const DefaultEmptyStateIcon: React.FC = () => ( + +); \ No newline at end of file diff --git a/react/src/components/MetricCard.test.tsx b/react/src/components/MetricCard.test.tsx new file mode 100644 index 00000000..9a65a486 --- /dev/null +++ b/react/src/components/MetricCard.test.tsx @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MIT +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { + MetricCard, + MetricStatBox, + formatMetricDelta, + formatMetricValue, + getMetricTrend, + METRIC_VALUE_PLACEHOLDER, +} from './MetricCard'; +import { theme } from '../theme'; + +describe('formatMetricValue', () => { + it('renders a bigint without going through Number', () => { + // 2^80 + 1: lossy as a double, exact as a string. + const huge = (1n << 80n) + 1n; + expect(formatMetricValue(huge)).toBe(huge.toString()); + expect(formatMetricValue(huge)).not.toBe(String(Number(huge))); + }); + + it('renders negative and zero bigints', () => { + expect(formatMetricValue(0n)).toBe('0'); + expect(formatMetricValue(-42n)).toBe('-42'); + }); + + it('renders numbers and strings verbatim', () => { + expect(formatMetricValue(1234)).toBe('1234'); + expect(formatMetricValue(-3.5)).toBe('-3.5'); + expect(formatMetricValue('1,234')).toBe('1,234'); + }); + + it('falls back to a placeholder for missing and non-finite values', () => { + expect(formatMetricValue(null)).toBe(METRIC_VALUE_PLACEHOLDER); + expect(formatMetricValue(undefined)).toBe(METRIC_VALUE_PLACEHOLDER); + expect(formatMetricValue(Number.NaN)).toBe(METRIC_VALUE_PLACEHOLDER); + expect(formatMetricValue(Number.POSITIVE_INFINITY)).toBe(METRIC_VALUE_PLACEHOLDER); + }); +}); + +describe('formatMetricDelta', () => { + it('signs positive and negative values', () => { + expect(formatMetricDelta(12.34)).toBe('+12.3%'); + expect(formatMetricDelta(-4)).toBe('-4.0%'); + }); + + it('leaves zero unsigned and non-finite values as a placeholder', () => { + expect(formatMetricDelta(0)).toBe('0.0%'); + expect(formatMetricDelta(Number.NaN)).toBe(METRIC_VALUE_PLACEHOLDER); + }); + + it('honours the requested precision', () => { + expect(formatMetricDelta(1.23456, 2)).toBe('+1.23%'); + }); +}); + +describe('getMetricTrend', () => { + it('derives the trend from the sign', () => { + expect(getMetricTrend(3)).toBe('up'); + expect(getMetricTrend(-3)).toBe('down'); + }); + + it('treats zero, nullish and non-finite deltas as flat', () => { + expect(getMetricTrend(0)).toBe('flat'); + expect(getMetricTrend(null)).toBe('flat'); + expect(getMetricTrend(undefined)).toBe('flat'); + expect(getMetricTrend(Number.NaN)).toBe('flat'); + }); +}); + +describe('MetricCard', () => { + it('renders the title, value and subtitle', () => { + render(); + + expect(screen.getByTestId('metric-card-title')).toHaveTextContent('Total supply'); + expect(screen.getByTestId('metric-card-value')).toHaveTextContent('12,500 BCC'); + expect(screen.getByTestId('metric-card-subtitle')).toHaveTextContent('Updated 2m ago'); + }); + + it('renders a placeholder when there is no value', () => { + render(); + + expect(screen.getByTestId('metric-card-value')).toHaveTextContent(METRIC_VALUE_PLACEHOLDER); + }); + + it('renders a bigint value losslessly', () => { + const supply = (1n << 90n) + 7n; + render(); + + expect(screen.getByTestId('metric-card-value')).toHaveTextContent(supply.toString()); + }); + + it('colours a positive delta with the success tokens', () => { + render(); + + const badge = screen.getByTestId('metric-card-delta'); + expect(badge).toHaveAttribute('data-trend', 'up'); + expect(badge).toHaveTextContent('+8.5%'); + expect(badge).toHaveStyle({ + backgroundColor: theme.successBackground, + color: theme.successText, + }); + expect(screen.getByTestId('metric-card-delta-label')).toHaveTextContent('vs last week'); + }); + + it('colours a negative delta with the danger tokens', () => { + render(); + + const badge = screen.getByTestId('metric-card-delta'); + expect(badge).toHaveAttribute('data-trend', 'down'); + expect(badge).toHaveTextContent('-3.0%'); + expect(badge).toHaveStyle({ + backgroundColor: theme.dangerBackground, + color: theme.dangerText, + }); + }); + + it('colours a zero delta with the neutral tokens', () => { + render(); + + const badge = screen.getByTestId('metric-card-delta'); + expect(badge).toHaveAttribute('data-trend', 'flat'); + expect(badge).toHaveStyle({ + backgroundColor: theme.neutralBackground, + color: theme.neutralText, + }); + }); + + it('omits the delta row when no delta is supplied', () => { + render(); + + expect(screen.queryByTestId('metric-card-delta')).not.toBeInTheDocument(); + expect(screen.queryByTestId('metric-card-delta-label')).not.toBeInTheDocument(); + }); + + it('hides the trend arrow from assistive technology', () => { + render(); + + const arrow = screen.getByTestId('metric-card-delta').firstElementChild; + expect(arrow).toHaveAttribute('aria-hidden', 'true'); + }); + + it('renders a skeleton while loading', () => { + render(); + + const card = screen.getByTestId('metric-card'); + expect(card).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByTestId('metric-card-skeleton')).toBeInTheDocument(); + expect(screen.getAllByTestId('metric-card-skeleton-bar').length).toBeGreaterThan(0); + }); + + it('announces an accessible loading label while loading', () => { + render(); + + expect(screen.getByRole('status')).toHaveTextContent('Total supply: Fetching supply…'); + }); + + it('keeps the numeric value out of the accessibility tree while loading', () => { + render(); + + expect(screen.getByTestId('metric-card-value')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByRole('status')).toHaveTextContent('Total supply: Loading…'); + }); + + it('replaces the skeleton with the value once loading finishes', () => { + const { rerender } = render(); + rerender(); + + const card = screen.getByTestId('metric-card'); + expect(card).toHaveAttribute('aria-busy', 'false'); + expect(screen.queryByTestId('metric-card-skeleton')).not.toBeInTheDocument(); + expect(screen.getByTestId('metric-card-value')).not.toHaveAttribute('aria-hidden'); + expect(screen.getByTestId('metric-card-value')).toHaveTextContent('500'); + }); + + it('does not render the delta or subtitle while loading', () => { + render( + , + ); + + expect(screen.queryByTestId('metric-card-delta')).not.toBeInTheDocument(); + expect(screen.queryByTestId('metric-card-subtitle')).not.toBeInTheDocument(); + }); + + it('scales the typography with the size preset', () => { + const { rerender } = render(); + expect(screen.getByTestId('metric-card-value')).toHaveStyle({ fontSize: '20px' }); + + rerender(); + expect(screen.getByTestId('metric-card-value')).toHaveStyle({ fontSize: '36px' }); + }); + + it('lets a consumer style override the defaults and forwards a ref', () => { + const ref = React.createRef(); + render( + , + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(screen.getByTestId('metric-card')).toHaveClass('custom'); + expect(screen.getByTestId('metric-card')).toHaveStyle({ backgroundColor: 'rgb(4, 5, 6)' }); + }); +}); + +describe('MetricStatBox', () => { + it('renders the label as the card title with the compact default size', () => { + render(); + + expect(screen.getByTestId('metric-card-title')).toHaveTextContent('Holders'); + expect(screen.getByTestId('metric-card-value')).toHaveStyle({ fontSize: '20px' }); + }); + + it('forwards the delta, loading state and size', () => { + render(); + + expect(screen.getByTestId('metric-card-delta')).toHaveAttribute('data-trend', 'down'); + expect(screen.getByTestId('metric-card-value')).toHaveStyle({ fontSize: '36px' }); + }); + + it('renders a skeleton while loading', () => { + render(); + + expect(screen.getByTestId('metric-card')).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByTestId('metric-card-skeleton')).toBeInTheDocument(); + }); + + it('forwards a ref', () => { + const ref = React.createRef(); + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); \ No newline at end of file diff --git a/react/src/components/MetricCard.tsx b/react/src/components/MetricCard.tsx new file mode 100644 index 00000000..a902c38d --- /dev/null +++ b/react/src/components/MetricCard.tsx @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MIT +import React, { forwardRef } from 'react'; +import { theme } from '../theme'; + +/** Rendered in place of a value that is missing or not a finite number. */ +export const METRIC_VALUE_PLACEHOLDER = '—'; + +/** Values a metric card is able to display. */ +export type MetricCardValue = string | number | bigint | null | undefined; + +/** Sizing preset for a metric card. */ +export type MetricCardSize = 'sm' | 'md' | 'lg'; + +/** Direction a metric is moving, derived from the sign of {@link MetricCardProps.delta}. */ +export type MetricTrend = 'up' | 'down' | 'flat'; + +/** + * Renders a metric value as display text. + * + * `bigint` is stringified directly: converting through `Number` would silently + * drop precision above `2^53`, which is exactly where token balances and + * stroop amounts live. `null`/`undefined` and non-finite numbers (a `0/0` ratio, + * an `Infinity` share price) fall back to {@link METRIC_VALUE_PLACEHOLDER} so the + * card never renders `NaN` to a user. + */ +export function formatMetricValue(value: MetricCardValue): string { + if (value === null || value === undefined) return METRIC_VALUE_PLACEHOLDER; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'number') { + return Number.isFinite(value) ? value.toString() : METRIC_VALUE_PLACEHOLDER; + } + return value; +} + +/** + * Formats a percentage delta, prefixing positive values with `+` so the sign + * is unambiguous. Zero and non-finite inputs carry no sign. + */ +export function formatMetricDelta(delta: number, fractionDigits = 1): string { + if (!Number.isFinite(delta)) return METRIC_VALUE_PLACEHOLDER; + const sign = delta > 0 ? '+' : ''; + return `${sign}${delta.toFixed(fractionDigits)}%`; +} + +/** + * Maps a delta to its trend: positive is `up`, negative is `down`, and `0`, + * `null`, `undefined` and non-finite values are all `flat` — a metric with no + * direction should not be coloured as a gain or a loss. + */ +export function getMetricTrend(delta: number | null | undefined): MetricTrend { + if (delta === null || delta === undefined) return 'flat'; + if (!Number.isFinite(delta) || delta === 0) return 'flat'; + return delta > 0 ? 'up' : 'down'; +} + +export interface MetricCardProps extends Omit, 'title'> { + /** Heading of the metric, e.g. `'Total supply'`. */ + title: string; + /** The metric itself. `null`/`undefined` render {@link METRIC_VALUE_PLACEHOLDER}. */ + value: MetricCardValue; + /** + * Percentage change against the previous period. The sign selects the trend + * colour: positive uses the success tokens, negative the danger tokens and + * zero the neutral ones. + */ + delta?: number | null; + /** Caption shown inside the delta badge, e.g. `'vs last week'`. */ + deltaLabel?: string; + /** Secondary line under the value, e.g. a timestamp or a range. */ + subtitle?: React.ReactNode; + /** Renders the skeleton state instead of the value. @default false */ + loading?: boolean; + /** Text announced while the skeleton is showing. @default 'Loading…' */ + loadingLabel?: string; + /** Sizing preset for the card and its typography. @default 'md' */ + size?: MetricCardSize; +} + +export interface MetricStatBoxProps extends Omit { + /** Short label for the stat; renders as the card title. */ + label: string; + /** Sizing preset for the stat box. @default 'sm' */ + size?: MetricCardSize; +} + +const CARD_BASE_STYLE: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + border: '1px solid', + borderRadius: 10, + backgroundColor: theme.surface, + color: theme.text, + fontFamily: 'inherit', + boxSizing: 'border-box', +}; + +const SIZE_STYLES: Record = { + sm: { padding: '10px 12px', gap: 2 }, + md: { padding: '14px 16px', gap: 4 }, + lg: { padding: '20px 24px', gap: 6 }, +}; + +const TITLE_SIZE_STYLES: Record = { + sm: { fontSize: 12 }, + md: { fontSize: 13 }, + lg: { fontSize: 14 }, +}; + +const VALUE_SIZE_STYLES: Record = { + sm: { fontSize: 20, lineHeight: 1.2 }, + md: { fontSize: 28, lineHeight: 1.2 }, + lg: { fontSize: 36, lineHeight: 1.15 }, +}; + +const SUBTITLE_SIZE_STYLES: Record = { + sm: { fontSize: 11 }, + md: { fontSize: 12 }, + lg: { fontSize: 13 }, +}; + +const TREND_STYLES: Record = { + up: { backgroundColor: theme.successBackground, color: theme.successText }, + down: { backgroundColor: theme.dangerBackground, color: theme.dangerText }, + flat: { backgroundColor: theme.neutralBackground, color: theme.neutralText }, +}; + +const DELTA_ROW_STYLE: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 6, + flexWrap: 'wrap', +}; + +const DELTA_BADGE_STYLE: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 4, + padding: '1px 8px', + borderRadius: 10, + fontSize: 12, + fontWeight: 600, + lineHeight: 1.4, + whiteSpace: 'nowrap', +}; + +const DELTA_LABEL_STYLE: React.CSSProperties = { + fontSize: 12, + color: theme.neutralText, +}; + +const SUBTITLE_STYLE: React.CSSProperties = { + color: theme.neutralText, +}; + +const SKELETON_BAR_STYLE: React.CSSProperties = { + borderRadius: 4, + backgroundColor: theme.neutralBackground, + height: 12, +}; + +const SKELETON_STYLE: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, + paddingTop: 2, +}; + +/** Placeholder bars share the value's typography so the card does not jump when the real value lands. */ +const SKELETON_VALUE_STYLE: React.CSSProperties = { + ...SKELETON_BAR_STYLE, + width: '55%', + height: 28, + marginTop: 2, +}; + +const SKELETON_SMALL_STYLE: React.CSSProperties = { + ...SKELETON_BAR_STYLE, + width: '35%', + height: 10, +}; + +const SKELETON_WIDE_STYLE: React.CSSProperties = { + ...SKELETON_BAR_STYLE, + width: '70%', + height: 10, +}; + +/** + * Removes an element from both the visual and the accessibility trees while + * keeping it in the DOM. + */ +const HIDDEN_STYLE: React.CSSProperties = { + display: 'none', +}; + +/** + * Standard visually-hidden pattern: off-screen but still announced, so the + * skeleton state can expose a readable loading label. + */ +const VISUALLY_HIDDEN_STYLE: React.CSSProperties = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + border: 0, +}; + +/** + * Metric tile for dashboards: a title, a large value, an optional delta badge + * and a secondary subtitle (#1166). + * + * The sign of `delta` picks the trend colours — positive renders on the success + * tokens, negative on the danger tokens and zero on the neutral ones — and the + * badge itself carries an arrow glyph that is hidden from assistive technology + * so the sign is announced once, through the formatted percentage. + * + * While `loading` is true the card shows a skeleton of placeholder bars, sets + * `aria-busy="true"`, and keeps the real value out of both the visual and the + * accessibility trees so a screen reader never announces a stale figure behind + * the placeholder. + */ +export const MetricCard = forwardRef(function MetricCard( + { + title, + value, + delta, + deltaLabel, + subtitle, + loading = false, + loadingLabel = 'Loading…', + size = 'md', + style, + ...rest + }, + ref, +) { + const trend = getMetricTrend(delta); + // Formatted up front so the badge renders from a plain string and a missing + // delta needs no narrowing at the call site. + const formattedDelta = + delta !== null && delta !== undefined && Number.isFinite(delta) + ? formatMetricDelta(delta) + : null; + + return ( +
+ + {title} + + + + {formatMetricValue(value)} + + + {loading ? ( +
+
+
+
+ + {`${title}: ${loadingLabel}`} + +
+ ) : null} + + {!loading && formattedDelta !== null ? ( + + + + {formattedDelta} + + {deltaLabel ? ( + + {deltaLabel} + + ) : null} + + ) : null} + + {!loading && subtitle ? ( + + {subtitle} + + ) : null} +
+ ); +}); + +/** + * Compact stat box (#1166) — the same metric tile as {@link MetricCard} with a + * short `label` in place of a `title`, defaulted to the `sm` sizing so it can be + * dropped inline next to a table or inside a card header. + */ +export const MetricStatBox = forwardRef(function MetricStatBox( + { label, size = 'sm', ...rest }, + ref, +) { + return ; +}); \ No newline at end of file diff --git a/react/src/components/Pagination.test.tsx b/react/src/components/Pagination.test.tsx new file mode 100644 index 00000000..b166472a --- /dev/null +++ b/react/src/components/Pagination.test.tsx @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: MIT +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { + Pagination, + getPageItems, + PAGINATION_ELLIPSIS, + DEFAULT_PAGE_SIZE_OPTIONS, + type PaginationProps, +} from './Pagination'; +import { theme } from '../theme'; + +/** Renders a `Pagination` with sensible defaults for the tests that only vary one prop. */ +const renderPagination = (props: Partial = {}) => { + const onPageChange = props.onPageChange ?? jest.fn(); + const utils = render( + , + ); + return { ...utils, onPageChange }; +}; + +describe('getPageItems', () => { + it('returns every page when the range fits without a gap', () => { + expect(getPageItems({ page: 1, pageCount: 5 })).toEqual([1, 2, 3, 4, 5]); + }); + + it('returns an empty list for a non-positive page count', () => { + expect(getPageItems({ page: 1, pageCount: 0 })).toEqual([]); + expect(getPageItems({ page: 1, pageCount: -3 })).toEqual([]); + }); + + it('collapses the leading pages into a single gap marker', () => { + expect(getPageItems({ page: 5, pageCount: 20 })).toEqual([1, null, 4, 5, 6, null, 20]); + }); + + it('collapses the trailing pages into a single gap marker', () => { + expect(getPageItems({ page: 2, pageCount: 20 })).toEqual([1, 2, 3, null, 20]); + }); + + it('honours siblingCount on both sides of the current page', () => { + expect(getPageItems({ page: 10, pageCount: 20, siblingCount: 2 })).toEqual([ + 1, + null, + 8, + 9, + 10, + 11, + 12, + null, + 20, + ]); + }); + + it('honours siblingCount of 0 by keeping only the current page', () => { + expect(getPageItems({ page: 10, pageCount: 20, siblingCount: 0 })).toEqual([ + 1, + null, + 10, + null, + 20, + ]); + }); + + it('clamps an out-of-range page instead of emitting an out-of-range entry', () => { + expect(getPageItems({ page: 99, pageCount: 10 })).toEqual([1, null, 9, 10]); + expect(getPageItems({ page: -4, pageCount: 10 })).toEqual([1, 2, null, 10]); + }); + + it('tolerates fractional and non-finite inputs', () => { + expect(getPageItems({ page: 2.9, pageCount: 4.8 })).toEqual([1, 2, 3, 4]); + expect(getPageItems({ page: Number.NaN, pageCount: Number.NaN })).toEqual([]); + }); + + it('never repeats a page number', () => { + const items = getPageItems({ page: 6, pageCount: 40, siblingCount: 3 }); + const pages = items.filter((item): item is number => item !== null); + expect(new Set(pages).size).toBe(pages.length); + }); +}); + +describe('Pagination', () => { + it('renders a labelled navigation landmark', () => { + renderPagination({ label: 'Transaction pages' }); + + const nav = screen.getByRole('navigation', { name: 'Transaction pages' }); + expect(nav).toBeInTheDocument(); + expect(nav).toHaveAttribute('data-testid', 'pagination'); + }); + + it('gives every control an accessible name', () => { + renderPagination({ page: 2, pageCount: 5, label: 'Rows' }); + + expect(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Page 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Page 5' })).toBeInTheDocument(); + }); + + it('accepts custom control labels', () => { + renderPagination({ previousLabel: 'Older', nextLabel: 'Newer' }); + + expect(screen.getByRole('button', { name: 'Older' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Newer' })).toBeInTheDocument(); + }); + + it('marks the active page with aria-current', () => { + renderPagination({ page: 3, pageCount: 5 }); + + const current = screen.getByRole('button', { name: 'Page 3' }); + expect(current).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: 'Page 2' })).not.toHaveAttribute('aria-current'); + }); + + it('emits onPageChange when a page number is clicked', () => { + const { onPageChange } = renderPagination({ page: 1, pageCount: 5, pageSize: 25 }); + + fireEvent.click(screen.getByRole('button', { name: 'Page 4' })); + + expect(onPageChange).toHaveBeenCalledTimes(1); + expect(onPageChange).toHaveBeenCalledWith(4, 25); + }); + + it('emits onPageChange for the previous and next controls', () => { + const { onPageChange } = renderPagination({ page: 3, pageCount: 5, pageSize: 10 }); + + fireEvent.click(screen.getByTestId('pagination-previous')); + expect(onPageChange).toHaveBeenLastCalledWith(2, 10); + + fireEvent.click(screen.getByTestId('pagination-next')); + expect(onPageChange).toHaveBeenLastCalledWith(4, 10); + expect(onPageChange).toHaveBeenCalledTimes(2); + }); + + it('disables previous on the first page', () => { + renderPagination({ page: 1, pageCount: 5 }); + + expect(screen.getByTestId('pagination-previous')).toBeDisabled(); + expect(screen.getByTestId('pagination-next')).not.toBeDisabled(); + }); + + it('disables next on the last page', () => { + renderPagination({ page: 5, pageCount: 5 }); + + expect(screen.getByTestId('pagination-next')).toBeDisabled(); + expect(screen.getByTestId('pagination-previous')).not.toBeDisabled(); + }); + + it('disables both controls on a single-page listing', () => { + renderPagination({ page: 1, pageCount: 1 }); + + expect(screen.getByTestId('pagination-previous')).toBeDisabled(); + expect(screen.getByTestId('pagination-next')).toBeDisabled(); + }); + + it('never emits a page outside the range', () => { + const { onPageChange } = renderPagination({ page: 99, pageCount: 5 }); + + // The out-of-range page prop is clamped, so next is already at the boundary. + expect(screen.getByRole('button', { name: 'Page 5' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByTestId('pagination-next')).toBeDisabled(); + + fireEvent.click(screen.getByTestId('pagination-next')); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('does not emit when the current page is clicked again', () => { + const { onPageChange } = renderPagination({ page: 3, pageCount: 5 }); + + fireEvent.click(screen.getByRole('button', { name: 'Page 3' })); + + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('renders a single ellipsis for each collapsed run of pages', () => { + renderPagination({ page: 10, pageCount: 40 }); + + const ellipses = screen.getAllByTestId('pagination-ellipsis'); + expect(ellipses).toHaveLength(2); + for (const ellipsis of ellipses) { + expect(ellipsis).toHaveTextContent(PAGINATION_ELLIPSIS); + expect(ellipsis).toHaveAttribute('aria-hidden', 'true'); + } + }); + + it('renders no ellipsis when every page fits', () => { + renderPagination({ page: 1, pageCount: 4 }); + + expect(screen.queryAllByTestId('pagination-ellipsis')).toHaveLength(0); + expect(screen.getAllByTestId('pagination-page')).toHaveLength(4); + }); + + it('widens the window with siblingCount', () => { + renderPagination({ page: 10, pageCount: 40, siblingCount: 2 }); + + expect(screen.getByRole('button', { name: 'Page 8' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Page 12' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Page 13' })).not.toBeInTheDocument(); + }); + + it('disables every control when disabled is set', () => { + const { onPageChange } = renderPagination({ page: 3, pageCount: 5, disabled: true }); + + expect(screen.getByTestId('pagination-previous')).toBeDisabled(); + expect(screen.getByTestId('pagination-next')).toBeDisabled(); + expect(screen.getByTestId('pagination-page-size')).toBeDisabled(); + screen.getAllByTestId('pagination-page').forEach((button) => expect(button).toBeDisabled()); + + fireEvent.click(screen.getByRole('button', { name: 'Page 4' })); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('renders a labelled, keyboard reachable page size selector', () => { + renderPagination({ page: 1, pageCount: 5, pageSize: 25 }); + + const select = screen.getByLabelText('Rows per page'); + expect(select).toHaveAttribute('data-testid', 'pagination-page-size'); + expect(select).toHaveValue('25'); + expect(select).not.toBeDisabled(); + + for (const option of DEFAULT_PAGE_SIZE_OPTIONS) { + expect(screen.getByRole('option', { name: String(option) })).toBeInTheDocument(); + } + }); + + it('emits onPageChange with page 1 and the new page size', () => { + const { onPageChange } = renderPagination({ page: 4, pageCount: 10, pageSize: 10 }); + + fireEvent.change(screen.getByLabelText('Rows per page'), { target: { value: '50' } }); + + expect(onPageChange).toHaveBeenCalledTimes(1); + expect(onPageChange).toHaveBeenCalledWith(1, 50); + }); + + it('does not emit when the page size is unchanged', () => { + const { onPageChange } = renderPagination({ page: 2, pageCount: 5, pageSize: 25 }); + + fireEvent.change(screen.getByLabelText('Rows per page'), { target: { value: '25' } }); + + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('keeps an off-list page size selectable', () => { + renderPagination({ page: 1, pageCount: 3, pageSize: 7, pageSizeOptions: [10, 50] }); + + const select = screen.getByLabelText('Rows per page'); + expect(select).toHaveValue('7'); + expect(screen.getByRole('option', { name: '7' })).toBeInTheDocument(); + }); + + it('hides the page size selector on request', () => { + renderPagination({ showPageSizeSelector: false }); + + expect(screen.queryByTestId('pagination-page-size')).not.toBeInTheDocument(); + }); + + it('accepts a custom page size label', () => { + renderPagination({ pageSizeLabel: 'Results per page' }); + + expect(screen.getByLabelText('Results per page')).toBeInTheDocument(); + }); + + it('applies the size preset to the controls', () => { + renderPagination({ size: 'lg' }); + + expect(screen.getByTestId('pagination-next')).toHaveStyle({ height: '40px' }); + }); + + it('lets a consumer style override the defaults and forwards a ref', () => { + const ref = React.createRef(); + render( + , + ); + + expect(ref.current).toBeInstanceOf(HTMLElement); + expect(screen.getByTestId('pagination')).toHaveClass('custom'); + expect(screen.getByTestId('pagination')).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' }); + }); + + it('shows a focus ring on the control that has focus', () => { + renderPagination({ page: 1, pageCount: 3 }); + + const next = screen.getByTestId('pagination-next'); + // React 17+ delegates onFocus/onBlur through focusin/focusout. + fireEvent.focusIn(next); + expect(next.getAttribute('style')).toContain(theme.focusRing); + + fireEvent.focusOut(next); + expect(next.getAttribute('style')).not.toContain(theme.focusRing); + }); +}); \ No newline at end of file diff --git a/react/src/components/Pagination.tsx b/react/src/components/Pagination.tsx new file mode 100644 index 00000000..39376da8 --- /dev/null +++ b/react/src/components/Pagination.tsx @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: MIT +import React, { forwardRef, useId, useState } from 'react'; +import { theme } from '../theme'; + +/** Glyph rendered in place of a collapsed run of page numbers. */ +export const PAGINATION_ELLIPSIS = '…'; + +/** Rows per page assumed when `pageSize` is omitted or unusable. */ +export const DEFAULT_PAGE_SIZE = 10; + +/** Page sizes offered by the selector when `pageSizeOptions` is omitted. */ +export const DEFAULT_PAGE_SIZE_OPTIONS: readonly number[] = [10, 25, 50, 100]; + +/** Default accessible name for the `nav` landmark. */ +export const DEFAULT_PAGINATION_LABEL = 'Pagination'; + +/** Sizing preset for the pagination controls. */ +export type PaginationSize = 'sm' | 'md' | 'lg'; + +/** + * One slot of the rendered page list: either a 1-based page number, or `null` + * marking a gap that the component collapses into a {@link PAGINATION_ELLIPSIS}. + */ +export type PaginationItem = number | null; + +/** Inputs for {@link getPageItems}. */ +export interface GetPageItemsOptions { + /** Current page; clamped into `[1, pageCount]` before anything is built. */ + page: number; + /** Total number of pages. */ + pageCount: number; + /** Pages kept on each side of the current one. @default 1 */ + siblingCount?: number; +} + +/** + * Builds the page-button list behind {@link Pagination}. + * + * The first and last page are always present and the current page is kept with + * `siblingCount` neighbours on either side; every collapsed run collapses into a + * single `null` entry so the caller can render one ellipsis instead of a long + * strip of buttons. Short ranges are returned in full with no gaps at all. + * + * Every input is coerced, so a `page` of `0`, a `page` past the end, a + * fractional `pageCount` or a `NaN` can never produce a duplicate or an + * out-of-range entry. + * + * @returns Page numbers and `null` gap markers, in ascending order. + */ +export function getPageItems({ + page, + pageCount, + siblingCount = 1, +}: GetPageItemsOptions): PaginationItem[] { + const total = toCount(pageCount); + if (total === 0) return []; + + const siblings = toCount(siblingCount); + const current = clamp(page, 1, total); + + // First + last + the current window + one slot for each of the two gaps. + const maxItems = siblings * 2 + 5; + if (total <= maxItems) { + return Array.from({ length: total }, (_, index) => index + 1); + } + + // `total > maxItems >= 5`, so the inner window bounds are always valid. + const start = clamp(current - siblings, 2, total - 1); + const end = clamp(current + siblings, 2, total - 1); + + const items: PaginationItem[] = [1]; + if (start > 2) items.push(null); + for (let candidate = start; candidate <= end; candidate += 1) items.push(candidate); + if (end < total - 1) items.push(null); + items.push(total); + return items; +} + +export interface PaginationProps extends React.HTMLAttributes { + /** Current 1-based page. Clamped into `[1, pageCount]` before rendering. */ + page: number; + /** Total pages; anything below 1 is treated as a single page. */ + pageCount: number; + /** + * Rows per page. Reported back through {@link PaginationProps.onPageChange} + * and preselected in the page size selector. @default {@link DEFAULT_PAGE_SIZE} + */ + pageSize?: number; + /** + * Page sizes offered by the selector. The active `pageSize` is always added + * so the control can render a selected option. @default + * {@link DEFAULT_PAGE_SIZE_OPTIONS} + */ + pageSizeOptions?: readonly number[]; + /** Hide the page size selector, e.g. for a fixed-size listing. @default false */ + showPageSizeSelector?: boolean; + /** Pages kept on each side of the current one. @default 1 */ + siblingCount?: number; + /** Disables every control, e.g. while a page is loading. @default false */ + disabled?: boolean; + /** Sizing preset for the controls. @default 'md' */ + size?: PaginationSize; + /** Accessible name of the `nav` landmark. @default 'Pagination' */ + label?: string; + /** Accessible name of the previous-page control. @default 'Previous page' */ + previousLabel?: string; + /** Accessible name of the next-page control. @default 'Next page' */ + nextLabel?: string; + /** + * Visible (and therefore accessible) label of the page size selector. + * @default 'Rows per page' + */ + pageSizeLabel?: string; + /** + * Called with the requested page — always inside `[1, pageCount]` — and the + * active page size. Changing the page size reports page `1`, since a resize + * invalidates the offset the current page was read at. + */ + onPageChange?: (page: number, pageSize: number) => void; +} + +const ROOT_STYLE: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: 12, + fontFamily: 'inherit', + boxSizing: 'border-box', +}; + +const CONTROLS_STYLE: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 4, +}; + +const CONTROL_BASE: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + border: `1px solid ${theme.border}`, + borderRadius: 6, + backgroundColor: theme.surface, + color: theme.text, + fontFamily: 'inherit', + cursor: 'pointer', + boxSizing: 'border-box', + transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.15s', +}; + +/** Focus ring for a control that currently holds focus. */ +const CONTROL_FOCUS: React.CSSProperties = { + outline: 'none', + borderColor: theme.focus, + boxShadow: `0 0 0 2px ${theme.focusRing}`, +}; + +const CONTROL_DISABLED: React.CSSProperties = { + opacity: 0.45, + cursor: 'not-allowed', +}; + +const CURRENT_PAGE_STYLE: React.CSSProperties = { + backgroundColor: theme.primaryBackground, + borderColor: theme.primaryBorder, + color: theme.primaryText, + fontWeight: 600, +}; + +const SIZE_STYLES: Record = { + sm: { fontSize: 12, minWidth: 28, height: 28, padding: '0 8px' }, + md: { fontSize: 14, minWidth: 34, height: 34, padding: '0 10px' }, + lg: { fontSize: 16, minWidth: 40, height: 40, padding: '0 14px' }, +}; + +const ELLIPSIS_STYLE: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 24, + color: theme.neutralText, + userSelect: 'none', +}; + +const SELECT_BASE: React.CSSProperties = { + border: `1px solid ${theme.border}`, + borderRadius: 6, + backgroundColor: theme.surface, + color: theme.text, + fontFamily: 'inherit', + cursor: 'pointer', + boxSizing: 'border-box', + transition: 'border-color 0.15s, box-shadow 0.15s', +}; + +const SELECT_FOCUS: React.CSSProperties = { + outline: 'none', + borderColor: theme.focus, + boxShadow: `0 0 0 2px ${theme.focusRing}`, +}; + +const SELECT_SIZE_STYLES: Record = { + sm: { fontSize: 12, height: 28, padding: '0 6px' }, + md: { fontSize: 14, height: 34, padding: '0 8px' }, + lg: { fontSize: 16, height: 40, padding: '0 10px' }, +}; + +const SIZE_SELECTOR_STYLE: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + fontSize: 13, + color: theme.neutralText, +}; + +const SIZE_SELECTOR_LABEL_STYLE: React.CSSProperties = { + fontSize: 13, + color: theme.neutralText, + cursor: 'pointer', +}; + +/** + * Coerces an arbitrary prop into a non-negative integer count. + * + * Non-finite and negative inputs collapse to `0` so a bad prop degrades to the + * smallest sensible rendering instead of throwing or producing a negative range. + */ +function toCount(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.floor(value)); +} + +/** Constrains `value` to `[min, max]`, tolerating an inverted range and `NaN`. */ +function clamp(value: number, min: number, max: number): number { + const low = Math.min(min, max); + const high = Math.max(min, max); + if (!Number.isFinite(value)) return low; + return Math.min(Math.max(value, low), high); +} + +/** + * Pagination control for data listings: previous/next buttons, the page number + * buttons, and a page size dropdown (#1165). + * + * The component is fully controlled — it never mutates `page` itself. It + * clamps whatever it renders and re-clamps at the call site, so + * `onPageChange` can never observe a page outside `[1, pageCount]` even when + * the consumer passes an out-of-range `page`. Previous is disabled on the first + * page, next on the last, and collapsed page ranges render a single + * {@link PAGINATION_ELLIPSIS} that is hidden from assistive technology. + * + * Accessibility: the root is a labelled `nav` landmark, every control has an + * accessible name, the current page carries `aria-current="page"`, and each + * control shows a visible focus ring driven from state (inline styles have no + * `:focus-visible`). + */ +export const Pagination = forwardRef(function Pagination( + { + page, + pageCount, + pageSize = DEFAULT_PAGE_SIZE, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + showPageSizeSelector = true, + siblingCount = 1, + disabled = false, + size = 'md', + label = DEFAULT_PAGINATION_LABEL, + previousLabel = 'Previous page', + nextLabel = 'Next page', + pageSizeLabel = 'Rows per page', + onPageChange, + style, + ...rest + }, + ref, +) { + const pageSizeSelectId = useId(); + + // Inline styles cannot express `:focus-visible`, so the focus ring is toggled + // from state keyed by whichever control currently holds focus. + const [focusedControl, setFocusedControl] = useState(null); + + const totalPages = Math.max(1, toCount(pageCount)); + const currentPage = clamp(page, 1, totalPages); + const isFirstPage = currentPage <= 1; + const isLastPage = currentPage >= totalPages; + + const requestedSize = toCount(pageSize); + const effectivePageSize = requestedSize > 0 ? requestedSize : DEFAULT_PAGE_SIZE; + + const items = getPageItems({ page: currentPage, pageCount: totalPages, siblingCount }); + + // The active page size is always selectable, even when it is not part of + // `pageSizeOptions`, so the control never renders with a blank selection. + const sizes = Array.from( + new Set( + [...pageSizeOptions.map((option) => toCount(option)), effectivePageSize].filter( + (option) => option > 0, + ), + ), + ).sort((a, b) => a - b); + + /** Focus handlers for one control; `blur` clears whichever ring is showing. */ + const focusHandlers = (key: string) => ({ + onFocus: () => setFocusedControl(key), + onBlur: () => setFocusedControl(null), + }); + + /** Base style for a button, with the focus ring and disabled treatment layered on. */ + const controlStyle = (key: string, isDisabled: boolean): React.CSSProperties => ({ + ...CONTROL_BASE, + ...SIZE_STYLES[size], + ...(focusedControl === key ? CONTROL_FOCUS : {}), + ...(isDisabled ? CONTROL_DISABLED : {}), + }); + + /** Moves to `target`, clamped into range; a no-op move emits nothing. */ + const goToPage = (target: number) => { + if (disabled) return; + const nextPage = clamp(target, 1, totalPages); + if (nextPage === currentPage) return; + onPageChange?.(nextPage, effectivePageSize); + }; + + /** Resizing changes how many rows fit on a page, so it returns to page one. */ + const handlePageSizeChange = (event: React.ChangeEvent) => { + if (disabled) return; + const parsed = Number.parseInt(event.target.value, 10); + const nextSize = Number.isFinite(parsed) && parsed > 0 ? parsed : effectivePageSize; + if (nextSize === effectivePageSize) return; + onPageChange?.(1, nextSize); + }; + + return ( + + ); +}); \ No newline at end of file diff --git a/react/src/components/index.ts b/react/src/components/index.ts index bc6e9a9f..a59f071a 100644 --- a/react/src/components/index.ts +++ b/react/src/components/index.ts @@ -5,9 +5,50 @@ export type { AlertProps, AlertVariant } from './Alert'; export { Badge } from './Badge'; export type { BadgeProps, BadgeVariant, BadgeSize } from './Badge'; +export { ConfirmationDialog } from './ConfirmationDialog'; +export type { + ConfirmationDialogProps, + ConfirmationDialogVariant, + ConfirmationMode, +} from './ConfirmationDialog'; +export { DEFAULT_CONFIRMATION_KEYWORD } from './ConfirmationDialog'; + export { Dropdown } from './Dropdown'; export type { DropdownProps, DropdownVariant, DropdownSize, DropdownItem } from './Dropdown'; +export { EmptyState } from './EmptyState'; +export type { + EmptyStateProps, + EmptyStateAction, + EmptyStateActionVariant, + EmptyStateSize, +} from './EmptyState'; + +export { + MetricCard, + MetricStatBox, + formatMetricDelta, + formatMetricValue, + getMetricTrend, + METRIC_VALUE_PLACEHOLDER, +} from './MetricCard'; +export type { + MetricCardProps, + MetricStatBoxProps, + MetricCardSize, + MetricCardValue, + MetricTrend, +} from './MetricCard'; + +export { Pagination, getPageItems } from './Pagination'; +export type { PaginationProps, PaginationItem, PaginationSize, GetPageItemsOptions } from './Pagination'; +export { + PAGINATION_ELLIPSIS, + DEFAULT_PAGE_SIZE, + DEFAULT_PAGE_SIZE_OPTIONS, + DEFAULT_PAGINATION_LABEL, +} from './Pagination'; + export { ConnectWallet } from './ConnectWallet'; export type { ConnectWalletProps } from './ConnectWallet'; diff --git a/react/stories/ConfirmationDialog.stories.tsx b/react/stories/ConfirmationDialog.stories.tsx new file mode 100644 index 00000000..8a30df9d --- /dev/null +++ b/react/stories/ConfirmationDialog.stories.tsx @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +import { useState, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { + ConfirmationDialog, + type ConfirmationDialogProps, +} from "../src/components/ConfirmationDialog"; + +const meta = { + title: "Primitives/ConfirmationDialog", + component: ConfirmationDialog, + args: { + open: true, + title: "Burn 1,000 BCC?", + description: + "This supply is removed from circulation forever. There is no way to undo it.", + confirmLabel: "Burn supply", + cancelLabel: "Keep supply", + onClose: () => {}, + onConfirm: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +interface HarnessProps extends Partial { + /** Extra content rendered between the description and the gate. */ + children?: ReactNode; +} + +/** + * Story-scoped harness so the dialog can actually be closed and reopened. + * + * The dialog normally portals into `document.body`, which would put it outside + * the story frame: the `play` functions could not reach it through + * `canvasElement`, and the visual baseline would only capture the trigger. The + * harness therefore gives it a stage *inside* the story and passes that as + * `portalContainer`. + * + * The stage carries a `transform`, which establishes a containing block for the + * dialog's `position: fixed` backdrop — without it the backdrop would still size + * itself to the viewport and paint over the whole page. + */ +function Harness({ children, ...args }: HarnessProps) { + const [open, setOpen] = useState(args.open ?? true); + const [lastAction, setLastAction] = useState("none"); + const [stage, setStage] = useState(null); + + return ( +
+ + + Last action: {lastAction} + +
+ { + setLastAction("cancelled"); + setOpen(false); + args.onClose?.(); + }} + onConfirm={() => { + setLastAction("confirmed"); + setOpen(false); + args.onConfirm?.(); + }} + > + {children} + +
+ ); +} + +export const BurnSupply: Story = { + render: (args) => ( + +

+ Recipient: GABC…7Y8Z +

+
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const confirm = canvas.getByTestId("confirmation-dialog-confirm"); + await expect(confirm).toBeDisabled(); + + await userEvent.click(canvas.getByTestId("confirmation-dialog-checkbox")); + await expect(confirm).toBeEnabled(); + + await userEvent.click(confirm); + await expect(canvas.getByTestId("last-action")).toHaveTextContent( + "Last action: confirmed", + ); + }, +}; + +export const KeywordGate: Story = { + args: { + title: "Revoke the admin role?", + description: + "You will lose the ability to mint, pause or upgrade the contract.", + confirmLabel: "Revoke role", + confirmationMode: "keyword", + confirmationKeyword: "REVOKE", + }, + render: (args) => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const confirm = canvas.getByTestId("confirmation-dialog-confirm"); + await expect(confirm).toBeDisabled(); + + await userEvent.type( + canvas.getByTestId("confirmation-dialog-keyword-input"), + "revoke", + ); + await expect(confirm).toBeEnabled(); + }, +}; + +export const WarningVariant: Story = { + args: { + title: "Pause all transfers?", + description: + "Users will not be able to move tokens until the contract is unpaused.", + confirmLabel: "Pause contract", + variant: "warning", + confirmationMode: "none", + }, + render: (args) => , +}; + +export const NoGate: Story = { + args: { + title: "Approve proposal?", + description: "Your vote is recorded on chain and cannot be withdrawn.", + confirmLabel: "Approve", + confirmationMode: "none", + }, + render: (args) => , +}; + +export const BusySubmitting: Story = { + args: { + title: "Burn 1,000 BCC?", + description: "Submitting the burn transaction.", + confirmLabel: "Burn supply", + busy: true, + busyLabel: "Submitting…", + confirmationMode: "none", + }, + render: (args) => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByTestId("confirmation-dialog-confirm"), + ).toBeDisabled(); + await expect( + canvas.getByTestId("confirmation-dialog-confirm"), + ).toHaveTextContent("Submitting…"); + await expect( + canvas.getByTestId("confirmation-dialog-cancel"), + ).toBeDisabled(); + + // Escape must not dismiss a dialog with a transaction in flight. + await userEvent.keyboard("{Escape}"); + await expect(canvas.getByTestId("confirmation-dialog")).toBeVisible(); + }, +}; + +export const EscapeCloses: Story = { + render: (args) => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.keyboard("{Escape}"); + await expect(canvas.queryByTestId("confirmation-dialog")).toBeNull(); + await expect(canvas.getByTestId("last-action")).toHaveTextContent( + "Last action: cancelled", + ); + }, +}; + +export const FocusTrap: Story = { + render: (args) => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Satisfy the gate first so Confirm is focusable. + await userEvent.click(canvas.getByTestId("confirmation-dialog-checkbox")); + + const confirm = canvas.getByTestId("confirmation-dialog-confirm"); + confirm.focus(); + + // Tab past the last control wraps back inside the dialog. + await userEvent.tab(); + await expect( + canvas.getByTestId("confirmation-dialog-checkbox"), + ).toHaveFocus(); + + await userEvent.tab({ shift: true }); + await expect(confirm).toHaveFocus(); + + // Drop focus before the frame is recorded. The focus ring is the only part + // of this story that renders differently at sub-pixel level between runs, + // and the visual suite compares at zero tolerance, so leaving it drawn made + // the baseline flap by around ten pixels. + (document.activeElement as HTMLElement | null)?.blur(); + }, +}; + +export const Closed: Story = { + args: { open: false }, + render: (args) => , +}; diff --git a/react/stories/EmptyState.stories.tsx b/react/stories/EmptyState.stories.tsx new file mode 100644 index 00000000..ad7951c8 --- /dev/null +++ b/react/stories/EmptyState.stories.tsx @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; +import { EmptyState } from '../src/components/EmptyState'; + +const meta = { + title: 'Primitives/EmptyState', + component: EmptyState, + args: { + title: 'No transactions yet', + description: 'Mint or transfer your first tokens and the activity shows up here.', + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithPrimaryAction: Story = { + args: { + action: { label: 'Mint your first token', onClick: () => {} }, + }, +}; + +export const WithActions: Story = { + args: { + title: 'No search results', + description: 'Nothing matched “stellar”. Try a different address, symbol or block.', + action: { label: 'Clear filters', onClick: () => {} }, + secondaryAction: { label: 'Browse tokens', onClick: () => {} }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('empty-state-action')).toBeVisible(); + await expect(canvas.getByTestId('empty-state-secondary-action')).toBeVisible(); + }, +}; + +export const DisabledAction: Story = { + args: { + title: 'No proposals to vote on', + description: 'Connect a wallet to review and approve proposals.', + action: { label: 'Connect wallet', onClick: () => {}, disabled: true }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('empty-state-action')).toBeDisabled(); + }, +}; + +export const TitleOnly: Story = { + args: { description: undefined, action: undefined }, +}; + +export const CustomIcon: Story = { + args: { + title: 'No vault deposits', + description: 'Deposit tokens to start earning a share of the vault rewards.', + icon: ( + + ), + action: { label: 'Deposit tokens', onClick: () => {} }, + }, +}; + +export const NoIcon: Story = { + args: { icon: null }, +}; + +export const Sizes: Story = { + render: (args) => ( +
+ {(['sm', 'md', 'lg'] as const).map((size) => ( +
+ {} }} + /> +
+ ))} +
+ ), +}; + +export const InsideATable: Story = { + args: { + title: 'No holders for this token', + description: 'Nobody holds this contract yet. Mint some to get started.', + action: { label: 'Mint tokens', onClick: () => {} }, + }, + render: (args) => ( +
+
+ Address + Balance +
+ +
+ ), +}; + +export const AnnouncedOnSearch: Story = { + args: { + role: 'status', + title: 'No matches', + description: 'The search finished and returned nothing.', + secondaryAction: { label: 'Clear search', onClick: () => {} }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('empty-state')).toHaveAttribute('role', 'status'); + }, +}; + +export const CustomActionNode: Story = { + args: { + title: 'Nothing archived', + action: ( + + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByTestId('custom-action-button')); + await expect(canvas.getByTestId('empty-state')).toBeVisible(); + }, +}; \ No newline at end of file diff --git a/react/stories/MetricCard.stories.tsx b/react/stories/MetricCard.stories.tsx new file mode 100644 index 00000000..be50d75b --- /dev/null +++ b/react/stories/MetricCard.stories.tsx @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { MetricCard, MetricStatBox } from "../src/components/MetricCard"; + +const meta = { + title: "Primitives/MetricCard", + component: MetricCard, + args: { + title: "Total supply", + value: "1,250,000 BCC", + subtitle: "Updated 2 minutes ago", + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const BIG_SUPPLY = 123456789012345678901234n; + +export const Default: Story = {}; + +export const Trends: Story = { + render: (args) => ( +
+ + + +
+ ), +}; + +export const Sizes: Story = { + render: (args) => ( +
+ {(["sm", "md", "lg"] as const).map((size) => ( + + ))} +
+ ), +}; + +export const Loading: Story = { + args: { title: "Total supply", value: "1,250,000 BCC", loading: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId("metric-card")).toHaveAttribute( + "aria-busy", + "true", + ); + await expect(canvas.getByTestId("metric-card-skeleton")).toBeVisible(); + await expect(canvas.getByRole("status")).toHaveTextContent( + "Total supply: Loading…", + ); + }, +}; + +export const BigintValue: Story = { + args: { + title: "Raw stroop balance", + value: BIG_SUPPLY, + subtitle: "Rendered from a bigint without precision loss", + }, +}; + +export const MissingValue: Story = { + args: { title: "Holders", value: null }, +}; + +export const NonFiniteValue: Story = { + args: { title: "Share price", value: Number.NaN }, +}; + +export const StatBox: Story = { + render: () => ( +
+ {(["sm", "md", "lg"] as const).map((size) => ( + + ))} +
+ ), +}; diff --git a/react/stories/Pagination.stories.tsx b/react/stories/Pagination.stories.tsx new file mode 100644 index 00000000..aa2c7821 --- /dev/null +++ b/react/stories/Pagination.stories.tsx @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; +import { Pagination, type PaginationProps } from '../src/components/Pagination'; + +const meta = { + title: 'Primitives/Pagination', + component: Pagination, + args: { + page: 1, + pageCount: 24, + pageSize: 10, + onPageChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Story-scoped wrapper so the controlled page actually moves when clicked. */ +function Controlled(args: PaginationProps) { + const [page, setPage] = useState(args.page); + const [pageSize, setPageSize] = useState(args.pageSize ?? 10); + + return ( +
+
+ Rows {(page - 1) * pageSize + 1}–{page * pageSize} · page {page} of {args.pageCount} +
+ { + setPage(nextPage); + setPageSize(nextSize); + args.onPageChange?.(nextPage, nextSize); + }} + style={{ marginTop: 12 }} + /> +
+ ); +} + +export const Default: Story = { + render: (args) => , +}; + +export const ShortRange: Story = { + args: { pageCount: 4 }, + render: (args) => , +}; + +export const MiddleOfLongRange: Story = { + args: { page: 12, pageCount: 24 }, + render: (args) => , +}; + +export const WideSiblings: Story = { + args: { page: 12, pageCount: 24, siblingCount: 2 }, + render: (args) => , +}; + +export const Sizes: Story = { + render: (args) => ( +
+ {(['sm', 'md', 'lg'] as const).map((size) => ( +
+ {size} + +
+ ))} +
+ ), +}; + +export const SinglePage: Story = { + args: { page: 1, pageCount: 1 }, +}; + +export const FirstPageDisabled: Story = { + args: { page: 1, pageCount: 24 }, +}; + +export const LastPageDisabled: Story = { + args: { page: 24, pageCount: 24 }, +}; + +export const Loading: Story = { + args: { page: 6, pageCount: 24, disabled: true }, +}; + +export const WithoutPageSizeSelector: Story = { + args: { page: 6, pageCount: 24, showPageSizeSelector: false }, +}; + +export const CustomLabels: Story = { + args: { + page: 6, + pageCount: 24, + label: 'Transaction history', + previousLabel: 'Newer transactions', + nextLabel: 'Older transactions', + pageSizeLabel: 'Transactions per page', + pageSizeOptions: [5, 10, 20, 50], + pageSize: 20, + }, +}; + +export const KeyboardNavigation: Story = { + args: { page: 12, pageCount: 24 }, + render: (args) => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const next = canvas.getByTestId('pagination-next'); + next.focus(); + + await userEvent.keyboard('{Enter}'); + await expect(canvas.getByRole('button', { name: 'Page 13' })).toHaveAttribute( + 'aria-current', + 'page', + ); + + await userEvent.tab(); + await expect(canvas.getByLabelText('Rows per page')).toBeVisible(); + }, +}; \ No newline at end of file diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--burn-supply.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--burn-supply.png new file mode 100644 index 00000000..a69ae16f Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--burn-supply.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--busy-submitting.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--busy-submitting.png new file mode 100644 index 00000000..dee9aa38 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--busy-submitting.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--closed.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--closed.png new file mode 100644 index 00000000..6c27b36b Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--closed.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--escape-closes.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--escape-closes.png new file mode 100644 index 00000000..a2871148 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--escape-closes.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--focus-trap.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--focus-trap.png new file mode 100644 index 00000000..0c1de3a4 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--focus-trap.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--keyword-gate.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--keyword-gate.png new file mode 100644 index 00000000..e57f9a20 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--keyword-gate.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--no-gate.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--no-gate.png new file mode 100644 index 00000000..27744a9f Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--no-gate.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--warning-variant.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--warning-variant.png new file mode 100644 index 00000000..f750d9a9 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-confirmationdialog--warning-variant.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--announced-on-search.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--announced-on-search.png new file mode 100644 index 00000000..9aec4040 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--announced-on-search.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--custom-action-node.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--custom-action-node.png new file mode 100644 index 00000000..1ae2566a Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--custom-action-node.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--custom-icon.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--custom-icon.png new file mode 100644 index 00000000..c0f58d72 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--custom-icon.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--default.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--default.png new file mode 100644 index 00000000..9fbdd1f8 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--default.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--disabled-action.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--disabled-action.png new file mode 100644 index 00000000..880e1ce1 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--disabled-action.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--inside-a-table.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--inside-a-table.png new file mode 100644 index 00000000..c522559c Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--inside-a-table.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--no-icon.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--no-icon.png new file mode 100644 index 00000000..33031fe2 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--no-icon.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--sizes.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--sizes.png new file mode 100644 index 00000000..079c6c45 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--sizes.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--title-only.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--title-only.png new file mode 100644 index 00000000..a5df5219 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--title-only.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--with-actions.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--with-actions.png new file mode 100644 index 00000000..f72be704 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--with-actions.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--with-primary-action.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--with-primary-action.png new file mode 100644 index 00000000..dd1db160 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-emptystate--with-primary-action.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--bigint-value.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--bigint-value.png new file mode 100644 index 00000000..fd289dcf Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--bigint-value.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--default.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--default.png new file mode 100644 index 00000000..361ac8b5 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--default.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--loading.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--loading.png new file mode 100644 index 00000000..9a0ea28d Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--loading.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--missing-value.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--missing-value.png new file mode 100644 index 00000000..cfe5ddeb Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--missing-value.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--non-finite-value.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--non-finite-value.png new file mode 100644 index 00000000..74120a2c Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--non-finite-value.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--sizes.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--sizes.png new file mode 100644 index 00000000..df0d57b1 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--sizes.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--stat-box.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--stat-box.png new file mode 100644 index 00000000..415448fc Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--stat-box.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--trends.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--trends.png new file mode 100644 index 00000000..bbaed6ac Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-metriccard--trends.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--custom-labels.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--custom-labels.png new file mode 100644 index 00000000..5f4921cd Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--custom-labels.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--default.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--default.png new file mode 100644 index 00000000..90ad874e Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--default.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--first-page-disabled.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--first-page-disabled.png new file mode 100644 index 00000000..e1e1d2bb Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--first-page-disabled.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--keyboard-navigation.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--keyboard-navigation.png new file mode 100644 index 00000000..63182f42 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--keyboard-navigation.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--last-page-disabled.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--last-page-disabled.png new file mode 100644 index 00000000..9d504445 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--last-page-disabled.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--loading.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--loading.png new file mode 100644 index 00000000..70fede55 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--loading.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--middle-of-long-range.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--middle-of-long-range.png new file mode 100644 index 00000000..47e5735a Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--middle-of-long-range.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--short-range.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--short-range.png new file mode 100644 index 00000000..306d4818 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--short-range.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--single-page.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--single-page.png new file mode 100644 index 00000000..c85dca31 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--single-page.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--sizes.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--sizes.png new file mode 100644 index 00000000..9f98eb60 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--sizes.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--wide-siblings.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--wide-siblings.png new file mode 100644 index 00000000..6fcc3f90 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--wide-siblings.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--without-page-size-selector.png b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--without-page-size-selector.png new file mode 100644 index 00000000..bbd49749 Binary files /dev/null and b/react/visual/__screenshots__/linux-chromium-arm64/primitives-pagination--without-page-size-selector.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/products-mintform--ready.png b/react/visual/__screenshots__/linux-chromium-arm64/products-mintform--ready.png index d445264e..e8d778c9 100644 Binary files a/react/visual/__screenshots__/linux-chromium-arm64/products-mintform--ready.png and b/react/visual/__screenshots__/linux-chromium-arm64/products-mintform--ready.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/products-vaultdepositwidget--ready.png b/react/visual/__screenshots__/linux-chromium-arm64/products-vaultdepositwidget--ready.png index cff092d1..67fadff9 100644 Binary files a/react/visual/__screenshots__/linux-chromium-arm64/products-vaultdepositwidget--ready.png and b/react/visual/__screenshots__/linux-chromium-arm64/products-vaultdepositwidget--ready.png differ diff --git a/react/visual/__screenshots__/linux-chromium-arm64/products-vaultdepositwidget--success.png b/react/visual/__screenshots__/linux-chromium-arm64/products-vaultdepositwidget--success.png index 6d0a0ac2..84efdb0f 100644 Binary files a/react/visual/__screenshots__/linux-chromium-arm64/products-vaultdepositwidget--success.png and b/react/visual/__screenshots__/linux-chromium-arm64/products-vaultdepositwidget--success.png differ