@cosxai/ui 1.0.0-alpha.1 → 1.0.0-alpha.2

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +6 -1
  3. package/src/components/ActionBar.tsx +607 -0
  4. package/src/components/ActivityTimeline.tsx +77 -0
  5. package/src/components/AsyncSelect.tsx +205 -0
  6. package/src/components/Breadcrumb.tsx +129 -0
  7. package/src/components/Button.tsx +17 -2
  8. package/src/components/ChoiceCards.tsx +68 -0
  9. package/src/components/CodeInput.tsx +150 -0
  10. package/src/components/CommandPalette.tsx +228 -0
  11. package/src/components/ConfirmDialog.tsx +294 -0
  12. package/src/components/CopyField.tsx +104 -0
  13. package/src/components/DateInput.tsx +366 -0
  14. package/src/components/Dialog.tsx +27 -3
  15. package/src/components/Dropzone.tsx +187 -0
  16. package/src/components/FileCard.tsx +233 -0
  17. package/src/components/FileRow.tsx +159 -0
  18. package/src/components/FolderTree.tsx +377 -0
  19. package/src/components/ListToolbar.tsx +177 -0
  20. package/src/components/MentionInput.tsx +233 -0
  21. package/src/components/Menu.tsx +109 -0
  22. package/src/components/PageHeader.tsx +62 -0
  23. package/src/components/PageState.tsx +84 -0
  24. package/src/components/Progress.tsx +97 -0
  25. package/src/components/PulseDot.tsx +31 -0
  26. package/src/components/RecipientsInput.tsx +252 -0
  27. package/src/components/SearchField.tsx +137 -0
  28. package/src/components/SegmentedControl.tsx +95 -0
  29. package/src/components/SidePanel.tsx +130 -0
  30. package/src/components/Skeleton.tsx +73 -0
  31. package/src/components/StageProgress.tsx +51 -0
  32. package/src/components/Steps.tsx +58 -0
  33. package/src/components/SyncStatus.tsx +56 -0
  34. package/src/components/UploadCheck.tsx +118 -0
  35. package/src/components/UploadList.tsx +176 -0
  36. package/src/components/VirtualList.tsx +90 -0
  37. package/src/components/WindowDrop.tsx +131 -0
  38. package/src/components/inputs-aria.ts +5 -0
  39. package/src/components/inputs-date.ts +92 -0
  40. package/src/components/inputs-listbox.tsx +154 -0
  41. package/src/components/inputs-search.ts +60 -0
  42. package/src/components/status-files.ts +124 -0
  43. package/src/components/status-motion.ts +30 -0
  44. package/src/components/useSelection.ts +142 -0
  45. package/src/index.ts +38 -0
  46. package/src/components/core.test.tsx +0 -242
  47. package/src/components/forms.test.tsx +0 -280
  48. package/src/components/overlays.test.tsx +0 -245
