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

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 (62) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/package.json +10 -4
  3. package/src/brand.css +1 -1
  4. package/src/components/ActionBar.tsx +607 -0
  5. package/src/components/ActivityTimeline.tsx +77 -0
  6. package/src/components/AppRail.tsx +141 -0
  7. package/src/components/AsyncSelect.tsx +205 -0
  8. package/src/components/AuthLayout.tsx +127 -0
  9. package/src/components/Avatar.tsx +75 -0
  10. package/src/components/BottomSheet.tsx +151 -0
  11. package/src/components/BottomTabs.tsx +102 -0
  12. package/src/components/BrandLoader.tsx +122 -0
  13. package/src/components/Breadcrumb.tsx +129 -0
  14. package/src/components/Button.tsx +48 -40
  15. package/src/components/ChoiceCards.tsx +68 -0
  16. package/src/components/CodeInput.tsx +150 -0
  17. package/src/components/CommandPalette.tsx +228 -0
  18. package/src/components/ConfirmDialog.tsx +294 -0
  19. package/src/components/CopyField.tsx +104 -0
  20. package/src/components/DateInput.tsx +366 -0
  21. package/src/components/Dialog.tsx +27 -3
  22. package/src/components/Dropzone.tsx +187 -0
  23. package/src/components/FileCard.tsx +233 -0
  24. package/src/components/FileRow.tsx +159 -0
  25. package/src/components/FolderTree.tsx +377 -0
  26. package/src/components/ListToolbar.tsx +177 -0
  27. package/src/components/MentionInput.tsx +233 -0
  28. package/src/components/Menu.tsx +109 -0
  29. package/src/components/PageHeader.tsx +62 -0
  30. package/src/components/PageState.tsx +84 -0
  31. package/src/components/Popover.tsx +46 -0
  32. package/src/components/Progress.tsx +97 -0
  33. package/src/components/PulseDot.tsx +31 -0
  34. package/src/components/RecipientsInput.tsx +252 -0
  35. package/src/components/SearchField.tsx +137 -0
  36. package/src/components/SegmentedControl.tsx +95 -0
  37. package/src/components/SidePanel.tsx +130 -0
  38. package/src/components/Skeleton.tsx +73 -0
  39. package/src/components/StageProgress.tsx +51 -0
  40. package/src/components/Steps.tsx +58 -0
  41. package/src/components/SyncStatus.tsx +56 -0
  42. package/src/components/ThemeSwitch.tsx +99 -0
  43. package/src/components/Toast.tsx +45 -8
  44. package/src/components/UploadCheck.tsx +118 -0
  45. package/src/components/UploadList.tsx +176 -0
  46. package/src/components/VirtualList.tsx +90 -0
  47. package/src/components/WindowDrop.tsx +131 -0
  48. package/src/components/WorkspaceRow.tsx +80 -0
  49. package/src/components/inputs-aria.ts +5 -0
  50. package/src/components/inputs-date.ts +92 -0
  51. package/src/components/inputs-listbox.tsx +154 -0
  52. package/src/components/inputs-search.ts +60 -0
  53. package/src/components/status-files.ts +124 -0
  54. package/src/components/status-motion.ts +30 -0
  55. package/src/components/useSelection.ts +142 -0
  56. package/src/index.ts +52 -1
  57. package/src/theme-base.css +123 -0
  58. package/src/theme-reset.css +14 -0
  59. package/src/theme.css +2 -125
  60. package/src/components/core.test.tsx +0 -242
  61. package/src/components/forms.test.tsx +0 -280
  62. 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,99 @@
