@cosxai/ui 1.0.0-alpha.1 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +89 -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/AsyncSelect.tsx +205 -0
  7. package/src/components/AuthLayout.tsx +127 -0
  8. package/src/components/BrandLoader.tsx +122 -0
  9. package/src/components/Breadcrumb.tsx +129 -0
  10. package/src/components/Button.tsx +48 -40
  11. package/src/components/ChoiceCards.tsx +68 -0
  12. package/src/components/CodeInput.tsx +150 -0
  13. package/src/components/CommandPalette.tsx +228 -0
  14. package/src/components/ConfirmDialog.tsx +294 -0
  15. package/src/components/CopyField.tsx +104 -0
  16. package/src/components/DateInput.tsx +366 -0
  17. package/src/components/Dialog.tsx +27 -3
  18. package/src/components/Dropzone.tsx +187 -0
  19. package/src/components/FileCard.tsx +233 -0
  20. package/src/components/FileRow.tsx +159 -0
  21. package/src/components/FolderTree.tsx +377 -0
  22. package/src/components/ListToolbar.tsx +177 -0
  23. package/src/components/MentionInput.tsx +233 -0
  24. package/src/components/Menu.tsx +109 -0
  25. package/src/components/PageHeader.tsx +62 -0
  26. package/src/components/PageState.tsx +84 -0
  27. package/src/components/Progress.tsx +97 -0
  28. package/src/components/PulseDot.tsx +31 -0
  29. package/src/components/RecipientsInput.tsx +252 -0
  30. package/src/components/SearchField.tsx +137 -0
  31. package/src/components/SegmentedControl.tsx +95 -0
  32. package/src/components/SidePanel.tsx +130 -0
  33. package/src/components/Skeleton.tsx +73 -0
  34. package/src/components/StageProgress.tsx +51 -0
  35. package/src/components/Steps.tsx +58 -0
  36. package/src/components/SyncStatus.tsx +56 -0
  37. package/src/components/ThemeSwitch.tsx +99 -0
  38. package/src/components/Toast.tsx +45 -8
  39. package/src/components/UploadCheck.tsx +118 -0
  40. package/src/components/UploadList.tsx +176 -0
  41. package/src/components/VirtualList.tsx +90 -0
  42. package/src/components/WindowDrop.tsx +131 -0
  43. package/src/components/WorkspaceRow.tsx +80 -0
  44. package/src/components/inputs-aria.ts +5 -0
  45. package/src/components/inputs-date.ts +92 -0
  46. package/src/components/inputs-listbox.tsx +154 -0
  47. package/src/components/inputs-search.ts +60 -0
  48. package/src/components/status-files.ts +124 -0
  49. package/src/components/status-motion.ts +30 -0
  50. package/src/components/useSelection.ts +142 -0
  51. package/src/index.ts +45 -1
  52. package/src/theme-base.css +123 -0
  53. package/src/theme-reset.css +14 -0
  54. package/src/theme.css +2 -125
  55. package/src/components/core.test.tsx +0 -242
  56. package/src/components/forms.test.tsx +0 -280
  57. package/src/components/overlays.test.tsx +0 -245
