@cosxai/ui 1.0.0-alpha.1 → 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.
- package/CHANGELOG.md +22 -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 +17 -2
- 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,233 @@
|
|
|
1
|
+
import * as Popover from '@radix-ui/react-popover';
|
|
2
|
+
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { Button } from './Button';
|
|
6
|
+
import { useField } from './Field';
|
|
7
|
+
import { DEFAULT_LABELS, ListboxBody, type ListboxLabels, type ListItem } from './inputs-listbox';
|
|
8
|
+
import type { SearchStatus } from './inputs-search';
|
|
9
|
+
|
|
10
|
+
export type Mention = { value: string; label: string };
|
|
11
|
+
|
|
12
|
+
export type MentionInputProps = {
|
|
13
|
+
value?: string | undefined;
|
|
14
|
+
defaultValue?: string | undefined;
|
|
15
|
+
/** The text and the people mentioned in it (those whose @Name is still there). */
|
|
16
|
+
onChange?: ((text: string, mentions: Mention[]) => void) | undefined;
|
|
17
|
+
/** ⌘Enter / Ctrl+Enter, or the send button. */
|
|
18
|
+
onSubmit?: ((text: string, mentions: Mention[]) => void) | undefined;
|
|
19
|
+
/** People who can be mentioned; meta is their role (Member, Customer). */
|
|
20
|
+
people: ListItem[];
|
|
21
|
+
placeholder?: string | undefined;
|
|
22
|
+
/** @default "Send" — null hides the button (⌘Enter still sends). */
|
|
23
|
+
submitLabel?: ReactNode;
|
|
24
|
+
/** @default "⌘Enter to send" (Ctrl+Enter off the Mac) */
|
|
25
|
+
submitHint?: ReactNode;
|
|
26
|
+
labels?: Partial<ListboxLabels> | undefined;
|
|
27
|
+
disabled?: boolean | undefined;
|
|
28
|
+
/** Rows before it scrolls. @default 3 */
|
|
29
|
+
rows?: number | undefined;
|
|
30
|
+
/** Focus on mount, caret at the end (an @ being typed there opens the list). */
|
|
31
|
+
autoFocus?: boolean | undefined;
|
|
32
|
+
id?: string | undefined;
|
|
33
|
+
'aria-label'?: string | undefined;
|
|
34
|
+
className?: string | undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const isMac = () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
38
|
+
|
|
39
|
+
/** The @query being typed at the caret: "@" at a word start, no spaces since. */
|
|
40
|
+
export function mentionAt(text: string, caret: number): { start: number; query: string } | null {
|
|
41
|
+
const before = text.slice(0, caret);
|
|
42
|
+
const at = before.lastIndexOf('@');
|
|
43
|
+
if (at < 0) return null;
|
|
44
|
+
if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
|
|
45
|
+
const query = before.slice(at + 1);
|
|
46
|
+
if (/\s/.test(query) || query.length > 30) return null;
|
|
47
|
+
return { start: at, query };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* MentionInput — the comment box: @ opens the people who can be mentioned
|
|
52
|
+
* (arrows, Enter or Tab to insert, Esc to close); the mention goes in as
|
|
53
|
+
* @Name. ⌘Enter sends. Mentions are reported with the text, and only while
|
|
54
|
+
* their @Name is still in it.
|
|
55
|
+
*/
|
|
56
|
+
export function MentionInput({
|
|
57
|
+
value,
|
|
58
|
+
defaultValue = '',
|
|
59
|
+
onChange,
|
|
60
|
+
onSubmit,
|
|
61
|
+
people,
|
|
62
|
+
placeholder,
|
|
63
|
+
submitLabel = 'Send',
|
|
64
|
+
submitHint,
|
|
65
|
+
labels: labelsIn,
|
|
66
|
+
disabled,
|
|
67
|
+
rows = 3,
|
|
68
|
+
autoFocus = false,
|
|
69
|
+
id,
|
|
70
|
+
className,
|
|
71
|
+
...aria
|
|
72
|
+
}: MentionInputProps) {
|
|
73
|
+
const field = useField();
|
|
74
|
+
const auto = useId();
|
|
75
|
+
const listId = `mi${auto}-list`;
|
|
76
|
+
const optionId = (i: number) => `mi${auto}-o${i}`;
|
|
77
|
+
const labels = { ...DEFAULT_LABELS, ...labelsIn, empty: labelsIn?.empty ?? ((q: string) => `No one matches “${q}”.`) };
|
|
78
|
+
const [own, setOwn] = useState(defaultValue);
|
|
79
|
+
const text = value ?? own;
|
|
80
|
+
const [picked, setPicked] = useState<Mention[]>([]);
|
|
81
|
+
const [at, setAt] = useState<{ start: number; query: string } | null>(null);
|
|
82
|
+
const [active, setActive] = useState(0);
|
|
83
|
+
const area = useRef<HTMLTextAreaElement | null>(null);
|
|
84
|
+
// After a token goes in, put the caret after it once the new text is on screen.
|
|
85
|
+
const caretTo = useRef<number | null>(null);
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
const pos = caretTo.current;
|
|
88
|
+
if (pos === null || !area.current) return;
|
|
89
|
+
caretTo.current = null;
|
|
90
|
+
area.current.focus();
|
|
91
|
+
area.current.setSelectionRange(pos, pos);
|
|
92
|
+
}, [text]);
|
|
93
|
+
const off = Boolean(disabled || field?.disabled);
|
|
94
|
+
|
|
95
|
+
const matches = useMemo(() => {
|
|
96
|
+
if (!at) return [];
|
|
97
|
+
const q = at.query.toLowerCase();
|
|
98
|
+
return people.filter((p) => p.label.toLowerCase().includes(q) || (p.meta ?? '').toLowerCase().startsWith(q)).slice(0, 8);
|
|
99
|
+
}, [at, people]);
|
|
100
|
+
const status: SearchStatus = 'done';
|
|
101
|
+
const open = at !== null && !off;
|
|
102
|
+
|
|
103
|
+
const live = (t: string) => picked.filter((m, i, all) => t.includes(`@${m.label}`) && all.findIndex((x) => x.value === m.value) === i);
|
|
104
|
+
const set = (t: string, list = picked) => {
|
|
105
|
+
if (value === undefined) setOwn(t);
|
|
106
|
+
onChange?.(t, list.filter((m) => t.includes(`@${m.label}`)));
|
|
107
|
+
};
|
|
108
|
+
const track = (t: string, caret: number) => {
|
|
109
|
+
const next = mentionAt(t, caret);
|
|
110
|
+
setAt(next);
|
|
111
|
+
if (next && (!at || next.query !== at.query)) setActive(0);
|
|
112
|
+
};
|
|
113
|
+
// autoFocus: caret at the end, and an @ being typed there opens the list.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const el = area.current;
|
|
116
|
+
if (!autoFocus || !el) return;
|
|
117
|
+
el.focus();
|
|
118
|
+
el.setSelectionRange(el.value.length, el.value.length);
|
|
119
|
+
track(el.value, el.value.length);
|
|
120
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- on mount only
|
|
121
|
+
}, []);
|
|
122
|
+
const insert = (i: number) => {
|
|
123
|
+
const p = matches[i];
|
|
124
|
+
if (!p || !at) return;
|
|
125
|
+
const token = `@${p.label} `;
|
|
126
|
+
const caret = at.start + 1 + at.query.length;
|
|
127
|
+
const next = text.slice(0, at.start) + token + text.slice(caret);
|
|
128
|
+
const list = [...picked, { value: p.value, label: p.label }];
|
|
129
|
+
setPicked(list);
|
|
130
|
+
set(next, list);
|
|
131
|
+
setAt(null);
|
|
132
|
+
const pos = at.start + token.length;
|
|
133
|
+
caretTo.current = pos;
|
|
134
|
+
};
|
|
135
|
+
const submit = () => {
|
|
136
|
+
if (!text.trim()) return;
|
|
137
|
+
onSubmit?.(text, live(text));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
141
|
+
if (open && matches.length) {
|
|
142
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
setActive((a) => (e.key === 'ArrowDown' ? (a + 1) % matches.length : (a - 1 + matches.length) % matches.length));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if ((e.key === 'Enter' && !e.metaKey && !e.ctrlKey) || e.key === 'Tab') {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
insert(active);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (open && e.key === 'Escape') {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
setAt(null);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
submit();
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div
|
|
166
|
+
className={cn(
|
|
167
|
+
'flex w-full flex-col rounded-md border border-rule bg-sunk font-sans text-fg',
|
|
168
|
+
'transition-[border-color] duration-[120ms] ease-standard focus-within:border-fg focus-within:shadow-(--focus-ring)',
|
|
169
|
+
field?.invalid && 'border-error focus-within:border-error',
|
|
170
|
+
off && 'cursor-not-allowed bg-well text-fg-secondary',
|
|
171
|
+
className,
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
<Popover.Root open={open} onOpenChange={(o) => !o && setAt(null)}>
|
|
175
|
+
<Popover.Anchor asChild>
|
|
176
|
+
<textarea
|
|
177
|
+
ref={area}
|
|
178
|
+
id={id ?? field?.id}
|
|
179
|
+
rows={rows}
|
|
180
|
+
value={text}
|
|
181
|
+
placeholder={placeholder}
|
|
182
|
+
disabled={off}
|
|
183
|
+
aria-controls={open ? listId : undefined}
|
|
184
|
+
aria-autocomplete="list"
|
|
185
|
+
aria-activedescendant={open && matches.length ? optionId(active) : undefined}
|
|
186
|
+
aria-label={aria['aria-label']}
|
|
187
|
+
aria-describedby={field?.describedBy}
|
|
188
|
+
onChange={(e) => {
|
|
189
|
+
set(e.target.value);
|
|
190
|
+
track(e.target.value, e.target.selectionStart ?? e.target.value.length);
|
|
191
|
+
}}
|
|
192
|
+
onSelect={(e) => track(e.currentTarget.value, e.currentTarget.selectionStart ?? 0)}
|
|
193
|
+
onKeyDown={onKey}
|
|
194
|
+
onBlur={() => setAt(null)}
|
|
195
|
+
className="min-h-0 w-full resize-none border-none bg-transparent px-2.5 pt-2 pb-1 font-sans text-[13px] leading-normal text-inherit outline-none placeholder:text-fg-secondary"
|
|
196
|
+
/>
|
|
197
|
+
</Popover.Anchor>
|
|
198
|
+
<Popover.Portal>
|
|
199
|
+
<Popover.Content
|
|
200
|
+
align="start"
|
|
201
|
+
side="bottom"
|
|
202
|
+
sideOffset={4}
|
|
203
|
+
collisionPadding={12}
|
|
204
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
205
|
+
className="rise z-60 w-[260px] rounded-lg border border-rule bg-page p-1 font-sans [--motion-reveal:200ms]"
|
|
206
|
+
>
|
|
207
|
+
<ListboxBody
|
|
208
|
+
id={listId}
|
|
209
|
+
optionId={optionId}
|
|
210
|
+
items={matches}
|
|
211
|
+
active={active}
|
|
212
|
+
status={status}
|
|
213
|
+
query={at?.query ?? ''}
|
|
214
|
+
canCreate={false}
|
|
215
|
+
labels={labels}
|
|
216
|
+
onActive={setActive}
|
|
217
|
+
onChoose={insert}
|
|
218
|
+
onRetry={() => {}}
|
|
219
|
+
/>
|
|
220
|
+
</Popover.Content>
|
|
221
|
+
</Popover.Portal>
|
|
222
|
+
</Popover.Root>
|
|
223
|
+
<div className="flex items-center justify-end gap-3 px-2 pb-2">
|
|
224
|
+
<span className="text-meta text-fg-secondary">{submitHint ?? (isMac() ? '⌘Enter to send' : 'Ctrl+Enter to send')}</span>
|
|
225
|
+
{submitLabel !== null && (
|
|
226
|
+
<Button size="sm" disabled={off || !text.trim()} onClick={submit}>
|
|
227
|
+
{submitLabel}
|
|
228
|
+
</Button>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as MenuPrimitive from '@radix-ui/react-dropdown-menu';
|
|
2
|
+
import { Check, ChevronRight } from 'lucide-react';
|
|
3
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
|
|
7
|
+
/** Menu root: open / onOpenChange, or uncontrolled with MenuTrigger. */
|
|
8
|
+
export const Menu = MenuPrimitive.Root;
|
|
9
|
+
/** The element that opens it (asChild onto a Button or IconButton). */
|
|
10
|
+
export const MenuTrigger = MenuPrimitive.Trigger;
|
|
11
|
+
export const MenuSub = MenuPrimitive.Sub;
|
|
12
|
+
|
|
13
|
+
const surface = cn(
|
|
14
|
+
'z-[60] min-w-[200px] overflow-hidden rounded-lg border border-rule bg-page p-1.5 font-sans text-ui text-fg outline-none',
|
|
15
|
+
'max-h-(--radix-dropdown-menu-content-available-height)',
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export type MenuContentProps = ComponentProps<typeof MenuPrimitive.Content>;
|
|
19
|
+
|
|
20
|
+
/** MenuContent — renders in a portal (never clipped), flips to fit. No shadow: a hairline on the page colour. */
|
|
21
|
+
export function MenuContent({ className, sideOffset = 6, collisionPadding = 8, align = 'start', ...rest }: MenuContentProps) {
|
|
22
|
+
return (
|
|
23
|
+
<MenuPrimitive.Portal>
|
|
24
|
+
<MenuPrimitive.Content sideOffset={sideOffset} collisionPadding={collisionPadding} align={align} className={cn(surface, className)} {...rest} />
|
|
25
|
+
</MenuPrimitive.Portal>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const itemBase = cn(
|
|
30
|
+
'relative flex min-h-8 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 outline-none select-none',
|
|
31
|
+
'data-highlighted:bg-hover data-disabled:cursor-not-allowed data-disabled:opacity-40',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export type MenuItemProps = Omit<ComponentProps<typeof MenuPrimitive.Item>, 'children'> & {
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
icon?: ReactNode;
|
|
37
|
+
/** Shortcut hint, right-aligned (⌘,). */
|
|
38
|
+
shortcut?: string | undefined;
|
|
39
|
+
/** Red text: sign out, delete. Place it last, after a separator. */
|
|
40
|
+
destructive?: boolean | undefined;
|
|
41
|
+
/** Why it is unavailable: shown under the label (disables the item). */
|
|
42
|
+
disabledReason?: ReactNode;
|
|
43
|
+
/** A second line (a workspace's role). */
|
|
44
|
+
description?: ReactNode;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** MenuItem — one action. Keyboard: arrows, Home/End, typeahead, Enter (Radix). */
|
|
48
|
+
export function MenuItem({ children, icon, shortcut, destructive, disabledReason, description, disabled, className, ...rest }: MenuItemProps) {
|
|
49
|
+
return (
|
|
50
|
+
<MenuPrimitive.Item disabled={disabled || Boolean(disabledReason)} className={cn(itemBase, destructive && 'text-error-text', className)} {...rest}>
|
|
51
|
+
{icon && <span className="grid size-4 shrink-0 place-items-center text-fg-secondary">{icon}</span>}
|
|
52
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
53
|
+
<span className="truncate">{children}</span>
|
|
54
|
+
{(disabledReason || description) && <span className="text-meta text-fg-secondary">{disabledReason ?? description}</span>}
|
|
55
|
+
</span>
|
|
56
|
+
{shortcut && <kbd className="ml-4 font-sans text-meta font-medium text-fg-secondary">{shortcut}</kbd>}
|
|
57
|
+
</MenuPrimitive.Item>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type MenuCheckboxItemProps = Omit<ComponentProps<typeof MenuPrimitive.CheckboxItem>, 'children'> & { children: ReactNode; shortcut?: string | undefined };
|
|
62
|
+
|
|
63
|
+
/** A toggle in a menu; checked shows a tick. */
|
|
64
|
+
export function MenuCheckboxItem({ children, shortcut, className, ...rest }: MenuCheckboxItemProps) {
|
|
65
|
+
return (
|
|
66
|
+
<MenuPrimitive.CheckboxItem className={cn(itemBase, 'pl-8', className)} {...rest}>
|
|
67
|
+
<MenuPrimitive.ItemIndicator className="absolute left-2.5 grid size-4 place-items-center">
|
|
68
|
+
<Check size={14} strokeWidth={2} aria-hidden />
|
|
69
|
+
</MenuPrimitive.ItemIndicator>
|
|
70
|
+
<span className="flex-1 truncate">{children}</span>
|
|
71
|
+
{shortcut && <kbd className="ml-4 font-sans text-meta font-medium text-fg-secondary">{shortcut}</kbd>}
|
|
72
|
+
</MenuPrimitive.CheckboxItem>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A labelled group of items (Customer / Member when switching workspaces). */
|
|
77
|
+
export function MenuGroup({ label, children }: { label?: ReactNode; children: ReactNode }) {
|
|
78
|
+
return (
|
|
79
|
+
<MenuPrimitive.Group>
|
|
80
|
+
{label && <MenuPrimitive.Label className="px-2.5 pt-2 pb-1 text-meta font-medium text-fg-secondary">{label}</MenuPrimitive.Label>}
|
|
81
|
+
{children}
|
|
82
|
+
</MenuPrimitive.Group>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function MenuSeparator({ className }: { className?: string | undefined }) {
|
|
87
|
+
return <MenuPrimitive.Separator className={cn('mx-1 my-1.5 h-px bg-rule', className)} />;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type MenuSubTriggerProps = Omit<ComponentProps<typeof MenuPrimitive.SubTrigger>, 'children'> & { children: ReactNode; icon?: ReactNode };
|
|
91
|
+
|
|
92
|
+
/** Opens a nested menu (right arrow / Enter; left arrow closes). */
|
|
93
|
+
export function MenuSubTrigger({ children, icon, className, ...rest }: MenuSubTriggerProps) {
|
|
94
|
+
return (
|
|
95
|
+
<MenuPrimitive.SubTrigger className={cn(itemBase, 'data-[state=open]:bg-hover', className)} {...rest}>
|
|
96
|
+
{icon && <span className="grid size-4 shrink-0 place-items-center text-fg-secondary">{icon}</span>}
|
|
97
|
+
<span className="flex-1 truncate">{children}</span>
|
|
98
|
+
<ChevronRight size={14} aria-hidden className="text-fg-secondary" />
|
|
99
|
+
</MenuPrimitive.SubTrigger>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function MenuSubContent({ className, sideOffset = 4, ...rest }: ComponentProps<typeof MenuPrimitive.SubContent>) {
|
|
104
|
+
return (
|
|
105
|
+
<MenuPrimitive.Portal>
|
|
106
|
+
<MenuPrimitive.SubContent sideOffset={sideOffset} collisionPadding={8} className={cn(surface, className)} {...rest} />
|
|
107
|
+
</MenuPrimitive.Portal>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type PageHeaderFigure = { value: ReactNode; label: ReactNode };
|
|
6
|
+
|
|
7
|
+
export type PageHeaderProps = Omit<ComponentProps<'header'>, 'title'> & {
|
|
8
|
+
/** "Project · Series A" */
|
|
9
|
+
eyebrow?: ReactNode;
|
|
10
|
+
title: ReactNode;
|
|
11
|
+
/** Beside the title: a Badge. */
|
|
12
|
+
status?: ReactNode;
|
|
13
|
+
/** A note line under the title: sync state, counts ("Syncing · 6,831 items"). */
|
|
14
|
+
note?: ReactNode;
|
|
15
|
+
/** A brand dot before the note (background work running). */
|
|
16
|
+
noteDot?: boolean | undefined;
|
|
17
|
+
/** Stat figures; hidden on phones. */
|
|
18
|
+
figures?: PageHeaderFigure[] | undefined;
|
|
19
|
+
/** Right-hand actions. */
|
|
20
|
+
actions?: ReactNode;
|
|
21
|
+
/** Heading level of the title. @default 1 */
|
|
22
|
+
level?: 1 | 2 | 3 | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* PageHeader — the "seven in one" header card: eyebrow, title, status, a
|
|
27
|
+
* note line, figures (hidden on phones) and actions. Every part is optional
|
|
28
|
+
* but the title.
|
|
29
|
+
*/
|
|
30
|
+
export function PageHeader({ eyebrow, title, status, note, noteDot = false, figures, actions, level = 1, className, ...rest }: PageHeaderProps) {
|
|
31
|
+
const H = `h${level}` as const;
|
|
32
|
+
return (
|
|
33
|
+
<header className={cn('flex flex-col gap-4 rounded-lg bg-sunk p-5 text-fg', className)} {...rest}>
|
|
34
|
+
<div className="flex items-start justify-between gap-4">
|
|
35
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
36
|
+
{eyebrow && <div className="text-meta font-medium text-fg-secondary">{eyebrow}</div>}
|
|
37
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
|
38
|
+
<H className="m-0 truncate text-title leading-tight font-medium tracking-heading">{title}</H>
|
|
39
|
+
{status}
|
|
40
|
+
</div>
|
|
41
|
+
{note && (
|
|
42
|
+
<div className="flex items-center gap-2 text-meta text-fg-secondary">
|
|
43
|
+
{noteDot && <span aria-hidden className="size-1.5 shrink-0 rounded-pill bg-brand-mark" />}
|
|
44
|
+
<span className="truncate">{note}</span>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
|
49
|
+
</div>
|
|
50
|
+
{figures && figures.length > 0 && (
|
|
51
|
+
<dl className="m-0 hidden flex-wrap gap-x-8 gap-y-3 sm:flex">
|
|
52
|
+
{figures.map((f, i) => (
|
|
53
|
+
<div key={i} className="flex flex-col-reverse">
|
|
54
|
+
<dt className="text-meta text-fg-secondary">{f.label}</dt>
|
|
55
|
+
<dd className="m-0 text-title leading-tight font-medium tabular-nums">{f.value}</dd>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</dl>
|
|
59
|
+
)}
|
|
60
|
+
</header>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { CloudOff, FileQuestion, FolderOpen, Info, Lock, RefreshCw, SearchX, TriangleAlert, type LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { Icon } from './Icon';
|
|
6
|
+
|
|
7
|
+
export type PageStateKind = 'empty' | 'no-results' | 'forbidden' | 'not-found' | 'error' | 'offline';
|
|
8
|
+
|
|
9
|
+
const GLYPH: Record<PageStateKind, LucideIcon> = {
|
|
10
|
+
empty: FolderOpen,
|
|
11
|
+
'no-results': SearchX,
|
|
12
|
+
forbidden: Lock,
|
|
13
|
+
'not-found': FileQuestion,
|
|
14
|
+
error: TriangleAlert,
|
|
15
|
+
offline: CloudOff,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type PageStateProps = Omit<ComponentProps<'div'>, 'title' | 'children'> & {
|
|
19
|
+
kind: PageStateKind;
|
|
20
|
+
/** The one sentence: "No documents yet", "Couldn't load; your work is saved". */
|
|
21
|
+
title: ReactNode;
|
|
22
|
+
/** Optional second line. */
|
|
23
|
+
description?: ReactNode;
|
|
24
|
+
/** The one action (a Button): Upload files, Try again, Go to my documents. */
|
|
25
|
+
action?: ReactNode;
|
|
26
|
+
/** Tighter, for a panel or a list rather than a page. */
|
|
27
|
+
compact?: boolean | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PageState — one sentence and one action per state: nothing yet, no
|
|
32
|
+
* results, no access, not found, error, offline. Never "No results" while a
|
|
33
|
+
* search is still running (show a Skeleton instead).
|
|
34
|
+
*/
|
|
35
|
+
export function PageState({ kind, title, description, action, compact = false, className, ...rest }: PageStateProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
role={kind === 'error' || kind === 'offline' ? 'alert' : 'status'}
|
|
39
|
+
data-kind={kind}
|
|
40
|
+
className={cn('flex flex-col items-center text-center font-sans', compact ? 'gap-2 py-8' : 'gap-3 py-16', className)}
|
|
41
|
+
{...rest}
|
|
42
|
+
>
|
|
43
|
+
<span className={cn('grid place-items-center rounded-lg bg-sunk text-fg-secondary', compact ? 'size-10' : 'size-12')}>
|
|
44
|
+
<Icon icon={GLYPH[kind]} size={compact ? 18 : 22} />
|
|
45
|
+
</span>
|
|
46
|
+
<div className="flex flex-col gap-1">
|
|
47
|
+
<p className={cn('m-0 font-medium text-fg', compact ? 'text-small' : 'text-body')}>{title}</p>
|
|
48
|
+
{description && <p className="m-0 max-w-[44ch] text-small text-fg-secondary">{description}</p>}
|
|
49
|
+
</div>
|
|
50
|
+
{action && <div className="mt-1">{action}</div>}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type PageNoticeProps = Omit<ComponentProps<'div'>, 'children'> & {
|
|
56
|
+
/** info: a quiet sunk line (a truncated document); update: a new version is out. */
|
|
57
|
+
tone?: 'info' | 'update' | undefined;
|
|
58
|
+
children: ReactNode;
|
|
59
|
+
/** One action: Refresh. */
|
|
60
|
+
action?: ReactNode;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* PageNotice — a full-width line above the content: "This document has
|
|
65
|
+
* 1,240 pages. Only the first 500 were processed…", or "MetaRoom has been
|
|
66
|
+
* updated. Refresh to load this page."
|
|
67
|
+
*/
|
|
68
|
+
export function PageNotice({ tone = 'info', children, action, className, ...rest }: PageNoticeProps) {
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
role={tone === 'update' ? 'alert' : 'status'}
|
|
72
|
+
className={cn(
|
|
73
|
+
'flex items-center gap-3 rounded-md px-4 py-2.5 font-sans text-small',
|
|
74
|
+
tone === 'update' ? 'bg-brand-field text-ink' : 'bg-sunk text-fg',
|
|
75
|
+
className,
|
|
76
|
+
)}
|
|
77
|
+
{...rest}
|
|
78
|
+
>
|
|
79
|
+
<Icon icon={tone === 'update' ? RefreshCw : Info} size={15} className={tone === 'update' ? undefined : 'text-fg-secondary'} />
|
|
80
|
+
<span className="flex-1">{children}</span>
|
|
81
|
+
{action}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
import { useLoop } from './status-motion';
|
|
5
|
+
|
|
6
|
+
export type ProgressSegment = 'done' | 'current' | 'failed' | 'todo';
|
|
7
|
+
|
|
8
|
+
export type ProgressProps = Omit<ComponentProps<'div'>, 'children'> & {
|
|
9
|
+
/** Accessible name ("Uploading Cap table.xlsx"). */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Determinate: how far. Leave undefined (and no segments) for indeterminate. */
|
|
12
|
+
value?: number | undefined;
|
|
13
|
+
/** @default 100 */
|
|
14
|
+
max?: number | undefined;
|
|
15
|
+
/** Segmented: one entry per step — done ink, current the brand colour, failed red. */
|
|
16
|
+
segments?: ProgressSegment[] | undefined;
|
|
17
|
+
/** Always-visible progress text: "1.2 of 3.4 MB", "Preparing download", "Batch 3 of 5". */
|
|
18
|
+
text?: ReactNode;
|
|
19
|
+
/** Announced once when it ends. */
|
|
20
|
+
status?: 'active' | 'done' | 'failed' | undefined;
|
|
21
|
+
/** Words for the announcements. */
|
|
22
|
+
labels?: { done?: string; failed?: string } | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** 0, 25, 50, 75 or 100 — screen readers hear these steps, not every tick. */
|
|
26
|
+
export function progressStep(value: number, max: number): number {
|
|
27
|
+
if (max <= 0) return 0;
|
|
28
|
+
const pct = Math.min(100, Math.max(0, (value / max) * 100));
|
|
29
|
+
return Math.floor(pct / 25) * 25;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SEGMENT = { done: 'bg-fg', current: 'bg-brand-mark', failed: 'bg-error', todo: 'bg-well' };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Progress — one component, three forms: determinate, indeterminate,
|
|
36
|
+
* segmented. The text is always shown; screen readers hear 25% steps and
|
|
37
|
+
* the ending (done or failed), never every tick.
|
|
38
|
+
*/
|
|
39
|
+
export function Progress({ label, value, max = 100, segments, text, status = 'active', labels, className, ...rest }: ProgressProps) {
|
|
40
|
+
const determinate = value !== undefined && !segments;
|
|
41
|
+
const step = determinate ? progressStep(value, max) : null;
|
|
42
|
+
const [announce, setAnnounce] = useState('');
|
|
43
|
+
const lastStep = useRef<number | null>(null);
|
|
44
|
+
const lastStatus = useRef(status);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (status !== lastStatus.current) {
|
|
48
|
+
lastStatus.current = status;
|
|
49
|
+
if (status === 'done') setAnnounce(`${label}: ${labels?.done ?? 'done'}`);
|
|
50
|
+
if (status === 'failed') setAnnounce(`${label}: ${labels?.failed ?? 'failed'}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (step === null || step === lastStep.current) return;
|
|
54
|
+
const first = lastStep.current === null;
|
|
55
|
+
lastStep.current = step;
|
|
56
|
+
if (!first) setAnnounce(`${label}: ${step}%`);
|
|
57
|
+
}, [step, status, label, labels?.done, labels?.failed]);
|
|
58
|
+
|
|
59
|
+
const bar = useRef<HTMLDivElement | null>(null);
|
|
60
|
+
useLoop(bar, [{ transform: 'translateX(-100%)' }, { transform: 'translateX(250%)' }], { duration: 1400, easing: 'cubic-bezier(.2,0,.2,1)' }, value === undefined && !segments);
|
|
61
|
+
|
|
62
|
+
const aria =
|
|
63
|
+
determinate
|
|
64
|
+
? { 'aria-valuemin': 0, 'aria-valuemax': max, 'aria-valuenow': Math.min(max, Math.max(0, value)) }
|
|
65
|
+
: segments
|
|
66
|
+
? { 'aria-valuemin': 0, 'aria-valuemax': segments.length, 'aria-valuenow': segments.filter((s) => s === 'done').length }
|
|
67
|
+
: {};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className={cn('flex flex-col gap-1.5 font-sans', className)} {...rest}>
|
|
71
|
+
<div role="progressbar" aria-label={label} {...aria} aria-valuetext={typeof text === 'string' ? text : undefined} className="relative">
|
|
72
|
+
{segments ? (
|
|
73
|
+
<div className="flex gap-1">
|
|
74
|
+
{segments.map((s, i) => (
|
|
75
|
+
<span key={i} data-segment={s} className={cn('h-1 flex-1 rounded-pill', SEGMENT[s])} />
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
) : (
|
|
79
|
+
<div className={cn('h-1 overflow-hidden rounded-pill bg-well', status === 'failed' && 'bg-error-wash')}>
|
|
80
|
+
{determinate ? (
|
|
81
|
+
<div
|
|
82
|
+
className={cn('h-full rounded-pill transition-[width] duration-[180ms] ease-standard', status === 'failed' ? 'bg-error' : 'bg-brand-mark')}
|
|
83
|
+
style={{ width: `${Math.min(100, Math.max(0, (value / max) * 100))}%` }}
|
|
84
|
+
/>
|
|
85
|
+
) : (
|
|
86
|
+
<div ref={bar} className="h-full w-2/5 rounded-pill bg-brand-mark" />
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
{text !== undefined && <div className="text-meta font-medium text-fg-secondary tabular-nums">{text}</div>}
|
|
92
|
+
<span className="sr-only" aria-live="polite">
|
|
93
|
+
{announce}
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useRef, type ComponentProps } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
import { useLoop } from './status-motion';
|
|
5
|
+
|
|
6
|
+
export type PulseDotProps = Omit<ComponentProps<'span'>, 'children'> & {
|
|
7
|
+
/** Pulse (background work or an agent running). Off: a still dot. @default true */
|
|
8
|
+
active?: boolean | undefined;
|
|
9
|
+
/** px. @default 9 */
|
|
10
|
+
size?: number | undefined;
|
|
11
|
+
/** Accessible text ("Agent running"). Without it the dot is decorative. */
|
|
12
|
+
label?: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** PulseDot — a slowly pulsing brand dot while work runs; a still brand
|
|
16
|
+
* dot under reduced motion. */
|
|
17
|
+
export function PulseDot({ active = true, size = 9, label, className, style, ...rest }: PulseDotProps) {
|
|
18
|
+
const ref = useRef<HTMLSpanElement | null>(null);
|
|
19
|
+
useLoop(ref, [{ opacity: 1 }, { opacity: 0.35 }, { opacity: 1 }], { duration: 2400, easing: 'ease-in-out' }, active);
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
ref={ref}
|
|
23
|
+
role={label ? 'img' : undefined}
|
|
24
|
+
aria-label={label}
|
|
25
|
+
aria-hidden={label ? undefined : true}
|
|
26
|
+
className={cn('inline-block shrink-0 rounded-pill bg-brand-mark', className)}
|
|
27
|
+
style={{ width: size, height: size, ...style }}
|
|
28
|
+
{...rest}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|