@cosxai/ui 1.0.0-alpha.0 → 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 (74) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/package.json +32 -3
  3. package/src/brand.css +13 -0
  4. package/src/components/ActionBar.tsx +607 -0
  5. package/src/components/ActivityTimeline.tsx +77 -0
  6. package/src/components/AsyncSelect.tsx +205 -0
  7. package/src/components/Badge.tsx +68 -0
  8. package/src/components/Breadcrumb.tsx +129 -0
  9. package/src/components/Button.tsx +193 -0
  10. package/src/components/Card.tsx +42 -0
  11. package/src/components/Checkbox.tsx +67 -0
  12. package/src/components/ChoiceCards.tsx +68 -0
  13. package/src/components/CodeInput.tsx +150 -0
  14. package/src/components/CommandPalette.tsx +228 -0
  15. package/src/components/ConfirmDialog.tsx +294 -0
  16. package/src/components/CopyField.tsx +104 -0
  17. package/src/components/DateInput.tsx +366 -0
  18. package/src/components/Dialog.tsx +135 -0
  19. package/src/components/Dropzone.tsx +187 -0
  20. package/src/components/Field.tsx +66 -0
  21. package/src/components/Figure.tsx +30 -0
  22. package/src/components/FileCard.tsx +233 -0
  23. package/src/components/FileRow.tsx +159 -0
  24. package/src/components/FolderTree.tsx +377 -0
  25. package/src/components/Icon.tsx +32 -0
  26. package/src/components/IconButton.tsx +55 -0
  27. package/src/components/Input.tsx +74 -0
  28. package/src/components/ListToolbar.tsx +177 -0
  29. package/src/components/Logo.tsx +62 -0
  30. package/src/components/Marker.tsx +26 -0
  31. package/src/components/MentionInput.tsx +233 -0
  32. package/src/components/Menu.tsx +109 -0
  33. package/src/components/MetaLabel.tsx +38 -0
  34. package/src/components/PageHeader.tsx +62 -0
  35. package/src/components/PageState.tsx +84 -0
  36. package/src/components/Progress.tsx +97 -0
  37. package/src/components/PulseDot.tsx +31 -0
  38. package/src/components/Radio.tsx +72 -0
  39. package/src/components/RecipientsInput.tsx +252 -0
  40. package/src/components/SearchField.tsx +137 -0
  41. package/src/components/SegmentedControl.tsx +95 -0
  42. package/src/components/Select.tsx +351 -0
  43. package/src/components/SidePanel.tsx +130 -0
  44. package/src/components/Skeleton.tsx +73 -0
  45. package/src/components/Spinner.tsx +20 -0
  46. package/src/components/StageProgress.tsx +51 -0
  47. package/src/components/Steps.tsx +58 -0
  48. package/src/components/Switch.tsx +67 -0
  49. package/src/components/SyncStatus.tsx +56 -0
  50. package/src/components/Table.tsx +196 -0
  51. package/src/components/Tabs.tsx +124 -0
  52. package/src/components/Tag.tsx +35 -0
  53. package/src/components/Textarea.tsx +46 -0
  54. package/src/components/ThemeProvider.tsx +80 -0
  55. package/src/components/Toast.tsx +159 -0
  56. package/src/components/Tooltip.tsx +57 -0
  57. package/src/components/UploadCheck.tsx +118 -0
  58. package/src/components/UploadList.tsx +176 -0
  59. package/src/components/VirtualList.tsx +90 -0
  60. package/src/components/WindowDrop.tsx +131 -0
  61. package/src/components/forms-control.ts +36 -0
  62. package/src/components/inputs-aria.ts +5 -0
  63. package/src/components/inputs-date.ts +92 -0
  64. package/src/components/inputs-listbox.tsx +154 -0
  65. package/src/components/inputs-search.ts +60 -0
  66. package/src/components/logo-art.ts +4 -0
  67. package/src/components/status-files.ts +124 -0
  68. package/src/components/status-motion.ts +30 -0
  69. package/src/components/useButtonAction.ts +57 -0
  70. package/src/components/useSelection.ts +142 -0
  71. package/src/index.ts +67 -2
  72. package/src/modes.css +10 -0
  73. package/src/styles.css +2 -0
  74. package/src/theme.css +11 -0