@@ -0,0 +1,366 @@
1
+ import * as Popover from '@radix-ui/react-popover';
2
+ import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
4
+ import { DayPicker, type ChevronProps } from 'react-day-picker';
5
+ import { zhCN } from 'react-day-picker/locale';
6
+
7
+ import { cn } from '../lib/cn';
8
+ import { Field, useField } from './Field';
9
+ import { controlFrame, controlInner } from './forms-control';
10
+ import { SegmentedControl } from './SegmentedControl';
11
+ import { formatDate, fromDate, fromIso, langOf, parseDate, toDate, toIso, type PartialDate } from './inputs-date';
12
+
13
+ type Common = {
14
+ label?: ReactNode;
15
+ hint?: ReactNode;
16
+ /** An error of the caller's own (required, out of range). */
17
+ error?: ReactNode;
18
+ disabled?: boolean | undefined;
19
+ size?: 'sm' | 'md' | 'lg' | undefined;
20
+ id?: string | undefined;
21
+ name?: string | undefined;
22
+ className?: string | undefined;
23
+ /** Shown when the text cannot be read. */
24
+ formatHint?: ReactNode;
25
+ };
26
+
27
+ export type DateInputProps = Common & {
28
+ /** ISO date, YYYY-MM-DD; null when empty. */
29
+ value?: string | null | undefined;
30
+ defaultValue?: string | null | undefined;
31
+ /** ISO date, or null when cleared. Unreadable text is not a change. */
32
+ onChange?: ((value: string | null) => void) | undefined;
33
+ /** Accessible name of the calendar button. @default "Choose a date" */
34
+ calendarLabel?: string | undefined;
35
+ /** Earliest / latest choosable (ISO). */
36
+ min?: string | undefined;
37
+ max?: string | undefined;
38
+ placeholder?: string | undefined;
39
+ /** Start with the calendar open. */
40
+ defaultOpen?: boolean | undefined;
41
+ };
42
+
43
+ const calendarClasses = {
44
+ root: 'font-sans text-fg',
45
+ months: 'relative',
46
+ month: 'flex flex-col gap-2',
47
+ month_caption: 'flex h-8 items-center px-1 text-ui font-medium',
48
+ caption_label: '',
49
+ nav: 'absolute top-0 right-0 flex gap-1',
50
+ button_previous:
51
+ 'inline-grid size-8 cursor-pointer place-items-center rounded-md text-fg outline-none hover:bg-hover focus-visible:shadow-(--focus-ring) disabled:opacity-40',
52
+ button_next:
53
+ 'inline-grid size-8 cursor-pointer place-items-center rounded-md text-fg outline-none hover:bg-hover focus-visible:shadow-(--focus-ring) disabled:opacity-40',
54
+ month_grid: 'border-collapse',
55
+ weekdays: '',
56
+ weekday: 'h-8 w-9 text-meta font-medium text-fg-secondary',
57
+ week: '',
58
+ day: 'p-0 text-center',
59
+ day_button:
60
+ 'm-px inline-grid size-[34px] cursor-pointer place-items-center rounded-md text-[13px] tabular-nums text-fg outline-none hover:bg-hover focus-visible:shadow-(--focus-ring)',
61
+ selected: '[&>button]:bg-ink [&>button]:font-semibold [&>button]:text-linen [&>button]:hover:bg-ink-raised ink:[&>button]:bg-yellow-accent ink:[&>button]:text-ink ink:[&>button]:hover:bg-yellow-accent-hover',
62
+ today: '[&>button]:underline [&>button]:decoration-yellow-accent [&>button]:decoration-2 [&>button]:underline-offset-4',
63
+ outside: '[&>button]:text-fg-secondary [&>button]:opacity-50',
64
+ disabled: '[&>button]:cursor-not-allowed [&>button]:opacity-30',
65
+ hidden: 'invisible',
66
+ chevron: '',
67
+ };
68
+
69
+ function Chevron({ orientation }: ChevronProps) {
70
+ return orientation === 'left' ? <ChevronLeft size={15} aria-hidden /> : <ChevronRight size={15} aria-hidden />;
71
+ }
72
+
73
+ /** A Field is built only from the component's own label / hint / error —
74
+ * props that do not flip while typing — so the control never remounts when
75
+ * the format note comes and goes. Without one, the note sits below. */
76
+ function ownField(common: Common) {
77
+ return common.label !== undefined || common.hint !== undefined || common.error !== undefined;
78
+ }
79
+
80
+ function describedBy(common: Common, field: ReturnType<typeof useField>, unreadable: boolean, noteId: string) {
81
+ const ids = [field?.describedBy, unreadable && !ownField(common) ? noteId : undefined].filter(Boolean);
82
+ return ids.length ? ids.join(' ') : undefined;
83
+ }
84
+
85
+ function wrap(common: Common, invalidNote: ReactNode | null, control: ReactNode, noteId: string) {
86
+ const { label, hint, error } = common;
87
+ if (ownField(common)) {
88
+ return (
89
+ <Field label={label} hint={hint} error={error ?? invalidNote ?? undefined} disabled={common.disabled} id={common.id}>
90
+ {control}
91
+ </Field>
92
+ );
93
+ }
94
+ return (
95
+ <>
96
+ {control}
97
+ {invalidNote !== null && (
98
+ <span id={noteId} className="mt-1.5 block text-meta leading-normal text-error-text ink:text-error">
99
+ {invalidNote}
100
+ </span>
101
+ )}
102
+ </>
103
+ );
104
+ }
105
+
106
+ /**
107
+ * DateInput — type a date or pick one. Reads 12/03/2019 (day first),
108
+ * 2019-03-12, 12 Mar 2019 and 2019年3月12日; shows it back as 12 Mar 2019.
109
+ * The calendar sits on paper; the chosen day on the brand colour, today
110
+ * underlined in the accent.
111
+ */
112
+ export function DateInput(props: DateInputProps) {
113
+ const { value, defaultValue = null, onChange, calendarLabel = 'Choose a date', min, max, placeholder = 'e.g. 12 Mar 2019', defaultOpen = false, formatHint = 'Use a date like 12 Mar 2019 or 2019-03-12.', name } =
114
+ props;
115
+ const [own, setOwn] = useState<string | null>(defaultValue);
116
+ const iso = value === undefined ? own : value;
117
+ const parsed = fromIso(iso);
118
+ const frame = useRef<HTMLDivElement | null>(null);
119
+ const [lang, setLang] = useState<'en' | 'zh'>('en');
120
+ useEffect(() => setLang(langOf(frame.current)), []);
121
+ const [text, setText] = useState(parsed ? formatDate(parsed, lang) : '');
122
+ const [unreadable, setUnreadable] = useState(false);
123
+ const noteId = useId();
124
+ const [open, setOpen] = useState(defaultOpen);
125
+
126
+ // Follow value changes from outside.
127
+ useEffect(() => {
128
+ setText(parsed ? formatDate(parsed, lang) : '');
129
+ setUnreadable(false);
130
+ // eslint-disable-next-line react-hooks/exhaustive-deps
131
+ }, [iso, lang]);
132
+
133
+ const commit = (next: string | null) => {
134
+ if (value === undefined) setOwn(next);
135
+ onChange?.(next);
136
+ };
137
+ const readText = () => {
138
+ if (!text.trim()) {
139
+ setUnreadable(false);
140
+ if (iso !== null) commit(null);
141
+ return;
142
+ }
143
+ const p = parseDate(text);
144
+ if (!p) {
145
+ setUnreadable(true);
146
+ return;
147
+ }
148
+ setUnreadable(false);
149
+ const next = toIso(p);
150
+ setText(formatDate(p, lang));
151
+ if (next !== iso) commit(next);
152
+ };
153
+
154
+ const bounds = [min && fromIso(min) ? { before: toDate(fromIso(min)!) } : null, max && fromIso(max) ? { after: toDate(fromIso(max)!) } : null].filter(
155
+ (m): m is { before: Date } | { after: Date } => m !== null,
156
+ );
157
+
158
+ const control = (
159
+ <Inner {...props} frame={frame} invalid={unreadable}>
160
+ {(field) => (
161
+ <>
162
+ <input
163
+ id={props.id ?? field?.id}
164
+ value={text}
165
+ placeholder={placeholder}
166
+ disabled={props.disabled || field?.disabled}
167
+ aria-invalid={unreadable || Boolean(props.error) || field?.invalid || undefined}
168
+ aria-describedby={describedBy(props, field, unreadable, noteId)}
169
+ onChange={(e) => {
170
+ setText(e.target.value);
171
+ if (unreadable) setUnreadable(false);
172
+ }}
173
+ onBlur={readText}
174
+ onKeyDown={(e) => {
175
+ if (e.key === 'Enter') readText();
176
+ if (e.key === 'ArrowDown' && e.altKey) {
177
+ e.preventDefault();
178
+ setOpen(true);
179
+ }
180
+ }}
181
+ className={controlInner}
182
+ />
183
+ {name && <input type="hidden" name={name} value={iso ?? ''} />}
184
+ <Popover.Root open={open} onOpenChange={setOpen}>
185
+ <Popover.Trigger asChild disabled={props.disabled || field?.disabled}>
186
+ <button
187
+ type="button"
188
+ aria-label={calendarLabel}
189
+ className="-mr-1 inline-grid size-7 shrink-0 cursor-pointer place-items-center rounded-sm text-fg-secondary outline-none hover:bg-hover hover:text-fg focus-visible:shadow-(--focus-ring) disabled:cursor-not-allowed"
190
+ >
191
+ <CalendarDays size={15} aria-hidden />
192
+ </button>
193
+ </Popover.Trigger>
194
+ <Popover.Portal>
195
+ <Popover.Content
196
+ align="end"
197
+ sideOffset={6}
198
+ collisionPadding={12}
199
+ aria-label={calendarLabel}
200
+ // Focus lands on the chosen day (or today), not the first nav button.
201
+ onOpenAutoFocus={(e) => e.preventDefault()}
202
+ className="rise z-60 rounded-lg border border-rule bg-page p-3 [--motion-reveal:200ms]"
203
+ >
204
+ <DayPicker
205
+ autoFocus
206
+ {...(lang === 'zh' ? { locale: zhCN } : {})}
207
+ mode="single"
208
+ weekStartsOn={1}
209
+ {...(parsed?.day ? { selected: toDate(parsed), defaultMonth: toDate(parsed) } : {})}
210
+ onSelect={(d) => {
211
+ if (!d) return;
212
+ commit(toIso(fromDate(d)));
213
+ setOpen(false);
214
+ }}
215
+ disabled={bounds}
216
+ classNames={calendarClasses}
217
+ components={{ Chevron }}
218
+ />
219
+ </Popover.Content>
220
+ </Popover.Portal>
221
+ </Popover.Root>
222
+ </>
223
+ )}
224
+ </Inner>
225
+ );
226
+ return wrap(props, unreadable ? formatHint : null, control, noteId);
227
+ }
228
+
229
+ function Inner({
230
+ size,
231
+ disabled,
232
+ error,
233
+ className,
234
+ frame,
235
+ invalid,
236
+ children,
237
+ }: Common & { frame: React.RefObject<HTMLDivElement | null>; invalid: boolean; children: (field: ReturnType<typeof useField>) => ReactNode }) {
238
+ const field = useField();
239
+ const bad = invalid || Boolean(error) || Boolean(field?.invalid);
240
+ const off = Boolean(disabled || field?.disabled);
241
+ return (
242
+ <div ref={frame} className={cn(controlFrame({ size, invalid: bad, disabled: off }), className)}>
243
+ {children(field)}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ export type DatePrecision = 'day' | 'month' | 'year';
249
+
250
+ export type FuzzyDateInputProps = Common & {
251
+ /** YYYY-MM-DD, YYYY-MM or YYYY — or, when it could not be read, the text as typed. */
252
+ value?: string | null | undefined;
253
+ defaultValue?: string | null | undefined;
254
+ /**
255
+ * The date as ISO (partial allowed), `valid: false` with the raw text when
256
+ * it cannot be read — which still saves: the caller decides.
257
+ */
258
+ onChange?: ((value: string | null, meta: { valid: boolean }) => void) | undefined;
259
+ /** Precision labels. @default { day: 'Day', month: 'Month', year: 'Year', precision: 'Precision' } */
260
+ labels?: { day: string; month: string; year: string; precision?: string | undefined } | undefined;
261
+ };
262
+
263
+ const precisionOf = (p: PartialDate): DatePrecision => (p.day !== undefined ? 'day' : p.month !== undefined ? 'month' : 'year');
264
+ const EXAMPLE: Record<DatePrecision, string> = { day: '2019-03-12', month: '2019-03', year: '2019' };
265
+
266
+ /**
267
+ * FuzzyDateInput — a date people only partly remember (first entry to the
268
+ * UK: 2019-03). One field that reads 12/03/2019, 2019-03-12, 12 Mar 2019,
269
+ * Mar 2019 or 2019, and a Day · Month · Year switch that shows how much is
270
+ * known — choosing a coarser one drops the rest. If the text cannot be read
271
+ * it says the expected format — and still lets the form save.
272
+ */
273
+ export function FuzzyDateInput(props: FuzzyDateInputProps) {
274
+ const { value, defaultValue = null, onChange, labels: labelsIn, formatHint = 'Year required; day and month if known, e.g. 2019-03 or 12 Mar 2019.', size, name } = props;
275
+ const labels = { day: 'Day', month: 'Month', year: 'Year', precision: 'Precision', ...labelsIn };
276
+ const current = value === undefined ? defaultValue : value;
277
+ const [text, setText] = useState(current ?? '');
278
+ const [unreadable, setUnreadable] = useState(false);
279
+ const [wanted, setWanted] = useState<DatePrecision | null>(null);
280
+ const noteId = useId();
281
+ const input = useRef<HTMLInputElement | null>(null);
282
+
283
+ useEffect(() => {
284
+ if (value === undefined) return;
285
+ if (value === null || fromIso(value)) setText(value ?? '');
286
+ }, [value]);
287
+
288
+ const parsed = parseDate(text, { partial: true });
289
+ const precision = wanted ?? (parsed ? precisionOf(parsed) : 'month');
290
+
291
+ const commit = (raw: string) => {
292
+ const t = raw.trim();
293
+ if (!t) {
294
+ setUnreadable(false);
295
+ setText('');
296
+ onChange?.(null, { valid: true });
297
+ return;
298
+ }
299
+ const p = parseDate(t, { partial: true });
300
+ setUnreadable(!p);
301
+ if (p) {
302
+ setText(toIso(p));
303
+ setWanted(null);
304
+ }
305
+ onChange?.(p ? toIso(p) : t, { valid: Boolean(p) });
306
+ };
307
+
308
+ const choose = (next: DatePrecision) => {
309
+ const rank = { year: 0, month: 1, day: 2 };
310
+ if (parsed && rank[next] < rank[precisionOf(parsed)]) {
311
+ const cut: PartialDate = next === 'year' ? { year: parsed.year } : { year: parsed.year, month: parsed.month };
312
+ setText(toIso(cut));
313
+ setWanted(null);
314
+ onChange?.(toIso(cut), { valid: true });
315
+ return;
316
+ }
317
+ // Finer than what is known: say what to add, and let them type it.
318
+ setWanted(next);
319
+ input.current?.focus();
320
+ };
321
+
322
+ const control = (
323
+ <FuzzyField>
324
+ {(field) => (
325
+ <div className={cn('flex items-center gap-2', props.className)}>
326
+ <span className={cn(controlFrame({ size, invalid: unreadable || Boolean(props.error) || Boolean(field?.invalid), disabled: Boolean(props.disabled || field?.disabled) }), 'min-w-0 flex-1')}>
327
+ <input
328
+ ref={input}
329
+ id={props.id ?? field?.id}
330
+ value={text}
331
+ placeholder={`e.g. ${EXAMPLE[precision]}`}
332
+ autoComplete="off"
333
+ disabled={props.disabled || field?.disabled}
334
+ aria-invalid={unreadable || undefined}
335
+ aria-describedby={describedBy(props, field, unreadable, noteId)}
336
+ onChange={(e) => setText(e.target.value)}
337
+ onBlur={(e) => commit(e.target.value)}
338
+ onKeyDown={(e) => {
339
+ if (e.key === 'Enter') commit(e.currentTarget.value);
340
+ }}
341
+ className={cn(controlInner, 'tabular-nums')}
342
+ />
343
+ </span>
344
+ <SegmentedControl
345
+ aria-label={labels.precision}
346
+ value={precision}
347
+ onChange={(v) => choose(v as DatePrecision)}
348
+ segments={[
349
+ { value: 'day', label: labels.day },
350
+ { value: 'month', label: labels.month },
351
+ { value: 'year', label: labels.year },
352
+ ]}
353
+ className="shrink-0"
354
+ {...(size === 'sm' ? { size: 'sm' as const } : {})}
355
+ />
356
+ {name && <input type="hidden" name={name} value={parsed ? toIso(parsed) : text} />}
357
+ </div>
358
+ )}
359
+ </FuzzyField>
360
+ );
361
+ return wrap(props, unreadable ? formatHint : null, control, noteId);
362
+ }
363
+
364
+ function FuzzyField({ children }: { children: (field: ReturnType<typeof useField>) => ReactNode }) {
365
+ return <>{children(useField())}</>;
366
+ }
@@ -1,6 +1,6 @@
1
1
  import * as DialogPrimitive from '@radix-ui/react-dialog';
2
2
  import { X } from 'lucide-react';
3
- import type { ComponentProps, ReactNode } from 'react';
3
+ import { useRef, type ComponentProps, type ReactNode } from 'react';
4
4
 
5
5
  import { cn } from '../lib/cn';
6
6
  import { IconButton } from './IconButton';
@@ -12,7 +12,15 @@ export const DialogTrigger = DialogPrimitive.Trigger;
12
12
  /** Closes the dialog (asChild onto the Cancel button). */
13
13
  export const DialogClose = DialogPrimitive.Close;
14
14
 
15
- const WIDTH = { sm: 'sm:max-w-[400px]', md: 'sm:max-w-[520px]', lg: 'sm:max-w-[640px]' } as const;
15
+ // sm/md/lg are the site's 400 / 520 / 640; xl (a wizard) and split (a
16
+ // document beside its detail) are the Metaroom spec's 800 and 1120.
17
+ const WIDTH = {
18
+ sm: 'sm:max-w-[400px]',
19
+ md: 'sm:max-w-[520px]',
20
+ lg: 'sm:max-w-[640px]',
21
+ xl: 'sm:max-w-[800px]',
22
+ split: 'sm:max-w-[1120px]',
23
+ } as const;
16
24
 
17
25
  const MOBILE = {
18
26
  // Confirmations stay centred.
@@ -32,7 +40,7 @@ export type DialogContentProps = Omit<ComponentProps<typeof DialogPrimitive.Cont
32
40
  description?: ReactNode;
33
41
  /** Actions, bottom right: secondary left, primary right. A destructive primary is Button variant="danger". */
34
42
  footer?: ReactNode;
35
- /** sm 400 (confirm) · md 520 (form, up to five fields) · lg 640. @default "md" */
43
+ /** sm 400 (confirm) · md 520 (form, up to five fields) · lg 640 · xl 800 (wizard) · split 1120 (two panes). @default "md" */
36
44
  size?: keyof typeof WIDTH | undefined;
37
45
  /** How it shows on phones: center (confirmations) · page (long forms) · sheet (light choices). @default "center" */
38
46
  mobile?: keyof typeof MOBILE | undefined;
@@ -60,8 +68,13 @@ export function DialogContent({
60
68
  className,
61
69
  children,
62
70
  onOpenAutoFocus,
71
+ onCloseAutoFocus,
63
72
  ...rest
64
73
  }: DialogContentProps) {
74
+ // Whatever had focus when it opened gets it back on close — a
75
+ // DialogTrigger or a button that set `open` (Radix only restores its own
76
+ // Trigger).
77
+ const opener = useRef<Element | null>(null);
65
78
  return (
66
79
  <DialogPrimitive.Portal>
67
80
  <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-scrim" />
@@ -73,11 +86,22 @@ export function DialogContent({
73
86
  close button: no ring before the person has done anything. Tab
74
87
  then reaches the controls; focus stays trapped. */
75
88
  onOpenAutoFocus={(e) => {
89
+ // Focus has not moved yet: this is what opened it.
90
+ opener.current = document.activeElement;
76
91
  onOpenAutoFocus?.(e);
77
92
  if (e.defaultPrevented) return;
78
93
  e.preventDefault();
79
94
  (e.currentTarget as HTMLElement | null)?.focus();
80
95
  }}
96
+ onCloseAutoFocus={(e) => {
97
+ onCloseAutoFocus?.(e);
98
+ if (e.defaultPrevented) return;
99
+ const el = opener.current;
100
+ if (el instanceof HTMLElement && el.isConnected && el !== document.body) {
101
+ e.preventDefault();
102
+ el.focus();
103
+ }
104
+ }}
81
105
  className={cn(
82
106
  'fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-48px)] w-[calc(100%-48px)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden',
83
107
  'rounded-lg border border-rule bg-page font-sans text-fg outline-none',
@@ -0,0 +1,187 @@
1
+ import { FileX, Upload } from 'lucide-react';
2
+ import { useRef, 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 { checkFiles, DEFAULT_MAX_SIZE, formatBytes, fromInput, hasFiles, readDataTransfer, type DroppedFile, type FileRules, type Rejected } from './status-files';
8
+ import { useWindowDrop } from './WindowDrop';
9
+
10
+ export type DropzoneLabels = {
11
+ idle?: string;
12
+ limit?: (maxSize: number) => string;
13
+ dragging?: string;
14
+ release?: (target: string) => string;
15
+ choose?: string;
16
+ chooseFolder?: string;
17
+ compact?: string;
18
+ refused?: (r: Rejected) => string;
19
+ };
20
+
21
+ export type DropzoneProps = Omit<ComponentProps<'div'>, 'onDrop' | 'children'> &
22
+ FileRules & {
23
+ /** Allow several files. @default true */
24
+ multiple?: boolean | undefined;
25
+ /** Offer "Choose a folder" too. */
26
+ directory?: boolean | undefined;
27
+ /** Folder name shown on hover: "Release to upload to <target>". */
28
+ target?: string | undefined;
29
+ /** One line, for list views. */
30
+ compact?: boolean | undefined;
31
+ disabled?: boolean | undefined;
32
+ /** Files that passed the checks, with folder paths. */
33
+ onFiles: (files: DroppedFile[]) => void;
34
+ /** Files refused, with the reason. */
35
+ onReject?: ((items: Rejected[]) => void) | undefined;
36
+ labels?: DropzoneLabels | undefined;
37
+ /** Extra actions beside Choose files. */
38
+ actions?: ReactNode;
39
+ };
40
+
41
+ export function refusedText(r: Rejected, maxSize = DEFAULT_MAX_SIZE): string {
42
+ if (r.reason === 'type') {
43
+ const ext = r.file.name.includes('.') ? r.file.name.slice(r.file.name.lastIndexOf('.')) : r.file.name;
44
+ return `${ext} files aren’t supported`;
45
+ }
46
+ if (r.reason === 'size') return `Files over ${formatBytes(maxSize)} aren’t supported`;
47
+ return 'Folders nested this deep aren’t supported';
48
+ }
49
+
50
+ /**
51
+ * Dropzone — drop files or folders here, or choose them (a real button:
52
+ * drag and drop is never the only way). Idle: a dashed edge; while files
53
+ * are dragged into the window: lit; over the zone: the yellow field naming
54
+ * the folder; refused: says why, not just red.
55
+ */
56
+ export function Dropzone({
57
+ accept,
58
+ maxSize = DEFAULT_MAX_SIZE,
59
+ maxDepth,
60
+ multiple = true,
61
+ directory = false,
62
+ target,
63
+ compact = false,
64
+ disabled = false,
65
+ onFiles,
66
+ onReject,
67
+ labels,
68
+ actions,
69
+ className,
70
+ ...rest
71
+ }: DropzoneProps) {
72
+ const { dragging, setHoverTarget } = useWindowDrop();
73
+ const [over, setOver] = useState(false);
74
+ const [refused, setRefused] = useState<Rejected | null>(null);
75
+ const files = useRef<HTMLInputElement | null>(null);
76
+ const folder = useRef<HTMLInputElement | null>(null);
77
+ const depth = useRef(0);
78
+
79
+ const take = (items: DroppedFile[]) => {
80
+ const { accepted, rejected } = checkFiles(items, { accept, maxSize, maxDepth });
81
+ const kept = multiple ? accepted : accepted.slice(0, 1);
82
+ setRefused(kept.length === 0 && rejected[0] ? rejected[0] : null);
83
+ if (kept.length) onFiles(kept);
84
+ if (rejected.length) onReject?.(rejected);
85
+ };
86
+
87
+ const release = labels?.release ?? ((t: string) => `Release to upload to ${t}`);
88
+ const state = disabled ? 'disabled' : over ? 'over' : refused ? 'refused' : dragging ? 'dragging' : 'idle';
89
+
90
+ return (
91
+ <div
92
+ data-state={state}
93
+ onDragEnter={(e) => {
94
+ if (disabled || !hasFiles(e.dataTransfer)) return;
95
+ depth.current += 1;
96
+ setOver(true);
97
+ setRefused(null);
98
+ if (target) setHoverTarget(target);
99
+ }}
100
+ onDragOver={(e) => {
101
+ if (disabled || !hasFiles(e.dataTransfer)) return;
102
+ e.preventDefault();
103
+ e.dataTransfer.dropEffect = 'copy';
104
+ }}
105
+ onDragLeave={(e) => {
106
+ if (!hasFiles(e.dataTransfer)) return;
107
+ depth.current = Math.max(0, depth.current - 1);
108
+ if (depth.current === 0) {
109
+ setOver(false);
110
+ setHoverTarget(null);
111
+ }
112
+ }}
113
+ onDrop={(e) => {
114
+ if (disabled || !hasFiles(e.dataTransfer)) return;
115
+ e.preventDefault();
116
+ e.stopPropagation(); // the window layer must not also take it
117
+ depth.current = 0;
118
+ setOver(false);
119
+ setHoverTarget(null);
120
+ void readDataTransfer(e.dataTransfer).then(take);
121
+ }}
122
+ className={cn(
123
+ 'flex rounded-lg border font-sans transition-[background-color,border-color] duration-[120ms] ease-standard',
124
+ compact ? 'items-center justify-between gap-3 px-4 py-2.5' : 'flex-col items-center justify-center gap-2 px-6 py-8 text-center',
125
+ state === 'idle' && 'border-dashed border-rule bg-transparent',
126
+ state === 'dragging' && 'border-dashed border-fg bg-yellow-wash-20 ink:bg-sunk',
127
+ state === 'over' && 'border-solid border-ink bg-brand-field text-ink',
128
+ state === 'refused' && 'border-dashed border-error bg-error-wash text-error-text ink:bg-error/20 ink:text-fg',
129
+ state === 'disabled' && 'border-dashed border-rule opacity-40',
130
+ className,
131
+ )}
132
+ {...rest}
133
+ >
134
+ <div className={cn('flex items-center', compact ? 'gap-2' : 'flex-col gap-2')}>
135
+ <Icon icon={state === 'refused' ? FileX : Upload} size={compact ? 16 : 20} />
136
+ <span aria-live="polite" className={cn('font-medium', compact ? 'text-small' : 'text-ui')}>
137
+ {state === 'over' && target
138
+ ? release(target)
139
+ : state === 'refused' && refused
140
+ ? (labels?.refused ?? ((r: Rejected) => refusedText(r, maxSize)))(refused)
141
+ : state === 'dragging' || state === 'over'
142
+ ? (labels?.dragging ?? 'Drop here')
143
+ : compact
144
+ ? (labels?.compact ?? 'Drop files or choose')
145
+ : (labels?.idle ?? (directory ? 'Drop files or folders' : 'Drop files'))}
146
+ </span>
147
+ {!compact && state === 'idle' && <span className="text-meta text-fg-secondary">{(labels?.limit ?? ((n: number) => `Up to ${formatBytes(n)} each`))(maxSize)}</span>}
148
+ </div>
149
+ <div className={cn('flex items-center gap-2', !compact && 'mt-2')}>
150
+ <Button size="sm" variant={compact ? 'secondary' : 'primary'} disabled={disabled} onClick={() => files.current?.click()}>
151
+ {labels?.choose ?? 'Choose files'}
152
+ </Button>
153
+ {directory && (
154
+ <Button size="sm" variant="ghost" disabled={disabled} onClick={() => folder.current?.click()}>
155
+ {labels?.chooseFolder ?? 'Choose a folder'}
156
+ </Button>
157
+ )}
158
+ {actions}
159
+ </div>
160
+ <input
161
+ ref={files}
162
+ type="file"
163
+ hidden
164
+ tabIndex={-1}
165
+ multiple={multiple}
166
+ accept={accept}
167
+ onChange={(e) => {
168
+ take(fromInput(e.target.files));
169
+ e.target.value = '';
170
+ }}
171
+ />
172
+ {directory && (
173
+ <input
174
+ ref={folder}
175
+ type="file"
176
+ hidden
177
+ tabIndex={-1}
178
+ {...({ webkitdirectory: '' } as object)}
179
+ onChange={(e) => {
180
+ take(fromInput(e.target.files));
181
+ e.target.value = '';
182
+ }}
183
+ />
184
+ )}
185
+ </div>
186
+ );
187
+ }