Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions react/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
testMatch: ['**/*.test.ts', '**/*.test.tsx'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{ tsconfig: { jsx: 'react-jsx', module: 'commonjs', esModuleInterop: true } },
],
},
};
1 change: 1 addition & 0 deletions react/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
10 changes: 9 additions & 1 deletion react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
"lint": "eslint src/**/*.ts"
"lint": "eslint src/**/*.ts",
"test": "jest"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
Expand All @@ -21,10 +22,17 @@
"@bc-forge/sdk": "file:../sdk"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@types/jest": "^29.5.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"ts-jest": "^29.1.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
}
Expand Down
71 changes: 71 additions & 0 deletions react/src/components/Alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { forwardRef } from 'react';

export type AlertVariant = 'info' | 'success' | 'warning' | 'danger';

export interface AlertProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
/** Visual + semantic style of the alert. @default 'info' */
variant?: AlertVariant;
/** Optional bold title rendered above the content. */
title?: React.ReactNode;
/** When provided, renders a dismiss button that calls this handler. */
onDismiss?: () => void;
/** Accessible label for the dismiss button. @default 'Dismiss alert' */
dismissLabel?: string;
}

const VARIANT_STYLES: Record<AlertVariant, React.CSSProperties> = {
info: { backgroundColor: '#eff6ff', borderColor: '#bfdbfe', color: '#1e40af' },
success: { backgroundColor: '#f0fdf4', borderColor: '#bbf7d0', color: '#166534' },
warning: { backgroundColor: '#fffbeb', borderColor: '#fde68a', color: '#92400e' },
danger: { backgroundColor: '#fef2f2', borderColor: '#fecaca', color: '#991b1b' },
};

/** Alert banner; role is "alert" for danger/warning and "status" otherwise. */
export const Alert = forwardRef<HTMLDivElement, AlertProps>(function Alert(
{ variant = 'info', title, onDismiss, dismissLabel = 'Dismiss alert', style, children, ...rest },
ref,
) {
const defaultRole = variant === 'danger' || variant === 'warning' ? 'alert' : 'status';

return (
<div
ref={ref}
role={defaultRole}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: '12px 14px',
border: '1px solid',
borderRadius: 8,
...VARIANT_STYLES[variant],
...style,
}}
{...rest}
>
<div style={{ flex: 1, minWidth: 0 }}>
{title ? <div style={{ fontWeight: 700, marginBottom: 2 }}>{title}</div> : null}
<div style={{ fontSize: 14 }}>{children}</div>
</div>
{onDismiss ? (
<button
type="button"
onClick={onDismiss}
aria-label={dismissLabel}
style={{
flexShrink: 0,
border: 'none',
background: 'transparent',
cursor: 'pointer',
color: 'inherit',
fontSize: 18,
lineHeight: 1,
padding: 2,
}}
>
&times;
</button>
) : null}
</div>
);
});
64 changes: 64 additions & 0 deletions react/src/components/Badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { forwardRef } from 'react';

export type BadgeVariant =
| 'default'
| 'primary'
| 'success'
| 'warning'
| 'danger'
| 'info';

export type BadgeSize = 'sm' | 'md' | 'lg';

export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
/** Visual style of the badge. @default 'default' */
variant?: BadgeVariant;
/** Size of the badge. @default 'md' */
size?: BadgeSize;
}

const VARIANT_STYLES: Record<BadgeVariant, React.CSSProperties> = {
default: { backgroundColor: '#f3f4f6', color: '#374151' },
primary: { backgroundColor: '#e0e7ff', color: '#3730a3' },
success: { backgroundColor: '#dcfce7', color: '#166534' },
warning: { backgroundColor: '#fef3c7', color: '#92400e' },
danger: { backgroundColor: '#fee2e2', color: '#991b1b' },
info: { backgroundColor: '#cffafe', color: '#155e75' },
};

const SIZE_STYLES: Record<BadgeSize, React.CSSProperties> = {
sm: { fontSize: 11, padding: '1px 6px' },
md: { fontSize: 12, padding: '2px 8px' },
lg: { fontSize: 14, padding: '4px 10px' },
};

const BASE_STYLE: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 4,
borderRadius: 9999,
fontWeight: 600,
lineHeight: 1.4,
whiteSpace: 'nowrap',
};

/** Accessible status badge; forwards all standard span props and a ref. */
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
{ variant = 'default', size = 'md', style, children, ...rest },
ref,
) {
return (
<span
ref={ref}
style={{
...BASE_STYLE,
...VARIANT_STYLES[variant],
...SIZE_STYLES[size],
...style,
}}
{...rest}
>
{children}
</span>
);
});
113 changes: 113 additions & 0 deletions react/src/components/COMPONENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# @bc-forge/react UI components