@@ -0,0 +1,377 @@
1
+ import { Check, ChevronRight, Folder, FolderOpen, Minus, type LucideIcon } from 'lucide-react';
2
+ import { useCallback, useEffect, useId, useMemo, useRef, useState, type DragEvent, type KeyboardEvent, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { Spinner } from './Spinner';
6
+
7
+ export type TreeNode = {
8
+ id: string;
9
+ label: string;
10
+ /** Defaults to a folder (open when expanded). */
11
+ icon?: LucideIcon | undefined;
12
+ /** Right-aligned: a count, "System". */
13
+ meta?: ReactNode;
14
+ /** Known children. Leave undefined with hasChildren for lazy loading. */
15
+ children?: TreeNode[] | undefined;
16
+ /** Has children not loaded yet (lazy). */
17
+ hasChildren?: boolean | undefined;
18
+ disabled?: boolean | undefined;
19
+ };
20
+
21
+ export type FolderTreeProps = {
22
+ nodes: TreeNode[];
23
+ /** Accessible name of the tree. */
24
+ label: string;
25
+ /** The current folder (brand field). */
26
+ selectedId?: string | null | undefined;
27
+ onSelect?: ((id: string) => void) | undefined;
28
+ /** Controlled expansion. */
29
+ expandedIds?: readonly string[] | undefined;
30
+ defaultExpandedIds?: readonly string[] | undefined;
31
+ onExpandedChange?: ((ids: string[]) => void) | undefined;
32
+ /** Expand every ancestor of this id (opening a folder elsewhere reveals it here) and move focus to it. */
33
+ revealId?: string | null | undefined;
34
+ /** Loads a lazy node's children on first expand. */
35
+ loadChildren?: ((id: string) => Promise<TreeNode[]>) | undefined;
36
+ /** Checkbox mode: ticks with partial (mixed) parents. Ids of ticked nodes. */
37
+ checkedIds?: readonly string[] | undefined;
38
+ onCheckedChange?: ((ids: string[]) => void) | undefined;
39
+ /** Nodes become drop targets while something is dragged over them. */
40
+ onDropItems?: ((targetId: string, data: DataTransfer) => void) | undefined;
41
+ /** Whether a drop is allowed here. @default all but disabled nodes */
42
+ canDrop?: ((targetId: string) => boolean) | undefined;
43
+ /** @default "Loading" */
44
+ loadingLabel?: string | undefined;
45
+ className?: string | undefined;
46
+ };
47
+
48
+ type Flat = { node: TreeNode; level: number; parentId: string | null; posinset: number; setsize: number };
49
+
50
+ /** Resolved children: loaded ones override what the node declares. */
51
+ function kids(n: TreeNode, loaded: Map<string, TreeNode[]>): TreeNode[] | undefined {
52
+ return loaded.get(n.id) ?? n.children;
53
+ }
54
+
55
+ function expandable(n: TreeNode, loaded: Map<string, TreeNode[]>): boolean {
56
+ const c = kids(n, loaded);
57
+ return c ? c.length > 0 : Boolean(n.hasChildren);
58
+ }
59
+
60
+ /** Every known descendant id (for ticks). */
61
+ function descendants(n: TreeNode, loaded: Map<string, TreeNode[]>): string[] {
62
+ const out: string[] = [];
63
+ const walk = (x: TreeNode) => {
64
+ for (const c of kids(x, loaded) ?? []) {
65
+ out.push(c.id);
66
+ walk(c);
67
+ }
68
+ };
69
+ walk(n);
70
+ return out;
71
+ }
72
+
73
+ /** Path of ids from a root to `id` (inclusive), or null. */
74
+ function pathTo(nodes: TreeNode[], id: string, loaded: Map<string, TreeNode[]>): string[] | null {
75
+ for (const n of nodes) {
76
+ if (n.id === id) return [n.id];
77
+ const c = kids(n, loaded);
78
+ if (c) {
79
+ const p = pathTo(c, id, loaded);
80
+ if (p) return [n.id, ...p];
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * FolderTree — the WAI-ARIA tree for folders. The chevron and the name are
88
+ * separate targets (the chevron only expands, the name opens). Arrows,
89
+ * Home/End and typing a name move focus; Right/Left expand, collapse and
90
+ * step in and out. Lazy children load on first expand; revealId opens a
91
+ * folder's ancestors; checkbox mode shows partial ticks; nodes accept drops.
92
+ */
93
+ export function FolderTree({
94
+ nodes,
95
+ label,
96
+ selectedId,
97
+ onSelect,
98
+ expandedIds,
99
+ defaultExpandedIds,
100
+ onExpandedChange,
101
+ revealId,
102
+ loadChildren,
103
+ checkedIds,
104
+ onCheckedChange,
105
+ onDropItems,
106
+ canDrop,
107
+ loadingLabel = 'Loading',
108
+ className,
109
+ }: FolderTreeProps) {
110
+ const [innerExpanded, setInnerExpanded] = useState<Set<string>>(() => new Set(defaultExpandedIds ?? []));
111
+ const expanded = useMemo(() => (expandedIds ? new Set(expandedIds) : innerExpanded), [expandedIds, innerExpanded]);
112
+ const [loaded, setLoaded] = useState<Map<string, TreeNode[]>>(() => new Map());
113
+ const [loading, setLoading] = useState<Set<string>>(() => new Set());
114
+ const [focusId, setFocusId] = useState<string | null>(null);
115
+ const [dropId, setDropId] = useState<string | null>(null);
116
+ const items = useRef(new Map<string, HTMLLIElement>());
117
+ const typeahead = useRef({ text: '', at: 0 });
118
+ // A treeitem's name would otherwise include its whole subtree.
119
+ const idBase = useId();
120
+ const labelId = (id: string) => `${idBase}-${id}`;
121
+
122
+ const setExpanded = useCallback(
123
+ (next: Set<string>) => {
124
+ if (!expandedIds) setInnerExpanded(next);
125
+ onExpandedChange?.([...next]);
126
+ },
127
+ [expandedIds, onExpandedChange],
128
+ );
129
+
130
+ const load = useCallback(
131
+ async (n: TreeNode) => {
132
+ if (!loadChildren || kids(n, loaded) || loading.has(n.id) || !n.hasChildren) return;
133
+ setLoading((s) => new Set(s).add(n.id));
134
+ try {
135
+ const children = await loadChildren(n.id);
136
+ setLoaded((m) => new Map(m).set(n.id, children));
137
+ } finally {
138
+ setLoading((s) => {
139
+ const next = new Set(s);
140
+ next.delete(n.id);
141
+ return next;
142
+ });
143
+ }
144
+ },
145
+ [loadChildren, loaded, loading],
146
+ );
147
+
148
+ const toggleExpand = useCallback(
149
+ (n: TreeNode, open?: boolean) => {
150
+ const willOpen = open ?? !expanded.has(n.id);
151
+ const next = new Set(expanded);
152
+ if (willOpen) {
153
+ next.add(n.id);
154
+ void load(n);
155
+ } else next.delete(n.id);
156
+ setExpanded(next);
157
+ },
158
+ [expanded, load, setExpanded],
159
+ );
160
+
161
+ // Visible rows in order.
162
+ const flat = useMemo(() => {
163
+ const out: Flat[] = [];
164
+ const walk = (list: TreeNode[], level: number, parentId: string | null) => {
165
+ list.forEach((n, i) => {
166
+ out.push({ node: n, level, parentId, posinset: i + 1, setsize: list.length });
167
+ const c = kids(n, loaded);
168
+ if (expanded.has(n.id) && c) walk(c, level + 1, n.id);
169
+ });
170
+ };
171
+ walk(nodes, 1, null);
172
+ return out;
173
+ }, [nodes, loaded, expanded]);
174
+
175
+ // Reveal: expand every ancestor, focus the node.
176
+ useEffect(() => {
177
+ if (!revealId) return;
178
+ const path = pathTo(nodes, revealId, loaded);
179
+ if (!path) return;
180
+ const ancestors = path.slice(0, -1);
181
+ if (ancestors.some((id) => !expanded.has(id))) setExpanded(new Set([...expanded, ...ancestors]));
182
+ setFocusId(revealId);
183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
184
+ }, [revealId, nodes, loaded]);
185
+
186
+ const current = focusId && flat.some((f) => f.node.id === focusId) ? focusId : (selectedId ?? flat[0]?.node.id ?? null);
187
+ const moveFocus = (id: string | undefined) => {
188
+ if (!id) return;
189
+ setFocusId(id);
190
+ items.current.get(id)?.focus();
191
+ };
192
+
193
+ // Ticks
194
+ const checked = useMemo(() => new Set(checkedIds ?? []), [checkedIds]);
195
+ const tickState = (n: TreeNode): boolean | 'mixed' => {
196
+ const d = descendants(n, loaded);
197
+ if (d.length === 0) return checked.has(n.id);
198
+ const on = d.filter((id) => checked.has(id)).length;
199
+ if (on === 0) return false;
200
+ return on === d.length ? true : 'mixed';
201
+ };
202
+ const toggleTick = (n: TreeNode) => {
203
+ if (!onCheckedChange) return;
204
+ const state = tickState(n);
205
+ const ids = [n.id, ...descendants(n, loaded)];
206
+ const next = new Set(checked);
207
+ if (state === true) ids.forEach((id) => next.delete(id));
208
+ else ids.forEach((id) => next.add(id));
209
+ onCheckedChange([...next]);
210
+ };
211
+
212
+ const onKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {
213
+ const i = flat.findIndex((f) => f.node.id === current);
214
+ const here = flat[i];
215
+ if (!here) return;
216
+ const n = here.node;
217
+ switch (e.key) {
218
+ case 'ArrowDown':
219
+ moveFocus(flat[i + 1]?.node.id);
220
+ break;
221
+ case 'ArrowUp':
222
+ moveFocus(flat[i - 1]?.node.id);
223
+ break;
224
+ case 'Home':
225
+ moveFocus(flat[0]?.node.id);
226
+ break;
227
+ case 'End':
228
+ moveFocus(flat[flat.length - 1]?.node.id);
229
+ break;
230
+ case 'ArrowRight':
231
+ if (expandable(n, loaded) && !expanded.has(n.id)) toggleExpand(n, true);
232
+ else if (expanded.has(n.id)) moveFocus(flat[i + 1]?.parentId === n.id ? flat[i + 1]!.node.id : undefined);
233
+ break;
234
+ case 'ArrowLeft':
235
+ if (expanded.has(n.id)) toggleExpand(n, false);
236
+ else if (here.parentId) moveFocus(here.parentId);
237
+ break;
238
+ case 'Enter':
239
+ if (!n.disabled) onSelect?.(n.id);
240
+ break;
241
+ case ' ':
242
+ if (onCheckedChange && !n.disabled) toggleTick(n);
243
+ else if (!n.disabled) onSelect?.(n.id);
244
+ break;
245
+ default:
246
+ if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
247
+ const now = Date.now();
248
+ const ta = typeahead.current;
249
+ ta.text = now - ta.at > 600 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
250
+ ta.at = now;
251
+ const order = [...flat.slice(i + 1), ...flat.slice(0, i + 1)];
252
+ const hit = order.find((f) => f.node.label.toLowerCase().startsWith(ta.text)) ?? order.find((f) => f.node.label.toLowerCase().startsWith(e.key.toLowerCase()));
253
+ if (hit) moveFocus(hit.node.id);
254
+ }
255
+ return;
256
+ }
257
+ e.preventDefault();
258
+ };
259
+
260
+ const allowDrop = (id: string, n: TreeNode) => Boolean(onDropItems) && !n.disabled && (canDrop ? canDrop(id) : true);
261
+
262
+ const renderLevel = (list: TreeNode[], level: number): ReactNode =>
263
+ list.map((n, i) => {
264
+ const c = kids(n, loaded);
265
+ const canExpand = expandable(n, loaded);
266
+ const open = canExpand && expanded.has(n.id);
267
+ const isLoading = loading.has(n.id);
268
+ const Glyph = n.icon ?? (open ? FolderOpen : Folder);
269
+ const selected = selectedId === n.id;
270
+ const tick = onCheckedChange ? tickState(n) : undefined;
271
+ return (
272
+ <li
273
+ key={n.id}
274
+ ref={(el) => {
275
+ if (el) items.current.set(n.id, el);
276
+ else items.current.delete(n.id);
277
+ }}
278
+ role="treeitem"
279
+ aria-labelledby={labelId(n.id)}
280
+ aria-level={level}
281
+ aria-posinset={i + 1}
282
+ aria-setsize={list.length}
283
+ aria-expanded={canExpand ? open : undefined}
284
+ aria-selected={onCheckedChange ? undefined : selected}
285
+ aria-checked={tick}
286
+ aria-disabled={n.disabled || undefined}
287
+ aria-busy={isLoading || undefined}
288
+ tabIndex={current === n.id ? 0 : -1}
289
+ onFocus={(e) => {
290
+ if (e.target === e.currentTarget) setFocusId(n.id);
291
+ }}
292
+ className="outline-none [&:focus-visible>div]:shadow-(--focus-ring)"
293
+ >
294
+ <div
295
+ data-drop={dropId === n.id || undefined}
296
+ onDragOver={(e: DragEvent) => {
297
+ if (!allowDrop(n.id, n)) return;
298
+ e.preventDefault();
299
+ setDropId(n.id);
300
+ }}
301
+ onDragLeave={() => setDropId((d) => (d === n.id ? null : d))}
302
+ onDrop={(e: DragEvent) => {
303
+ if (!allowDrop(n.id, n)) return;
304
+ e.preventDefault();
305
+ setDropId(null);
306
+ onDropItems?.(n.id, e.dataTransfer);
307
+ }}
308
+ className={cn(
309
+ 'flex h-[34px] items-center gap-1.5 rounded-md pr-2 text-ui',
310
+ selected ? 'bg-brand-field font-medium text-ink' : 'text-fg hover:bg-hover',
311
+ dropId === n.id && 'ring-2 ring-fg ring-inset',
312
+ n.disabled && 'opacity-40',
313
+ )}
314
+ style={{ paddingLeft: 4 + (level - 1) * 18 }}
315
+ >
316
+ {canExpand ? (
317
+ <span
318
+ aria-hidden
319
+ onClick={() => toggleExpand(n)}
320
+ className="grid size-5 shrink-0 cursor-pointer place-items-center rounded-xs text-fg hover:bg-hover"
321
+ >
322
+ <ChevronRight size={14} strokeWidth={2} className={cn('transition-transform duration-[120ms]', open && 'rotate-90')} />
323
+ </span>
324
+ ) : (
325
+ <span aria-hidden className="size-5 shrink-0" />
326
+ )}
327
+ {tick !== undefined && (
328
+ <span
329
+ aria-hidden
330
+ onClick={() => !n.disabled && toggleTick(n)}
331
+ className={cn(
332
+ 'grid size-4 shrink-0 cursor-pointer place-items-center rounded-xs border',
333
+ tick === false ? 'border-rule bg-page' : 'border-fg bg-fg text-page',
334
+ )}
335
+ >
336
+ {tick === true && <Check size={11} strokeWidth={3} />}
337
+ {tick === 'mixed' && <Minus size={11} strokeWidth={3} />}
338
+ </span>
339
+ )}
340
+ <span
341
+ onClick={() => {
342
+ if (n.disabled) return;
343
+ setFocusId(n.id);
344
+ onSelect?.(n.id);
345
+ }}
346
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-2"
347
+ >
348
+ <Glyph size={16} strokeWidth={1.75} aria-hidden className="shrink-0" />
349
+ <span id={labelId(n.id)} className="truncate">
350
+ {n.label}
351
+ </span>
352
+ </span>
353
+ {n.meta !== undefined && (
354
+ <span className={cn('shrink-0 text-meta tabular-nums', selected ? 'text-fg-on-yellow-secondary' : 'text-fg-secondary')}>{n.meta}</span>
355
+ )}
356
+ </div>
357
+ {open && (
358
+ <ul role="group" className="m-0 list-none p-0">
359
+ {isLoading && !c ? (
360
+ <li role="none" className="flex h-[34px] items-center text-fg-secondary" style={{ paddingLeft: 4 + level * 18 + 26 }}>
361
+ <Spinner label={loadingLabel} />
362
+ </li>
363
+ ) : (
364
+ renderLevel(c ?? [], level + 1)
365
+ )}
366
+ </ul>
367
+ )}
368
+ </li>
369
+ );
370
+ });
371
+
372
+ return (
373
+ <ul role="tree" aria-label={label} aria-multiselectable={onCheckedChange ? true : undefined} onKeyDown={onKeyDown} className={cn('m-0 flex list-none flex-col gap-0.5 p-0', className)}>
374
+ {renderLevel(nodes, 1)}
375
+ </ul>
376
+ );
377
+ }
@@ -0,0 +1,32 @@
1
+ import type { LucideIcon, LucideProps } from 'lucide-react';
2
+
3
+ import { cn } from '../lib/cn';
4
+
5
+ export type IconProps = Omit<LucideProps, 'ref'> & {
6
+ /** A Lucide icon component: `import { Share2 } from 'lucide-react'`. */
7
+ icon: LucideIcon;
8
+ /** Accessible name. Without it the icon is decorative (aria-hidden). */
9
+ label?: string | undefined;
10
+ /** px. 14–16 inside controls, 16–20 on its own. @default 16 */
11
+ size?: number | undefined;
12
+ };
13
+
14
+ /**
15
+ * Icon — a Lucide line icon in currentColor. Lucide is the system's icon
16
+ * set (3.0 flagged it as a substitute; confirmed for 1.0). Status is never
17
+ * an icon — it is a Badge.
18
+ */
19
+ export function Icon({ icon: Glyph, label, size = 16, className, strokeWidth = 1.75, ...rest }: IconProps) {
20
+ return (
21
+ <Glyph
22
+ size={size}
23
+ strokeWidth={strokeWidth}
24
+ aria-hidden={label ? undefined : true}
25
+ aria-label={label}
26
+ role={label ? 'img' : undefined}
27
+ focusable="false"
28
+ className={cn('shrink-0', className)}
29
+ {...rest}
30
+ />
31
+ );
32
+ }
@@ -0,0 +1,55 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { LucideIcon } from 'lucide-react';
3
+ import type { ComponentProps } from 'react';
4
+
5
+ import { cn } from '../lib/cn';
6
+ import { formatCount } from './Button';
7
+ import { Icon } from './Icon';
8
+
9
+ const iconButton = cva(
10
+ [
11
+ 'relative inline-grid shrink-0 place-items-center rounded-md border p-0',
12
+ 'transition-[background-color,border-color] duration-[120ms] ease-standard',
13
+ 'cursor-pointer outline-none focus-visible:shadow-(--focus-ring)',
14
+ 'disabled:cursor-not-allowed disabled:opacity-40',
15
+ ],
16
+ {
17
+ variants: {
18
+ variant: {
19
+ outline: 'border-rule bg-transparent text-fg hover:border-fg',
20
+ ghost: 'border-transparent bg-transparent text-fg hover:bg-hover',
21
+ solid: 'border-ink bg-ink text-linen hover:bg-ink-raised ink:border-yellow-accent ink:bg-yellow-accent ink:text-ink ink:hover:bg-yellow-accent-hover',
22
+ },
23
+ size: { sm: 'size-8', md: 'size-[38px]', lg: 'size-11' },
24
+ },
25
+ defaultVariants: { variant: 'ghost', size: 'md' },
26
+ },
27
+ );
28
+
29
+ export type IconButtonProps = Omit<ComponentProps<'button'>, 'children' | 'aria-label'> & {
30
+ icon: LucideIcon;
31
+ /** Required: the accessible name (and the hover title). */
32
+ label: string;
33
+ /** outline (--rule edge) · ghost (hover wash) · solid (ink). @default "ghost" */
34
+ variant?: 'outline' | 'ghost' | 'solid' | undefined;
35
+ /** 32 · 38 · 44. @default "md" */
36
+ size?: 'sm' | 'md' | 'lg' | undefined;
37
+ /** A count badge on the corner; hidden at zero, 99+ above 99. */
38
+ count?: number | undefined;
39
+ };
40
+
41
+ /** IconButton — a square icon-only button. It always carries a label. */
42
+ export function IconButton({ icon, label, variant, size, count, className, ...rest }: IconButtonProps) {
43
+ const shown = count === undefined ? null : formatCount(count);
44
+ const glyph = size === 'sm' ? 16 : size === 'lg' ? 20 : 18;
45
+ return (
46
+ <button type="button" aria-label={shown ? `${label} (${shown})` : label} title={label} className={cn(iconButton({ variant, size }), className)} {...rest}>
47
+ <Icon icon={icon} size={glyph} />
48
+ {shown && (
49
+ <span aria-hidden className="absolute -top-1.5 -right-1.5 min-w-[18px] rounded-pill bg-brand-mark px-1 text-[11px] leading-[18px] font-semibold text-ink tabular-nums">
50
+ {shown}
51
+ </span>
52
+ )}
53
+ </button>
54
+ );
55
+ }
@@ -0,0 +1,74 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { Field, useField } from './Field';
5
+ import { controlFrame, controlInner } from './forms-control';
6
+
7
+ export type InputProps = Omit<ComponentProps<'input'>, 'size' | 'prefix'> & {
8
+ /** Label above the field; with it (or hint/error) the input renders its own Field. */
9
+ label?: ReactNode;
10
+ hint?: ReactNode;
11
+ /** The error in words; sets invalid. */
12
+ error?: ReactNode;
13
+ /** Error state without a message of its own (the message lives elsewhere). */
14
+ invalid?: boolean | undefined;
15
+ /** Inside, before the text — a currency, an icon. */
16
+ prefix?: ReactNode;
17
+ /** Inside, after the text — a domain, a unit. */
18
+ suffix?: ReactNode;
19
+ /** 32 · 38 · 44 (mobile). @default "md" */
20
+ size?: 'sm' | 'md' | 'lg' | undefined;
21
+ /** default (sunk) · borderless (inline editing: no chrome until hover/focus). */
22
+ variant?: 'default' | 'borderless' | undefined;
23
+ /** Class for the outer frame (className goes to the <input>). */
24
+ frameClassName?: string | undefined;
25
+ };
26
+
27
+ function Control({
28
+ prefix,
29
+ suffix,
30
+ size,
31
+ variant,
32
+ invalid,
33
+ frameClassName,
34
+ className,
35
+ disabled,
36
+ readOnly,
37
+ id,
38
+ ...rest
39
+ }: Omit<InputProps, 'label' | 'hint' | 'error'>) {
40
+ const field = useField();
41
+ const bad = Boolean(invalid || field?.invalid);
42
+ const off = Boolean(disabled || field?.disabled);
43
+ return (
44
+ <div className={cn(controlFrame({ size, variant, invalid: bad, disabled: off, readOnly: Boolean(readOnly) && !off }), frameClassName)}>
45
+ {prefix && <span className="flex shrink-0 text-fg-secondary">{prefix}</span>}
46
+ <input
47
+ {...rest}
48
+ id={id ?? field?.id}
49
+ disabled={off}
50
+ readOnly={readOnly}
51
+ aria-invalid={bad || undefined}
52
+ aria-describedby={cn(rest['aria-describedby'], field?.describedBy) || undefined}
53
+ className={cn(controlInner, className)}
54
+ />
55
+ {suffix && <span className="flex shrink-0 text-fg-secondary">{suffix}</span>}
56
+ </div>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Input — a single-line text field. Sinks to linen and takes an ink edge
62
+ * on focus. Always labelled: pass label here, or wrap in a Field. Errors
63
+ * say how to fix it; validate on blur.
64
+ */
65
+ export function Input({ label, hint, error, required, ...rest }: InputProps) {
66
+ if (label !== undefined || hint !== undefined || error !== undefined) {
67
+ return (
68
+ <Field label={label} hint={hint} error={error} required={required} disabled={rest.disabled} id={rest.id}>
69
+ <Control {...rest} required={required} />
70
+ </Field>
71
+ );
72
+ }
73
+ return <Control {...rest} required={required} />;
74
+ }