@@ -0,0 +1,77 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+
5
+ export type ActivityEvent = {
6
+ id: string;
7
+ /** Who did it. Initials default from the name (first letters of up to two words; a single CJK name → its first character). */
8
+ actor: { name: string; initials?: string | undefined };
9
+ /** What they did, without the name: "viewed Cap table.xlsx for 6 min". */
10
+ action: ReactNode;
11
+ /** Shown time ("14:02"); `dateTime` makes it a <time>. */
12
+ time: ReactNode;
13
+ dateTime?: string | undefined;
14
+ /** Extra detail after the time: "pages 2–4 most". */
15
+ detail?: ReactNode;
16
+ };
17
+
18
+ export type ActivityGroup = { label: ReactNode; events: ActivityEvent[] };
19
+
20
+ export type ActivityTimelineProps = Omit<ComponentProps<'section'>, 'children'> & {
21
+ /** Grouped by time, newest first: Today, Yesterday, then dates. */
22
+ groups: ActivityGroup[];
23
+ /** Above the groups: the scope switch ("This share / Whole chain"). */
24
+ scope?: ReactNode;
25
+ /** Heading level of the group labels. @default 3 */
26
+ level?: 2 | 3 | 4 | undefined;
27
+ /** When there is nothing yet. */
28
+ empty?: ReactNode;
29
+ };
30
+
31
+ export function initialsOf(name: string): string {
32
+ const t = name.trim();
33
+ if (!t) return '?';
34
+ if (/[㐀-鿿]/.test(t[0]!)) return t[0]!;
35
+ return t
36
+ .split(/\s+/)
37
+ .slice(0, 2)
38
+ .map((w) => w[0]!.toUpperCase())
39
+ .join('');
40
+ }
41
+
42
+ /** ActivityTimeline — who did what, grouped by time. */
43
+ export function ActivityTimeline({ groups, scope, level = 3, empty, className, ...rest }: ActivityTimelineProps) {
44
+ const H = `h${level}` as const;
45
+ const any = groups.some((g) => g.events.length > 0);
46
+ return (
47
+ <section className={cn('flex flex-col gap-3', className)} {...rest}>
48
+ {scope}
49
+ {!any && empty}
50
+ {groups
51
+ .filter((g) => g.events.length > 0)
52
+ .map((g, gi) => (
53
+ <div key={gi} className="flex flex-col gap-2">
54
+ <H className="m-0 text-meta font-medium text-fg-secondary">{g.label}</H>
55
+ <ol className="m-0 flex list-none flex-col gap-3 p-0">
56
+ {g.events.map((e) => (
57
+ <li key={e.id} className="flex gap-2.5">
58
+ <span aria-hidden className="grid size-6 shrink-0 place-items-center rounded-pill bg-sunk text-[10px] font-semibold text-fg">
59
+ {e.actor.initials ?? initialsOf(e.actor.name)}
60
+ </span>
61
+ <div className="flex min-w-0 flex-col">
62
+ <span className="text-ui text-fg">
63
+ <span className="font-medium">{e.actor.name}</span> {e.action}
64
+ </span>
65
+ <span className="text-meta text-fg-secondary">
66
+ {e.dateTime ? <time dateTime={e.dateTime}>{e.time}</time> : e.time}
67
+ {e.detail && <> · {e.detail}</>}
68
+ </span>
69
+ </div>
70
+ </li>
71
+ ))}
72
+ </ol>
73
+ </div>
74
+ ))}
75
+ </section>
76
+ );
77
+ }
@@ -0,0 +1,205 @@
1
+ import * as Popover from '@radix-ui/react-popover';
2
+ import { Search, X } from 'lucide-react';
3
+ import { useId, useRef, useState, type KeyboardEvent } from 'react';
4
+
5
+ import { cn } from '../lib/cn';
6
+ import { useField } from './Field';
7
+ import { controlFrame, controlInner } from './forms-control';
8
+ import { Avatar, DEFAULT_LABELS, ListboxBody, type ListboxLabels, type ListItem } from './inputs-listbox';
9
+ import { useDebouncedSearch } from './inputs-search';
10
+ import { Spinner } from './Spinner';
11
+
12
+ export type AsyncSelectProps = {
13
+ /** Look up matches; the signal aborts when newer input arrives. */
14
+ search: (query: string, signal: AbortSignal) => Promise<ListItem[]>;
15
+ value?: ListItem | null | undefined;
16
+ onChange?: ((item: ListItem | null) => void) | undefined;
17
+ /** Offer “Create …” for text that matches nothing exactly. */
18
+ onCreate?: ((query: string) => void | ListItem | Promise<ListItem | void>) | undefined;
19
+ /** @default "Search" */
20
+ placeholder?: string | undefined;
21
+ /** ms. @default 250 */
22
+ debounce?: number | undefined;
23
+ labels?: Partial<ListboxLabels> & { clear?: string | undefined };
24
+ invalid?: boolean | undefined;
25
+ disabled?: boolean | undefined;
26
+ /** Start with this search typed and its results open. */
27
+ defaultQuery?: string | undefined;
28
+ size?: 'sm' | 'md' | 'lg' | undefined;
29
+ id?: string | undefined;
30
+ 'aria-label'?: string | undefined;
31
+ className?: string | undefined;
32
+ };
33
+
34
+ /**
35
+ * AsyncSelect — choose one from a large or remote set (a customer, a
36
+ * project). Typing searches after a short pause and drops stale answers;
37
+ * a spinner shows until the answer comes, then results with their meta, a
38
+ * Create row, “nothing matches”, or an error with Retry. The choice
39
+ * becomes a card that clears with ×.
40
+ */
41
+ export function AsyncSelect({
42
+ search,
43
+ value,
44
+ onChange,
45
+ onCreate,
46
+ placeholder = 'Search',
47
+ debounce = 250,
48
+ labels: labelsIn,
49
+ invalid,
50
+ disabled,
51
+ defaultQuery = '',
52
+ size,
53
+ id,
54
+ className,
55
+ ...aria
56
+ }: AsyncSelectProps) {
57
+ const field = useField();
58
+ const auto = useId();
59
+ const listId = `as${auto}-list`;
60
+ const optionId = (i: number) => `as${auto}-o${i}`;
61
+ const labels = { ...DEFAULT_LABELS, ...labelsIn };
62
+ const [own, setOwn] = useState<ListItem | null>(null);
63
+ const chosen = value === undefined ? own : value;
64
+ const [query, setQuery] = useState(defaultQuery);
65
+ const [open, setOpen] = useState(defaultQuery !== '');
66
+ const [active, setActive] = useState(0);
67
+ const input = useRef<HTMLInputElement | null>(null);
68
+ const { status, result, retry } = useDebouncedSearch(query, search, { delay: debounce });
69
+ const items = status === 'done' ? (result ?? []) : [];
70
+ const q = query.trim();
71
+ const canCreate = Boolean(onCreate) && q.length > 0 && !items.some((i) => i.label.toLowerCase() === q.toLowerCase());
72
+ const count = items.length + (canCreate ? 1 : 0);
73
+ const bad = Boolean(invalid || field?.invalid);
74
+ const off = Boolean(disabled || field?.disabled);
75
+
76
+ const pick = (item: ListItem | null) => {
77
+ if (value === undefined) setOwn(item);
78
+ onChange?.(item);
79
+ setQuery('');
80
+ setOpen(false);
81
+ };
82
+ const choose = async (i: number) => {
83
+ if (i < items.length) return pick(items[i]!);
84
+ if (i === items.length && canCreate) {
85
+ const made = await onCreate!(q);
86
+ if (made && typeof made === 'object') pick(made);
87
+ else {
88
+ setQuery('');
89
+ setOpen(false);
90
+ }
91
+ }
92
+ };
93
+ const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
94
+ if (e.key === 'ArrowDown') {
95
+ e.preventDefault();
96
+ setOpen(true);
97
+ setActive((a) => Math.min(count - 1, a + 1));
98
+ } else if (e.key === 'ArrowUp') {
99
+ e.preventDefault();
100
+ setActive((a) => Math.max(0, a - 1));
101
+ } else if (e.key === 'Enter' && open && count > 0) {
102
+ e.preventDefault();
103
+ void choose(active);
104
+ } else if (e.key === 'Escape') {
105
+ setOpen(false);
106
+ }
107
+ };
108
+
109
+ if (chosen) {
110
+ return (
111
+ <div
112
+ className={cn(
113
+ 'flex min-h-[38px] items-center gap-2.5 rounded-md border bg-sunk px-2 py-1.5 font-sans text-ui text-fg',
114
+ bad ? 'border-error' : 'border-rule-soft',
115
+ off && 'bg-well text-fg-secondary',
116
+ className,
117
+ )}
118
+ >
119
+ <Avatar item={chosen} className="size-8 rounded-sm bg-ink text-linen ink:bg-yellow-accent ink:text-ink" />
120
+ <span className="flex min-w-0 flex-1 flex-col">
121
+ <span id={id ?? field?.id} className="truncate font-medium">
122
+ {chosen.label}
123
+ </span>
124
+ {chosen.meta && <span className="truncate text-meta text-fg-secondary">{chosen.meta}</span>}
125
+ </span>
126
+ {!off && (
127
+ <button
128
+ type="button"
129
+ aria-label={`${labelsIn?.clear ?? 'Clear'} ${chosen.label}`}
130
+ onClick={() => {
131
+ pick(null);
132
+ requestAnimationFrame(() => input.current?.focus());
133
+ }}
134
+ className="inline-grid shrink-0 cursor-pointer place-items-center rounded-xs p-0.5 text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
135
+ >
136
+ <X size={13} aria-hidden />
137
+ </button>
138
+ )}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ const showList = open && q.length > 0;
144
+ return (
145
+ <Popover.Root open={showList} onOpenChange={setOpen}>
146
+ <Popover.Anchor asChild>
147
+ <div className={cn(controlFrame({ size, invalid: bad, disabled: off }), className)}>
148
+ <Search size={14} aria-hidden className="shrink-0 text-fg-secondary" />
149
+ <input
150
+ ref={input}
151
+ id={id ?? field?.id}
152
+ role="combobox"
153
+ aria-expanded={showList}
154
+ aria-controls={showList ? listId : undefined}
155
+ aria-autocomplete="list"
156
+ aria-activedescendant={showList && status === 'done' && count > 0 ? optionId(active) : undefined}
157
+ aria-invalid={bad || undefined}
158
+ aria-describedby={field?.describedBy}
159
+ aria-label={aria['aria-label']}
160
+ disabled={off}
161
+ value={query}
162
+ placeholder={placeholder}
163
+ autoComplete="off"
164
+ onChange={(e) => {
165
+ setQuery(e.target.value);
166
+ setActive(0);
167
+ setOpen(true);
168
+ }}
169
+ onFocus={() => q && setOpen(true)}
170
+ onKeyDown={onKey}
171
+ className={controlInner}
172
+ />
173
+ {status === 'searching' && <Spinner size={12} className="text-fg-secondary" />}
174
+ </div>
175
+ </Popover.Anchor>
176
+ <Popover.Portal>
177
+ <Popover.Content
178
+ align="start"
179
+ sideOffset={6}
180
+ collisionPadding={12}
181
+ onOpenAutoFocus={(e) => e.preventDefault()}
182
+ onInteractOutside={(e) => {
183
+ if (e.target instanceof Node && input.current?.parentElement?.contains(e.target)) e.preventDefault();
184
+ }}
185
+ className="rise z-60 rounded-lg border border-rule bg-page p-1 font-sans [--motion-reveal:200ms]"
186
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
187
+ >
188
+ <ListboxBody
189
+ id={listId}
190
+ optionId={optionId}
191
+ items={items}
192
+ active={active}
193
+ status={status}
194
+ query={q}
195
+ canCreate={canCreate}
196
+ labels={labels}
197
+ onActive={setActive}
198
+ onChoose={(i) => void choose(i)}
199
+ onRetry={retry}
200
+ />
201
+ </Popover.Content>
202
+ </Popover.Portal>
203
+ </Popover.Root>
204
+ );
205
+ }
@@ -0,0 +1,129 @@
1
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+
6
+ export type BreadcrumbItem = {
7
+ label: ReactNode;
8
+ /** A link; or use onSelect for in-app navigation. */
9
+ href?: string | undefined;
10
+ onSelect?: (() => void) | undefined;
11
+ /** A trailing tag on the current page: its format (PDF). */
12
+ tag?: ReactNode;
13
+ };
14
+
15
+ export type BreadcrumbProps = Omit<ComponentProps<'nav'>, 'children'> & {
16
+ items: BreadcrumbItem[];
17
+ /** What starts the trail — the workspace brand (on a customer domain it replaces the platform name). */
18
+ root?: ReactNode;
19
+ /** Beyond this many segments the middle folds into a menu. @default 4 */
20
+ max?: number | undefined;
21
+ /** @default "Breadcrumb" */
22
+ label?: string | undefined;
23
+ /** Accessible name of the fold button. @default "Show hidden folders" */
24
+ foldLabel?: string | undefined;
25
+ /** A line under the trail: the current item's status. */
26
+ status?: ReactNode;
27
+ };
28
+
29
+ const linkClass = 'cursor-pointer text-fg-secondary outline-none hover:text-fg hover:underline underline-offset-2 focus-visible:shadow-(--focus-ring) rounded-xs';
30
+
31
+ function Crumb({ item }: { item: BreadcrumbItem }) {
32
+ if (item.href)
33
+ return (
34
+ <a href={item.href} onClick={item.onSelect} className={linkClass}>
35
+ {item.label}
36
+ </a>
37
+ );
38
+ if (item.onSelect)
39
+ return (
40
+ <button type="button" onClick={item.onSelect} className={linkClass}>
41
+ {item.label}
42
+ </button>
43
+ );
44
+ return <span className="text-fg-secondary">{item.label}</span>;
45
+ }
46
+
47
+ const Sep = () => (
48
+ <span aria-hidden className="text-fg-secondary">
49
+ /
50
+ </span>
51
+ );
52
+
53
+ /**
54
+ * Breadcrumb — where this page sits. The current page is the last segment
55
+ * and not a link; beyond four segments the middle folds into a menu. On a
56
+ * customer domain the trail starts at the workspace brand (root).
57
+ */
58
+ export function Breadcrumb({ items, root, max = 4, label = 'Breadcrumb', foldLabel = 'Show hidden folders', status, className, ...rest }: BreadcrumbProps) {
59
+ const last = items[items.length - 1];
60
+ const before = items.slice(0, -1);
61
+ const total = items.length + (root ? 1 : 0);
62
+ const fold = total > max && before.length > 1;
63
+ // Folded: [root or the first ancestor] / … / parent / current.
64
+ const head = fold ? (root ? [] : before.slice(0, 1)) : before;
65
+ const folded = fold ? before.slice(root ? 0 : 1, -1) : [];
66
+ const tail = fold ? before.slice(-1) : [];
67
+ const segment = 'flex items-center gap-2';
68
+
69
+ return (
70
+ <nav aria-label={label} className={cn('flex flex-col gap-1.5', className)} {...rest}>
71
+ <ol className="m-0 flex list-none flex-wrap items-center gap-x-2 gap-y-1 p-0 text-ui">
72
+ {root && (
73
+ <li className={segment}>
74
+ {root}
75
+ {items.length > 0 && <Sep />}
76
+ </li>
77
+ )}
78
+ {head.map((it, i) => (
79
+ <li key={`h${i}`} className={segment}>
80
+ <Crumb item={it} />
81
+ <Sep />
82
+ </li>
83
+ ))}
84
+ {folded.length > 0 && (
85
+ <li className={segment}>
86
+ <DropdownMenu.Root>
87
+ <DropdownMenu.Trigger
88
+ aria-label={foldLabel}
89
+ className="inline-grid h-6 min-w-6 cursor-pointer place-items-center rounded-sm bg-sunk px-1.5 text-meta font-semibold text-fg outline-none hover:bg-well focus-visible:shadow-(--focus-ring)"
90
+ >
91
+ …
92
+ </DropdownMenu.Trigger>
93
+ <DropdownMenu.Portal>
94
+ <DropdownMenu.Content align="start" sideOffset={6} className="z-50 min-w-[180px] rounded-lg border border-rule bg-page p-1.5 text-ui text-fg">
95
+ {folded.map((it, i) => (
96
+ <DropdownMenu.Item
97
+ key={i}
98
+ onSelect={() => {
99
+ if (it.href) window.location.assign(it.href);
100
+ else it.onSelect?.();
101
+ }}
102
+ className="flex h-8 cursor-pointer items-center rounded-md px-2.5 outline-none data-[highlighted]:bg-hover"
103
+ >
104
+ {it.label}
105
+ </DropdownMenu.Item>
106
+ ))}
107
+ </DropdownMenu.Content>
108
+ </DropdownMenu.Portal>
109
+ </DropdownMenu.Root>
110
+ <Sep />
111
+ </li>
112
+ )}
113
+ {tail.map((it, i) => (
114
+ <li key={`t${i}`} className={segment}>
115
+ <Crumb item={it} />
116
+ <Sep />
117
+ </li>
118
+ ))}
119
+ {last && (
120
+ <li aria-current="page" className="flex items-center gap-2 font-medium text-fg">
121
+ {last.label}
122
+ {last.tag && <span className="rounded-sm bg-sunk px-1.5 py-0.5 text-[11px] font-semibold text-fg">{last.tag}</span>}
123
+ </li>
124
+ )}
125
+ </ol>
126
+ {status && <div className="text-meta text-fg-secondary">{status}</div>}
127
+ </nav>
128
+ );
129
+ }
@@ -17,7 +17,9 @@ const button = cva(
17
17
  'rounded-md border border-transparent font-sans font-semibold leading-none',
18
18
  'transition-[background-color,border-color,color] duration-[120ms] ease-standard',
19
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',
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',
21
23
  ],
22
24
  {
23
25
  variants: {
@@ -45,6 +47,17 @@ const button = cva(
45
47
  // Focus ring follows the ground.
46
48
  { ground: 'yellow', class: 'focus-visible:shadow-[0_0_0_3px_var(--yellow),0_0_0_5px_var(--ink)]' },
47
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
+ },
48
61
  ],
49
62
  defaultVariants: { size: 'md', variant: 'primary', ground: 'auto' },
50
63
  },
@@ -141,7 +154,9 @@ export function Button({
141
154
  className={classes}
142
155
  style={lockedWidth ? { ...style, width: lockedWidth } : style}
143
156
  disabled={disabled && !disabledReason ? true : undefined}
144
- aria-disabled={blocked || busy ? true : undefined}
157
+ // Busy is not disabled (spec: "busy and disabled are different states"):
158
+ // it keeps its look; aria-busy says so and repeat clicks are ignored below.
159
+ aria-disabled={blocked ? true : undefined}
145
160
  aria-busy={busy || undefined}
146
161
  title={blocked ? disabledReason : rest.title}
147
162
  onClick={(e) => {
@@ -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
+ }