@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/package.json +27 -3
- package/src/brand.css +13 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/Button.tsx +178 -0
- package/src/components/Card.tsx +42 -0
- package/src/components/Checkbox.tsx +67 -0
- package/src/components/Dialog.tsx +111 -0
- package/src/components/Field.tsx +66 -0
- package/src/components/Figure.tsx +30 -0
- package/src/components/Icon.tsx +32 -0
- package/src/components/IconButton.tsx +55 -0
- package/src/components/Input.tsx +74 -0
- package/src/components/Logo.tsx +62 -0
- package/src/components/Marker.tsx +26 -0
- package/src/components/MetaLabel.tsx +38 -0
- package/src/components/Radio.tsx +72 -0
- package/src/components/Select.tsx +351 -0
- package/src/components/Spinner.tsx +20 -0
- package/src/components/Switch.tsx +67 -0
- package/src/components/Table.tsx +196 -0
- package/src/components/Tabs.tsx +124 -0
- package/src/components/Tag.tsx +35 -0
- package/src/components/Textarea.tsx +46 -0
- package/src/components/ThemeProvider.tsx +80 -0
- package/src/components/Toast.tsx +159 -0
- package/src/components/Tooltip.tsx +57 -0
- package/src/components/core.test.tsx +242 -0
- package/src/components/forms-control.ts +36 -0
- package/src/components/forms.test.tsx +280 -0
- package/src/components/logo-art.ts +4 -0
- package/src/components/overlays.test.tsx +245 -0
- package/src/components/useButtonAction.ts +57 -0
- package/src/index.ts +29 -2
- package/src/modes.css +10 -0
- package/src/styles.css +2 -0
- package/src/theme.css +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## 1.0.0-alpha.1 (2026-09-29)
|
|
2
|
+
|
|
3
|
+
All 22 COSX Design System 3.0 primitives, checked against their
|
|
4
|
+
design.cosx.co pages in light / ink × English / Chinese.
|
|
5
|
+
|
|
6
|
+
- Core: Button (six types, grounds, 32/38/44, shortcut and count,
|
|
7
|
+
disabled-with-reason, busy/done in place + `useButtonAction`),
|
|
8
|
+
IconButton, Badge (shape carries urgency; compact dot keeps the
|
|
9
|
+
wording), Tag, Card, Figure, Marker, MetaLabel, Logo, Icon (Lucide),
|
|
10
|
+
Spinner, ThemeProvider (system / light / ink).
|
|
11
|
+
- Forms: Field (label, hint, error, prefix/suffix, read-only), Input,
|
|
12
|
+
Textarea, Select (portal, auto placement, keyboard, groups, disabled
|
|
13
|
+
rows, meta, filter above 8), Checkbox (indeterminate), RadioGroup, Switch.
|
|
14
|
+
- Tabs (underline, pills), Dialog, Toast (`toast()` + `<Toaster>`),
|
|
15
|
+
Tooltip, Table (sortable, washes for attention/overdue, cards on phones).
|
|
16
|
+
- `--brand-field` / `--brand-mark` for workspace brands; the 3.0 ink
|
|
17
|
+
marker rule for `data-mode="ink"`; an `ink:` Tailwind variant.
|
|
18
|
+
- 65 tests (behaviour + axe). Spec/source differences and what was
|
|
19
|
+
built: docs/workdocs/2026-09-29_feature-ui-1.0.
|
|
20
|
+
|
|
1
21
|
## 1.0.0-alpha.0 (2026-09-29)
|
|
2
22
|
|
|
3
23
|
**A new system — breaking for every 0.x consumer.** 0.x continues on the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosxai/ui",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.1",
|
|
4
4
|
"description": "COSX Design System 3.0 — tokens, Tailwind v4 theme and React components",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
33
|
"test": "vitest run",
|
|
34
|
-
"check:css": "tailwindcss -i test/fixture.css -o test/out.css"
|
|
34
|
+
"check:css": "tailwindcss -i test/fixture.css -o test/out.css",
|
|
35
|
+
"playground": "vite --config playground/vite.config.ts"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
38
|
"react": "^19.0.0",
|
|
@@ -40,12 +41,23 @@
|
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@tailwindcss/cli": "^4",
|
|
44
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
45
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
46
|
+
"@testing-library/react": "^16.3.3",
|
|
47
|
+
"@testing-library/user-event": "^14.6.7",
|
|
43
48
|
"@types/node": "^26.6.3",
|
|
44
49
|
"@types/react": "^19.0.0",
|
|
45
50
|
"@types/react-dom": "^19.0.0",
|
|
51
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
52
|
+
"axe-core": "^4.13.0",
|
|
53
|
+
"jsdom": "^30.1.1",
|
|
54
|
+
"react": "^19.0.0",
|
|
55
|
+
"react-dom": "^19.0.0",
|
|
46
56
|
"tailwindcss": "^4",
|
|
47
57
|
"typescript": "^5.6.0",
|
|
48
|
-
"
|
|
58
|
+
"vite": "^6.0.0",
|
|
59
|
+
"vitest": "^3.2.0",
|
|
60
|
+
"vitest-axe": "^0.1.0"
|
|
49
61
|
},
|
|
50
62
|
"sideEffects": [
|
|
51
63
|
"**/*.css"
|
|
@@ -53,7 +65,19 @@
|
|
|
53
65
|
"dependencies": {
|
|
54
66
|
"@fontsource-variable/geist": "^5.3.0",
|
|
55
67
|
"@fontsource-variable/noto-sans-sc": "^5.3.0",
|
|
68
|
+
"@radix-ui/react-checkbox": "^1.3.11",
|
|
69
|
+
"@radix-ui/react-dialog": "^1.1.23",
|
|
70
|
+
"@radix-ui/react-label": "^2.1.15",
|
|
71
|
+
"@radix-ui/react-popover": "^1.1.23",
|
|
72
|
+
"@radix-ui/react-radio-group": "^1.4.7",
|
|
73
|
+
"@radix-ui/react-slot": "^1.3.3",
|
|
74
|
+
"@radix-ui/react-switch": "^1.3.7",
|
|
75
|
+
"@radix-ui/react-tabs": "^1.1.21",
|
|
76
|
+
"@radix-ui/react-toast": "^1.2.23",
|
|
77
|
+
"@radix-ui/react-tooltip": "^1.2.16",
|
|
78
|
+
"class-variance-authority": "^0.7.1",
|
|
56
79
|
"clsx": "^2.1.1",
|
|
80
|
+
"lucide-react": "^1.48.0",
|
|
57
81
|
"tailwind-merge": "^3.3.1"
|
|
58
82
|
},
|
|
59
83
|
"peerDependenciesMeta": {
|
package/src/brand.css
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* @cosxai/ui — workspace brand injection (MetaRoom spec §01).
|
|
2
|
+
*
|
|
3
|
+
* A customer's colour replaces the COSX yellow through two variables;
|
|
4
|
+
* everything else stays ink, so any colour keeps text readable:
|
|
5
|
+
* --brand-field large areas: selected nav and tabs, section grounds
|
|
6
|
+
* --brand-mark small marks: dots, progress, highlights, attention fills
|
|
7
|
+
* Ink (text, primary button, icons) and --status-error are not injectable.
|
|
8
|
+
* Default: the COSX yellow. A workspace sets both on <html>.
|
|
9
|
+
*/
|
|
10
|
+
:root {
|
|
11
|
+
--brand-field: var(--yellow);
|
|
12
|
+
--brand-mark: var(--yellow-accent);
|
|
13
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type BadgeStatus = 'neutral' | 'attention' | 'error' | 'progress' | 'complete';
|
|
6
|
+
export type BadgeAppearance = 'fill' | 'outline' | 'dot';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_APPEARANCE: Record<BadgeStatus, BadgeAppearance> = {
|
|
9
|
+
attention: 'fill',
|
|
10
|
+
error: 'fill',
|
|
11
|
+
progress: 'outline',
|
|
12
|
+
complete: 'dot',
|
|
13
|
+
neutral: 'dot',
|
|
14
|
+
};
|
|
15
|
+
const STATUSES = new Set<string>(Object.keys(DEFAULT_APPEARANCE));
|
|
16
|
+
|
|
17
|
+
export type BadgeProps = Omit<ComponentProps<'span'>, 'children'> & {
|
|
18
|
+
/** The state. Unknown values fall back to neutral. @default "neutral" */
|
|
19
|
+
status?: BadgeStatus | (string & {}) | undefined;
|
|
20
|
+
/** fill · outline · dot. Defaults by status; change it only to match density in a column. */
|
|
21
|
+
appearance?: BadgeAppearance | undefined;
|
|
22
|
+
/** Dot density for rows: only the dot shows; the wording stays for screen readers and appears on hover. */
|
|
23
|
+
compact?: boolean | undefined;
|
|
24
|
+
/** The wording — always present: name the state (Awaiting you), not the action. */
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Badge — product status in yellow, ink and one red. Shape carries urgency:
|
|
30
|
+
* a fill needs a person (attention: brand fill, ink text; error: the red),
|
|
31
|
+
* an outline is under way (progress), a dot is a known state (complete:
|
|
32
|
+
* ink dot, neutral: grey dot). Colour is never the only signal.
|
|
33
|
+
* Product UI only — never on brand, marketing or decks.
|
|
34
|
+
*/
|
|
35
|
+
export function Badge({ status = 'neutral', appearance, compact = false, className, children, title, ...rest }: BadgeProps) {
|
|
36
|
+
const s = (STATUSES.has(status) ? status : 'neutral') as BadgeStatus;
|
|
37
|
+
const mode = compact ? 'dot' : (appearance ?? DEFAULT_APPEARANCE[s]);
|
|
38
|
+
const base = 'inline-flex items-center gap-[7px] whitespace-nowrap rounded-sm text-meta leading-none font-semibold';
|
|
39
|
+
|
|
40
|
+
if (mode === 'fill') {
|
|
41
|
+
const fill = s === 'error' ? 'bg-error text-white' : s === 'attention' ? 'bg-brand-mark text-ink' : 'bg-fg text-page';
|
|
42
|
+
return (
|
|
43
|
+
<span data-status={s} className={cn(base, 'px-2 py-[5px]', fill, className)} title={title} {...rest}>
|
|
44
|
+
{children}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (mode === 'outline') {
|
|
49
|
+
return (
|
|
50
|
+
<span data-status={s} className={cn(base, 'border border-fg px-[7px] py-1 text-fg', className)} title={title} {...rest}>
|
|
51
|
+
{children}
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const dot =
|
|
56
|
+
s === 'error' ? 'bg-error' : s === 'attention' ? 'bg-brand-mark' : s === 'complete' || s === 'progress' ? 'bg-fg' : 'bg-fg-secondary';
|
|
57
|
+
return (
|
|
58
|
+
<span
|
|
59
|
+
data-status={s}
|
|
60
|
+
className={cn(base, 'text-fg-secondary', className)}
|
|
61
|
+
title={compact ? (title ?? (typeof children === 'string' ? children : undefined)) : title}
|
|
62
|
+
{...rest}
|
|
63
|
+
>
|
|
64
|
+
<span aria-hidden className={cn('size-[9px] shrink-0 rounded-pill', dot)} />
|
|
65
|
+
<span className={compact ? 'sr-only' : undefined}>{children}</span>
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import { useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn';
|
|
7
|
+
import { Spinner } from './Spinner';
|
|
8
|
+
|
|
9
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'yellow' | 'ink' | 'danger';
|
|
10
|
+
export type ButtonGround = 'paper' | 'yellow' | 'ink';
|
|
11
|
+
/** idle → busy (spinner, width locked, clicks ignored) → done (confirmed in place) → idle. */
|
|
12
|
+
export type ButtonState = 'idle' | 'busy' | 'done';
|
|
13
|
+
|
|
14
|
+
const button = cva(
|
|
15
|
+
[
|
|
16
|
+
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap select-none',
|
|
17
|
+
'rounded-md border border-transparent font-sans font-semibold leading-none',
|
|
18
|
+
'transition-[background-color,border-color,color] duration-[120ms] ease-standard',
|
|
19
|
+
'cursor-pointer outline-none focus-visible:shadow-(--focus-ring)',
|
|
20
|
+
'disabled:cursor-not-allowed disabled:opacity-40 aria-disabled:cursor-not-allowed aria-disabled:opacity-40',
|
|
21
|
+
],
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
size: {
|
|
25
|
+
sm: 'h-8 px-3.5 text-[13px]',
|
|
26
|
+
md: 'h-[38px] px-[18px] text-ui',
|
|
27
|
+
lg: 'h-11 px-6 text-[16px]',
|
|
28
|
+
},
|
|
29
|
+
variant: { primary: '', secondary: 'bg-transparent', ghost: 'bg-transparent', yellow: 'bg-yellow text-ink hover:bg-yellow-hover', ink: 'bg-ink text-linen hover:bg-ink-raised ink:border-inv-rule ink:bg-ink-raised ink:hover:border-linen', danger: 'bg-error text-white hover:bg-error-text' },
|
|
30
|
+
ground: { auto: '', paper: '', yellow: '', ink: '' },
|
|
31
|
+
},
|
|
32
|
+
compoundVariants: [
|
|
33
|
+
// Primary: ink on paper and on the yellow; the yellow on ink (and in ink mode).
|
|
34
|
+
{ variant: 'primary', ground: 'auto', class: 'bg-ink text-linen hover:bg-ink-raised ink:bg-yellow-accent ink:text-ink ink:hover:bg-yellow-accent-hover' },
|
|
35
|
+
{ variant: 'primary', ground: ['paper', 'yellow'], class: 'bg-ink text-linen hover:bg-ink-raised' },
|
|
36
|
+
{ variant: 'primary', ground: 'ink', class: 'bg-yellow-accent text-ink hover:bg-yellow-accent-hover' },
|
|
37
|
+
// Secondary: a hairline that turns ink on hover.
|
|
38
|
+
{ variant: 'secondary', ground: 'auto', class: 'text-fg border-[rgba(17,17,17,.18)] hover:border-fg ink:border-rule' },
|
|
39
|
+
{ variant: 'secondary', ground: ['paper', 'yellow'], class: 'text-ink border-[rgba(17,17,17,.18)] hover:border-ink' },
|
|
40
|
+
{ variant: 'secondary', ground: 'ink', class: 'text-linen border-inv-rule hover:border-linen' },
|
|
41
|
+
// Ghost (subtle): text only; hover deepens one step.
|
|
42
|
+
{ variant: 'ghost', ground: 'auto', class: 'text-fg hover:bg-hover' },
|
|
43
|
+
{ variant: 'ghost', ground: ['paper', 'yellow'], class: 'text-ink hover:bg-[rgba(17,17,17,.05)]' },
|
|
44
|
+
{ variant: 'ghost', ground: 'ink', class: 'text-linen hover:bg-ink-raised' },
|
|
45
|
+
// Focus ring follows the ground.
|
|
46
|
+
{ ground: 'yellow', class: 'focus-visible:shadow-[0_0_0_3px_var(--yellow),0_0_0_5px_var(--ink)]' },
|
|
47
|
+
{ ground: 'ink', class: 'focus-visible:shadow-[0_0_0_3px_var(--ink),0_0_0_5px_var(--yellow)]' },
|
|
48
|
+
],
|
|
49
|
+
defaultVariants: { size: 'md', variant: 'primary', ground: 'auto' },
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
export type ButtonProps = Omit<ComponentProps<'button'>, 'disabled'> & {
|
|
54
|
+
/** primary (one per view) · secondary · ghost (subtle: cancel, secondary links) · yellow (inline attention) · ink · danger (destructive, inside a confirming dialog). @default "primary" */
|
|
55
|
+
variant?: ButtonVariant | undefined;
|
|
56
|
+
/** Height 32 · 38 · 44 (the product control heights). @default "md" */
|
|
57
|
+
size?: 'sm' | 'md' | 'lg' | undefined;
|
|
58
|
+
/** The surface underneath; sets the primary colour and the focus ring. Unset: paper, flipping to ink in ink mode. */
|
|
59
|
+
ground?: ButtonGround | undefined;
|
|
60
|
+
iconLeft?: ReactNode;
|
|
61
|
+
iconRight?: ReactNode;
|
|
62
|
+
/** Shortcut hint at the end of the button (⇧S, Esc). */
|
|
63
|
+
shortcut?: string | undefined;
|
|
64
|
+
/** A count at the end (Comments 12). Hidden at zero, 99+ above 99. */
|
|
65
|
+
count?: number | undefined;
|
|
66
|
+
/** Async state: busy locks the width and shows a spinner; done confirms in place. See useButtonAction. */
|
|
67
|
+
state?: ButtonState | undefined;
|
|
68
|
+
/** Label shown while state="done". @default the children */
|
|
69
|
+
doneLabel?: ReactNode;
|
|
70
|
+
disabled?: boolean | undefined;
|
|
71
|
+
/** Why it is unavailable. Keeps the button focusable (aria-disabled) so the reason can be read and shown. */
|
|
72
|
+
disabledReason?: string | undefined;
|
|
73
|
+
/** Render the child (a link) with the button's look. Async state is not applied. */
|
|
74
|
+
asChild?: boolean | undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function formatCount(n: number): string | null {
|
|
78
|
+
if (!n || n <= 0) return null;
|
|
79
|
+
return n > 99 ? '99+' : String(n);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Button — one button for every action. Contrast is ink's job: the primary
|
|
84
|
+
* is ink on paper and on the yellow, the yellow on ink. One primary per
|
|
85
|
+
* view. Async results stay in the button: busy (width locked, repeat clicks
|
|
86
|
+
* ignored), done (confirmed in place); a failure is explained beside it.
|
|
87
|
+
* Hover deepens one step — no lift, no scale, no shadow.
|
|
88
|
+
*/
|
|
89
|
+
export function Button({
|
|
90
|
+
variant,
|
|
91
|
+
size,
|
|
92
|
+
ground,
|
|
93
|
+
iconLeft,
|
|
94
|
+
iconRight,
|
|
95
|
+
shortcut,
|
|
96
|
+
count,
|
|
97
|
+
state = 'idle',
|
|
98
|
+
doneLabel,
|
|
99
|
+
disabled,
|
|
100
|
+
disabledReason,
|
|
101
|
+
asChild,
|
|
102
|
+
className,
|
|
103
|
+
children,
|
|
104
|
+
onClick,
|
|
105
|
+
style,
|
|
106
|
+
ref,
|
|
107
|
+
...rest
|
|
108
|
+
}: ButtonProps) {
|
|
109
|
+
const inner = useRef<HTMLButtonElement | null>(null);
|
|
110
|
+
const [lockedWidth, setLockedWidth] = useState<number | null>(null);
|
|
111
|
+
|
|
112
|
+
// Lock the idle width so busy / done never resize the button.
|
|
113
|
+
useLayoutEffect(() => {
|
|
114
|
+
if (state === 'idle') setLockedWidth(null);
|
|
115
|
+
else if (lockedWidth === null && inner.current) setLockedWidth(inner.current.getBoundingClientRect().width);
|
|
116
|
+
}, [state, lockedWidth]);
|
|
117
|
+
|
|
118
|
+
const classes = cn(button({ variant, size, ground: ground ?? 'auto' }), className);
|
|
119
|
+
if (asChild) {
|
|
120
|
+
return (
|
|
121
|
+
<Slot className={classes} {...(rest as object)}>
|
|
122
|
+
{children}
|
|
123
|
+
</Slot>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const busy = state === 'busy';
|
|
128
|
+
const blocked = Boolean(disabledReason) && disabled;
|
|
129
|
+
const shownCount = count === undefined ? null : formatCount(count);
|
|
130
|
+
const setRefs = (el: HTMLButtonElement | null) => {
|
|
131
|
+
inner.current = el;
|
|
132
|
+
if (typeof ref === 'function') ref(el);
|
|
133
|
+
else if (ref) ref.current = el;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
{...rest}
|
|
140
|
+
ref={setRefs}
|
|
141
|
+
className={classes}
|
|
142
|
+
style={lockedWidth ? { ...style, width: lockedWidth } : style}
|
|
143
|
+
disabled={disabled && !disabledReason ? true : undefined}
|
|
144
|
+
aria-disabled={blocked || busy ? true : undefined}
|
|
145
|
+
aria-busy={busy || undefined}
|
|
146
|
+
title={blocked ? disabledReason : rest.title}
|
|
147
|
+
onClick={(e) => {
|
|
148
|
+
if (blocked || busy || state === 'done') {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
onClick?.(e);
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{busy ? (
|
|
156
|
+
<Spinner />
|
|
157
|
+
) : state === 'done' ? (
|
|
158
|
+
<>
|
|
159
|
+
<Check size={14} strokeWidth={2} aria-hidden />
|
|
160
|
+
{doneLabel ?? children}
|
|
161
|
+
</>
|
|
162
|
+
) : (
|
|
163
|
+
<>
|
|
164
|
+
{iconLeft}
|
|
165
|
+
{children}
|
|
166
|
+
{iconRight}
|
|
167
|
+
{shownCount && <span className="font-medium tabular-nums opacity-70">{shownCount}</span>}
|
|
168
|
+
{shortcut && (
|
|
169
|
+
<kbd className="ml-0.5 font-sans text-[12px] font-medium opacity-60" aria-hidden>
|
|
170
|
+
{shortcut}
|
|
171
|
+
</kbd>
|
|
172
|
+
)}
|
|
173
|
+
</>
|
|
174
|
+
)}
|
|
175
|
+
{busy && <span className="sr-only">{children}</span>}
|
|
176
|
+
</button>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
|
|
7
|
+
const card = cva('overflow-hidden transition-[background-color,border-color] duration-[120ms] ease-standard', {
|
|
8
|
+
variants: {
|
|
9
|
+
ground: {
|
|
10
|
+
page: 'bg-page text-fg',
|
|
11
|
+
sunk: 'bg-sunk text-fg',
|
|
12
|
+
field: 'bg-brand-field text-ink',
|
|
13
|
+
ink: 'bg-ink text-linen ink:bg-ink-raised',
|
|
14
|
+
},
|
|
15
|
+
radius: { panel: 'rounded-lg', section: 'rounded-xl' },
|
|
16
|
+
outline: { true: 'border border-rule', false: '' },
|
|
17
|
+
interactive: { true: 'cursor-pointer outline-none hover:border-fg focus-visible:shadow-(--focus-ring)', false: '' },
|
|
18
|
+
accent: { true: 'border-t-4 border-t-yellow-accent', false: '' },
|
|
19
|
+
},
|
|
20
|
+
compoundVariants: [{ interactive: true, ground: 'page', class: 'hover:bg-hover' }],
|
|
21
|
+
defaultVariants: { ground: 'page', radius: 'panel', interactive: false, accent: false },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type CardProps = ComponentProps<'div'> & {
|
|
25
|
+
/** page (paper) · sunk (linen) · field (the brand yellow) · ink. @default "page" */
|
|
26
|
+
ground?: 'page' | 'sunk' | 'field' | 'ink' | undefined;
|
|
27
|
+
/** panel 16px · section 24px (whole page-level sections). @default "panel" */
|
|
28
|
+
radius?: 'panel' | 'section' | undefined;
|
|
29
|
+
/** Hairline edge. Defaults to on for page, off for the other grounds. */
|
|
30
|
+
outline?: boolean | undefined;
|
|
31
|
+
/** Clickable card: hover wash + ink edge. Use asChild to make it the link or button. */
|
|
32
|
+
interactive?: boolean | undefined;
|
|
33
|
+
/** A 4px accent rule along the top. */
|
|
34
|
+
accent?: boolean | undefined;
|
|
35
|
+
asChild?: boolean | undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Card — a rounded region of the page. No shadow: depth is cut into the page. */
|
|
39
|
+
export function Card({ ground = 'page', radius, outline, interactive, accent, asChild, className, ...rest }: CardProps) {
|
|
40
|
+
const Comp = asChild ? Slot : 'div';
|
|
41
|
+
return <Comp className={cn(card({ ground, radius, outline: outline ?? ground === 'page', interactive, accent }), className)} {...rest} />;
|
|
42
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
|
2
|
+
import { Check, Minus } from 'lucide-react';
|
|
3
|
+
import { useId, type ComponentProps, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
|
|
7
|
+
export type CheckboxProps = Omit<ComponentProps<typeof CheckboxPrimitive.Root>, 'checked' | 'onCheckedChange' | 'onChange' | 'children'> & {
|
|
8
|
+
/** true · false · "indeterminate" (some children selected). */
|
|
9
|
+
checked?: boolean | 'indeterminate' | undefined;
|
|
10
|
+
onChange?: ((checked: boolean) => void) | undefined;
|
|
11
|
+
/** The label; clicking it toggles too. */
|
|
12
|
+
label?: ReactNode;
|
|
13
|
+
/** A line below the label — say why when disabled. */
|
|
14
|
+
description?: ReactNode;
|
|
15
|
+
/** Class for the whole row (className goes to the box). */
|
|
16
|
+
rowClassName?: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Checkbox — a choice in a form that applies on Save or Send (a setting
|
|
21
|
+
* that applies at once is a Switch). Sunk box, ink when checked; the mixed
|
|
22
|
+
* state means some children are selected. The whole row toggles.
|
|
23
|
+
*/
|
|
24
|
+
export function Checkbox({ checked, onChange, label, description, disabled, id, className, rowClassName, ...rest }: CheckboxProps) {
|
|
25
|
+
const auto = useId();
|
|
26
|
+
const boxId = id ?? `cb${auto}`;
|
|
27
|
+
const descId = description ? `${boxId}-desc` : undefined;
|
|
28
|
+
const box = (
|
|
29
|
+
<CheckboxPrimitive.Root
|
|
30
|
+
id={boxId}
|
|
31
|
+
{...(checked !== undefined && { checked })}
|
|
32
|
+
onCheckedChange={(v) => onChange?.(v === true)}
|
|
33
|
+
disabled={disabled}
|
|
34
|
+
aria-describedby={descId}
|
|
35
|
+
className={cn(
|
|
36
|
+
'peer mt-0.5 grid size-4 shrink-0 cursor-pointer place-items-center rounded-xs border border-fg-secondary bg-sunk text-page',
|
|
37
|
+
'transition-[background-color,border-color] duration-[120ms] ease-standard outline-none focus-visible:shadow-(--focus-ring)',
|
|
38
|
+
'data-[state=checked]:border-fg data-[state=checked]:bg-fg data-[state=indeterminate]:border-fg data-[state=indeterminate]:bg-fg',
|
|
39
|
+
'disabled:cursor-not-allowed',
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
{...rest}
|
|
43
|
+
>
|
|
44
|
+
<CheckboxPrimitive.Indicator className="grid place-items-center">
|
|
45
|
+
{checked === 'indeterminate' ? <Minus size={11} strokeWidth={2.5} aria-hidden /> : <Check size={11} strokeWidth={2.5} aria-hidden />}
|
|
46
|
+
</CheckboxPrimitive.Indicator>
|
|
47
|
+
</CheckboxPrimitive.Root>
|
|
48
|
+
);
|
|
49
|
+
if (!label && !description) return box;
|
|
50
|
+
return (
|
|
51
|
+
<div className={cn('inline-flex items-start gap-2.5 font-sans', disabled && 'opacity-40', rowClassName)}>
|
|
52
|
+
{box}
|
|
53
|
+
<span className="flex flex-col gap-0.5">
|
|
54
|
+
{label && (
|
|
55
|
+
<label htmlFor={boxId} className={cn('text-small leading-[1.45] text-fg', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
|
|
56
|
+
{label}
|
|
57
|
+
</label>
|
|
58
|
+
)}
|
|
59
|
+
{description && (
|
|
60
|
+
<span id={descId} className="text-meta leading-normal text-fg-secondary">
|
|
61
|
+
{description}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
import { IconButton } from './IconButton';
|
|
7
|
+
|
|
8
|
+
/** Dialog root: `open` / `onOpenChange` (or uncontrolled with DialogTrigger). */
|
|
9
|
+
export const Dialog = DialogPrimitive.Root;
|
|
10
|
+
/** The element that opens it (asChild onto a Button). Focus returns here on close. */
|
|
11
|
+
export const DialogTrigger = DialogPrimitive.Trigger;
|
|
12
|
+
/** Closes the dialog (asChild onto the Cancel button). */
|
|
13
|
+
export const DialogClose = DialogPrimitive.Close;
|
|
14
|
+
|
|
15
|
+
const WIDTH = { sm: 'sm:max-w-[400px]', md: 'sm:max-w-[520px]', lg: 'sm:max-w-[640px]' } as const;
|
|
16
|
+
|
|
17
|
+
const MOBILE = {
|
|
18
|
+
// Confirmations stay centred.
|
|
19
|
+
center: 'max-sm:left-4 max-sm:right-4 max-sm:top-1/2 max-sm:-translate-y-1/2 max-sm:rounded-lg',
|
|
20
|
+
// Long forms push in full screen.
|
|
21
|
+
page: 'max-sm:inset-0 max-sm:max-h-none max-sm:rounded-none max-sm:border-0',
|
|
22
|
+
// Light choices become a bottom sheet.
|
|
23
|
+
sheet: 'max-sm:inset-x-0 max-sm:bottom-0 max-sm:rounded-t-lg max-sm:rounded-b-none max-sm:border-b-0',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export type DialogContentProps = Omit<ComponentProps<typeof DialogPrimitive.Content>, 'title'> & {
|
|
27
|
+
/** A question or an action: "Revoke this share?" — the primary repeats its verb. Labels the dialog. */
|
|
28
|
+
title: ReactNode;
|
|
29
|
+
/** A line above the title. */
|
|
30
|
+
eyebrow?: ReactNode;
|
|
31
|
+
/** The consequence, read out with the title ("Anna loses access now."). */
|
|
32
|
+
description?: ReactNode;
|
|
33
|
+
/** Actions, bottom right: secondary left, primary right. A destructive primary is Button variant="danger". */
|
|
34
|
+
footer?: ReactNode;
|
|
35
|
+
/** sm 400 (confirm) · md 520 (form, up to five fields) · lg 640. @default "md" */
|
|
36
|
+
size?: keyof typeof WIDTH | undefined;
|
|
37
|
+
/** How it shows on phones: center (confirmations) · page (long forms) · sheet (light choices). @default "center" */
|
|
38
|
+
mobile?: keyof typeof MOBILE | undefined;
|
|
39
|
+
/** Accessible name of the close button. @default "Close" */
|
|
40
|
+
closeLabel?: string | undefined;
|
|
41
|
+
hideClose?: boolean | undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* DialogContent — something that interrupts the task and needs an answer.
|
|
46
|
+
* Page colour lifted by the scrim; 16px corners; no shadow. Focus moves in,
|
|
47
|
+
* is trapped, and returns to the trigger; Esc closes only the top layer
|
|
48
|
+
* (Radix Dialog). Otherwise prefer a side panel, an inline expansion or a
|
|
49
|
+
* toast.
|
|
50
|
+
*/
|
|
51
|
+
export function DialogContent({
|
|
52
|
+
title,
|
|
53
|
+
eyebrow,
|
|
54
|
+
description,
|
|
55
|
+
footer,
|
|
56
|
+
size = 'md',
|
|
57
|
+
mobile = 'center',
|
|
58
|
+
closeLabel = 'Close',
|
|
59
|
+
hideClose = false,
|
|
60
|
+
className,
|
|
61
|
+
children,
|
|
62
|
+
onOpenAutoFocus,
|
|
63
|
+
...rest
|
|
64
|
+
}: DialogContentProps) {
|
|
65
|
+
return (
|
|
66
|
+
<DialogPrimitive.Portal>
|
|
67
|
+
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-scrim" />
|
|
68
|
+
<DialogPrimitive.Content
|
|
69
|
+
/* Radix links the Description itself; without one, opt out so no
|
|
70
|
+
dangling reference (and no warning) remains. */
|
|
71
|
+
{...(description ? {} : { 'aria-describedby': undefined })}
|
|
72
|
+
/* Focus the dialog itself on open (read out by its title), not the
|
|
73
|
+
close button: no ring before the person has done anything. Tab
|
|
74
|
+
then reaches the controls; focus stays trapped. */
|
|
75
|
+
onOpenAutoFocus={(e) => {
|
|
76
|
+
onOpenAutoFocus?.(e);
|
|
77
|
+
if (e.defaultPrevented) return;
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
(e.currentTarget as HTMLElement | null)?.focus();
|
|
80
|
+
}}
|
|
81
|
+
className={cn(
|
|
82
|
+
'fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-48px)] w-[calc(100%-48px)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden',
|
|
83
|
+
'rounded-lg border border-rule bg-page font-sans text-fg outline-none',
|
|
84
|
+
WIDTH[size],
|
|
85
|
+
MOBILE[mobile],
|
|
86
|
+
mobile !== 'center' && 'max-sm:translate-x-0 max-sm:translate-y-0 max-sm:w-auto max-sm:top-auto max-sm:left-0',
|
|
87
|
+
mobile === 'page' && 'max-sm:top-0',
|
|
88
|
+
className,
|
|
89
|
+
)}
|
|
90
|
+
{...rest}
|
|
91
|
+
>
|
|
92
|
+
<div className="flex items-start gap-4 px-7 pt-6">
|
|
93
|
+
<div className="min-w-0 flex-1">
|
|
94
|
+
{eyebrow && <div className="mb-2.5 text-meta font-medium text-fg-secondary">{eyebrow}</div>}
|
|
95
|
+
<DialogPrimitive.Title className="m-0 text-[21px] leading-[1.2] font-medium tracking-heading">{title}</DialogPrimitive.Title>
|
|
96
|
+
</div>
|
|
97
|
+
{!hideClose && (
|
|
98
|
+
<DialogPrimitive.Close asChild>
|
|
99
|
+
<IconButton icon={X} label={closeLabel} size="sm" className="-mt-1 -mr-2" />
|
|
100
|
+
</DialogPrimitive.Close>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-7 pt-3.5 pb-6 text-small leading-relaxed text-fg-secondary">
|
|
104
|
+
{description && <DialogPrimitive.Description className="m-0">{description}</DialogPrimitive.Description>}
|
|
105
|
+
{children && <div className={description ? 'mt-3' : undefined}>{children}</div>}
|
|
106
|
+
</div>
|
|
107
|
+
{footer && <div className="flex justify-end gap-2.5 border-t border-rule bg-sunk px-7 py-4 max-sm:flex-col-reverse">{footer}</div>}
|
|
108
|
+
</DialogPrimitive.Content>
|
|
109
|
+
</DialogPrimitive.Portal>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createContext, useContext, useId, type ComponentProps, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type FieldContextValue = {
|
|
6
|
+
/** id for the control; the label points at it. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** id of the visible label, when there is one. */
|
|
9
|
+
labelId: string | undefined;
|
|
10
|
+
/** id of the hint / error line, for aria-describedby. */
|
|
11
|
+
describedBy: string | undefined;
|
|
12
|
+
invalid: boolean;
|
|
13
|
+
disabled: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const FieldContext = createContext<FieldContextValue | null>(null);
|
|
17
|
+
|
|
18
|
+
/** The surrounding Field, if any — controls take their id and aria from it. */
|
|
19
|
+
export function useField(): FieldContextValue | null {
|
|
20
|
+
return useContext(FieldContext);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type FieldProps = Omit<ComponentProps<'div'>, 'children'> & {
|
|
24
|
+
/** Always visible. The placeholder shows an example; it never replaces the label. */
|
|
25
|
+
label?: ReactNode;
|
|
26
|
+
/** A helper line below the control. */
|
|
27
|
+
hint?: ReactNode;
|
|
28
|
+
/** The error, in words that say how to fix it. Replaces the hint and marks the control invalid. */
|
|
29
|
+
error?: ReactNode;
|
|
30
|
+
required?: boolean | undefined;
|
|
31
|
+
disabled?: boolean | undefined;
|
|
32
|
+
/** Use this id for the control instead of a generated one. */
|
|
33
|
+
id?: string | undefined;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Field — label, hint and error around one control. The label is tied to
|
|
39
|
+
* the control; the hint or error is its aria-describedby; an error sets
|
|
40
|
+
* aria-invalid. Error is wording in the one red, never colour alone.
|
|
41
|
+
*/
|
|
42
|
+
export function Field({ label, hint, error, required = false, disabled = false, id, className, children, ...rest }: FieldProps) {
|
|
43
|
+
const auto = useId();
|
|
44
|
+
const controlId = id ?? `f${auto}`;
|
|
45
|
+
const noteId = `${controlId}-note`;
|
|
46
|
+
const note = error ?? hint;
|
|
47
|
+
const value: FieldContextValue = { id: controlId, labelId: label ? `${controlId}-label` : undefined, describedBy: note ? noteId : undefined, invalid: Boolean(error), disabled };
|
|
48
|
+
return (
|
|
49
|
+
<FieldContext.Provider value={value}>
|
|
50
|
+
<div className={cn('flex flex-col gap-[7px] font-sans', className)} {...rest}>
|
|
51
|
+
{label && (
|
|
52
|
+
<label id={`${controlId}-label`} htmlFor={controlId} className={cn('text-meta font-medium text-fg-secondary', disabled && 'opacity-40')}>
|
|
53
|
+
{label}
|
|
54
|
+
{required && <span> · required</span>}
|
|
55
|
+
</label>
|
|
56
|
+
)}
|
|
57
|
+
{children}
|
|
58
|
+
{note && (
|
|
59
|
+
<span id={noteId} className={cn('text-meta leading-normal', error ? 'text-error-text ink:text-error' : 'text-fg-secondary')}>
|
|
60
|
+
{note}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
</FieldContext.Provider>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type FigureProps = Omit<ComponentProps<'div'>, 'children'> & {
|
|
6
|
+
/** The number. */
|
|
7
|
+
value: ReactNode;
|
|
8
|
+
/** What it counts. */
|
|
9
|
+
label: ReactNode;
|
|
10
|
+
qualifier?: ReactNode;
|
|
11
|
+
/** Where it comes from — every figure is sourced or it is removed. */
|
|
12
|
+
source?: ReactNode;
|
|
13
|
+
/** px size of the number. @default 64 */
|
|
14
|
+
size?: number | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Figure — the stat treatment: a large tabular number, a short yellow rule, the label. */
|
|
18
|
+
export function Figure({ value, label, qualifier, source, size = 64, className, ...rest }: FigureProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div className={cn('flex flex-col items-start font-sans text-fg', className)} {...rest}>
|
|
21
|
+
<div className="leading-none font-bold tracking-figure tabular-nums" style={{ fontSize: size }}>
|
|
22
|
+
{value}
|
|
23
|
+
</div>
|
|
24
|
+
<div aria-hidden className="mt-3.5 mb-3 h-1 w-10 rounded-[2px] bg-yellow-accent" />
|
|
25
|
+
<div className="text-[12.5px] font-medium text-fg-secondary">{label}</div>
|
|
26
|
+
{qualifier && <div className="mt-1.5 max-w-[28ch] text-small leading-normal text-fg-secondary">{qualifier}</div>}
|
|
27
|
+
{source && <div className="mt-2 text-meta text-fg-secondary">{source}</div>}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|