@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,137 @@
1
+ import { Search, X } from 'lucide-react';
2
+ import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { useField } from './Field';
6
+ import { controlFrame, controlInner } from './forms-control';
7
+ import { useDebouncedSearch } from './inputs-search';
8
+ import { Spinner } from './Spinner';
9
+
10
+ export type SearchFieldProps = Omit<ComponentProps<'input'>, 'size' | 'value' | 'defaultValue' | 'onChange' | 'type'> & {
11
+ value?: string | undefined;
12
+ defaultValue?: string | undefined;
13
+ /** Every keystroke (the field is controlled or not). */
14
+ onChange?: ((value: string) => void) | undefined;
15
+ /**
16
+ * Runs after the debounce with an AbortSignal that fires when newer input
17
+ * arrives. Resolve with the result count to show it (14 results); a late
18
+ * answer to an older query is ignored.
19
+ */
20
+ onSearch?: ((query: string, signal: AbortSignal) => Promise<number | void>) | undefined;
21
+ /** ms. @default 250 */
22
+ debounce?: number | undefined;
23
+ /** Focus with / or ⌘K / Ctrl+K. One field per page. */
24
+ shortcut?: boolean | undefined;
25
+ /** The count line. @default (n) => `${n} results` */
26
+ formatCount?: ((count: number) => ReactNode) | undefined;
27
+ /** Accessible name of the clear button. @default "Clear search" */
28
+ clearLabel?: string | undefined;
29
+ size?: 'sm' | 'md' | 'lg' | undefined;
30
+ };
31
+
32
+ function isTyping(el: EventTarget | null): boolean {
33
+ const e = el as HTMLElement | null;
34
+ return Boolean(e && (e.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.tagName)));
35
+ }
36
+
37
+ /**
38
+ * SearchField — search with the debounce built in, so pages stop writing
39
+ * their own. Four states: empty (with the / hint when it has a shortcut),
40
+ * searching (a spinner — never "No results" before the answer), a result
41
+ * count, and cleared. New input cancels the request in flight.
42
+ */
43
+ export function SearchField({
44
+ value,
45
+ defaultValue = '',
46
+ onChange,
47
+ onSearch,
48
+ debounce = 250,
49
+ shortcut = false,
50
+ formatCount = (n) => (n === 1 ? '1 result' : `${n} results`),
51
+ clearLabel = 'Clear search',
52
+ size,
53
+ placeholder = 'Search',
54
+ className,
55
+ id,
56
+ ref,
57
+ ...rest
58
+ }: SearchFieldProps) {
59
+ const field = useField();
60
+ const [own, setOwn] = useState(defaultValue);
61
+ const text = value ?? own;
62
+ const inner = useRef<HTMLInputElement | null>(null);
63
+ const { status, result } = useDebouncedSearch(text, onSearch, { delay: debounce });
64
+
65
+ const set = (v: string) => {
66
+ if (value === undefined) setOwn(v);
67
+ onChange?.(v);
68
+ };
69
+
70
+ useEffect(() => {
71
+ if (!shortcut) return;
72
+ const onKey = (e: KeyboardEvent) => {
73
+ const k = e.key.toLowerCase();
74
+ if ((k === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !isTyping(e.target) && !e.metaKey && !e.ctrlKey)) {
75
+ e.preventDefault();
76
+ inner.current?.focus();
77
+ inner.current?.select();
78
+ }
79
+ };
80
+ window.addEventListener('keydown', onKey);
81
+ return () => window.removeEventListener('keydown', onKey);
82
+ }, [shortcut]);
83
+
84
+ const off = Boolean(rest.disabled || field?.disabled);
85
+ const count = status === 'done' && typeof result === 'number' ? result : null;
86
+
87
+ return (
88
+ <div role="search" className={cn(controlFrame({ size, disabled: off, invalid: Boolean(field?.invalid) }), className)}>
89
+ <Search size={14} aria-hidden className="shrink-0 text-fg-secondary" />
90
+ <input
91
+ {...rest}
92
+ ref={(el) => {
93
+ inner.current = el;
94
+ if (typeof ref === 'function') ref(el);
95
+ else if (ref) ref.current = el;
96
+ }}
97
+ id={id ?? field?.id}
98
+ type="search"
99
+ value={text}
100
+ placeholder={placeholder}
101
+ disabled={off}
102
+ aria-describedby={cn(rest['aria-describedby'], field?.describedBy) || undefined}
103
+ onChange={(e) => set(e.target.value)}
104
+ onKeyDown={(e) => {
105
+ rest.onKeyDown?.(e);
106
+ if (e.key === 'Escape' && text) {
107
+ e.preventDefault();
108
+ set('');
109
+ }
110
+ }}
111
+ className={cn(controlInner, '[&::-webkit-search-cancel-button]:hidden')}
112
+ />
113
+ <span aria-live="polite" className="flex shrink-0 items-center gap-2 text-meta whitespace-nowrap text-fg-secondary tabular-nums">
114
+ {status === 'searching' ? <Spinner label="Searching" size={12} /> : count !== null ? formatCount(count) : null}
115
+ </span>
116
+ {text ? (
117
+ <button
118
+ type="button"
119
+ aria-label={clearLabel}
120
+ onClick={() => {
121
+ set('');
122
+ inner.current?.focus();
123
+ }}
124
+ 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)"
125
+ >
126
+ <X size={13} aria-hidden />
127
+ </button>
128
+ ) : (
129
+ shortcut && (
130
+ <kbd aria-hidden className="shrink-0 rounded-xs border border-rule px-1.5 font-sans text-[11px] leading-[18px] text-fg-secondary">
131
+ /
132
+ </kbd>
133
+ )
134
+ )}
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,95 @@
1
+ import * as RadioPrimitive from '@radix-ui/react-radio-group';
2
+ import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { definedAria } from './inputs-aria';
6
+ import { Tooltip } from './Tooltip';
7
+
8
+ export type Segment = {
9
+ value: string;
10
+ label: ReactNode;
11
+ /** Unavailable; say why (shown on hover and focus, and read out). */
12
+ disabled?: boolean | undefined;
13
+ reason?: string | undefined;
14
+ };
15
+
16
+ export type SegmentedControlProps = {
17
+ segments: Segment[];
18
+ value?: string | undefined;
19
+ defaultValue?: string | undefined;
20
+ onChange?: ((value: string) => void) | undefined;
21
+ /** Accessible name when there is no visible label. */
22
+ 'aria-label'?: string | undefined;
23
+ 'aria-labelledby'?: string | undefined;
24
+ /** 32 · 38. @default "md" */
25
+ size?: 'sm' | 'md' | undefined;
26
+ className?: string | undefined;
27
+ };
28
+
29
+ /**
30
+ * SegmentedControl — two to five mutually exclusive views or modes, side
31
+ * by side. Radio semantics: one tab stop, arrows move and choose. The
32
+ * selected segment sits on the brand colour and the marker glides to it
33
+ * (still, with reduced motion). A disabled segment explains why.
34
+ */
35
+ export function SegmentedControl({ segments, value, defaultValue, onChange, size = 'md', className, ...aria }: SegmentedControlProps) {
36
+ const [own, setOwn] = useState(defaultValue ?? segments.find((s) => !s.disabled)?.value);
37
+ const current = value ?? own;
38
+ const root = useRef<HTMLDivElement | null>(null);
39
+ const [marker, setMarker] = useState<{ left: number; width: number } | null>(null);
40
+
41
+ useLayoutEffect(() => {
42
+ const el = root.current?.querySelector<HTMLElement>('[data-state="checked"]');
43
+ setMarker(el ? { left: el.offsetLeft, width: el.offsetWidth } : null);
44
+ }, [current, segments]);
45
+
46
+ return (
47
+ <RadioPrimitive.Root
48
+ ref={root}
49
+ value={current ?? ''}
50
+ onValueChange={(v) => {
51
+ if (value === undefined) setOwn(v);
52
+ onChange?.(v);
53
+ }}
54
+ orientation="horizontal"
55
+ loop
56
+ {...definedAria(aria)}
57
+ className={cn('relative inline-flex rounded-md border border-rule bg-sunk p-[3px] font-sans', className)}
58
+ >
59
+ {marker && (
60
+ <span
61
+ aria-hidden
62
+ className="absolute top-[3px] bottom-[3px] rounded-sm bg-brand-field motion-safe:transition-[left,width] motion-safe:duration-[320ms] motion-safe:ease-out"
63
+ style={{ left: marker.left, width: marker.width }}
64
+ />
65
+ )}
66
+ {segments.map((s) => {
67
+ const item = (
68
+ <RadioPrimitive.Item
69
+ key={s.value}
70
+ value={s.value}
71
+ disabled={s.disabled}
72
+ className={cn(
73
+ 'relative z-[1] inline-flex cursor-pointer items-center gap-1.5 rounded-sm px-3 font-medium whitespace-nowrap text-fg-secondary outline-none',
74
+ 'transition-colors duration-[120ms] ease-standard hover:text-fg focus-visible:shadow-(--focus-ring)',
75
+ 'data-[state=checked]:text-ink disabled:cursor-not-allowed disabled:opacity-40',
76
+ size === 'sm' ? 'h-[24px] text-meta' : 'h-[30px] text-[13px]',
77
+ )}
78
+ >
79
+ {s.label}
80
+ {s.disabled && s.reason && <span className="sr-only">, {s.reason}</span>}
81
+ </RadioPrimitive.Item>
82
+ );
83
+ return s.disabled && s.reason ? (
84
+ <Tooltip key={s.value} content={s.reason}>
85
+ <span className="relative z-[1] inline-flex" tabIndex={-1}>
86
+ {item}
87
+ </span>
88
+ </Tooltip>
89
+ ) : (
90
+ item
91
+ );
92
+ })}
93
+ </RadioPrimitive.Root>
94
+ );
95
+ }
@@ -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
+ }