1
+ import { Monitor, Moon, Sun, type LucideIcon } from 'lucide-react';
2
+ import { useRef, useState, type ComponentProps } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { useTheme, type ThemeMode } from './ThemeProvider';
6
+
7
+ export type ThemeSwitchLabels = {
8
+ /** @default "Theme" */
9
+ group?: string | undefined;
10
+ /** @default "Theme: match system" */
11
+ system?: string | undefined;
12
+ /** @default "Theme: light" */
13
+ light?: string | undefined;
14
+ /** @default "Theme: dark" */
15
+ ink?: string | undefined;
16
+ };
17
+
18
+ export type ThemeSwitchProps = Omit<ComponentProps<'div'>, 'onChange' | 'children'> & {
19
+ /** The choice. Unset: the ThemeProvider's. */
20
+ value?: ThemeMode | undefined;
21
+ /** Unset: the ThemeProvider's setter. */
22
+ onChange?: ((mode: ThemeMode) => void) | undefined;
23
+ labels?: ThemeSwitchLabels | undefined;
24
+ /** Start opened (documentation specimens); it folds once the pointer leaves. */
25
+ defaultOpen?: boolean | undefined;
26
+ };
27
+
28
+ /** A click on an option only picks it once the switch has been open this long. */
29
+ const PICK_AFTER_MS = 800;
30
+
31
+ /**
32
+ * ThemeSwitch — match system · light · dark, folded to the current choice.
33
+ * Hovering or focusing opens all three. A click anywhere steps to the next;
34
+ * a click on an option picks it only once the switch has been open a moment,
35
+ * so the first tap on touch (which opens it) steps instead of landing on
36
+ * whatever slid under the finger.
37
+ */
38
+ export function ThemeSwitch({ value, onChange, labels, defaultOpen = false, className, ...rest }: ThemeSwitchProps) {
39
+ const theme = useTheme();
40
+ const mode = value ?? theme.mode;
41
+ const setMode = onChange ?? theme.setMode;
42
+ const [open, setOpen] = useState(defaultOpen);
43
+ const openedAt = useRef(0);
44
+ const show = () => {
45
+ if (open) return;
46
+ openedAt.current = Date.now();
47
+ setOpen(true);
48
+ };
49
+ const options: Array<[ThemeMode, LucideIcon, string]> = [
50
+ ['system', Monitor, labels?.system ?? 'Theme: match system'],
51
+ ['light', Sun, labels?.light ?? 'Theme: light'],
52
+ ['ink', Moon, labels?.ink ?? 'Theme: dark'],
53
+ ];
54
+ const cycle = () => {
55
+ const i = options.findIndex(([v]) => v === mode);
56
+ setMode(options[(i + 1) % options.length]![0]);
57
+ };
58
+ return (
59
+ <div
60
+ role="radiogroup"
61
+ aria-label={labels?.group ?? 'Theme'}
62
+ className={cn('inline-flex cursor-pointer rounded-[10px] bg-sunk p-[3px] transition-[gap] duration-[180ms] ease-standard', open ? 'gap-0.5' : 'gap-0', className)}
63
+ onClick={cycle}
64
+ onMouseEnter={show}
65
+ onMouseLeave={() => setOpen(false)}
66
+ onFocus={show}
67
+ onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && setOpen(false)}
68
+ {...rest}
69
+ >
70
+ {options.map(([v, Glyph, label]) => {
71
+ const on = mode === v;
72
+ const shown = open || on;
73
+ return (
74
+ <button
75
+ key={v}
76
+ type="button"
77
+ role="radio"
78
+ aria-checked={on}
79
+ aria-label={label}
80
+ title={label}
81
+ tabIndex={shown ? 0 : -1}
82
+ onClick={(e) => {
83
+ e.stopPropagation();
84
+ if (open && Date.now() - openedAt.current > PICK_AFTER_MS) setMode(v);
85
+ else cycle();
86
+ }}
87
+ className={cn(
88
+ 'grid h-7 cursor-pointer place-items-center overflow-hidden rounded-[8px] border-0 p-0 outline-none transition-[width,opacity] duration-[180ms] ease-standard focus-visible:shadow-(--focus-ring)',
89
+ shown ? 'w-[30px] opacity-100' : 'w-0 opacity-0',
90
+ on ? 'bg-page text-fg' : 'bg-transparent text-fg-secondary',
91
+ )}
92
+ >
93
+ <Glyph size={15} strokeWidth={1.75} aria-hidden />
94
+ </button>
95
+ );
96
+ })}
97
+ </div>
98
+ );
99
+ }
@@ -91,6 +91,47 @@ const DEFAULT_LABELS: ToastLabels = {
91
91
  region: 'Notifications',
92
92
  };
93
93
 
