@cosxai/ui 0.25.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.
Files changed (165) hide show
  1. package/CHANGELOG.md +597 -0
  2. package/NOTICE +11 -0
  3. package/README.md +52 -0
  4. package/package.json +57 -7
  5. package/src/brand.css +13 -0
  6. package/src/components/Badge.tsx +68 -0
  7. package/src/components/Button.tsx +178 -0
  8. package/src/components/Card.tsx +42 -0
  9. package/src/components/Checkbox.tsx +67 -0
  10. package/src/components/Dialog.tsx +111 -0
  11. package/src/components/Field.tsx +66 -0
  12. package/src/components/Figure.tsx +30 -0
  13. package/src/components/Icon.tsx +32 -0
  14. package/src/components/IconButton.tsx +55 -0
  15. package/src/components/Input.tsx +74 -0
  16. package/src/components/Logo.tsx +62 -0
  17. package/src/components/Marker.tsx +26 -0
  18. package/src/components/MetaLabel.tsx +38 -0
  19. package/src/components/Radio.tsx +72 -0
  20. package/src/components/Select.tsx +351 -0
  21. package/src/components/Spinner.tsx +20 -0
  22. package/src/components/Switch.tsx +67 -0
  23. package/src/components/Table.tsx +196 -0
  24. package/src/components/Tabs.tsx +124 -0
  25. package/src/components/Tag.tsx +35 -0
  26. package/src/components/Textarea.tsx +46 -0
  27. package/src/components/ThemeProvider.tsx +80 -0
  28. package/src/components/Toast.tsx +159 -0
  29. package/src/components/Tooltip.tsx +57 -0
  30. package/src/components/core.test.tsx +242 -0
  31. package/src/components/forms-control.ts +36 -0
  32. package/src/components/forms.test.tsx +280 -0
  33. package/src/components/logo-art.ts +4 -0
  34. package/src/components/overlays.test.tsx +245 -0
  35. package/src/components/useButtonAction.ts +57 -0
  36. package/src/fonts.css +20 -0
  37. package/src/index.ts +34 -24
  38. package/src/lib/cn.ts +23 -6
  39. package/src/modes.css +10 -0
  40. package/src/styles.css +19 -0
  41. package/src/theme.css +143 -0
  42. package/src/tokens/colors.css +112 -0
  43. package/src/tokens/compat.css +36 -0
  44. package/src/tokens/elevation.css +14 -0
  45. package/src/tokens/motion.css +42 -0
  46. package/src/tokens/spacing.css +55 -0
  47. package/src/tokens/typography-cjk.css +42 -0
  48. package/src/tokens/typography.css +81 -0
  49. package/src/actionbar/ActionBar.tsx +0 -837
  50. package/src/actionbar/ActionBarButton.tsx +0 -111
  51. package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
  52. package/src/actionbar/ActionBarModeHandle.tsx +0 -171
  53. package/src/actionbar/ActionBarProvider.tsx +0 -91
  54. package/src/actionbar/actionbar-context.ts +0 -45
  55. package/src/actionbar/index.ts +0 -28
  56. package/src/actionbar/types.ts +0 -147
  57. package/src/actionbar/useActionBarItems.ts +0 -60
  58. package/src/actionbar/useActionBarMode.ts +0 -144
  59. package/src/actionbar/useActionBarPanel.ts +0 -56
  60. package/src/actionbar/useActionBarStatusDot.ts +0 -49
  61. package/src/actionbar/useActionBarToast.ts +0 -33
  62. package/src/ambient/AmbientBackdrop.tsx +0 -74
  63. package/src/ambient/CommandInput.tsx +0 -107
  64. package/src/ambient/SuperbarStrip.tsx +0 -36
  65. package/src/ambient/index.ts +0 -6
  66. package/src/bento/BentoCell.tsx +0 -66
  67. package/src/bento/BentoGrid.tsx +0 -42
  68. package/src/bento/index.ts +0 -2
  69. package/src/command/CommandPalette.tsx +0 -329
  70. package/src/command/CommandProvider.tsx +0 -57
  71. package/src/command/command-context.ts +0 -12
  72. package/src/command/index.ts +0 -6
  73. package/src/command/rank.ts +0 -50
  74. package/src/command/types.ts +0 -37
  75. package/src/command/useCommandSource.ts +0 -37
  76. package/src/dialogs/DialogsProvider.tsx +0 -216
  77. package/src/dialogs/Modal.tsx +0 -260
  78. package/src/dialogs/Toast.tsx +0 -85
  79. package/src/dialogs/dialogs-context.ts +0 -6
  80. package/src/dialogs/index.ts +0 -10
  81. package/src/dialogs/types.ts +0 -37
  82. package/src/dialogs/useDialogs.ts +0 -8
  83. package/src/editorial/EditorialSpotlight.tsx +0 -63
  84. package/src/editorial/Folio.tsx +0 -52
  85. package/src/editorial/PlateMarker.tsx +0 -33
  86. package/src/editorial/RomanSection.tsx +0 -65
  87. package/src/editorial/RunningMarginalia.tsx +0 -65
  88. package/src/editorial/index.ts +0 -10
  89. package/src/hooks/index.ts +0 -5
  90. package/src/hooks/useKeyboardHotkey.ts +0 -93
  91. package/src/hooks/useReducedMotion.ts +0 -20
  92. package/src/hooks/useViewport.ts +0 -61
  93. package/src/layout/Breadcrumb.tsx +0 -74
  94. package/src/layout/LeftNavRail.tsx +0 -139
  95. package/src/layout/MobileTabBar.tsx +0 -101
  96. package/src/layout/NavItem.tsx +0 -128
  97. package/src/layout/NavSearchTrigger.tsx +0 -88
  98. package/src/layout/NavSection.tsx +0 -40
  99. package/src/layout/RightSidebarPanel.tsx +0 -111
  100. package/src/layout/Shell.tsx +0 -91
  101. package/src/layout/SidePanel.tsx +0 -316
  102. package/src/layout/StickyBanner.tsx +0 -83
  103. package/src/layout/Topbar.tsx +0 -68
  104. package/src/layout/index.ts +0 -24
  105. package/src/layout/useNavRailState.ts +0 -69
  106. package/src/lib/time-utils.ts +0 -44
  107. package/src/neobrutalism/Marquee.tsx +0 -81
  108. package/src/neobrutalism/Sticker.tsx +0 -71
  109. package/src/neobrutalism/index.ts +0 -4
  110. package/src/primitives/Avatar.tsx +0 -53
  111. package/src/primitives/Button.tsx +0 -67
  112. package/src/primitives/Card.tsx +0 -41
  113. package/src/primitives/Checkbox.tsx +0 -87
  114. package/src/primitives/Chip.tsx +0 -191
  115. package/src/primitives/Combobox.tsx +0 -507
  116. package/src/primitives/CopyField.tsx +0 -139
  117. package/src/primitives/CountBadge.tsx +0 -50
  118. package/src/primitives/Input.tsx +0 -204
  119. package/src/primitives/Kbd.tsx +0 -45
  120. package/src/primitives/MentionCombobox.tsx +0 -612
  121. package/src/primitives/MultiCombobox.tsx +0 -327
  122. package/src/primitives/PageHeader.tsx +0 -77
  123. package/src/primitives/Radio.tsx +0 -131
  124. package/src/primitives/SegmentedControl.tsx +0 -135
  125. package/src/primitives/Select.tsx +0 -779
  126. package/src/primitives/SignaturePad.tsx +0 -461
  127. package/src/primitives/Tag.tsx +0 -56
  128. package/src/primitives/Textarea.tsx +0 -62
  129. package/src/primitives/ToggleSwitch.tsx +0 -79
  130. package/src/primitives/Tooltip.tsx +0 -213
  131. package/src/primitives/index.ts +0 -53
  132. package/src/pwa/InstallPromptBanner.tsx +0 -132
  133. package/src/pwa/index.ts +0 -4
  134. package/src/pwa/manifest.template.json +0 -20
  135. package/src/pwa/registerSW.ts +0 -55
  136. package/src/riso/Halftone.tsx +0 -85
  137. package/src/riso/Misregister.tsx +0 -63
  138. package/src/riso/RisoStamp.tsx +0 -76
  139. package/src/riso/index.ts +0 -3
  140. package/src/sketch/HandUnderline.tsx +0 -53
  141. package/src/sketch/RoughArrow.tsx +0 -91
  142. package/src/sketch/RoughBox.tsx +0 -73
  143. package/src/sketch/StickyNote.tsx +0 -56
  144. package/src/sketch/index.ts +0 -4
  145. package/src/styles/base.css +0 -186
  146. package/src/styles/chrome-ambient.css +0 -235
  147. package/src/styles/chrome-bento.css +0 -192
  148. package/src/styles/chrome-editorial.css +0 -168
  149. package/src/styles/chrome-neobrutalism.css +0 -326
  150. package/src/styles/chrome-riso.css +0 -339
  151. package/src/styles/chrome-sketch.css +0 -362
  152. package/src/styles/chrome-swiss.css +0 -265
  153. package/src/styles/chrome-terminal.css +0 -272
  154. package/src/styles/fonts.css +0 -1034
  155. package/src/styles/index.css +0 -343
  156. package/src/styles/tokens.css +0 -941
  157. package/src/terminal/AsciiBox.tsx +0 -65
  158. package/src/terminal/BrailleSpinner.tsx +0 -46
  159. package/src/terminal/index.ts +0 -4
  160. package/src/theme/ThemeProvider.tsx +0 -99
  161. package/src/theme/index.ts +0 -6
  162. package/src/theme/inline-script.ts +0 -40
  163. package/src/theme/theme-context.ts +0 -7
  164. package/src/theme/types.ts +0 -39
  165. package/src/theme/useTheme.ts +0 -8
