@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +109 -0
- package/package.json +36 -6
- package/src/brand.css +13 -0
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/AuthLayout.tsx +127 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/BrandLoader.tsx +122 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +186 -0
- package/src/components/Card.tsx +42 -0
- package/src/components/Checkbox.tsx +67 -0
- package/src/components/ChoiceCards.tsx +68 -0
- package/src/components/CodeInput.tsx +150 -0
- package/src/components/CommandPalette.tsx +228 -0
- package/src/components/ConfirmDialog.tsx +294 -0
- package/src/components/CopyField.tsx +104 -0
- package/src/components/DateInput.tsx +366 -0
- package/src/components/Dialog.tsx +135 -0
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/Field.tsx +66 -0
- package/src/components/Figure.tsx +30 -0
- package/src/components/FileCard.tsx +233 -0
- package/src/components/FileRow.tsx +159 -0
- package/src/components/FolderTree.tsx +377 -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/ListToolbar.tsx +177 -0
- package/src/components/Logo.tsx +62 -0
- package/src/components/Marker.tsx +26 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/MetaLabel.tsx +38 -0
- package/src/components/PageHeader.tsx +62 -0
- package/src/components/PageState.tsx +84 -0
- package/src/components/Progress.tsx +97 -0
- package/src/components/PulseDot.tsx +31 -0
- package/src/components/Radio.tsx +72 -0
- package/src/components/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/Select.tsx +351 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/Spinner.tsx +20 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/Switch.tsx +67 -0
- package/src/components/SyncStatus.tsx +56 -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/ThemeSwitch.tsx +99 -0
- package/src/components/Toast.tsx +196 -0
- package/src/components/Tooltip.tsx +57 -0
- package/src/components/UploadCheck.tsx +118 -0
- package/src/components/UploadList.tsx +176 -0
- package/src/components/VirtualList.tsx +90 -0
- package/src/components/WindowDrop.tsx +131 -0
- package/src/components/WorkspaceRow.tsx +80 -0
- package/src/components/forms-control.ts +36 -0
- package/src/components/inputs-aria.ts +5 -0
- package/src/components/inputs-date.ts +92 -0
- package/src/components/inputs-listbox.tsx +154 -0
- package/src/components/inputs-search.ts +60 -0
- package/src/components/logo-art.ts +4 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useButtonAction.ts +57 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +73 -2
- package/src/modes.css +10 -0
- package/src/styles.css +2 -0
- package/src/theme-base.css +123 -0
- package/src/theme-reset.css +14 -0
- package/src/theme.css +2 -114
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import type { ComponentProps, 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 sinks: the well with grey text (3.0 readme and design.cosx.co;
|
|
21
|
+
// the 3.0 Button source used opacity). "!" so hover never lifts it back.
|
|
22
|
+
'disabled:cursor-not-allowed aria-disabled:cursor-not-allowed',
|
|
23
|
+
],
|
|
24
|
+
{
|
|
25
|
+
variants: {
|
|
26
|
+
size: {
|
|
27
|
+
sm: 'h-8 px-3.5 text-[13px]',
|
|
28
|
+
md: 'h-[38px] px-[18px] text-ui',
|
|
29
|
+
lg: 'h-11 px-6 text-[16px]',
|
|
30
|
+
},
|
|
31
|
+
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' },
|
|
32
|
+
ground: { auto: '', paper: '', yellow: '', ink: '' },
|
|
33
|
+
},
|
|
34
|
+
compoundVariants: [
|
|
35
|
+
// Primary: ink on paper and on the yellow; the yellow on ink (and in ink mode).
|
|
36
|
+
{ 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' },
|
|
37
|
+
{ variant: 'primary', ground: ['paper', 'yellow'], class: 'bg-ink text-linen hover:bg-ink-raised' },
|
|
38
|
+
{ variant: 'primary', ground: 'ink', class: 'bg-yellow-accent text-ink hover:bg-yellow-accent-hover' },
|
|
39
|
+
// Secondary: a hairline that turns ink on hover.
|
|
40
|
+
{ variant: 'secondary', ground: 'auto', class: 'text-fg border-[rgba(17,17,17,.18)] hover:border-fg ink:border-rule' },
|
|
41
|
+
{ variant: 'secondary', ground: ['paper', 'yellow'], class: 'text-ink border-[rgba(17,17,17,.18)] hover:border-ink' },
|
|
42
|
+
{ variant: 'secondary', ground: 'ink', class: 'text-linen border-inv-rule hover:border-linen' },
|
|
43
|
+
// Ghost (subtle): text only; hover deepens one step.
|
|
44
|
+
{ variant: 'ghost', ground: 'auto', class: 'text-fg hover:bg-hover' },
|
|
45
|
+
{ variant: 'ghost', ground: ['paper', 'yellow'], class: 'text-ink hover:bg-[rgba(17,17,17,.05)]' },
|
|
46
|
+
{ variant: 'ghost', ground: 'ink', class: 'text-linen hover:bg-ink-raised' },
|
|
47
|
+
// Focus ring follows the ground.
|
|
48
|
+
{ ground: 'yellow', class: 'focus-visible:shadow-[0_0_0_3px_var(--yellow),0_0_0_5px_var(--ink)]' },
|
|
49
|
+
{ ground: 'ink', class: 'focus-visible:shadow-[0_0_0_3px_var(--ink),0_0_0_5px_var(--yellow)]' },
|
|
50
|
+
// Disabled: filled types sink into the well; outlined and text-only turn grey.
|
|
51
|
+
{
|
|
52
|
+
variant: ['primary', 'yellow', 'ink', 'danger'],
|
|
53
|
+
class:
|
|
54
|
+
'disabled:border-transparent! disabled:bg-well! disabled:text-fg-secondary! aria-disabled:border-transparent! aria-disabled:bg-well! aria-disabled:text-fg-secondary!',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
variant: ['secondary', 'ghost'],
|
|
58
|
+
class:
|
|
59
|
+
'disabled:bg-transparent! disabled:text-fg-secondary! aria-disabled:bg-transparent! aria-disabled:text-fg-secondary! disabled:hover:border-[rgba(17,17,17,.18)] aria-disabled:hover:border-[rgba(17,17,17,.18)]',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
defaultVariants: { size: 'md', variant: 'primary', ground: 'auto' },
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
export type ButtonProps = Omit<ComponentProps<'button'>, 'disabled'> & {
|
|
67
|
+
/** primary (one per view) · secondary · ghost (subtle: cancel, secondary links) · yellow (inline attention) · ink · danger (destructive, inside a confirming dialog). @default "primary" */
|
|
68
|
+
variant?: ButtonVariant | undefined;
|
|
69
|
+
/** Height 32 · 38 · 44 (the product control heights). @default "md" */
|
|
70
|
+
size?: 'sm' | 'md' | 'lg' | undefined;
|
|
71
|
+
/** The surface underneath; sets the primary colour and the focus ring. Unset: paper, flipping to ink in ink mode. */
|
|
72
|
+
ground?: ButtonGround | undefined;
|
|
73
|
+
iconLeft?: ReactNode;
|
|
74
|
+
iconRight?: ReactNode;
|
|
75
|
+
/** Shortcut hint at the end of the button (⇧S, Esc). */
|
|
76
|
+
shortcut?: string | undefined;
|
|
77
|
+
/** A count at the end (Comments 12). Hidden at zero, 99+ above 99. */
|
|
78
|
+
count?: number | undefined;
|
|
79
|
+
/** Async state: busy shows a spinner, done confirms in place; the width never changes. See useButtonAction. */
|
|
80
|
+
state?: ButtonState | undefined;
|
|
81
|
+
/** Label shown while state="done". @default the children */
|
|
82
|
+
doneLabel?: ReactNode;
|
|
83
|
+
disabled?: boolean | undefined;
|
|
84
|
+
/** Why it is unavailable. Keeps the button focusable (aria-disabled) so the reason can be read and shown. */
|
|
85
|
+
disabledReason?: string | undefined;
|
|
86
|
+
/** Render the child (a link) with the button's look. Async state is not applied. */
|
|
87
|
+
asChild?: boolean | undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function formatCount(n: number): string | null {
|
|
91
|
+
if (!n || n <= 0) return null;
|
|
92
|
+
return n > 99 ? '99+' : String(n);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Button — one button for every action. Contrast is ink's job: the primary
|
|
97
|
+
* is ink on paper and on the yellow, the yellow on ink. One primary per
|
|
98
|
+
* view. Async results stay in the button: busy (width locked, repeat clicks
|
|
99
|
+
* ignored), done (confirmed in place); a failure is explained beside it.
|
|
100
|
+
* Hover deepens one step — no lift, no scale, no shadow.
|
|
101
|
+
*/
|
|
102
|
+
export function Button({
|
|
103
|
+
variant,
|
|
104
|
+
size,
|
|
105
|
+
ground,
|
|
106
|
+
iconLeft,
|
|
107
|
+
iconRight,
|
|
108
|
+
shortcut,
|
|
109
|
+
count,
|
|
110
|
+
state = 'idle',
|
|
111
|
+
doneLabel,
|
|
112
|
+
disabled,
|
|
113
|
+
disabledReason,
|
|
114
|
+
asChild,
|
|
115
|
+
className,
|
|
116
|
+
children,
|
|
117
|
+
onClick,
|
|
118
|
+
style,
|
|
119
|
+
ref,
|
|
120
|
+
...rest
|
|
121
|
+
}: ButtonProps) {
|
|
122
|
+
const classes = cn(button({ variant, size, ground: ground ?? 'auto' }), className);
|
|
123
|
+
if (asChild) {
|
|
124
|
+
return (
|
|
125
|
+
<Slot className={classes} {...(rest as object)}>
|
|
126
|
+
{children}
|
|
127
|
+
</Slot>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const busy = state === 'busy';
|
|
132
|
+
// Busy and done keep the idle content in place (hidden) and draw on top of
|
|
133
|
+
// it, so the button never resizes — also when it first renders busy.
|
|
134
|
+
const overlaid = state !== 'idle';
|
|
135
|
+
const blocked = Boolean(disabledReason) && disabled;
|
|
136
|
+
const shownCount = count === undefined ? null : formatCount(count);
|
|
137
|
+
return (
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
{...rest}
|
|
141
|
+
ref={ref}
|
|
142
|
+
className={classes}
|
|
143
|
+
style={style}
|
|
144
|
+
disabled={disabled && !disabledReason ? true : undefined}
|
|
145
|
+
// Busy is not disabled (spec: "busy and disabled are different states"):
|
|
146
|
+
// it keeps its look; aria-busy says so and repeat clicks are ignored below.
|
|
147
|
+
aria-disabled={blocked ? true : undefined}
|
|
148
|
+
aria-busy={busy || undefined}
|
|
149
|
+
title={blocked ? disabledReason : rest.title}
|
|
150
|
+
onClick={(e) => {
|
|
151
|
+
if (blocked || busy || state === 'done') {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
onClick?.(e);
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
<span className={cn('contents', overlaid && 'invisible')} aria-hidden={overlaid || undefined} data-button-content="">
|
|
159
|
+
{iconLeft}
|
|
160
|
+
{children}
|
|
161
|
+
{iconRight}
|
|
162
|
+
{shownCount && <span className="font-medium tabular-nums opacity-70">{shownCount}</span>}
|
|
163
|
+
{shortcut && (
|
|
164
|
+
<kbd className="ml-0.5 font-sans text-[12px] font-medium opacity-60" aria-hidden>
|
|
165
|
+
{shortcut}
|
|
166
|
+
</kbd>
|
|
167
|
+
)}
|
|
168
|
+
</span>
|
|
169
|
+
{overlaid && (
|
|
170
|
+
<span className="absolute inset-0 flex items-center justify-center gap-2" data-button-overlay="">
|
|
171
|
+
{busy ? (
|
|
172
|
+
<>
|
|
173
|
+
<Spinner />
|
|
174
|
+
<span className="sr-only">{children}</span>
|
|
175
|
+
</>
|
|
176
|
+
) : (
|
|
177
|
+
<>
|
|
178
|
+
<Check size={14} strokeWidth={2} aria-hidden />
|
|
179
|
+
{doneLabel ?? children}
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
182
|
+
</span>
|
|
183
|
+
)}
|
|
184
|
+
</button>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -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,68 @@
|
|
|
1
|
+
import * as RadioPrimitive from '@radix-ui/react-radio-group';
|
|
2
|
+
import { useState, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { definedAria } from './inputs-aria';
|
|
6
|
+
|
|
7
|
+
export type Choice = {
|
|
8
|
+
value: string;
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
description?: ReactNode;
|
|
11
|
+
/** A glyph at the start (an Icon). */
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
disabled?: boolean | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ChoiceCardsProps = {
|
|
17
|
+
choices: Choice[];
|
|
18
|
+
value?: string | undefined;
|
|
19
|
+
defaultValue?: string | undefined;
|
|
20
|
+
onChange?: ((value: string) => void) | undefined;
|
|
21
|
+
/** Cards per row on wide screens; they stack on phones. @default 2 */
|
|
22
|
+
columns?: 2 | 3 | undefined;
|
|
23
|
+
'aria-label'?: string | undefined;
|
|
24
|
+
'aria-labelledby'?: string | undefined;
|
|
25
|
+
className?: string | undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* ChoiceCards — a few weighty options, each a card with a title and a
|
|
30
|
+
* sentence (Add a person · Add an organisation). Radio semantics; the
|
|
31
|
+
* chosen card sits on the brand colour with an ink edge.
|
|
32
|
+
*/
|
|
33
|
+
export function ChoiceCards({ choices, value, defaultValue, onChange, columns = 2, className, ...aria }: ChoiceCardsProps) {
|
|
34
|
+
const [own, setOwn] = useState(defaultValue);
|
|
35
|
+
const current = value ?? own;
|
|
36
|
+
return (
|
|
37
|
+
<RadioPrimitive.Root
|
|
38
|
+
value={current ?? ''}
|
|
39
|
+
onValueChange={(v) => {
|
|
40
|
+
if (value === undefined) setOwn(v);
|
|
41
|
+
onChange?.(v);
|
|
42
|
+
}}
|
|
43
|
+
loop
|
|
44
|
+
{...definedAria(aria)}
|
|
45
|
+
className={cn('grid grid-cols-1 gap-3', columns === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2', className)}
|
|
46
|
+
>
|
|
47
|
+
{choices.map((c) => (
|
|
48
|
+
<RadioPrimitive.Item
|
|
49
|
+
key={c.value}
|
|
50
|
+
value={c.value}
|
|
51
|
+
disabled={c.disabled}
|
|
52
|
+
className={cn(
|
|
53
|
+
'group flex cursor-pointer items-start gap-3 rounded-lg border border-rule bg-page p-4 text-left font-sans text-fg outline-none',
|
|
54
|
+
'transition-[background-color,border-color] duration-[120ms] ease-standard hover:border-fg focus-visible:shadow-(--focus-ring)',
|
|
55
|
+
'data-[state=checked]:border-fg data-[state=checked]:bg-hover',
|
|
56
|
+
'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-rule',
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{c.icon && <span className="mt-0.5 flex shrink-0">{c.icon}</span>}
|
|
60
|
+
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
|
61
|
+
<span className="text-ui font-medium">{c.title}</span>
|
|
62
|
+
{c.description && <span className="text-meta leading-normal text-fg-secondary">{c.description}</span>}
|
|
63
|
+
</span>
|
|
64
|
+
</RadioPrimitive.Item>
|
|
65
|
+
))}
|
|
66
|
+
</RadioPrimitive.Root>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
import { useField } from './Field';
|
|
5
|
+
|
|
6
|
+
export type CodeInputProps = {
|
|
7
|
+
/** Number of cells. @default 6 */
|
|
8
|
+
length?: number | undefined;
|
|
9
|
+
value?: string | undefined;
|
|
10
|
+
onChange?: ((code: string) => void) | undefined;
|
|
11
|
+
/** Called once every cell is filled — typing the last digit or pasting the whole code. */
|
|
12
|
+
onComplete?: ((code: string) => void) | undefined;
|
|
13
|
+
/** Digits only (default) or letters and digits. */
|
|
14
|
+
mode?: 'numeric' | 'alphanumeric' | undefined;
|
|
15
|
+
invalid?: boolean | undefined;
|
|
16
|
+
disabled?: boolean | undefined;
|
|
17
|
+
/** Seconds until the code can be sent again; the countdown runs here. */
|
|
18
|
+
resendIn?: number | undefined;
|
|
19
|
+
onResend?: (() => void) | undefined;
|
|
20
|
+
/** @default (s) => `Resend in ${s} s` */
|
|
21
|
+
formatCountdown?: ((seconds: number) => ReactNode) | undefined;
|
|
22
|
+
/** @default "Send a new code" */
|
|
23
|
+
resendLabel?: string | undefined;
|
|
24
|
+
/** Name of the group for screen readers when there is no Field label. @default "Verification code" */
|
|
25
|
+
'aria-label'?: string | undefined;
|
|
26
|
+
className?: string | undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CodeInput — one cell per character of a one-time code. Typing moves on,
|
|
31
|
+
* Backspace moves back, arrows move; pasting the whole code fills every
|
|
32
|
+
* cell and completes. The first cell carries autocomplete=one-time-code
|
|
33
|
+
* so phones offer the code from the message.
|
|
34
|
+
*/
|
|
35
|
+
export function CodeInput({
|
|
36
|
+
length = 6,
|
|
37
|
+
value,
|
|
38
|
+
onChange,
|
|
39
|
+
onComplete,
|
|
40
|
+
mode = 'numeric',
|
|
41
|
+
invalid,
|
|
42
|
+
disabled,
|
|
43
|
+
resendIn,
|
|
44
|
+
onResend,
|
|
45
|
+
formatCountdown = (s) => `Resend in ${s} s`,
|
|
46
|
+
resendLabel = 'Send a new code',
|
|
47
|
+
'aria-label': ariaLabel = 'Verification code',
|
|
48
|
+
className,
|
|
49
|
+
}: CodeInputProps) {
|
|
50
|
+
const field = useField();
|
|
51
|
+
const [own, setOwn] = useState('');
|
|
52
|
+
const code = (value ?? own).slice(0, length);
|
|
53
|
+
const cells = useRef<Array<HTMLInputElement | null>>([]);
|
|
54
|
+
const allowed = mode === 'numeric' ? /\d/ : /[a-z0-9]/i;
|
|
55
|
+
const bad = Boolean(invalid || field?.invalid);
|
|
56
|
+
const off = Boolean(disabled || field?.disabled);
|
|
57
|
+
|
|
58
|
+
const [left, setLeft] = useState(resendIn ?? 0);
|
|
59
|
+
useEffect(() => setLeft(resendIn ?? 0), [resendIn]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (left <= 0) return;
|
|
62
|
+
const t = setTimeout(() => setLeft((s) => s - 1), 1000);
|
|
63
|
+
return () => clearTimeout(t);
|
|
64
|
+
}, [left]);
|
|
65
|
+
|
|
66
|
+
const set = (next: string) => {
|
|
67
|
+
const clean = next.slice(0, length);
|
|
68
|
+
if (value === undefined) setOwn(clean);
|
|
69
|
+
onChange?.(clean);
|
|
70
|
+
if (clean.length === length && !clean.includes(' ')) onComplete?.(clean);
|
|
71
|
+
};
|
|
72
|
+
const focus = (i: number) => cells.current[Math.max(0, Math.min(length - 1, i))]?.focus();
|
|
73
|
+
const chars = Array.from({ length }, (_, i) => code[i] ?? '');
|
|
74
|
+
|
|
75
|
+
const put = (i: number, raw: string) => {
|
|
76
|
+
const typed = [...raw].filter((c) => allowed.test(c)).map((c) => c.toUpperCase());
|
|
77
|
+
if (!typed.length) return;
|
|
78
|
+
const next = [...chars];
|
|
79
|
+
typed.forEach((c, k) => {
|
|
80
|
+
if (i + k < length) next[i + k] = c;
|
|
81
|
+
});
|
|
82
|
+
// Empty cells are a space inside; the value has no trailing spaces.
|
|
83
|
+
set(next.map((c) => c || ' ').join('').trimEnd());
|
|
84
|
+
focus(i + typed.length);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className={cn('flex flex-col gap-2 font-sans', className)}>
|
|
89
|
+
<div role="group" aria-label={field?.labelId ? undefined : ariaLabel} aria-labelledby={field?.labelId} className="flex gap-2">
|
|
90
|
+
{chars.map((c, i) => (
|
|
91
|
+
<input
|
|
92
|
+
key={i}
|
|
93
|
+
ref={(el) => {
|
|
94
|
+
cells.current[i] = el;
|
|
95
|
+
}}
|
|
96
|
+
id={i === 0 ? field?.id : undefined}
|
|
97
|
+
value={c.trim()}
|
|
98
|
+
inputMode={mode === 'numeric' ? 'numeric' : 'text'}
|
|
99
|
+
autoComplete={i === 0 ? 'one-time-code' : 'off'}
|
|
100
|
+
maxLength={length}
|
|
101
|
+
disabled={off}
|
|
102
|
+
aria-label={`${i + 1} of ${length}`}
|
|
103
|
+
aria-invalid={bad || undefined}
|
|
104
|
+
aria-describedby={field?.describedBy}
|
|
105
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
106
|
+
onChange={(e) => put(i, e.target.value)}
|
|
107
|
+
onPaste={(e) => {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
put(0, e.clipboardData.getData('text'));
|
|
110
|
+
}}
|
|
111
|
+
onKeyDown={(e) => {
|
|
112
|
+
if (e.key === 'Backspace') {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
const next = [...chars];
|
|
115
|
+
const at = c.trim() ? i : i - 1;
|
|
116
|
+
if (at < 0) return;
|
|
117
|
+
next[at] = ' ';
|
|
118
|
+
set(next.map((ch) => ch || ' ').join('').trimEnd());
|
|
119
|
+
focus(at);
|
|
120
|
+
} else if (e.key === 'ArrowLeft') {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
focus(i - 1);
|
|
123
|
+
} else if (e.key === 'ArrowRight') {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
focus(i + 1);
|
|
126
|
+
}
|
|
127
|
+
}}
|
|
128
|
+
className={cn(
|
|
129
|
+
'h-[52px] w-11 rounded-md border bg-sunk text-center font-sans text-[20px] font-medium text-fg tabular-nums outline-none',
|
|
130
|
+
'transition-[border-color] duration-[120ms] ease-standard focus:border-fg focus:shadow-(--focus-ring)',
|
|
131
|
+
bad ? 'border-transparent bg-error-wash focus:border-error' : 'border-transparent',
|
|
132
|
+
off && 'cursor-not-allowed bg-well text-fg-secondary',
|
|
133
|
+
)}
|
|
134
|
+
/>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
{resendIn !== undefined && (
|
|
138
|
+
<div className="text-meta text-fg-secondary" aria-live="polite">
|
|
139
|
+
{left > 0 ? (
|
|
140
|
+
formatCountdown(left)
|
|
141
|
+
) : (
|
|
142
|
+
<button type="button" onClick={onResend} className="cursor-pointer rounded-xs font-medium text-fg underline underline-offset-3 outline-none focus-visible:shadow-(--focus-ring)">
|
|
143
|
+
{resendLabel}
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|