@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +109 -0
- package/package.json +36 -6
- package/src/brand.css +13 -0
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/AuthLayout.tsx +127 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/BrandLoader.tsx +122 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +186 -0
- package/src/components/Card.tsx +42 -0
- package/src/components/Checkbox.tsx +67 -0
- 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 +135 -0
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/Field.tsx +66 -0
- package/src/components/Figure.tsx +30 -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/Icon.tsx +32 -0
- package/src/components/IconButton.tsx +55 -0
- package/src/components/Input.tsx +74 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/Logo.tsx +62 -0
- package/src/components/Marker.tsx +26 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/MetaLabel.tsx +38 -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/Radio.tsx +72 -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/Select.tsx +351 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/Spinner.tsx +20 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/Switch.tsx +67 -0
- package/src/components/SyncStatus.tsx +56 -0
- package/src/components/Table.tsx +196 -0
- package/src/components/Tabs.tsx +124 -0
- package/src/components/Tag.tsx +35 -0
- package/src/components/Textarea.tsx +46 -0
- package/src/components/ThemeProvider.tsx +80 -0
- package/src/components/ThemeSwitch.tsx +99 -0
- package/src/components/Toast.tsx +196 -0
- package/src/components/Tooltip.tsx +57 -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/WorkspaceRow.tsx +80 -0
- package/src/components/forms-control.ts +36 -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/logo-art.ts +4 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useButtonAction.ts +57 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +73 -2
- package/src/modes.css +10 -0
- package/src/styles.css +2 -0
- package/src/theme-base.css +123 -0
- package/src/theme-reset.css +14 -0
- package/src/theme.css +2 -114
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Folder } from 'lucide-react';
|
|
2
|
+
import { useMemo, useState, type ComponentProps } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { Button } from './Button';
|
|
6
|
+
import { Checkbox } from './Checkbox';
|
|
7
|
+
import { Icon } from './Icon';
|
|
8
|
+
import { checkFiles, DEFAULT_MAX_SIZE, formatBytes, isHidden, type DroppedFile, type FileRules, type Rejected } from './status-files';
|
|
9
|
+
|
|
10
|
+
type Group = { key: string; label: string; files: DroppedFile[]; hidden: boolean };
|
|
11
|
+
|
|
12
|
+
const REASON: Record<Rejected['reason'], string> = { type: 'Unsupported type', size: 'Too large', depth: 'Nested too deep' };
|
|
13
|
+
|
|
14
|
+
/** Groups a folder's accepted files by their first sub-folder ("" = loose files in the root). */
|
|
15
|
+
export function groupByFolder(items: DroppedFile[]): Group[] {
|
|
16
|
+
const map = new Map<string, DroppedFile[]>();
|
|
17
|
+
for (const it of items) {
|
|
18
|
+
const parts = it.path.split('/');
|
|
19
|
+
const key = parts.length > 2 ? parts[1]! : '';
|
|
20
|
+
map.set(key, [...(map.get(key) ?? []), it]);
|
|
21
|
+
}
|
|
22
|
+
return [...map.entries()]
|
|
23
|
+
.map(([key, files]) => ({ key, label: key, files, hidden: key ? key.startsWith('.') : files.every((f) => isHidden(f.path)) }))
|
|
24
|
+
.sort((a, b) => Number(a.hidden) - Number(b.hidden) || a.key.localeCompare(b.key));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type UploadCheckLabels = {
|
|
28
|
+
looseFiles?: string;
|
|
29
|
+
wontUpload?: (n: number) => string;
|
|
30
|
+
upload?: (n: number) => string;
|
|
31
|
+
cancel?: string;
|
|
32
|
+
of?: (included: number, total: number) => string;
|
|
33
|
+
summary?: (files: number, size: string) => string;
|
|
34
|
+
refused?: (r: Rejected) => string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type UploadCheckProps = Omit<ComponentProps<'div'>, 'children'> &
|
|
38
|
+
FileRules & {
|
|
39
|
+
/** The dropped folder's name. */
|
|
40
|
+
folder: string;
|
|
41
|
+
/** Everything read from it, with paths ("<folder>/Financials/a.pdf"). */
|
|
42
|
+
items: DroppedFile[];
|
|
43
|
+
onConfirm: (files: DroppedFile[]) => void;
|
|
44
|
+
onCancel?: (() => void) | undefined;
|
|
45
|
+
labels?: UploadCheckLabels | undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* UploadCheck — before a folder uploads: what will go (by sub-folder, with
|
|
50
|
+
* counts), what is unticked by default (hidden items such as .cache), and
|
|
51
|
+
* what won't upload and why (unsupported type, too large, nested too deep).
|
|
52
|
+
*/
|
|
53
|
+
export function UploadCheck({ folder, items, accept, maxSize = DEFAULT_MAX_SIZE, maxDepth, onConfirm, onCancel, labels, className, ...rest }: UploadCheckProps) {
|
|
54
|
+
const { accepted, rejected } = useMemo(() => checkFiles(items, { accept, maxSize, maxDepth }), [items, accept, maxSize, maxDepth]);
|
|
55
|
+
const groups = useMemo(() => groupByFolder(accepted), [accepted]);
|
|
56
|
+
const [off, setOff] = useState<Set<string>>(() => new Set(groups.filter((g) => g.hidden).map((g) => g.key)));
|
|
57
|
+
|
|
58
|
+
const chosen = groups.filter((g) => !off.has(g.key)).flatMap((g) => g.files);
|
|
59
|
+
const bytes = chosen.reduce((n, f) => n + f.file.size, 0);
|
|
60
|
+
const toggle = (key: string, on: boolean) =>
|
|
61
|
+
setOff((s) => {
|
|
62
|
+
const next = new Set(s);
|
|
63
|
+
if (on) next.delete(key);
|
|
64
|
+
else next.add(key);
|
|
65
|
+
return next;
|
|
66
|
+
});
|
|
67
|
+
const L = {
|
|
68
|
+
looseFiles: labels?.looseFiles ?? 'Files in this folder',
|
|
69
|
+
wontUpload: labels?.wontUpload ?? ((n: number) => `${n} won’t upload`),
|
|
70
|
+
upload: labels?.upload ?? ((n: number) => `Upload ${n} ${n === 1 ? 'file' : 'files'}`),
|
|
71
|
+
of: labels?.of ?? ((a: number, b: number) => `${a} of ${b}`),
|
|
72
|
+
refused: labels?.refused ?? ((r: Rejected) => REASON[r.reason]),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={cn('flex flex-col gap-4 font-sans text-fg', className)} {...rest}>
|
|
77
|
+
<div className="flex items-center gap-2">
|
|
78
|
+
<Icon icon={Folder} size={18} />
|
|
79
|
+
<span className="flex-1 truncate text-ui font-medium">{folder}</span>
|
|
80
|
+
<span className="text-meta font-medium text-fg-secondary tabular-nums">{L.of(chosen.length, items.length)}</span>
|
|
81
|
+
</div>
|
|
82
|
+
<ul className="m-0 flex list-none flex-col rounded-md border border-rule p-0">
|
|
83
|
+
{groups.map((g) => (
|
|
84
|
+
<li key={g.key || '(root)'} className="flex items-center gap-3 border-b border-rule-soft px-3 py-2.5 last:border-b-0">
|
|
85
|
+
<Checkbox
|
|
86
|
+
checked={!off.has(g.key)}
|
|
87
|
+
onChange={(v) => toggle(g.key, v)}
|
|
88
|
+
label={g.key ? g.label : L.looseFiles}
|
|
89
|
+
rowClassName={cn('flex-1', off.has(g.key) && 'text-fg-secondary')}
|
|
90
|
+
/>
|
|
91
|
+
<span className="text-meta text-fg-secondary tabular-nums">{g.files.length}</span>
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
{rejected.map((r) => (
|
|
95
|
+
<li key={r.path} data-rejected={r.reason} className="flex items-center gap-3 border-b border-rule-soft px-3 py-2.5 last:border-b-0">
|
|
96
|
+
<span className="size-4 shrink-0" />
|
|
97
|
+
<span className="flex-1 truncate text-small text-fg-secondary">{r.path.split('/').slice(1).join(' / ') || r.path}</span>
|
|
98
|
+
<span className="text-meta font-medium text-error-text">{L.refused(r)}</span>
|
|
99
|
+
</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
<div className="flex items-center gap-3">
|
|
103
|
+
<span className="flex-1 text-meta text-fg-secondary tabular-nums">
|
|
104
|
+
{(labels?.summary ?? ((n: number, s: string) => `${n} ${n === 1 ? 'file' : 'files'} · ${s}`))(chosen.length, formatBytes(bytes))}
|
|
105
|
+
{rejected.length > 0 && ` · ${L.wontUpload(rejected.length)}`}
|
|
106
|
+
</span>
|
|
107
|
+
{onCancel && (
|
|
108
|
+
<Button variant="ghost" onClick={onCancel}>
|
|
109
|
+
{labels?.cancel ?? 'Cancel'}
|
|
110
|
+
</Button>
|
|
111
|
+
)}
|
|
112
|
+
<Button disabled={chosen.length === 0} onClick={() => onConfirm(chosen)}>
|
|
113
|
+
{L.upload(chosen.length)}
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Check, File, Folder, X } from 'lucide-react';
|
|
2
|
+
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { Button } from './Button';
|
|
6
|
+
import { Icon } from './Icon';
|
|
7
|
+
import { IconButton } from './IconButton';
|
|
8
|
+
import { Progress } from './Progress';
|
|
9
|
+
import { formatBytes } from './status-files';
|
|
10
|
+
|
|
11
|
+
export type UploadState = 'queued' | 'uploading' | 'retrying' | 'failed' | 'done';
|
|
12
|
+
|
|
13
|
+
export type UploadItem = {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
state: UploadState;
|
|
17
|
+
/** 0–100 while uploading. */
|
|
18
|
+
progress?: number | undefined;
|
|
19
|
+
/** Bytes, for "1.2 of 3.4 MB". */
|
|
20
|
+
loaded?: number | undefined;
|
|
21
|
+
size?: number | undefined;
|
|
22
|
+
/** When the next attempt starts (epoch ms), while retrying. */
|
|
23
|
+
retryAt?: number | undefined;
|
|
24
|
+
/** Why it failed: "The connection dropped." */
|
|
25
|
+
error?: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type UploadRowLabels = {
|
|
29
|
+
/** s is 0 once the retry has started. */
|
|
30
|
+
retryingIn?: (s: number) => string;
|
|
31
|
+
failed?: string;
|
|
32
|
+
retry?: string;
|
|
33
|
+
dismiss?: string;
|
|
34
|
+
cancel?: string;
|
|
35
|
+
done?: string;
|
|
36
|
+
queued?: string;
|
|
37
|
+
/** "1.2 MB of 3.4 MB" */
|
|
38
|
+
sizeOf?: (loaded: string, total: string) => string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Seconds left until `at`, ticking once a second. */
|
|
42
|
+
function useSecondsUntil(at: number | undefined): number {
|
|
43
|
+
const [now, setNow] = useState(() => Date.now());
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (at === undefined) return;
|
|
46
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
47
|
+
return () => clearInterval(t);
|
|
48
|
+
}, [at]);
|
|
49
|
+
return at === undefined ? 0 : Math.max(0, Math.ceil((at - now) / 1000));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type UploadRowProps = Omit<ComponentProps<'li'>, 'children'> & {
|
|
53
|
+
item: UploadItem;
|
|
54
|
+
onRetry?: ((id: string) => void) | undefined;
|
|
55
|
+
onDismiss?: ((id: string) => void) | undefined;
|
|
56
|
+
onCancel?: ((id: string) => void) | undefined;
|
|
57
|
+
labels?: UploadRowLabels | undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* UploadRow — one file: name, progress, cancel. Retrying counts down;
|
|
62
|
+
* a failure takes a red wash with the reason and Retry while the rest keep
|
|
63
|
+
* going; done confirms until the real card takes its place.
|
|
64
|
+
*/
|
|
65
|
+
export function UploadRow({ item, onRetry, onDismiss, onCancel, labels, className, ...rest }: UploadRowProps) {
|
|
66
|
+
const secs = useSecondsUntil(item.state === 'retrying' ? item.retryAt : undefined);
|
|
67
|
+
const L = {
|
|
68
|
+
retryingIn: labels?.retryingIn ?? ((s: number) => (s > 0 ? `Retrying in ${s} s` : 'Retrying…')),
|
|
69
|
+
failed: labels?.failed ?? 'Upload failed.',
|
|
70
|
+
retry: labels?.retry ?? 'Retry',
|
|
71
|
+
dismiss: labels?.dismiss ?? 'Dismiss',
|
|
72
|
+
cancel: labels?.cancel ?? 'Cancel',
|
|
73
|
+
done: labels?.done ?? 'Uploaded',
|
|
74
|
+
queued: labels?.queued ?? 'Waiting',
|
|
75
|
+
};
|
|
76
|
+
const pct = Math.round(item.progress ?? 0);
|
|
77
|
+
const text =
|
|
78
|
+
item.state === 'uploading'
|
|
79
|
+
? item.size
|
|
80
|
+
? (labels?.sizeOf ?? ((a: string, b: string) => `${a} of ${b}`))(formatBytes(item.loaded ?? (item.size * pct) / 100), formatBytes(item.size))
|
|
81
|
+
: `${pct}%`
|
|
82
|
+
: item.state === 'retrying'
|
|
83
|
+
? L.retryingIn(secs)
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<li
|
|
88
|
+
data-state={item.state}
|
|
89
|
+
className={cn(
|
|
90
|
+
'flex list-none items-center gap-3 rounded-md px-3 py-2.5 font-sans',
|
|
91
|
+
item.state === 'failed' ? 'bg-error-wash text-ink ink:bg-error/20 ink:text-fg' : 'text-fg',
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
94
|
+
{...rest}
|
|
95
|
+
>
|
|
96
|
+
<Icon icon={item.state === 'done' ? Check : File} size={16} className={item.state === 'done' ? 'text-fg' : 'text-fg-secondary'} />
|
|
97
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
98
|
+
<div className="flex items-center gap-2">
|
|
99
|
+
<span className="min-w-0 flex-1 truncate text-small">{item.name}</span>
|
|
100
|
+
{item.state === 'uploading' && <span className="text-meta font-medium text-fg-secondary tabular-nums">{pct}%</span>}
|
|
101
|
+
{item.state === 'done' && <span className="text-meta font-medium text-fg-secondary">{L.done}</span>}
|
|
102
|
+
{item.state === 'queued' && <span className="text-meta text-fg-secondary">{L.queued}</span>}
|
|
103
|
+
</div>
|
|
104
|
+
{(item.state === 'uploading' || item.state === 'retrying') && (
|
|
105
|
+
<Progress label={item.name} value={item.state === 'uploading' ? pct : undefined} text={text} status="active" />
|
|
106
|
+
)}
|
|
107
|
+
{item.state === 'failed' && (
|
|
108
|
+
<span role="alert" className="text-meta font-medium text-error-text ink:text-fg">
|
|
109
|
+
{L.failed} {item.error}
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
{item.state === 'failed' ? (
|
|
114
|
+
<div className="flex items-center gap-1">
|
|
115
|
+
{onRetry && (
|
|
116
|
+
<Button size="sm" variant="secondary" ground="paper" onClick={() => onRetry(item.id)} className="ink:border-inv-rule ink:text-fg">
|
|
117
|
+
{L.retry}
|
|
118
|
+
</Button>
|
|
119
|
+
)}
|
|
120
|
+
{onDismiss && (
|
|
121
|
+
<Button size="sm" variant="ghost" ground="paper" onClick={() => onDismiss(item.id)} className="ink:text-fg">
|
|
122
|
+
{L.dismiss}
|
|
123
|
+
</Button>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
) : (
|
|
127
|
+
onCancel &&
|
|
128
|
+
item.state !== 'done' && <IconButton icon={X} label={`${L.cancel} ${item.name}`} size="sm" onClick={() => onCancel(item.id)} />
|
|
129
|
+
)}
|
|
130
|
+
</li>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type UploadFolderRowProps = Omit<ComponentProps<'li'>, 'children'> & {
|
|
135
|
+
name: string;
|
|
136
|
+
done: number;
|
|
137
|
+
total: number;
|
|
138
|
+
failed?: number | undefined;
|
|
139
|
+
labels?: { failed?: (n: number) => string } | undefined;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** A folder's batch: a placeholder card that tracks "28 / 41 · 1 failed". */
|
|
143
|
+
export function UploadFolderRow({ name, done, total, failed = 0, labels, className, ...rest }: UploadFolderRowProps) {
|
|
144
|
+
const failedText = labels?.failed ?? ((n: number) => `${n} failed`);
|
|
145
|
+
const text = `${done} / ${total}${failed ? ` · ${failedText(failed)}` : ''}`;
|
|
146
|
+
return (
|
|
147
|
+
<li className={cn('flex list-none items-center gap-3 rounded-md px-3 py-2.5 font-sans text-fg', className)} {...rest}>
|
|
148
|
+
<Icon icon={Folder} size={16} className="text-fg-secondary" />
|
|
149
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
150
|
+
<div className="flex items-center gap-2">
|
|
151
|
+
<span className="min-w-0 flex-1 truncate text-small">{name}</span>
|
|
152
|
+
<span className={cn('text-meta font-medium tabular-nums', failed ? 'text-error-text' : 'text-fg-secondary')}>{text}</span>
|
|
153
|
+
</div>
|
|
154
|
+
<Progress label={name} value={done} max={total} status={done + failed >= total ? (failed ? 'failed' : 'done') : 'active'} />
|
|
155
|
+
</div>
|
|
156
|
+
</li>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** UploadList — the rows of a batch. */
|
|
161
|
+
export function UploadList({ className, ...rest }: ComponentProps<'ul'>) {
|
|
162
|
+
return <ul className={cn('m-0 flex flex-col gap-1 p-0', className)} {...rest} />;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** While active, closing or reloading the page asks first. */
|
|
166
|
+
export function useBeforeUnloadWhile(active: boolean): void {
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!active) return;
|
|
169
|
+
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
e.returnValue = '';
|
|
172
|
+
};
|
|
173
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
174
|
+
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
175
|
+
}, [active]);
|
|
176
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
2
|
+
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { Spinner } from './Spinner';
|
|
6
|
+
|
|
7
|
+
export type VirtualListProps<T> = {
|
|
8
|
+
items: readonly T[];
|
|
9
|
+
/** Estimated row height in px (rows may differ; they are measured). @default 42 */
|
|
10
|
+
estimateSize?: number | undefined;
|
|
11
|
+
renderItem: (item: T, index: number) => ReactNode;
|
|
12
|
+
getKey?: ((item: T, index: number) => string | number) | undefined;
|
|
13
|
+
/** Rows rendered beyond the viewport. @default 8 */
|
|
14
|
+
overscan?: number | undefined;
|
|
15
|
+
/** The scroll box's height (the list scrolls inside it). */
|
|
16
|
+
height: number | string;
|
|
17
|
+
/** Accessible name of the list. */
|
|
18
|
+
label: string;
|
|
19
|
+
className?: string | undefined;
|
|
20
|
+
/** Rendered after the last row: an InfiniteLoader. */
|
|
21
|
+
footer?: ReactNode;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* VirtualList — renders only the visible rows of a long list (thousands of
|
|
26
|
+
* items), measuring each as it appears. The rows are list items.
|
|
27
|
+
*/
|
|
28
|
+
export function VirtualList<T>({ items, estimateSize = 42, renderItem, getKey, overscan = 8, height, label, className, footer }: VirtualListProps<T>) {
|
|
29
|
+
const scroller = useRef<HTMLDivElement | null>(null);
|
|
30
|
+
const v = useVirtualizer({
|
|
31
|
+
count: items.length,
|
|
32
|
+
getScrollElement: () => scroller.current,
|
|
33
|
+
estimateSize: () => estimateSize,
|
|
34
|
+
overscan,
|
|
35
|
+
...(getKey ? { getItemKey: (i: number) => getKey(items[i]!, i) } : {}),
|
|
36
|
+
});
|
|
37
|
+
return (
|
|
38
|
+
<div ref={scroller} className={cn('overflow-auto', className)} style={{ height }}>
|
|
39
|
+
<ul aria-label={label} className="relative m-0 list-none p-0" style={{ height: v.getTotalSize() }}>
|
|
40
|
+
{v.getVirtualItems().map((row) => (
|
|
41
|
+
<li
|
|
42
|
+
key={row.key}
|
|
43
|
+
data-index={row.index}
|
|
44
|
+
ref={v.measureElement}
|
|
45
|
+
className="absolute top-0 left-0 w-full"
|
|
46
|
+
style={{ transform: `translateY(${row.start}px)` } satisfies CSSProperties}
|
|
47
|
+
>
|
|
48
|
+
{renderItem(items[row.index]!, row.index)}
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
{footer}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type InfiniteLoaderProps = {
|
|
58
|
+
/** Called when the sentinel comes within `margin` of the viewport. */
|
|
59
|
+
onLoadMore: () => void;
|
|
60
|
+
hasMore: boolean;
|
|
61
|
+
loading?: boolean | undefined;
|
|
62
|
+
/** How early to load: the spec asks for 200px before the end. @default 200 */
|
|
63
|
+
margin?: number | undefined;
|
|
64
|
+
/** Read out while loading. @default "Loading more" */
|
|
65
|
+
loadingLabel?: string | undefined;
|
|
66
|
+
className?: string | undefined;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** InfiniteLoader — a sentinel at the end of a list that asks for the next
|
|
70
|
+
* page 200px before it comes into view. */
|
|
71
|
+
export function InfiniteLoader({ onLoadMore, hasMore, loading = false, margin = 200, loadingLabel = 'Loading more', className }: InfiniteLoaderProps) {
|
|
72
|
+
const el = useRef<HTMLDivElement | null>(null);
|
|
73
|
+
const latest = useRef(onLoadMore);
|
|
74
|
+
latest.current = onLoadMore;
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const node = el.current;
|
|
78
|
+
if (!node || !hasMore || loading || typeof IntersectionObserver === 'undefined') return;
|
|
79
|
+
const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && latest.current(), { rootMargin: `0px 0px ${margin}px 0px` });
|
|
80
|
+
io.observe(node);
|
|
81
|
+
return () => io.disconnect();
|
|
82
|
+
}, [hasMore, loading, margin]);
|
|
83
|
+
|
|
84
|
+
if (!hasMore && !loading) return null;
|
|
85
|
+
return (
|
|
86
|
+
<div ref={el} className={cn('flex h-10 items-center justify-center text-fg-secondary', className)}>
|
|
87
|
+
{loading && <Spinner label={loadingLabel} />}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Upload } from 'lucide-react';
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { Icon } from './Icon';
|
|
6
|
+
import { hasFiles, readDataTransfer, type DroppedFile } from './status-files';
|
|
7
|
+
|
|
8
|
+
type WindowDropContextValue = {
|
|
9
|
+
/** Files are being dragged over the window: every drop target lights up. */
|
|
10
|
+
dragging: boolean;
|
|
11
|
+
/** A local zone under the pointer names its folder here (null on leave). */
|
|
12
|
+
setHoverTarget: (target: string | null) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const WindowDropContext = createContext<WindowDropContextValue>({ dragging: false, setHoverTarget: () => {} });
|
|
16
|
+
|
|
17
|
+
/** Inside a WindowDrop: whether files are being dragged over the window. */
|
|
18
|
+
export function useWindowDrop(): WindowDropContextValue {
|
|
19
|
+
return useContext(WindowDropContext);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type WindowDropProps = {
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
/** Where a drop anywhere goes: the current folder, or "My uploads". */
|
|
25
|
+
target: string;
|
|
26
|
+
/** A drop outside any local zone. */
|
|
27
|
+
onDrop: (items: DroppedFile[], target: string) => void;
|
|
28
|
+
/** Read-only page: no layer — a line says you can only view here. */
|
|
29
|
+
readOnly?: boolean | undefined;
|
|
30
|
+
/** Force the layer open (previews, screenshots). */
|
|
31
|
+
open?: boolean | undefined;
|
|
32
|
+
onOpenChange?: ((open: boolean) => void) | undefined;
|
|
33
|
+
labels?: { release?: (target: string) => string; viewOnly?: string; close?: string } | undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const dialogOpen = () => typeof document !== 'undefined' && document.querySelector('[role="dialog"], [role="alertdialog"]') !== null;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* WindowDrop — the whole window accepts a drop while files are dragged in
|
|
40
|
+
* (only files: text, links or cards dragged on the page never trigger it).
|
|
41
|
+
* A layer above the page, below dialogs, names where the files will go;
|
|
42
|
+
* hovering a folder or a local Dropzone changes that wording. It leaves at
|
|
43
|
+
* once when the drag exits the window, on Esc, or after the drop.
|
|
44
|
+
*/
|
|
45
|
+
export function WindowDrop({ children, target, onDrop, readOnly = false, open, onOpenChange, labels }: WindowDropProps) {
|
|
46
|
+
const [dragging, setDragging] = useState(false);
|
|
47
|
+
const [hoverTarget, setHoverTarget] = useState<string | null>(null);
|
|
48
|
+
const depth = useRef(0);
|
|
49
|
+
|
|
50
|
+
const set = useCallback(
|
|
51
|
+
(v: boolean) => {
|
|
52
|
+
setDragging(v);
|
|
53
|
+
if (!v) setHoverTarget(null);
|
|
54
|
+
onOpenChange?.(v);
|
|
55
|
+
},
|
|
56
|
+
[onOpenChange],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const enter = (e: DragEvent) => {
|
|
61
|
+
if (!hasFiles(e.dataTransfer) || dialogOpen()) return;
|
|
62
|
+
depth.current += 1;
|
|
63
|
+
if (depth.current === 1) set(true);
|
|
64
|
+
};
|
|
65
|
+
const over = (e: DragEvent) => {
|
|
66
|
+
if (!hasFiles(e.dataTransfer) || dialogOpen()) return;
|
|
67
|
+
e.preventDefault(); // allow the drop
|
|
68
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = readOnly ? 'none' : 'copy';
|
|
69
|
+
};
|
|
70
|
+
const leave = (e: DragEvent) => {
|
|
71
|
+
if (!hasFiles(e.dataTransfer)) return;
|
|
72
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
73
|
+
if (depth.current === 0) set(false);
|
|
74
|
+
};
|
|
75
|
+
const drop = (e: DragEvent) => {
|
|
76
|
+
if (!hasFiles(e.dataTransfer)) return;
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
depth.current = 0;
|
|
79
|
+
set(false);
|
|
80
|
+
if (readOnly || !e.dataTransfer) return;
|
|
81
|
+
void readDataTransfer(e.dataTransfer).then((items) => items.length && onDrop(items, target));
|
|
82
|
+
};
|
|
83
|
+
const key = (e: KeyboardEvent) => {
|
|
84
|
+
if (e.key === 'Escape') {
|
|
85
|
+
depth.current = 0;
|
|
86
|
+
set(false);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
window.addEventListener('dragenter', enter);
|
|
90
|
+
window.addEventListener('dragover', over);
|
|
91
|
+
window.addEventListener('dragleave', leave);
|
|
92
|
+
window.addEventListener('drop', drop);
|
|
93
|
+
window.addEventListener('keydown', key);
|
|
94
|
+
return () => {
|
|
95
|
+
window.removeEventListener('dragenter', enter);
|
|
96
|
+
window.removeEventListener('dragover', over);
|
|
97
|
+
window.removeEventListener('dragleave', leave);
|
|
98
|
+
window.removeEventListener('drop', drop);
|
|
99
|
+
window.removeEventListener('keydown', key);
|
|
100
|
+
};
|
|
101
|
+
}, [onDrop, readOnly, set, target]);
|
|
102
|
+
|
|
103
|
+
const shown = open ?? dragging;
|
|
104
|
+
const release = labels?.release ?? ((t: string) => `Release to upload to ${t}`);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<WindowDropContext.Provider value={{ dragging: shown, setHoverTarget }}>
|
|
108
|
+
{children}
|
|
109
|
+
{shown && (
|
|
110
|
+
<div
|
|
111
|
+
data-window-drop
|
|
112
|
+
role="presentation"
|
|
113
|
+
onClick={() => set(false)}
|
|
114
|
+
className="pointer-events-none fixed inset-0 z-40 flex items-end justify-center bg-page/60 p-6 font-sans"
|
|
115
|
+
>
|
|
116
|
+
<div
|
|
117
|
+
role="status"
|
|
118
|
+
aria-live="polite"
|
|
119
|
+
className={cn(
|
|
120
|
+
'pointer-events-auto flex items-center gap-3 rounded-lg px-5 py-4 text-body font-medium',
|
|
121
|
+
readOnly ? 'bg-ink text-linen' : 'border-2 border-ink bg-brand-field text-ink',
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{!readOnly && <Icon icon={Upload} size={18} />}
|
|
125
|
+
{readOnly ? (labels?.viewOnly ?? 'You can only view here') : release(hoverTarget ?? target)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</WindowDropContext.Provider>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ChevronRight } from 'lucide-react';
|
|
2
|
+
import { forwardRef, useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { Badge } from './Badge';
|
|
6
|
+
import { Spinner } from './Spinner';
|
|
7
|
+
|
|
8
|
+
export type WorkspaceMarkProps = {
|
|
9
|
+
name: string;
|
|
10
|
+
/** The workspace's logo; its initial on the yellow when unset or it fails to load. */
|
|
11
|
+
logoUrl?: string | undefined;
|
|
12
|
+
/** px. @default 36 */
|
|
13
|
+
size?: number | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** WorkspaceMark — a workspace's logo, or its initial on the brand yellow. */
|
|
17
|
+
export function WorkspaceMark({ name, logoUrl, size = 36 }: WorkspaceMarkProps) {
|
|
18
|
+
const [failed, setFailed] = useState(false);
|
|
19
|
+
const img = useRef<HTMLImageElement>(null);
|
|
20
|
+
// A server-rendered page can fail the image before React is listening:
|
|
21
|
+
// onError never fires, so look once it is.
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const el = img.current;
|
|
24
|
+
if (el?.complete && el.naturalWidth === 0) setFailed(true);
|
|
25
|
+
}, [logoUrl]);
|
|
26
|
+
if (logoUrl && !failed) {
|
|
27
|
+
return <img ref={img} src={logoUrl} alt="" aria-hidden style={{ width: size, height: size }} className="shrink-0 rounded-[8px] object-cover" onError={() => setFailed(true)} />;
|
|
28
|
+
}
|
|
29
|
+
return (
|
|
30
|
+
<span aria-hidden style={{ width: size, height: size }} className="grid shrink-0 place-items-center rounded-[8px] bg-brand-field text-[13px] font-semibold text-ink">
|
|
31
|
+
{name.trim().charAt(0).toUpperCase()}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type WorkspaceRowProps = Omit<ComponentProps<'button'>, 'children'> & {
|
|
37
|
+
name: string;
|
|
38
|
+
/** The second line: the workspace's short name or address. */
|
|
39
|
+
detail?: ReactNode;
|
|
40
|
+
logoUrl?: string | undefined;
|
|
41
|
+
/** Being opened: a spinner in place of the chevron. */
|
|
42
|
+
busy?: boolean | undefined;
|
|
43
|
+
/** A state beside the chevron, as an attention badge: "Needs setup". */
|
|
44
|
+
badge?: ReactNode;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* WorkspaceRow — one workspace in a chooser: mark, name, detail, chevron;
|
|
49
|
+
* `badge` says what it still needs ("Needs setup").
|
|
50
|
+
* The whole row is the button; `busy` shows it opening (disable the others).
|
|
51
|
+
*/
|
|
52
|
+
export const WorkspaceRow = forwardRef<HTMLButtonElement, WorkspaceRowProps>(function WorkspaceRow(
|
|
53
|
+
{ name, detail, logoUrl, busy = false, badge, className, ...rest },
|
|
54
|
+
ref,
|
|
55
|
+
) {
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
ref={ref}
|
|
59
|
+
type="button"
|
|
60
|
+
aria-busy={busy || undefined}
|
|
61
|
+
className={cn(
|
|
62
|
+
'flex w-full cursor-pointer items-center gap-3 rounded-[12px] border-0 bg-page px-3.5 py-3 text-left text-fg shadow-[inset_0_0_0_1px_var(--rule)] outline-none transition-colors duration-[120ms] hover:bg-hover focus-visible:shadow-(--focus-ring) disabled:cursor-default',
|
|
63
|
+
className,
|
|
64
|
+
)}
|
|
65
|
+
{...rest}
|
|
66
|
+
>
|
|
67
|
+
<WorkspaceMark name={name} logoUrl={logoUrl} />
|
|
68
|
+
<span className="flex min-w-0 flex-1 flex-col leading-[1.35]">
|
|
69
|
+
<span className="truncate text-[14px] font-medium">{name}</span>
|
|
70
|
+
{detail && <span className="truncate text-[12px] text-fg-secondary">{detail}</span>}
|
|
71
|
+
</span>
|
|
72
|
+
{badge && (
|
|
73
|
+
<span className="flex shrink-0">
|
|
74
|
+
<Badge status="attention">{badge}</Badge>
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
{busy ? <Spinner size={16} /> : <ChevronRight size={16} aria-hidden className="text-fg-secondary" />}
|
|
78
|
+
</button>
|
|
79
|
+
);
|
|
80
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shared frame of text controls (Input, Textarea, Select trigger):
|
|
5
|
+
* sunk to linen, a --rule edge, ink edge + focus ring on focus, the one
|
|
6
|
+
* red on error, well + grey text when disabled. Single-line heights are
|
|
7
|
+
* fixed at 32 · 38 · 44 (Textarea overrides with h-auto).
|
|
8
|
+
*/
|
|
9
|
+
export const controlFrame = cva(
|
|
10
|
+
[
|
|
11
|
+
'flex w-full items-center gap-2 rounded-md border font-sans text-fg',
|
|
12
|
+
'transition-[border-color,background-color] duration-[120ms] ease-standard',
|
|
13
|
+
],
|
|
14
|
+
{
|
|
15
|
+
variants: {
|
|
16
|
+
variant: {
|
|
17
|
+
default: 'border-rule bg-sunk focus-within:border-fg focus-within:shadow-(--focus-ring)',
|
|
18
|
+
borderless:
|
|
19
|
+
'border-transparent bg-transparent hover:bg-hover focus-within:border-fg focus-within:bg-sunk focus-within:shadow-(--focus-ring)',
|
|
20
|
+
},
|
|
21
|
+
size: {
|
|
22
|
+
sm: 'h-8 px-2.5 text-[13px]',
|
|
23
|
+
md: 'h-[38px] px-2.5 text-[13px]',
|
|
24
|
+
lg: 'h-11 px-3 text-[15px]',
|
|
25
|
+
},
|
|
26
|
+
invalid: { true: 'border-error focus-within:border-error', false: '' },
|
|
27
|
+
disabled: { true: 'cursor-not-allowed border-rule-soft bg-well text-fg-secondary', false: '' },
|
|
28
|
+
readOnly: { true: 'border-rule-soft', false: '' },
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: { variant: 'default', size: 'md', invalid: false, disabled: false, readOnly: false },
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** The bare native element inside the frame. */
|
|
35
|
+
export const controlInner =
|
|
36
|
+
'min-w-0 flex-1 border-none bg-transparent p-0 font-sans text-inherit outline-none placeholder:text-fg-secondary disabled:cursor-not-allowed';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Only the aria props that were given — Radix roots reject explicit
|
|
2
|
+
* undefined under exactOptionalPropertyTypes. */
|
|
3
|
+
export function definedAria(aria: Record<string, string | undefined>): Record<string, string> {
|
|
4
|
+
return Object.fromEntries(Object.entries(aria).filter(([, v]) => v !== undefined)) as Record<string, string>;
|
|
5
|
+
}
|