@@ -0,0 +1,351 @@
1
+ import * as Popover from '@radix-ui/react-popover';
2
+ import { Check, ChevronDown, Search, X } from 'lucide-react';
3
+ import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
4
+
5
+ import { cn } from '../lib/cn';
6
+ import { useField } from './Field';
7
+ import { controlFrame } from './forms-control';
8
+
9
+ export type SelectOption = {
10
+ value: string;
11
+ label: string;
12
+ /** Right-aligned secondary text: a count, a date — or, on a disabled row, the reason. */
13
+ meta?: string | undefined;
14
+ /** Rows sharing a group label are headed by it, in source order. */
15
+ group?: string | undefined;
16
+ disabled?: boolean | undefined;
17
+ };
18
+
19
+ export type SelectProps = {
20
+ options: Array<string | SelectOption>;
21
+ value?: string | undefined;
22
+ onChange?: ((value: string) => void) | undefined;
23
+ /** @default "Choose one" */
24
+ placeholder?: string | undefined;
25
+ /** A filter field at the top of the menu. Defaults to on above 8 options. */
26
+ searchable?: boolean | undefined;
27
+ /** @default "Nothing matches that." */
28
+ emptyText?: string | undefined;
29
+ /** Placeholder of the filter field. @default "Filter" */
30
+ filterPlaceholder?: string | undefined;
31
+ invalid?: boolean | undefined;
32
+ disabled?: boolean | undefined;
33
+ /** 32 · 38 · 44. @default "md" */
34
+ size?: 'sm' | 'md' | 'lg' | undefined;
35
+ /** auto (default): opens upward when below is short and above has more room; top / bottom force it. */
36
+ placement?: 'auto' | 'top' | 'bottom' | undefined;
37
+ /** Start open (uncontrolled). */
38
+ defaultOpen?: boolean | undefined;
39
+ id?: string | undefined;
40
+ name?: string | undefined;
41
+ className?: string | undefined;
42
+ 'aria-label'?: string | undefined;
43
+ 'aria-labelledby'?: string | undefined;
44
+ 'aria-describedby'?: string | undefined;
45
+ };
46
+
47
+ const TYPEAHEAD_MS = 600;
48
+
49
+ function normalise(options: SelectProps['options']): SelectOption[] {
50
+ return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
51
+ }
52
+
53
+ /**
54
+ * Select — a styled listbox for a fixed set of 3 to 15 (two options: a
55
+ * switch or segment; many or remote: the async search select). The menu
56
+ * renders in a portal so dialogs don't clip it and opens upward when it
57
+ * must. Arrows move, Enter chooses, Esc closes, Home/End jump, a letter
58
+ * jumps to it. The chosen row sits on the brand colour; disabled rows stay
59
+ * visible with their reason. No shadow — paper lifted by its hairline.
60
+ */
61
+ export function Select({
62
+ options,
63
+ value,
64
+ onChange,
65
+ placeholder = 'Choose one',
66
+ searchable,
67
+ emptyText = 'Nothing matches that.',
68
+ filterPlaceholder = 'Filter',
69
+ invalid,
70
+ disabled,
71
+ size,
72
+ placement = 'auto',
73
+ defaultOpen = false,
74
+ id,
75
+ name,
76
+ className,
77
+ ...aria
78
+ }: SelectProps) {
79
+ const field = useField();
80
+ const auto = useId();
81
+ const listId = `sl${auto}-list`;
82
+ const triggerId = id ?? field?.id ?? `sl${auto}-trigger`;
83
+ const optionId = (i: number) => `sl${auto}-o${i}`;
84
+ const all = useMemo(() => normalise(options), [options]);
85
+ const canSearch = searchable ?? all.length > 8;
86
+ const selected = all.find((o) => o.value === value);
87
+ const bad = Boolean(invalid || field?.invalid);
88
+ const off = Boolean(disabled || field?.disabled);
89
+
90
+ const [open, setOpen] = useState(defaultOpen);
91
+ const [query, setQuery] = useState('');
92
+ const [active, setActive] = useState(() => (defaultOpen ? Math.max(0, all.findIndex((o) => o.value === value)) : -1));
93
+ const listRef = useRef<HTMLDivElement | null>(null);
94
+ const filterRef = useRef<HTMLInputElement | null>(null);
95
+ const typed = useRef({ text: '', at: 0 });
96
+
97
+ const shown = useMemo(() => {
98
+ const q = query.trim().toLowerCase();
99
+ return q ? all.filter((o) => `${o.label} ${o.meta ?? ''}`.toLowerCase().includes(q)) : all;
100
+ }, [all, query]);
101
+
102
+ const enabled = (i: number) => i >= 0 && i < shown.length && !shown[i]!.disabled;
103
+ const step = (from: number, dir: 1 | -1) => {
104
+ for (let i = from + dir; i >= 0 && i < shown.length; i += dir) if (enabled(i)) return i;
105
+ return from;
106
+ };
107
+ const first = () => step(-1, 1);
108
+ const last = () => step(shown.length, -1);
109
+
110
+ // Keep the active row in view.
111
+ useEffect(() => {
112
+ if (!open || active < 0) return;
113
+ const el = listRef.current?.querySelector<HTMLElement>(`#${CSS.escape(optionId(active))}`);
114
+ el?.scrollIntoView?.({ block: 'nearest' });
115
+ // eslint-disable-next-line react-hooks/exhaustive-deps
116
+ }, [active, open]);
117
+
118
+ const openWith = (index?: number) => {
119
+ setQuery('');
120
+ const cur = all.findIndex((o) => o.value === value);
121
+ setActive(index ?? (cur >= 0 && !all[cur]!.disabled ? cur : step(-1, 1)));
122
+ setOpen(true);
123
+ };
124
+ const choose = (i: number) => {
125
+ const o = shown[i];
126
+ if (!o || o.disabled) return;
127
+ onChange?.(o.value);
128
+ setOpen(false);
129
+ };
130
+
131
+ /** First-letter jump: repeated keys cycle, fast typing matches a prefix. */
132
+ const typeahead = (key: string, from: number) => {
133
+ const now = Date.now();
134
+ const t = typed.current;
135
+ t.text = now - t.at < TYPEAHEAD_MS ? t.text + key.toLowerCase() : key.toLowerCase();
136
+ t.at = now;
137
+ const cycling = t.text.length > 1 && t.text.split('').every((c) => c === t.text[0]);
138
+ const prefix = cycling ? t.text[0]! : t.text;
139
+ const start = cycling || t.text.length === 1 ? from + 1 : from;
140
+ for (let n = 0; n < shown.length; n++) {
141
+ const i = (start + n) % shown.length;
142
+ if (enabled(i) && shown[i]!.label.toLowerCase().startsWith(prefix)) return i;
143
+ }
144
+ return -1;
145
+ };
146
+
147
+ const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
148
+ if (off || open) return;
149
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
150
+ e.preventDefault();
151
+ openWith();
152
+ } else if (e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {
153
+ const cur = all.findIndex((o) => o.value === value);
154
+ const hit = typeahead(e.key, cur);
155
+ if (hit >= 0) {
156
+ e.preventDefault();
157
+ openWith(hit);
158
+ }
159
+ }
160
+ };
161
+
162
+ const onMenuKey = (e: KeyboardEvent<HTMLElement>) => {
163
+ const inFilter = e.currentTarget === filterRef.current;
164
+ switch (e.key) {
165
+ case 'ArrowDown':
166
+ e.preventDefault();
167
+ setActive((a) => (a < 0 ? first() : step(a, 1)));
168
+ return;
169
+ case 'ArrowUp':
170
+ e.preventDefault();
171
+ setActive((a) => (a < 0 ? last() : step(a, -1)));
172
+ return;
173
+ case 'Home':
174
+ if (inFilter) return;
175
+ e.preventDefault();
176
+ setActive(first());
177
+ return;
178
+ case 'End':
179
+ if (inFilter) return;
180
+ e.preventDefault();
181
+ setActive(last());
182
+ return;
183
+ case 'Enter':
184
+ e.preventDefault();
185
+ choose(active);
186
+ return;
187
+ case 'Tab':
188
+ setOpen(false);
189
+ return;
190
+ default:
191
+ if (!inFilter && e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {
192
+ const hit = typeahead(e.key, active);
193
+ if (hit >= 0) {
194
+ e.preventDefault();
195
+ setActive(hit);
196
+ }
197
+ }
198
+ }
199
+ };
200
+
201
+ // Rows in source order, runs of a group wrapped in a labelled group.
202
+ const rows: ReactNode[] = [];
203
+ let i = 0;
204
+ while (i < shown.length) {
205
+ const group = shown[i]!.group;
206
+ const start = i;
207
+ while (i < shown.length && shown[i]!.group === group) i++;
208
+ const items = shown.slice(start, i).map((o, k) => {
209
+ const idx = start + k;
210
+ const on = o.value === value;
211
+ return (
212
+ <div
213
+ key={o.value}
214
+ id={optionId(idx)}
215
+ role="option"
216
+ aria-label={o.meta ? `${o.label}, ${o.meta}` : undefined}
217
+ aria-selected={on}
218
+ aria-disabled={o.disabled || undefined}
219
+ data-active={idx === active || undefined}
220
+ onMouseMove={() => enabled(idx) && idx !== active && setActive(idx)}
221
+ onMouseDown={(e) => e.preventDefault()}
222
+ onClick={() => choose(idx)}
223
+ className={cn(
224
+ 'flex items-center gap-2 rounded-md px-2.5 py-2 text-[13px] leading-[1.35] text-fg transition-colors duration-[120ms] ease-standard',
225
+ o.disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
226
+ on ? 'bg-brand-field font-medium text-ink' : idx === active && 'bg-hover',
227
+ )}
228
+ >
229
+ <span className="min-w-0 flex-1 truncate">{o.label}</span>
230
+ {o.meta && <span className={cn('shrink-0 text-meta tabular-nums', on ? 'text-fg-on-yellow-secondary' : 'text-fg-secondary')}>{o.meta}</span>}
231
+ {on && <Check size={13} strokeWidth={2} aria-hidden className="shrink-0" />}
232
+ </div>
233
+ );
234
+ });
235
+ if (group) {
236
+ const gid = `sl${auto}-g${start}`;
237
+ rows.push(
238
+ <div key={gid} role="group" aria-labelledby={gid}>
239
+ <div id={gid} className="px-2.5 pt-2 pb-1 text-meta font-medium text-fg-secondary">
240
+ {group}
241
+ </div>
242
+ {items}
243
+ </div>,
244
+ );
245
+ } else rows.push(...items);
246
+ }
247
+
248
+ return (
249
+ <Popover.Root open={open} onOpenChange={(o) => (o ? openWith() : setOpen(false))}>
250
+ <Popover.Trigger asChild disabled={off}>
251
+ <button
252
+ type="button"
253
+ id={triggerId}
254
+ role="combobox"
255
+ aria-expanded={open}
256
+ aria-haspopup="listbox"
257
+ aria-controls={open ? listId : undefined}
258
+ aria-invalid={bad || undefined}
259
+ aria-label={aria['aria-label']}
260
+ aria-labelledby={aria['aria-labelledby']}
261
+ aria-describedby={cn(aria['aria-describedby'], field?.describedBy) || undefined}
262
+ disabled={off}
263
+ onKeyDown={onTriggerKey}
264
+ className={cn(
265
+ controlFrame({ size, invalid: bad, disabled: off }),
266
+ 'cursor-pointer text-left outline-none focus-visible:border-fg focus-visible:shadow-(--focus-ring) disabled:cursor-not-allowed',
267
+ open && !bad && 'border-fg shadow-(--focus-ring)',
268
+ className,
269
+ )}
270
+ >
271
+ <span className={cn('min-w-0 flex-1 truncate', !selected && 'text-fg-secondary')}>{selected ? selected.label : placeholder}</span>
272
+ {selected?.meta && <span className="shrink-0 text-meta text-fg-secondary">{selected.meta}</span>}
273
+ <ChevronDown
274
+ size={14}
275
+ aria-hidden
276
+ className={cn('shrink-0 text-fg-secondary transition-transform duration-[320ms] ease-out', open && 'rotate-180')}
277
+ />
278
+ </button>
279
+ </Popover.Trigger>
280
+ {name && <input type="hidden" name={name} value={value ?? ''} />}
281
+ <Popover.Portal>
282
+ <Popover.Content
283
+ side={placement === 'top' ? 'top' : 'bottom'}
284
+ align="start"
285
+ sideOffset={6}
286
+ {...(aria['aria-labelledby'] ?? field?.labelId
287
+ ? { 'aria-labelledby': aria['aria-labelledby'] ?? field?.labelId }
288
+ : { 'aria-label': aria['aria-label'] ?? placeholder })}
289
+ collisionPadding={12}
290
+ avoidCollisions={placement === 'auto'}
291
+ onOpenAutoFocus={(e) => {
292
+ e.preventDefault();
293
+ (canSearch ? filterRef.current : listRef.current)?.focus();
294
+ }}
295
+ className="rise z-60 flex flex-col overflow-hidden rounded-lg border border-rule bg-page p-1 font-sans text-fg [--motion-reveal:200ms]"
296
+ style={{
297
+ minWidth: 'var(--radix-popover-trigger-width)',
298
+ maxHeight: 'min(280px, calc(var(--radix-popover-content-available-height) - 12px))',
299
+ }}
300
+ >
301
+ {canSearch && (
302
+ <div className="mb-1 flex shrink-0 items-center gap-[7px] border-b border-rule-soft px-2 pt-[5px] pb-2">
303
+ <Search size={13} aria-hidden className="shrink-0 text-fg-secondary" />
304
+ <input
305
+ ref={filterRef}
306
+ value={query}
307
+ onChange={(e) => {
308
+ setQuery(e.target.value);
309
+ setActive(-1);
310
+ }}
311
+ onKeyDown={onMenuKey}
312
+ placeholder={filterPlaceholder}
313
+ aria-label={filterPlaceholder}
314
+ aria-controls={listId}
315
+ aria-activedescendant={active >= 0 ? optionId(active) : undefined}
316
+ aria-autocomplete="list"
317
+ role="combobox"
318
+ aria-expanded
319
+ className="min-w-0 flex-1 border-none bg-transparent p-0 font-sans text-[13px] text-fg outline-none placeholder:text-fg-secondary"
320
+ />
321
+ {query && (
322
+ <button
323
+ type="button"
324
+ aria-label="Clear"
325
+ onClick={() => {
326
+ setQuery('');
327
+ filterRef.current?.focus();
328
+ }}
329
+ className="inline-grid cursor-pointer place-items-center rounded-xs p-0.5 text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
330
+ >
331
+ <X size={12} aria-hidden />
332
+ </button>
333
+ )}
334
+ </div>
335
+ )}
336
+ <div
337
+ ref={listRef}
338
+ id={listId}
339
+ role="listbox"
340
+ tabIndex={canSearch ? undefined : -1}
341
+ aria-activedescendant={!canSearch && active >= 0 ? optionId(active) : undefined}
342
+ onKeyDown={canSearch ? undefined : onMenuKey}
343
+ className="min-h-0 flex-1 overflow-y-auto outline-none"
344
+ >
345
+ {shown.length === 0 ? <div className="px-2.5 pt-2.5 pb-3 text-[12.5px] text-fg-secondary">{emptyText}</div> : rows}
346
+ </div>
347
+ </Popover.Content>
348
+ </Popover.Portal>
349
+ </Popover.Root>
350
+ );
351
+ }
@@ -0,0 +1,20 @@
1
+ import { cn } from '../lib/cn';
2
+
3
+ export type SpinnerProps = { size?: number | undefined; className?: string | undefined; label?: string | undefined };
4
+
5
+ /** Spinner — a thin ring in currentColor. Still (a static arc) when the
6
+ * user asks for reduced motion. Decorative unless labelled. */
7
+ export function Spinner({ size = 14, className, label }: SpinnerProps) {
8
+ return (
9
+ <span
10
+ role={label ? 'status' : undefined}
11
+ aria-label={label}
12
+ aria-hidden={label ? undefined : true}
13
+ className={cn(
14
+ 'inline-block shrink-0 rounded-pill border-[1.5px] border-current border-r-transparent motion-safe:animate-spin',
15
+ className,
16
+ )}
17
+ style={{ width: size, height: size }}
18
+ />
19
+ );
20
+ }
@@ -0,0 +1,67 @@
1
+ import * as SwitchPrimitive from '@radix-ui/react-switch';
2
+ import { useId, type ComponentProps, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+
6
+ export type SwitchProps = Omit<ComponentProps<typeof SwitchPrimitive.Root>, 'checked' | 'onCheckedChange' | 'onChange' | 'children'> & {
7
+ checked?: boolean | undefined;
8
+ /** Called when flipped — the setting applies at once. */
9
+ onChange?: ((checked: boolean) => void) | undefined;
10
+ /** Name the setting ("Require two-step verification"), never the action. */
11
+ label?: ReactNode;
12
+ description?: ReactNode;
13
+ /** Class for the whole row (className goes to the track). */
14
+ rowClassName?: string | undefined;
15
+ };
16
+
17
+ /**
18
+ * Switch — one setting that applies at once (a form choice is a
19
+ * Checkbox). Pill track: chrome when off, ink when on (linen in ink
20
+ * mode); the knob has no shadow. role="switch" with aria-checked.
21
+ */
22
+ export function Switch({ checked, onChange, label, description, disabled, id, className, rowClassName, ...rest }: SwitchProps) {
23
+ const auto = useId();
24
+ const switchId = id ?? `sw${auto}`;
25
+ const descId = description ? `${switchId}-desc` : undefined;
26
+ const track = (
27
+ <SwitchPrimitive.Root
28
+ id={switchId}
29
+ {...(checked !== undefined && { checked })}
30
+ onCheckedChange={(v) => onChange?.(v)}
31
+ disabled={disabled}
32
+ aria-describedby={descId}
33
+ className={cn(
34
+ 'relative inline-flex h-5 w-[34px] shrink-0 cursor-pointer items-center rounded-pill border border-rule bg-chrome p-0',
35
+ 'transition-[background-color,border-color] duration-[120ms] ease-standard outline-none focus-visible:shadow-(--focus-ring)',
36
+ 'data-[state=checked]:border-fg data-[state=checked]:bg-fg disabled:cursor-not-allowed',
37
+ className,
38
+ )}
39
+ {...rest}
40
+ >
41
+ <SwitchPrimitive.Thumb
42
+ className={cn(
43
+ 'block size-3.5 translate-x-[2px] rounded-pill bg-paper transition-transform duration-[120ms] ease-standard',
44
+ 'data-[state=checked]:translate-x-[16px] data-[state=checked]:bg-page',
45
+ )}
46
+ />
47
+ </SwitchPrimitive.Root>
48
+ );
49
+ if (!label && !description) return track;
50
+ return (
51
+ <div className={cn('inline-flex items-center gap-2.5 font-sans', description && 'items-start', disabled && 'opacity-40', rowClassName)}>
52
+ {track}
53
+ <span className="flex flex-col gap-0.5">
54
+ {label && (
55
+ <label htmlFor={switchId} 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,196 @@
1
+ import { ArrowDown, ArrowUp } from 'lucide-react';
2
+ import type { ComponentProps, KeyboardEvent, ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+
6
+ export type TableSortDirection = 'asc' | 'desc';
7
+ export type TableSort = { key: string; direction: TableSortDirection };
8
+
9
+ export type TableColumn<R> = {
10
+ key: string;
11
+ label: ReactNode;
12
+ /** Numbers and dates right-aligned (tabular). @default "left" */
13
+ align?: 'left' | 'right' | undefined;
14
+ /** Quiet metadata cell: 12px grey, tabular. */
15
+ meta?: boolean | undefined;
16
+ width?: number | string | undefined;
17
+ /** Header click sorts by this column. */
18
+ sortable?: boolean | undefined;
19
+ render?: ((row: R) => ReactNode) | undefined;
20
+ };
21
+
22
+ export type TableRowBase = {
23
+ id: string | number;
24
+ /** attention (yellow wash) or error (red wash); progress and complete stay plain — the status column says them. */
25
+ status?: 'attention' | 'error' | 'progress' | 'complete' | 'neutral' | undefined;
26
+ [key: string]: unknown;
27
+ };
28
+
29
+ export type TableProps<R extends TableRowBase> = Omit<ComponentProps<'table'>, 'children'> & {
30
+ columns: TableColumn<R>[];
31
+ rows: R[];
32
+ /** Opening a row. The row is also reachable by keyboard (Enter). Prefer a real link in the first column (rowHref). */
33
+ onRowClick?: ((row: R) => void) | undefined;
34
+ /** Makes the first column a real link to the row. */
35
+ rowHref?: ((row: R) => string) | undefined;
36
+ sort?: TableSort | undefined;
37
+ onSortChange?: ((sort: TableSort) => void) | undefined;
38
+ dense?: boolean | undefined;
39
+ /** Keep the head and show five skeleton rows. */
40
+ loading?: boolean | undefined;
41
+ /** One line and one action for an empty table. */
42
+ empty?: ReactNode;
43
+ /** Accessible name / visible caption. */
44
+ caption?: ReactNode;
45
+ };
46
+
47
+ const ARIA_SORT = { asc: 'ascending', desc: 'descending' } as const;
48
+
49
+ /**
50
+ * Table — a register: linen head, hairline rows, a wash on rows that need a
51
+ * person (attention yellow, overdue red). For lists people compare and
52
+ * sort; for browsing use list rows or cards. Six columns at most. On phones
53
+ * each row becomes a card: the first column as the title, the rest one
54
+ * line of metadata.
55
+ */
56
+ export function Table<R extends TableRowBase>({
57
+ columns,
58
+ rows,
59
+ onRowClick,
60
+ rowHref,
61
+ sort,
62
+ onSortChange,
63
+ dense = false,
64
+ loading = false,
65
+ empty,
66
+ caption,
67
+ className,
68
+ ...rest
69
+ }: TableProps<R>) {
70
+ const pad = dense ? 'px-3 py-[7px]' : 'px-3.5 py-2.5';
71
+ const cell = (r: R, c: TableColumn<R>) => (c.render ? c.render(r) : (r[c.key] as ReactNode));
72
+
73
+ return (
74
+ <table className={cn('w-full border-collapse font-sans text-[13px] text-fg', 'max-sm:block', className)} {...rest}>
75
+ {caption && <caption className="pb-2 text-left text-meta font-medium text-fg-secondary max-sm:block">{caption}</caption>}
76
+ <thead className="max-sm:sr-only">
77
+ <tr>
78
+ {columns.map((c) => {
79
+ const active = sort?.key === c.key;
80
+ const next: TableSort = { key: c.key, direction: active && sort?.direction === 'asc' ? 'desc' : 'asc' };
81
+ return (
82
+ <th
83
+ key={c.key}
84
+ scope="col"
85
+ aria-sort={c.sortable ? (active ? ARIA_SORT[sort!.direction] : 'none') : undefined}
86
+ style={c.width !== undefined ? { width: c.width } : undefined}
87
+ className={cn(pad, 'border-b border-rule bg-sunk text-meta font-medium whitespace-nowrap text-fg-secondary', c.align === 'right' ? 'text-right' : 'text-left')}
88
+ >
89
+ {c.sortable && onSortChange ? (
90
+ <button
91
+ type="button"
92
+ onClick={() => onSortChange(next)}
93
+ className={cn(
94
+ 'inline-flex cursor-pointer items-center gap-1 rounded-xs border-0 bg-transparent p-0 font-[inherit] text-[length:inherit] font-medium outline-none hover:text-fg focus-visible:shadow-(--focus-ring)',
95
+ active ? 'text-fg' : 'text-fg-secondary',
96
+ c.align === 'right' && 'flex-row-reverse',
97
+ )}
98
+ >
99
+ {c.label}
100
+ {active && (sort!.direction === 'asc' ? <ArrowUp size={12} aria-hidden /> : <ArrowDown size={12} aria-hidden />)}
101
+ </button>
102
+ ) : (
103
+ c.label
104
+ )}
105
+ </th>
106
+ );
107
+ })}
108
+ </tr>
109
+ </thead>
110
+ <tbody className="max-sm:flex max-sm:w-full max-sm:flex-col max-sm:gap-2">
111
+ {loading
112
+ ? Array.from({ length: 5 }, (_, i) => (
113
+ <tr key={i} aria-hidden className="max-sm:flex max-sm:w-full max-sm:gap-3">
114
+ {columns.map((c) => (
115
+ <td key={c.key} className={cn(pad, 'border-b border-rule-soft')}>
116
+ <span className="block h-3 w-3/4 rounded-xs bg-well motion-safe:animate-pulse" />
117
+ </td>
118
+ ))}
119
+ </tr>
120
+ ))
121
+ : rows.length === 0 && empty
122
+ ? (
123
+ <tr>
124
+ <td colSpan={columns.length} className="px-3.5 py-6 text-center text-small text-fg-secondary">
125
+ {empty}
126
+ </td>
127
+ </tr>
128
+ )
129
+ : rows.map((r) => {
130
+ const wash =
131
+ r.status === 'attention'
132
+ ? 'bg-attention-wash ink:bg-yellow-accent/15'
133
+ : r.status === 'error'
134
+ ? 'bg-error-wash ink:bg-error/20'
135
+ : 'hover:bg-hover';
136
+ const clickable = Boolean(onRowClick);
137
+ return (
138
+ <tr
139
+ key={r.id}
140
+ data-status={r.status}
141
+ onClick={clickable ? () => onRowClick!(r) : undefined}
142
+ onKeyDown={
143
+ clickable && !rowHref
144
+ ? (e: KeyboardEvent<HTMLTableRowElement>) => {
145
+ if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
146
+ e.preventDefault();
147
+ onRowClick!(r);
148
+ }
149
+ }
150
+ : undefined
151
+ }
152
+ tabIndex={clickable && !rowHref ? 0 : undefined}
153
+ className={cn(
154
+ 'transition-colors duration-[120ms] ease-standard outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--text-primary)]',
155
+ wash,
156
+ clickable && 'cursor-pointer',
157
+ 'max-sm:box-border max-sm:flex max-sm:w-full max-sm:flex-wrap max-sm:gap-x-2 max-sm:rounded-lg max-sm:border max-sm:border-rule max-sm:px-4 max-sm:py-3',
158
+ )}
159
+ >
160
+ {columns.map((c, i) => {
161
+ const content = cell(r, c);
162
+ return (
163
+ <td
164
+ key={c.key}
165
+ className={cn(
166
+ pad,
167
+ 'border-b border-rule-soft align-middle',
168
+ c.align === 'right' && 'text-right tabular-nums',
169
+ c.meta && 'text-meta whitespace-nowrap text-fg-secondary tabular-nums',
170
+ // Phones: title line, then one line of metadata.
171
+ 'max-sm:border-0 max-sm:p-0',
172
+ i === 0 ? 'max-sm:w-full max-sm:text-ui max-sm:font-medium' : 'max-sm:text-meta max-sm:text-fg-secondary',
173
+ i > 1 && "max-sm:before:content-['·_']",
174
+ )}
175
+ >
176
+ {i === 0 && rowHref ? (
177
+ <a
178
+ href={rowHref(r)}
179
+ onClick={(e) => e.stopPropagation()}
180
+ className="rounded-xs text-fg no-underline outline-none hover:underline focus-visible:shadow-(--focus-ring)"
181
+ >
182
+ {content}
183
+ </a>
184
+ ) : (
185
+ content
186
+ )}
187
+ </td>
188
+ );
189
+ })}
190
+ </tr>
191
+ );
192
+ })}
193
+ </tbody>
194
+ </table>
195
+ );
196
+ }