Reusable, accessible, dependency-free React components. Each component ships
with inline styles (no CSS import or Tailwind setup required) and forwards
standard HTML attributes so it slots into any design system.

```tsx
import { Badge, Alert, Pagination, Modal, Tooltip } from '@bc-forge/react';
```

## Badge

Status pill for labels, counts, and states.

| Prop | Type | Default | Description |
| --------- | --------------------------------------------------------------------- | ----------- | ------------------------------------ |
| `variant` | `'default' \| 'primary' \| 'success' \| 'warning' \| 'danger' \| 'info'` | `'default'` | Visual style. |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Badge size. |
| `...rest` | `React.HTMLAttributes<HTMLSpanElement>` | — | Any span prop (`className`, `aria-*`). |

Renders a `<span>` and forwards a `ref`. Provide `aria-label` when the visible
text isn't descriptive on its own (e.g. a bare count).

```tsx
<Badge variant="success">Verified</Badge>
<Badge variant="danger" size="lg" aria-label="3 failed checks">3</Badge>
```

## Alert

Inline notification banner. The ARIA role is derived from the variant —
`danger`/`warning` → `role="alert"` (assertive), `info`/`success` →
`role="status"` (polite). Pass `role` to override.

| Prop | Type | Default | Description |
| -------------- | ----------------------------------------------- | ---------------- | ------------------------------------------ |
| `variant` | `'info' \| 'success' \| 'warning' \| 'danger'` | `'info'` | Visual + semantic style. |
| `title` | `React.ReactNode` | — | Optional bold heading. |
| `onDismiss` | `() => void` | — | When set, renders a keyboard-focusable dismiss button. |
| `dismissLabel` | `string` | `'Dismiss alert'`| Accessible label for the dismiss button. |
| `...rest` | `React.HTMLAttributes<HTMLDivElement>` | — | Any div prop. |

```tsx
<Alert variant="success" title="Saved">Your changes were stored.</Alert>
<Alert variant="danger" onDismiss={() => setError(null)}>Mint failed.</Alert>
```

## Pagination

Page navigation rendered as a `<nav>` landmark with native `<button>`s
(keyboard-accessible out of the box). The active page carries
`aria-current="page"`; Previous/Next are `disabled` at the bounds. Returns
`null` when `totalPages <= 1`.

| Prop | Type | Default | Description |
| -------------- | -------------------------- | -------------- | --------------------------------------------- |
| `currentPage` | `number` | — | 1-based current page. |
| `totalPages` | `number` | — | Total page count. |
| `onPageChange` | `(page: number) => void` | — | Called with the requested page (clamped). |
| `siblingCount` | `number` | `1` | Page buttons shown on each side of current. |
| `ariaLabel` | `string` | `'Pagination'` | Label for the `<nav>` landmark. |
| `...rest` | `React.HTMLAttributes<HTMLElement>` | — | Any element prop (minus `onChange`). |

The pure helper `getPaginationRange(currentPage, totalPages, siblingCount)` is
exported for testing/customisation; it returns page numbers with `'dots'`
sentinels where the range is collapsed.

```tsx
<Pagination currentPage={page} totalPages={20} onPageChange={setPage} />
```

## Modal

Accessible dialog with full focus management (WCAG 2.1 AA):

- `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → the title.
- Moves focus into the dialog on open and **restores** it to the trigger on close.
- **Traps** Tab / Shift+Tab inside the dialog.
- Closes on Escape and (optionally) on overlay click.

| Prop | Type | Default | Description |
| --------------------- | ----------------- | ---------------- | -------------------------------------------- |
| `open` | `boolean` | — | Whether the dialog is mounted and visible. |
| `onClose` | `() => void` | — | Called on Escape, close button, overlay click.|
| `title` | `React.ReactNode` | — | Dialog heading; also the accessible name. |
| `children` | `React.ReactNode` | — | Dialog body. |
| `closeOnOverlayClick` | `boolean` | `true` | Close when the backdrop is clicked. |
| `closeLabel` | `string` | `'Close dialog'` | Accessible label for the header close button.|

```tsx
<Modal open={isOpen} onClose={() => setIsOpen(false)} title="Confirm mint">
<p>Mint 1,000 tokens to G…ABC?</p>
<button onClick={confirm}>Confirm</button>
</Modal>
```

## Tooltip

Shows contextual text on hover **and** keyboard focus, dismissible with Escape.
Renders `role="tooltip"` and links the trigger via `aria-describedby`. Wraps a
single focusable element.

| Prop | Type | Default | Description |
| ----------- | ----------------------------------------- | ------- | ---------------------------- |
| `content` | `React.ReactNode` | — | Tooltip contents. |
| `children` | `React.ReactElement` | — | The (focusable) trigger. |
| `placement` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'top'` | Position relative to trigger.|

```tsx
<Tooltip content="Copy address">
<button onClick={copy}>📋</button>
</Tooltip>
```
Loading