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

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 +28 -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 +48 -40
  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,252 @@
1
+ import * as Popover from '@radix-ui/react-popover';
2
+ import { X } from 'lucide-react';
3
+ import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
4
+
5
+ import { cn } from '../lib/cn';
6
+ import { useField } from './Field';
7
+ import { DEFAULT_LABELS, ListboxBody, type ListboxLabels, type ListItem } from './inputs-listbox';
8
+ import { useDebouncedSearch } from './inputs-search';
9
+
10
+ export type RecipientStatus = 'valid' | 'invalid' | 'unverified';
11
+ export type Recipient = { email: string; name?: string | undefined; status: RecipientStatus };
12
+
13
+ export type RecipientsInputProps = {
14
+ value?: Recipient[] | undefined;
15
+ defaultValue?: Recipient[] | undefined;
16
+ onChange?: ((recipients: Recipient[]) => void) | undefined;
17
+ /** People to suggest while typing (value = email). */
18
+ search?: ((query: string, signal: AbortSignal) => Promise<ListItem[]>) | undefined;
19
+ /** Status for a typed address that is well formed: valid, or unverified (dashed) when the caller can't confirm it. @default () => 'valid' */
20
+ verify?: ((email: string) => 'valid' | 'unverified') | undefined;
21
+ /** @default "Add people or paste emails" */
22
+ placeholder?: string | undefined;
23
+ labels?: Partial<ListboxLabels> & { remove?: ((r: Recipient) => string) | undefined; invalid?: string | undefined; unverified?: string | undefined };
24
+ disabled?: boolean | undefined;
25
+ /** A leading word inside the field, e.g. "To". */
26
+ prefix?: ReactNode | undefined;
27
+ id?: string | undefined;
28
+ 'aria-label'?: string | undefined;
29
+ className?: string | undefined;
30
+ };
31
+
32
+ const EMAIL = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
33
+ const SPLIT = /[\s,;]+/;
34
+
35
+ /** "Anna K <anna@x.co>" → the address; anything else as typed. */
36
+ function addressOf(token: string): string {
37
+ const m = token.match(/<([^>]+)>/);
38
+ return (m ? m[1]! : token).trim();
39
+ }
40
+
41
+ /**
42
+ * RecipientsInput — the "To" field. Addresses become chips as they are
43
+ * confirmed (Enter, comma, space or leaving the field); pasting a list
44
+ * splits it. A malformed address gets the red outline and opens for
45
+ * editing in place when clicked; one the caller can't confirm is dashed.
46
+ * Backspace in the empty field removes the last chip.
47
+ */
48
+ export function RecipientsInput({
49
+ value,
50
+ defaultValue = [],
51
+ onChange,
52
+ search,
53
+ verify = () => 'valid',
54
+ placeholder = 'Add people or paste emails',
55
+ labels: labelsIn,
56
+ disabled,
57
+ prefix,
58
+ id,
59
+ className,
60
+ ...aria
61
+ }: RecipientsInputProps) {
62
+ const field = useField();
63
+ const auto = useId();
64
+ const listId = `ri${auto}-list`;
65
+ const optionId = (i: number) => `ri${auto}-o${i}`;
66
+ const labels = { ...DEFAULT_LABELS, ...labelsIn };
67
+ const [own, setOwn] = useState<Recipient[]>(defaultValue);
68
+ const list = value ?? own;
69
+ const [text, setText] = useState('');
70
+ const [editing, setEditing] = useState<number | null>(null);
71
+ const [active, setActive] = useState(0);
72
+ const input = useRef<HTMLInputElement | null>(null);
73
+ const off = Boolean(disabled || field?.disabled);
74
+ const { status, result, retry } = useDebouncedSearch(text, search, { delay: 250 });
75
+ const items = (status === 'done' ? (result ?? []) : []).filter((i) => !list.some((r) => r.email.toLowerCase() === i.value.toLowerCase()));
76
+ // Suggestions only while there is something to suggest: a new address
77
+ // that matches no one is normal here, not "nothing matches".
78
+ const showList = Boolean(search) && text.trim().length > 0 && editing === null && (status === 'searching' || status === 'error' || items.length > 0);
79
+
80
+ const commitList = (next: Recipient[]) => {
81
+ if (value === undefined) setOwn(next);
82
+ onChange?.(next);
83
+ };
84
+ const toRecipient = (token: string, name?: string): Recipient => {
85
+ const email = addressOf(token);
86
+ return { email, ...(name ? { name } : {}), status: EMAIL.test(email) ? verify(email) : 'invalid' };
87
+ };
88
+ /** Turn typed text into chips; returns true when something was added. */
89
+ const commitText = (raw: string, at: number | null = null) => {
90
+ const tokens = raw.split(SPLIT).map((t) => t.trim()).filter(Boolean);
91
+ if (!tokens.length) return false;
92
+ const fresh = tokens.map((t) => toRecipient(t)).filter((r) => !list.some((x, i) => i !== at && x.email.toLowerCase() === r.email.toLowerCase()));
93
+ const next = [...list];
94
+ if (at === null) next.push(...fresh);
95
+ else next.splice(at, 1, ...fresh);
96
+ commitList(next);
97
+ setText('');
98
+ setEditing(null);
99
+ return true;
100
+ };
101
+ const choose = (i: number) => {
102
+ const it = items[i];
103
+ if (!it) return;
104
+ commitList([...list, { email: it.value, name: it.label, status: 'valid' }]);
105
+ setText('');
106
+ };
107
+
108
+ const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
109
+ if (showList && items.length && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
110
+ e.preventDefault();
111
+ setActive((a) => (e.key === 'ArrowDown' ? Math.min(items.length - 1, a + 1) : Math.max(0, a - 1)));
112
+ return;
113
+ }
114
+ if (e.key === 'Enter' || e.key === ',' || e.key === ';' || (e.key === ' ' && text.trim()) || (e.key === 'Tab' && text.trim())) {
115
+ if (e.key === 'Enter' && showList && status === 'done' && items.length) {
116
+ e.preventDefault();
117
+ choose(active);
118
+ return;
119
+ }
120
+ if (commitText(text, editing)) e.preventDefault();
121
+ else if (e.key !== 'Tab' && e.key !== ' ') e.preventDefault();
122
+ } else if (e.key === 'Backspace' && !text && editing === null && list.length) {
123
+ e.preventDefault();
124
+ commitList(list.slice(0, -1));
125
+ } else if (e.key === 'Escape' && editing !== null) {
126
+ setEditing(null);
127
+ setText('');
128
+ }
129
+ };
130
+
131
+ const editInput = (
132
+ <input
133
+ ref={input}
134
+ id={editing === null ? (id ?? field?.id) : undefined}
135
+ value={text}
136
+ disabled={off}
137
+ placeholder={list.length || editing !== null ? undefined : placeholder}
138
+ aria-label={aria['aria-label'] ?? (field?.labelId ? undefined : placeholder)}
139
+ aria-labelledby={field?.labelId}
140
+ aria-describedby={field?.describedBy}
141
+ role={search ? 'combobox' : undefined}
142
+ aria-expanded={search ? showList : undefined}
143
+ aria-controls={search && showList ? listId : undefined}
144
+ aria-autocomplete={search ? 'list' : undefined}
145
+ aria-activedescendant={showList && status === 'done' && items.length ? optionId(active) : undefined}
146
+ autoComplete="off"
147
+ onChange={(e) => {
148
+ setText(e.target.value);
149
+ setActive(0);
150
+ }}
151
+ onPaste={(e) => {
152
+ const pasted = e.clipboardData.getData('text');
153
+ if (SPLIT.test(pasted.trim())) {
154
+ e.preventDefault();
155
+ commitText(text + pasted, editing);
156
+ }
157
+ }}
158
+ onBlur={() => text.trim() && commitText(text, editing)}
159
+ onKeyDown={onKey}
160
+ className={cn('min-w-[8ch] flex-1 border-none bg-transparent p-0 py-1 font-sans text-[13px] text-fg outline-none placeholder:text-fg-secondary', editing !== null && 'min-w-[16ch]')}
161
+ />
162
+ );
163
+
164
+ return (
165
+ <Popover.Root open={showList} onOpenChange={() => {}}>
166
+ <Popover.Anchor asChild>
167
+ <div
168
+ onClick={(e) => e.target === e.currentTarget && input.current?.focus()}
169
+ className={cn(
170
+ 'flex min-h-[38px] w-full flex-wrap items-center gap-1.5 rounded-md border border-rule bg-sunk px-2 py-1 font-sans text-fg',
171
+ 'transition-[border-color] duration-[120ms] ease-standard focus-within:border-fg focus-within:shadow-(--focus-ring)',
172
+ off && 'cursor-not-allowed bg-well text-fg-secondary',
173
+ className,
174
+ )}
175
+ >
176
+ {prefix !== undefined && <span className="pr-0.5 pl-1 text-[12.5px] text-fg-secondary">{prefix}</span>}
177
+ {list.map((r, i) =>
178
+ editing === i ? (
179
+ <span key={`edit-${i}`} className="inline-flex rounded-sm border border-error bg-page px-1.5">
180
+ {editInput}
181
+ </span>
182
+ ) : (
183
+ <span
184
+ key={r.email + i}
185
+ className={cn(
186
+ 'inline-flex max-w-full items-center gap-1 rounded-sm border bg-page py-0.5 pr-1 pl-2 text-[12.5px] leading-[1.4]',
187
+ r.status === 'invalid' ? 'border-error bg-error-wash' : r.status === 'unverified' ? 'border-dashed border-fg-secondary' : 'border-rule',
188
+ )}
189
+ >
190
+ {r.status === 'invalid' ? (
191
+ <button
192
+ type="button"
193
+ disabled={off}
194
+ onClick={() => {
195
+ setEditing(i);
196
+ setText(r.email);
197
+ requestAnimationFrame(() => input.current?.focus());
198
+ }}
199
+ className="cursor-text truncate rounded-xs outline-none focus-visible:shadow-(--focus-ring)"
200
+ >
201
+ {r.email}
202
+ <span className="sr-only">, {labelsIn?.invalid ?? 'not a valid address — edit'}</span>
203
+ </button>
204
+ ) : (
205
+ <span className="truncate" title={r.name ? r.email : undefined}>
206
+ {r.name ?? r.email}
207
+ {r.status === 'unverified' && <span className="sr-only">, {labelsIn?.unverified ?? 'unverified'}</span>}
208
+ </span>
209
+ )}
210
+ {!off && (
211
+ <button
212
+ type="button"
213
+ aria-label={labelsIn?.remove?.(r) ?? `Remove ${r.name ?? r.email}`}
214
+ onClick={() => commitList(list.filter((_, k) => k !== i))}
215
+ className="inline-grid cursor-pointer place-items-center rounded-xs text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
216
+ >
217
+ <X size={12} aria-hidden />
218
+ </button>
219
+ )}
220
+ </span>
221
+ ),
222
+ )}
223
+ {editing === null && editInput}
224
+ </div>
225
+ </Popover.Anchor>
226
+ <Popover.Portal>
227
+ <Popover.Content
228
+ align="start"
229
+ sideOffset={6}
230
+ collisionPadding={12}
231
+ onOpenAutoFocus={(e) => e.preventDefault()}
232
+ className="rise z-60 rounded-lg border border-rule bg-page p-1 font-sans [--motion-reveal:200ms]"
233
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
234
+ >
235
+ <ListboxBody
236
+ id={listId}
237
+ optionId={optionId}
238
+ items={items}
239
+ active={active}
240
+ status={status}
241
+ query={text.trim()}
242
+ canCreate={false}
243
+ labels={labels}
244
+ onActive={setActive}
245
+ onChoose={choose}
246
+ onRetry={retry}
247
+ />
248
+ </Popover.Content>
249
+ </Popover.Portal>
250
+ </Popover.Root>
251
+ );
252
+ }
@@ -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,130 @@
1
+ import { X } from 'lucide-react';
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useState,
10
+ type ComponentProps,
11
+ type ReactNode,
12
+ } from 'react';
13
+ import { createPortal } from 'react-dom';
14
+
15
+ import { cn } from '../lib/cn';
16
+ import { IconButton } from './IconButton';
17
+
18
+ type SlotContext = {
19
+ /** The panel in the slot, if any. */
20
+ openId: string | null;
21
+ open: (id: string) => void;
22
+ close: (id?: string) => void;
23
+ slot: HTMLElement | null;
24
+ setSlot: (el: HTMLElement | null) => void;
25
+ };
26
+
27
+ const SidePanelContext = createContext<SlotContext | null>(null);
28
+
29
+ /**
30
+ * SidePanelProvider — the page's one right-hand slot. Opening a panel
31
+ * closes whatever held the slot (Share and the Agent drawer never show
32
+ * together). Place <SidePanelSlot/> beside the main content.
33
+ */
34
+ export function SidePanelProvider({ children }: { children: ReactNode }) {
35
+ const [openId, setOpenId] = useState<string | null>(null);
36
+ const [slot, setSlot] = useState<HTMLElement | null>(null);
37
+ const open = useCallback((id: string) => setOpenId(id), []);
38
+ const close = useCallback((id?: string) => setOpenId((cur) => (id === undefined || cur === id ? null : cur)), []);
39
+ const value = useMemo(() => ({ openId, open, close, slot, setSlot }), [openId, open, close, slot]);
40
+ return <SidePanelContext.Provider value={value}>{children}</SidePanelContext.Provider>;
41
+ }
42
+
43
+ /** The slot's state and controls. */
44
+ export function useSidePanel() {
45
+ const ctx = useContext(SidePanelContext);
46
+ if (!ctx) throw new Error('useSidePanel needs a <SidePanelProvider>');
47
+ const { openId, open, close } = ctx;
48
+ return { openId, open, close };
49
+ }
50
+
51
+ /** Where the open panel renders: a flex sibling of the main content, so it pushes the content (no overlay). */
52
+ export function SidePanelSlot({ className, ...rest }: ComponentProps<'div'>) {
53
+ const ctx = useContext(SidePanelContext);
54
+ const setSlot = ctx?.setSlot;
55
+ return <div ref={setSlot} className={cn('flex h-full shrink-0', className)} {...rest} />;
56
+ }
57
+
58
+ const WIDTH = { sm: 'sm:w-[360px]', md: 'sm:w-[480px]', lg: 'sm:w-[640px]' } as const;
59
+
60
+ export type SidePanelProps = Omit<ComponentProps<'aside'>, 'title'> & {
61
+ /** The panel's id in the slot. */
62
+ id: string;
63
+ title: ReactNode;
64
+ /** A line above the title (the item it is about). */
65
+ eyebrow?: ReactNode;
66
+ /** 360 · 480 · 640. @default "sm" */
67
+ width?: keyof typeof WIDTH | undefined;
68
+ /** Pinned at the bottom (New share). */
69
+ footer?: ReactNode;
70
+ /** @default "Close" */
71
+ closeLabel?: string | undefined;
72
+ onClose?: (() => void) | undefined;
73
+ };
74
+
75
+ /**
76
+ * SidePanel — detail beside the page (sharing, properties, the Agent).
77
+ * No scrim and no focus trap: the page stays usable; it pushes the content
78
+ * aside. Esc closes it (unless a dialog is open above). On phones it pushes
79
+ * in full screen. Renders only while it holds the slot.
80
+ */
81
+ export function SidePanel({ id, title, eyebrow, width = 'sm', footer, closeLabel = 'Close', onClose, className, children, ...rest }: SidePanelProps) {
82
+ const ctx = useContext(SidePanelContext);
83
+ if (!ctx) throw new Error('SidePanel needs a <SidePanelProvider>');
84
+ const { openId, close, slot } = ctx;
85
+ const isOpen = openId === id;
86
+ const titleId = useId();
87
+
88
+ const dismiss = useCallback(() => {
89
+ close(id);
90
+ onClose?.();
91
+ }, [close, id, onClose]);
92
+
93
+ useEffect(() => {
94
+ if (!isOpen) return;
95
+ const onKey = (e: KeyboardEvent) => {
96
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
97
+ if (document.querySelector('[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]')) return;
98
+ dismiss();
99
+ };
100
+ document.addEventListener('keydown', onKey);
101
+ return () => document.removeEventListener('keydown', onKey);
102
+ }, [isOpen, dismiss]);
103
+
104
+ if (!isOpen || !slot) return null;
105
+ return createPortal(
106
+ <aside
107
+ aria-labelledby={titleId}
108
+ className={cn(
109
+ 'flex h-full flex-col border-l border-rule bg-page font-sans text-fg',
110
+ 'max-sm:fixed max-sm:inset-0 max-sm:z-40 max-sm:w-full max-sm:border-l-0',
111
+ WIDTH[width],
112
+ className,
113
+ )}
114
+ {...rest}
115
+ >
116
+ <header className="flex items-start gap-3 border-b border-rule px-5 py-4">
117
+ <div className="min-w-0 flex-1">
118
+ {eyebrow && <div className="mb-1 text-meta font-medium text-fg-secondary">{eyebrow}</div>}
119
+ <h2 id={titleId} className="m-0 truncate text-[16px] leading-[1.3] font-medium">
120
+ {title}
121
+ </h2>
122
+ </div>
123
+ <IconButton icon={X} label={closeLabel} size="sm" onClick={dismiss} className="-mt-1 -mr-2" />
124
+ </header>
125
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 text-ui">{children}</div>
126
+ {footer && <footer className="border-t border-rule px-5 py-3.5">{footer}</footer>}
127
+ </aside>,
128
+ slot,
129
+ );
130
+ }
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef, useState, type ComponentProps } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { useLoop } from './status-motion';
5
+
6
+ export type SkeletonProps = ComponentProps<'div'>;
7
+
8
+ /** Skeleton — a sunk placeholder block that breathes slowly (still under reduced motion). */
9
+ export function Skeleton({ className, ...rest }: SkeletonProps) {
10
+ const ref = useRef<HTMLDivElement | null>(null);
11
+ useLoop(ref, [{ opacity: 1 }, { opacity: 0.55 }, { opacity: 1 }], { duration: 1600, easing: 'ease-in-out' });
12
+ return <div ref={ref} aria-hidden className={cn('rounded-sm bg-well', className)} {...rest} />;
13
+ }
14
+
15
+ type Busy = { label?: string | undefined; className?: string | undefined };
16
+
17
+ /** A list of rows loading. */
18
+ export function SkeletonList({ rows = 6, label = 'Loading', className }: Busy & { rows?: number | undefined }) {
19
+ return (
20
+ <div role="status" aria-busy="true" aria-label={label} className={cn('flex flex-col', className)}>
21
+ {Array.from({ length: rows }, (_, i) => (
22
+ <div key={i} className="flex items-center gap-3 border-b border-rule-soft py-3">
23
+ <Skeleton className="size-8 rounded-md" />
24
+ <div className="flex flex-1 flex-col gap-2">
25
+ <Skeleton className="h-3 w-2/5" />
26
+ <Skeleton className="h-2.5 w-1/4" />
27
+ </div>
28
+ <Skeleton className="h-3 w-16" />
29
+ </div>
30
+ ))}
31
+ </div>
32
+ );
33
+ }
34
+
35
+ /** A grid of cards loading. */
36
+ export function SkeletonCards({ count = 6, label = 'Loading', className }: Busy & { count?: number | undefined }) {
37
+ return (
38
+ <div role="status" aria-busy="true" aria-label={label} className={cn('grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4', className)}>
39
+ {Array.from({ length: count }, (_, i) => (
40
+ <div key={i} className="flex flex-col gap-3 rounded-lg border border-rule p-3">
41
+ <Skeleton className="aspect-[4/3] w-full rounded-md" />
42
+ <Skeleton className="h-3 w-3/4" />
43
+ <Skeleton className="h-2.5 w-1/2" />
44
+ </div>
45
+ ))}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export type SkeletonViewerProps = Busy & {
51
+ /** What the wait is called as it gets longer. @default Opening… then, after 3 s, Rendering page 1… */
52
+ messages?: { after: number; text: string }[] | undefined;
53
+ };
54
+
55
+ /** A document opening: a page placeholder whose wording changes on long waits. */
56
+ export function SkeletonViewer({ messages = [{ after: 0, text: 'Opening…' }, { after: 3000, text: 'Rendering page 1…' }], className }: SkeletonViewerProps) {
57
+ const sorted = [...messages].sort((a, b) => a.after - b.after);
58
+ const [index, setIndex] = useState(0);
59
+ useEffect(() => {
60
+ const timers = sorted.slice(1).map((m, i) => setTimeout(() => setIndex(i + 1), m.after));
61
+ return () => timers.forEach(clearTimeout);
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, [JSON.stringify(sorted)]);
64
+ const text = sorted[index]?.text ?? '';
65
+ return (
66
+ <div role="status" aria-busy="true" className={cn('flex flex-col items-center gap-4 rounded-lg bg-sunk p-8', className)}>
67
+ <Skeleton className="aspect-[1/1.3] w-full max-w-[360px] rounded-md bg-page" />
68
+ <span aria-live="polite" className="text-meta font-medium text-fg-secondary">
69
+ {text}
70
+ </span>
71
+ </div>
72
+ );
73
+ }