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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +6 -1
  3. package/src/components/ActionBar.tsx +607 -0
  4. package/src/components/ActivityTimeline.tsx +77 -0
  5. package/src/components/AsyncSelect.tsx +205 -0
  6. package/src/components/Breadcrumb.tsx +129 -0
  7. package/src/components/Button.tsx +48 -40
  8. package/src/components/ChoiceCards.tsx +68 -0
  9. package/src/components/CodeInput.tsx +150 -0
  10. package/src/components/CommandPalette.tsx +228 -0
  11. package/src/components/ConfirmDialog.tsx +294 -0
  12. package/src/components/CopyField.tsx +104 -0
  13. package/src/components/DateInput.tsx +366 -0
  14. package/src/components/Dialog.tsx +27 -3
  15. package/src/components/Dropzone.tsx +187 -0
  16. package/src/components/FileCard.tsx +233 -0
  17. package/src/components/FileRow.tsx +159 -0
  18. package/src/components/FolderTree.tsx +377 -0
  19. package/src/components/ListToolbar.tsx +177 -0
  20. package/src/components/MentionInput.tsx +233 -0
  21. package/src/components/Menu.tsx +109 -0
  22. package/src/components/PageHeader.tsx +62 -0
  23. package/src/components/PageState.tsx +84 -0
  24. package/src/components/Progress.tsx +97 -0
  25. package/src/components/PulseDot.tsx +31 -0
  26. package/src/components/RecipientsInput.tsx +252 -0
  27. package/src/components/SearchField.tsx +137 -0
  28. package/src/components/SegmentedControl.tsx +95 -0
  29. package/src/components/SidePanel.tsx +130 -0
  30. package/src/components/Skeleton.tsx +73 -0
  31. package/src/components/StageProgress.tsx +51 -0
  32. package/src/components/Steps.tsx +58 -0
  33. package/src/components/SyncStatus.tsx +56 -0
  34. package/src/components/UploadCheck.tsx +118 -0
  35. package/src/components/UploadList.tsx +176 -0
  36. package/src/components/VirtualList.tsx +90 -0
  37. package/src/components/WindowDrop.tsx +131 -0
  38. package/src/components/inputs-aria.ts +5 -0
  39. package/src/components/inputs-date.ts +92 -0
  40. package/src/components/inputs-listbox.tsx +154 -0
  41. package/src/components/inputs-search.ts +60 -0
  42. package/src/components/status-files.ts +124 -0
  43. package/src/components/status-motion.ts +30 -0
  44. package/src/components/useSelection.ts +142 -0
  45. package/src/index.ts +38 -0
  46. package/src/components/core.test.tsx +0 -242
  47. package/src/components/forms.test.tsx +0 -280
  48. package/src/components/overlays.test.tsx +0 -245