94
+ const CARD = 'flex w-80 max-w-full items-start gap-3 rounded-[12px] bg-ink px-4 py-3.5 font-sans text-linen ink:border ink:border-inv-rule ink:bg-ink-raised';
95
+ const ACTION =
96
+ 'mt-2.5 inline-flex h-8 cursor-pointer items-center rounded-md border border-inv-rule bg-transparent px-3 text-[13px] font-semibold text-linen outline-none hover:border-linen focus-visible:shadow-[0_0_0_3px_var(--ink),0_0_0_5px_var(--yellow)]';
97
+ const CLOSE = 'flex cursor-pointer rounded-xs border-0 bg-transparent p-0.5 text-grey-inverse outline-none hover:text-linen focus-visible:shadow-[0_0_0_2px_var(--yellow)]';
98
+ const statusLabelClass = (status: ToastStatus) => cn('mb-0.5 text-meta font-medium', status === 'attention' ? 'text-yellow-accent' : 'text-grey-inverse');
99
+
100
+ export type ToastCardProps = Omit<ToastOptions, 'duration' | 'action'> & {
101
+ /** A label, or the full action (label + onClick). */
102
+ action?: string | ToastOptions['action'];
103
+ onClose?: (() => void) | undefined;
104
+ labels?: Partial<ToastLabels> | undefined;
105
+ className?: string | undefined;
106
+ };
107
+
108
+ /**
109
+ * ToastCard — one toast drawn in place, not queued: documentation
110
+ * specimens and previews. In the product, call toast() instead.
111
+ */
112
+ export function ToastCard({ title, description, status = 'neutral', action, onClose, labels, className }: ToastCardProps) {
113
+ const l = { ...DEFAULT_LABELS, ...labels };
114
+ const act = typeof action === 'string' ? { label: action, onClick: () => {} } : action;
115
+ return (
116
+ <div className={cn(CARD, className)}>
117
+ <span aria-hidden className={cn('mt-[5px] size-2 shrink-0 rounded-pill', DOT[status])} />
118
+ <div className="min-w-0 flex-1">
119
+ <div className={statusLabelClass(status)}>{l[status]}</div>
120
+ <div className="text-ui leading-[1.4] font-medium">{title}</div>
121
+ {description && <div className="mt-0.5 text-[13px] leading-normal text-grey-inverse">{description}</div>}
122
+ {act && (
123
+ <button type="button" onClick={act.onClick} className={ACTION}>
124
+ {act.label}
125
+ </button>
126
+ )}
127
+ </div>
128
+ <button type="button" aria-label={l.dismiss} onClick={onClose} className={CLOSE}>
129
+ <X size={14} strokeWidth={2} aria-hidden />
130
+ </button>
131
+ </div>
132
+ );
133
+ }
134
+
94
135
  export type ToasterProps = {
95
136
  /** Wording of the status labels and controls (e.g. Chinese). */
96
137
  labels?: Partial<ToastLabels> | undefined;
@@ -120,22 +161,18 @@ export function Toaster({ labels, offset = 24 }: ToasterProps) {
120
161
  }}
121
162
  duration={t.duration ?? (t.action ? 8000 : 4000)}
122
163
  type={status === 'error' ? 'foreground' : 'background'}
123
- className={cn(
124
- 'flex w-80 max-w-full items-start gap-3 rounded-[12px] bg-ink px-4 py-3.5 font-sans text-linen',
125
- 'ink:border ink:border-inv-rule ink:bg-ink-raised',
126
- 'data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=end]:hidden',
127
- )}
164
+ className={cn(CARD, 'data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=end]:hidden')}
128
165
  >
129
166
  <span aria-hidden className={cn('mt-[5px] size-2 shrink-0 rounded-pill', DOT[status])} />
130
167
  <div className="min-w-0 flex-1">
131
- <div className={cn('mb-0.5 text-meta font-medium', status === 'attention' ? 'text-yellow-accent' : 'text-grey-inverse')}>{l[status]}</div>
168
+ <div className={statusLabelClass(status)}>{l[status]}</div>
132
169
  <ToastPrimitive.Title className="text-ui leading-[1.4] font-medium">{t.title}</ToastPrimitive.Title>
133
170
  {t.description && <ToastPrimitive.Description className="mt-0.5 text-[13px] leading-normal text-grey-inverse">{t.description}</ToastPrimitive.Description>}
134
171
  {t.action && (
135
172
  <ToastPrimitive.Action
136
173
  altText={t.action.altText ?? t.action.label}
137
174
  onClick={t.action.onClick}
138
- className="mt-2.5 inline-flex h-8 cursor-pointer items-center rounded-md border border-inv-rule bg-transparent px-3 text-[13px] font-semibold text-linen outline-none hover:border-linen focus-visible:shadow-[0_0_0_3px_var(--ink),0_0_0_5px_var(--yellow)]"
175
+ className={ACTION}
139
176
  >
140
177
  {t.action.label}
141
178
  </ToastPrimitive.Action>
@@ -143,7 +180,7 @@ export function Toaster({ labels, offset = 24 }: ToasterProps) {
143
180
  </div>
144
181
  <ToastPrimitive.Close
145
182
  aria-label={l.dismiss}
146
- className="flex cursor-pointer rounded-xs border-0 bg-transparent p-0.5 text-grey-inverse outline-none hover:text-linen focus-visible:shadow-[0_0_0_2px_var(--yellow)]"
183
+ className={CLOSE}
147
184
  >
148
185
  <X size={14} strokeWidth={2} aria-hidden />
149
186
  </ToastPrimitive.Close>
@@ -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
+ }