@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.
- package/CHANGELOG.md +28 -0
- package/package.json +6 -1
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +48 -40
- package/src/components/ChoiceCards.tsx +68 -0
- package/src/components/CodeInput.tsx +150 -0
- package/src/components/CommandPalette.tsx +228 -0
- package/src/components/ConfirmDialog.tsx +294 -0
- package/src/components/CopyField.tsx +104 -0
- package/src/components/DateInput.tsx +366 -0
- package/src/components/Dialog.tsx +27 -3
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/FileCard.tsx +233 -0
- package/src/components/FileRow.tsx +159 -0
- package/src/components/FolderTree.tsx +377 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/PageHeader.tsx +62 -0
- package/src/components/PageState.tsx +84 -0
- package/src/components/Progress.tsx +97 -0
- package/src/components/PulseDot.tsx +31 -0
- package/src/components/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/SyncStatus.tsx +56 -0
- package/src/components/UploadCheck.tsx +118 -0
- package/src/components/UploadList.tsx +176 -0
- package/src/components/VirtualList.tsx +90 -0
- package/src/components/WindowDrop.tsx +131 -0
- package/src/components/inputs-aria.ts +5 -0
- package/src/components/inputs-date.ts +92 -0
- package/src/components/inputs-listbox.tsx +154 -0
- package/src/components/inputs-search.ts +60 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +38 -0
- package/src/components/core.test.tsx +0 -242
- package/src/components/forms.test.tsx +0 -280
- 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
|
+
}
|