@@ -0,0 +1,51 @@
1
+ import { Check, X } from 'lucide-react';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { PulseDot } from './PulseDot';
6
+
7
+ export type Stage = {
8
+ label: ReactNode;
9
+ state: 'done' | 'current' | 'todo' | 'failed';
10
+ /** Shown beside the state: "Batch 2 of 4", "Done", the failure. */
11
+ detail?: ReactNode;
12
+ };
13
+
14
+ export type StageProgressProps = Omit<ComponentProps<'ol'>, 'children'> & {
15
+ stages: Stage[];
16
+ /** Accessible name of the list. */
17
+ label?: string | undefined;
18
+ };
19
+
20
+ /**
21
+ * StageProgress — work that moves through named stages (Documents →
22
+ * People → Facts → Assessment → Report), not a percentage. Done stages are
23
+ * ink with a tick, the current one pulses on the brand colour, failed is red.
24
+ */
25
+ export function StageProgress({ stages, label, className, ...rest }: StageProgressProps) {
26
+ return (
27
+ <ol aria-label={label} className={cn('m-0 flex list-none flex-wrap items-center gap-x-5 gap-y-2 p-0 font-sans', className)} {...rest}>
28
+ {stages.map((s, i) => (
29
+ <li key={i} aria-current={s.state === 'current' ? 'step' : undefined} data-state={s.state} className="flex items-center gap-2 text-small">
30
+ {s.state === 'done' ? (
31
+ <span className="grid size-4 place-items-center rounded-pill bg-fg text-page">
32
+ <Check size={10} strokeWidth={3} aria-hidden />
33
+ </span>
34
+ ) : s.state === 'failed' ? (
35
+ <span className="grid size-4 place-items-center rounded-pill bg-error text-white">
36
+ <X size={10} strokeWidth={3} aria-hidden />
37
+ </span>
38
+ ) : s.state === 'current' ? (
39
+ <span className="grid size-4 place-items-center">
40
+ <PulseDot size={9} />
41
+ </span>
42
+ ) : (
43
+ <span aria-hidden className="size-4 rounded-pill border border-rule" />
44
+ )}
45
+ <span className={cn(s.state === 'todo' ? 'text-fg-secondary' : 'text-fg', s.state === 'current' && 'font-medium')}>{s.label}</span>
46
+ {s.detail && <span className={cn('text-meta', s.state === 'failed' ? 'text-error-text' : 'text-fg-secondary')}>{s.detail}</span>}
47
+ </li>
48
+ ))}
49
+ </ol>
50
+ );
51
+ }
@@ -0,0 +1,58 @@
1
+ import { Check, Lock } from 'lucide-react';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+
6
+ export type StepState = 'done' | 'current' | 'upcoming' | 'blocked';
7
+
8
+ export type Step = {
9
+ title: ReactNode;
10
+ state: StepState;
11
+ /** The second line: "Done 22 Sep", "Current step", "Blocked · opens at the Term sheet stage". */
12
+ detail?: ReactNode;
13
+ };
14
+
15
+ export type StepsProps = Omit<ComponentProps<'ol'>, 'children'> & {
16
+ steps: Step[];
17
+ /** Read out for each state (the marker shows it visually). */
18
+ stateLabels?: Partial<Record<StepState, string>> | undefined;
19
+ };
20
+
21
+ const STATE_LABELS: Record<StepState, string> = { done: 'Done', current: 'Current step', upcoming: 'Not started', blocked: 'Blocked' };
22
+
23
+ /**
24
+ * Steps — a path in four states: done (ink tick), current (brand disc,
25
+ * aria-current="step"), upcoming (outlined number), blocked (lock, with the
26
+ * reason in the detail line).
27
+ */
28
+ export function Steps({ steps, stateLabels, className, ...rest }: StepsProps) {
29
+ const said = { ...STATE_LABELS, ...stateLabels };
30
+ return (
31
+ <ol className={cn('m-0 flex list-none flex-col p-0', className)} {...rest}>
32
+ {steps.map((s, i) => (
33
+ <li key={i} aria-current={s.state === 'current' ? 'step' : undefined} className="relative flex gap-3 pb-4 last:pb-0">
34
+ {i < steps.length - 1 && <span aria-hidden className="absolute top-6 bottom-0 left-[11px] w-px bg-rule" />}
35
+ <span
36
+ aria-hidden
37
+ className={cn(
38
+ 'relative z-[1] grid size-[22px] shrink-0 place-items-center rounded-pill text-[11px] font-semibold tabular-nums',
39
+ s.state === 'done' && 'bg-fg text-page',
40
+ s.state === 'current' && 'bg-brand-mark text-ink',
41
+ s.state === 'upcoming' && 'border border-rule bg-page text-fg-secondary',
42
+ s.state === 'blocked' && 'bg-sunk text-fg-secondary',
43
+ )}
44
+ >
45
+ {s.state === 'done' ? <Check size={12} strokeWidth={2.5} /> : s.state === 'blocked' ? <Lock size={11} strokeWidth={2} /> : i + 1}
46
+ </span>
47
+ <div className="flex min-w-0 flex-col pt-0.5">
48
+ <span className={cn('text-ui font-medium', s.state === 'upcoming' || s.state === 'blocked' ? 'text-fg-secondary' : 'text-fg')}>
49
+ {s.title}
50
+ <span className="sr-only">, {said[s.state]}</span>
51
+ </span>
52
+ {s.detail && <span className="text-meta text-fg-secondary">{s.detail}</span>}
53
+ </div>
54
+ </li>
55
+ ))}
56
+ </ol>
57
+ );
58
+ }
@@ -0,0 +1,56 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { PulseDot } from './PulseDot';
5
+
6
+ /** synced (quiet) · syncing (pulsing brand dot) · error (red) · paused / off (grey ring). */
7
+ export type SyncState = 'synced' | 'syncing' | 'error' | 'paused' | 'off';
8
+
9
+ function Dot({ state }: { state: SyncState }) {
10
+ if (state === 'syncing') return <PulseDot size={8} />;
11
+ return (
12
+ <span
13
+ aria-hidden
14
+ className={cn(
15
+ 'inline-block size-2 shrink-0 rounded-pill',
16
+ state === 'error' && 'bg-error',
17
+ state === 'synced' && 'bg-fg-secondary',
18
+ (state === 'paused' || state === 'off') && 'border border-fg-secondary',
19
+ )}
20
+ />
21
+ );
22
+ }
23
+
24
+ export type SyncStatusProps = Omit<ComponentProps<'div'>, 'children'> & {
25
+ state: SyncState;
26
+ /** The wording, always present: "In sync", "Sync error", "Importing 2 batches". */
27
+ children: ReactNode;
28
+ /** Secondary detail: "Updated just now", "attempt 3". */
29
+ detail?: ReactNode;
30
+ /** One action, e.g. Retry. */
31
+ action?: ReactNode;
32
+ /** Dot only (the global indicator); the wording stays for screen readers and on hover. */
33
+ compact?: boolean | undefined;
34
+ };
35
+
36
+ /**
37
+ * SyncStatus — the global sync dot and the per-source sync lines ("Retrying
38
+ * after an error · attempt 3", "Paused by Wei Li", "Last synced 4 minutes
39
+ * ago"). Colour is never the only signal: the wording is always there.
40
+ */
41
+ export function SyncStatus({ state, children, detail, action, compact = false, className, title, ...rest }: SyncStatusProps) {
42
+ return (
43
+ <div
44
+ role="status"
45
+ data-state={state}
46
+ title={compact ? (title ?? (typeof children === 'string' ? children : undefined)) : title}
47
+ className={cn('inline-flex items-center gap-2 font-sans text-small', state === 'error' ? 'text-error-text' : 'text-fg', className)}
48
+ {...rest}
49
+ >
50
+ <Dot state={state} />
51
+ <span className={compact ? 'sr-only' : undefined}>{children}</span>
52
+ {detail && <span className={cn('text-meta text-fg-secondary', compact && 'sr-only')}>{detail}</span>}
53
+ {action && !compact && <span className="ml-1">{action}</span>}
54
+ </div>
55
+ );
56
+ }
@@ -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
+ }