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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +22 -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 +17 -2
  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,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,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
+ }
@@ -0,0 +1,92 @@
1
+ // Date parsing for DateInput / FuzzyDateInput. Values are ISO strings
2
+ // (YYYY-MM-DD, or YYYY-MM / YYYY for a fuzzy date) — no time zones.
3
+
4
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
5
+ const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
6
+
7
+ export type PartialDate = { year: number; month?: number | undefined; day?: number | undefined };
8
+
9
+ const pad = (n: number) => String(n).padStart(2, '0');
10
+
11
+ export function daysIn(year: number, month: number): number {
12
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
13
+ }
14
+
15
+ function valid(p: PartialDate): boolean {
16
+ if (!Number.isInteger(p.year) || p.year < 1000 || p.year > 9999) return false;
17
+ if (p.month === undefined) return p.day === undefined;
18
+ if (p.month < 1 || p.month > 12) return false;
19
+ if (p.day === undefined) return true;
20
+ return p.day >= 1 && p.day <= daysIn(p.year, p.month);
21
+ }
22
+
23
+ function monthOf(word: string): number | undefined {
24
+ const i = MONTHS.indexOf(word.slice(0, 3).toLowerCase());
25
+ return i < 0 ? undefined : i + 1;
26
+ }
27
+
28
+ /**
29
+ * Parse what people type: 12/03/2019 (day first, British), 12.03.2019,
30
+ * 2019-03-12, 12 Mar 2019, March 12 2019, 2019年3月12日 — and, when
31
+ * `partial`, 2019-03, 03/2019, Mar 2019, 2019年3月, 2019. Returns null when
32
+ * it cannot tell.
33
+ */
34
+ export function parseDate(text: string, { partial = false }: { partial?: boolean } = {}): PartialDate | null {
35
+ const t = text.trim().replace(/\s+/g, ' ');
36
+ if (!t) return null;
37
+ let m: RegExpMatchArray | null;
38
+ let p: PartialDate | null = null;
39
+ if ((m = t.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/))) p = { year: +m[1]!, month: +m[2]!, day: +m[3]! };
40
+ else if ((m = t.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$/))) p = { year: +m[3]!, month: +m[2]!, day: +m[1]! };
41
+ else if ((m = t.match(/^(\d{1,2}) ([a-z]+)\.?,? (\d{4})$/i)) && monthOf(m[2]!)) p = { year: +m[3]!, month: monthOf(m[2]!), day: +m[1]! };
42
+ else if ((m = t.match(/^([a-z]+)\.? (\d{1,2}),? (\d{4})$/i)) && monthOf(m[1]!)) p = { year: +m[3]!, month: monthOf(m[1]!), day: +m[2]! };
43
+ else if ((m = t.match(/^(\d{4}) ?年 ?(\d{1,2}) ?月 ?(\d{1,2}) ?日?$/))) p = { year: +m[1]!, month: +m[2]!, day: +m[3]! };
44
+ else if (partial) {
45
+ if ((m = t.match(/^(\d{4})[-/.](\d{1,2})$/))) p = { year: +m[1]!, month: +m[2]! };
46
+ else if ((m = t.match(/^(\d{1,2})[-/.](\d{4})$/))) p = { year: +m[2]!, month: +m[1]! };
47
+ else if ((m = t.match(/^([a-z]+)\.? (\d{4})$/i)) && monthOf(m[1]!)) p = { year: +m[2]!, month: monthOf(m[1]!) };
48
+ else if ((m = t.match(/^(\d{4}) ?年 ?(\d{1,2}) ?月$/))) p = { year: +m[1]!, month: +m[2]! };
49
+ else if ((m = t.match(/^(\d{4})$/))) p = { year: +m[1]! };
50
+ }
51
+ return p && valid(p) ? p : null;
52
+ }
53
+
54
+ /** 2019-03-12 · 2019-03 · 2019 */
55
+ export function toIso(p: PartialDate): string {
56
+ if (p.month === undefined) return String(p.year);
57
+ if (p.day === undefined) return `${p.year}-${pad(p.month)}`;
58
+ return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
59
+ }
60
+
61
+ /** Inverse of toIso; null for anything else. */
62
+ export function fromIso(iso: string | null | undefined): PartialDate | null {
63
+ if (!iso) return null;
64
+ const m = iso.match(/^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/);
65
+ if (!m) return null;
66
+ const p: PartialDate = { year: +m[1]!, month: m[2] ? +m[2] : undefined, day: m[3] ? +m[3] : undefined };
67
+ return valid(p) ? p : null;
68
+ }
69
+
70
+ /** 12 Mar 2019 · Mar 2019 · 2019 — or 2019 年 3 月 12 日 in Chinese. */
71
+ export function formatDate(p: PartialDate, lang: 'en' | 'zh' = 'en'): string {
72
+ if (lang === 'zh') {
73
+ return `${p.year} 年${p.month ? ` ${p.month} 月` : ''}${p.day ? ` ${p.day} 日` : ''}`;
74
+ }
75
+ if (p.month === undefined) return String(p.year);
76
+ const mon = MONTH_NAMES[p.month - 1]!;
77
+ return p.day === undefined ? `${mon} ${p.year}` : `${p.day} ${mon} ${p.year}`;
78
+ }
79
+
80
+ export function toDate(p: PartialDate): Date {
81
+ return new Date(p.year, (p.month ?? 1) - 1, p.day ?? 1);
82
+ }
83
+
84
+ export function fromDate(d: Date): PartialDate {
85
+ return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
86
+ }
87
+
88
+ /** zh when the element (or page) is Chinese. */
89
+ export function langOf(el: Element | null): 'en' | 'zh' {
90
+ const lang = (el?.closest('[lang]')?.getAttribute('lang') ?? (typeof document !== 'undefined' ? document.documentElement.lang : '')).toLowerCase();
91
+ return lang.startsWith('zh') ? 'zh' : 'en';
92
+ }
@@ -0,0 +1,154 @@
1
+ import { Plus } from 'lucide-react';
2
+ import type { ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import type { SearchStatus } from './inputs-search';
6
+ import { Spinner } from './Spinner';
7
+
8
+ export type ListItem = {
9
+ value: string;
10
+ label: string;
11
+ /** Right-hand line: Customer · 3 projects. */
12
+ meta?: string | undefined;
13
+ /** Initials in the avatar square (HV). Derived from the label if omitted. */
14
+ initials?: string | undefined;
15
+ };
16
+
17
+ export function initialsOf(item: ListItem): string {
18
+ if (item.initials) return item.initials;
19
+ const words = item.label.trim().split(/\s+/);
20
+ const first = words[0] ?? '';
21
+ // One hanzi reads as an initial on its own (王).
22
+ if (/[㐀-鿿]/.test(first)) return first[0]!;
23
+ return ((first[0] ?? '') + (words.length > 1 ? (words[words.length - 1]![0] ?? '') : '')).toUpperCase();
24
+ }
25
+
26
+ export function Avatar({ item, className }: { item: ListItem; className?: string | undefined }) {
27
+ return (
28
+ <span aria-hidden className={cn('inline-grid size-6 shrink-0 place-items-center rounded-pill bg-well text-[10.5px] font-semibold text-fg', className)}>
29
+ {initialsOf(item)}
30
+ </span>
31
+ );
32
+ }
33
+
34
+ export type ListboxLabels = {
35
+ searching: string;
36
+ empty: (query: string) => string;
37
+ error: string;
38
+ retry: string;
39
+ create: (query: string) => string;
40
+ };
41
+
42
+ export const DEFAULT_LABELS: ListboxLabels = {
43
+ searching: 'Searching',
44
+ empty: (q) => `Nothing matches “${q}”.`,
45
+ error: 'Couldn’t load results.',
46
+ retry: 'Retry',
47
+ create: (q) => `Create “${q}”`,
48
+ };
49
+
50
+ /** Rows of an async listbox: results, then an optional Create row; or one state row. */
51
+ export function ListboxBody({
52
+ id,
53
+ optionId,
54
+ items,
55
+ active,
56
+ status,
57
+ query,
58
+ canCreate,
59
+ labels,
60
+ onActive,
61
+ onChoose,
62
+ onRetry,
63
+ selectedValues,
64
+ }: {
65
+ id: string;
66
+ optionId: (i: number) => string;
67
+ items: ListItem[];
68
+ /** Index of the active row; items.length means the Create row. */
69
+ active: number;
70
+ status: SearchStatus;
71
+ query: string;
72
+ canCreate: boolean;
73
+ labels: ListboxLabels;
74
+ onActive: (i: number) => void;
75
+ onChoose: (i: number) => void;
76
+ onRetry: () => void;
77
+ selectedValues?: ReadonlySet<string>;
78
+ }) {
79
+ const row = (i: number, content: ReactNode, selected = false) => (
80
+ <div
81
+ key={optionId(i)}
82
+ id={optionId(i)}
83
+ role="option"
84
+ aria-selected={selected}
85
+ data-active={i === active || undefined}
86
+ onMouseMove={() => i !== active && onActive(i)}
87
+ onMouseDown={(e) => e.preventDefault()}
88
+ onClick={() => onChoose(i)}
89
+ className={cn(
90
+ 'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-[13px] leading-[1.35] text-fg',
91
+ i === active ? 'bg-brand-field text-ink' : selected && 'bg-hover',
92
+ )}
93
+ >
94
+ {content}
95
+ </div>
96
+ );
97
+
98
+ let state: ReactNode = null;
99
+ if (status === 'searching') {
100
+ state = (
101
+ <div className="flex items-center gap-2 px-2.5 py-2.5 text-[12.5px] text-fg-secondary">
102
+ <Spinner size={12} /> {labels.searching}
103
+ </div>
104
+ );
105
+ } else if (status === 'error') {
106
+ state = (
107
+ <div className="flex items-center gap-2 px-2.5 py-2 text-[12.5px] text-fg-secondary">
108
+ <span className="flex-1">{labels.error}</span>
109
+ <button
110
+ type="button"
111
+ onMouseDown={(e) => e.preventDefault()}
112
+ onClick={onRetry}
113
+ className="cursor-pointer rounded-xs font-medium text-fg underline underline-offset-3 outline-none focus-visible:shadow-(--focus-ring)"
114
+ >
115
+ {labels.retry}
116
+ </button>
117
+ </div>
118
+ );
119
+ } else if (status === 'done' && items.length === 0 && !canCreate) {
120
+ state = <div className="px-2.5 py-2.5 text-[12.5px] text-fg-secondary">{labels.empty(query)}</div>;
121
+ }
122
+
123
+ return (
124
+ <div id={id} role="listbox" className="flex max-h-[260px] flex-col overflow-y-auto">
125
+ {status === 'done' &&
126
+ items.map((it, i) =>
127
+ row(
128
+ i,
129
+ <>
130
+ <Avatar item={it} className={i === active ? 'bg-ink text-linen' : undefined} />
131
+ <span className="min-w-0 flex-1 truncate">{it.label}</span>
132
+ {it.meta && <span className="shrink-0 text-meta text-fg-secondary">{it.meta}</span>}
133
+ </>,
134
+ selectedValues?.has(it.value),
135
+ ),
136
+ )}
137
+ {status === 'done' && items.length === 0 && canCreate && (
138
+ <div className="px-2.5 pt-1.5 pb-1 text-meta text-fg-secondary">{labels.empty(query)}</div>
139
+ )}
140
+ {status === 'done' &&
141
+ canCreate &&
142
+ row(
143
+ items.length,
144
+ <>
145
+ <span aria-hidden className="inline-grid size-6 shrink-0 place-items-center rounded-sm border border-dashed border-rule text-fg-secondary">
146
+ <Plus size={13} />
147
+ </span>
148
+ <span className="font-medium">{labels.create(query)}</span>
149
+ </>,
150
+ )}
151
+ {state}
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,60 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ export type SearchStatus = 'idle' | 'searching' | 'done' | 'error';
4
+
5
+ /**
6
+ * Debounced, cancellable search. Each new query aborts the previous
7
+ * request (its AbortSignal fires) and a late answer to an old query is
8
+ * dropped, so results never flicker back. While a request is pending the
9
+ * status is "searching" — callers show a spinner, never "No results".
10
+ */
11
+ export function useDebouncedSearch<T>(
12
+ query: string,
13
+ search: ((query: string, signal: AbortSignal) => Promise<T>) | undefined,
14
+ { delay = 250, minLength = 1 }: { delay?: number; minLength?: number } = {},
15
+ ) {
16
+ const [status, setStatus] = useState<SearchStatus>('idle');
17
+ const [result, setResult] = useState<T | undefined>(undefined);
18
+ const [error, setError] = useState<unknown>(null);
19
+ const [attempt, setAttempt] = useState(0);
20
+ const seq = useRef(0);
21
+ const ctrl = useRef<AbortController | null>(null);
22
+
23
+ useEffect(() => {
24
+ const q = query.trim();
25
+ ctrl.current?.abort();
26
+ ctrl.current = null;
27
+ if (!search || q.length < minLength) {
28
+ seq.current++;
29
+ setStatus('idle');
30
+ setResult(undefined);
31
+ setError(null);
32
+ return;
33
+ }
34
+ setStatus('searching');
35
+ const mine = ++seq.current;
36
+ const timer = setTimeout(() => {
37
+ const c = new AbortController();
38
+ ctrl.current = c;
39
+ search(q, c.signal).then(
40
+ (r) => {
41
+ if (mine !== seq.current || c.signal.aborted) return;
42
+ setResult(r);
43
+ setError(null);
44
+ setStatus('done');
45
+ },
46
+ (e: unknown) => {
47
+ if (mine !== seq.current || c.signal.aborted) return;
48
+ setError(e);
49
+ setStatus('error');
50
+ },
51
+ );
52
+ }, delay);
53
+ return () => clearTimeout(timer);
54
+ }, [query, search, delay, minLength, attempt]);
55
+
56
+ useEffect(() => () => ctrl.current?.abort(), []);
57
+
58
+ const retry = useCallback(() => setAttempt((a) => a + 1), []);
59
+ return { status, result, error, retry };
60
+ }
@@ -0,0 +1,124 @@
1
+ // Shared by Dropzone, WindowDrop and UploadCheck: what was dropped, and
2
+ // whether each item may upload.
3
+
4
+ /** A file with its path inside a dropped folder ("Q3/Financials/a.pdf"). */
5
+ export type DroppedFile = { file: File; path: string };
6
+
7
+ export type RejectReason = 'type' | 'size' | 'depth';
8
+ export type Rejected = DroppedFile & { reason: RejectReason };
9
+
10
+ export type FileRules = {
11
+ /** e.g. ".pdf,.docx,image/*" — empty accepts everything. */
12
+ accept?: string | undefined;
13
+ /** Per-file limit in bytes. @default 2 GB */
14
+ maxSize?: number | undefined;
15
+ /** Deepest folder level accepted (a/b/c = 3). @default 6 */
16
+ maxDepth?: number | undefined;
17
+ };
18
+
19
+ export const DEFAULT_MAX_SIZE = 2 * 1000 ** 3; // "2 GB"
20
+ export const DEFAULT_MAX_DEPTH = 6;
21
+
22
+ /** A drag carries files (not text, links or cards dragged on the page). */
23
+ export function hasFiles(dt: DataTransfer | null | undefined): boolean {
24
+ if (!dt) return false;
25
+ return Array.from(dt.types ?? []).includes('Files');
26
+ }
27
+
28
+ /** Decimal units, as the spec writes sizes (318 MB, 1.2 of 3.4 MB) and as macOS shows them. */
29
+ export function formatBytes(n: number): string {
30
+ if (n < 1000) return `${n} B`;
31
+ const units = ['KB', 'MB', 'GB', 'TB'];
32
+ let v = n / 1000;
33
+ let i = 0;
34
+ while (v >= 1000 && i < units.length - 1) {
35
+ v /= 1000;
36
+ i++;
37
+ }
38
+ return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
39
+ }
40
+
41
+ function matchesAccept(file: File, accept: string | undefined): boolean {
42
+ if (!accept?.trim()) return true;
43
+ const name = file.name.toLowerCase();
44
+ const type = (file.type || '').toLowerCase();
45
+ return accept
46
+ .split(',')
47
+ .map((a) => a.trim().toLowerCase())
48
+ .filter(Boolean)
49
+ .some((a) => (a.startsWith('.') ? name.endsWith(a) : a.endsWith('/*') ? type.startsWith(a.slice(0, -1)) : type === a));
50
+ }
51
+
52
+ /** Folder depth of a path: "a.pdf" → 0, "Q3/a.pdf" → 1. */
53
+ export function depthOf(path: string): number {
54
+ return Math.max(0, path.split('/').length - 1);
55
+ }
56
+
57
+ /** Hidden items (a segment starting with "."), unticked by default. */
58
+ export function isHidden(path: string): boolean {
59
+ return path.split('/').some((s) => s.startsWith('.'));
60
+ }
61
+
62
+ export function checkFiles(items: DroppedFile[], rules: FileRules = {}): { accepted: DroppedFile[]; rejected: Rejected[] } {
63
+ const maxSize = rules.maxSize ?? DEFAULT_MAX_SIZE;
64
+ const maxDepth = rules.maxDepth ?? DEFAULT_MAX_DEPTH;
65
+ const accepted: DroppedFile[] = [];
66
+ const rejected: Rejected[] = [];
67
+ for (const it of items) {
68
+ if (depthOf(it.path) > maxDepth) rejected.push({ ...it, reason: 'depth' });
69
+ else if (!matchesAccept(it.file, rules.accept)) rejected.push({ ...it, reason: 'type' });
70
+ else if (it.file.size > maxSize) rejected.push({ ...it, reason: 'size' });
71
+ else accepted.push(it);
72
+ }
73
+ return { accepted, rejected };
74
+ }
75
+
76
+ // Minimal shapes of the (non-standard, universally shipped) entry API.
77
+ type Entry = { isFile: boolean; isDirectory: boolean; name: string; fullPath?: string };
78
+ type FileEntry = Entry & { file: (ok: (f: File) => void, fail: (e: unknown) => void) => void };
79
+ type DirEntry = Entry & { createReader: () => { readEntries: (ok: (e: Entry[]) => void, fail: (e: unknown) => void) => void } };
80
+
81
+ async function walk(entry: Entry, prefix: string, out: DroppedFile[]): Promise<void> {
82
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
83
+ if (entry.isFile) {
84
+ const file = await new Promise<File>((ok, fail) => (entry as FileEntry).file(ok, fail));
85
+ out.push({ file, path });
86
+ return;
87
+ }
88
+ if (!entry.isDirectory) return;
89
+ const reader = (entry as DirEntry).createReader();
90
+ // readEntries returns batches (≤100 in Chrome) until an empty one.
91
+ for (;;) {
92
+ const batch = await new Promise<Entry[]>((ok, fail) => reader.readEntries(ok, fail));
93
+ if (!batch.length) break;
94
+ for (const child of batch) await walk(child, path, out);
95
+ }
96
+ }
97
+
98
+ /** Everything in a drop, folders read recursively into files with paths. */
99
+ export async function readDataTransfer(dt: DataTransfer): Promise<DroppedFile[]> {
100
+ const items = Array.from(dt.items ?? []);
101
+ const entries = items
102
+ .filter((i) => i.kind === 'file')
103
+ .map((i) => (i as DataTransferItem & { webkitGetAsEntry?: () => Entry | null }).webkitGetAsEntry?.() ?? null);
104
+ if (entries.length && entries.every(Boolean)) {
105
+ const out: DroppedFile[] = [];
106
+ for (const e of entries) await walk(e!, '', out);
107
+ return out;
108
+ }
109
+ return Array.from(dt.files ?? []).map((file) => ({ file, path: file.name }));
110
+ }
111
+
112
+ /** Files from an <input type="file"> (webkitdirectory gives relative paths). */
113
+ export function fromInput(files: FileList | null): DroppedFile[] {
114
+ return Array.from(files ?? []).map((file) => ({
115
+ file,
116
+ path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
117
+ }));
118
+ }
119
+
120
+ /** "3 files · 1 folder" for what is being dragged or dropped. */
121
+ export function describeDrop(items: DroppedFile[]): { files: number; folders: number } {
122
+ const folders = new Set(items.map((i) => i.path.split('/')).filter((p) => p.length > 1).map((p) => p[0]));
123
+ return { files: items.filter((i) => depthOf(i.path) === 0).length, folders: folders.size };
124
+ }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useState, type RefObject } from 'react';
2
+
3
+ const REDUCE = '(prefers-reduced-motion: reduce)';
4
+
5
+ /** Whether the person asked for reduced motion (live). */
6
+ export function useReducedMotion(): boolean {
7
+ const [reduce, setReduce] = useState(() => typeof window !== 'undefined' && window.matchMedia?.(REDUCE).matches === true);
8
+ useEffect(() => {
9
+ const mq = window.matchMedia?.(REDUCE);
10
+ if (!mq) return;
11
+ const on = (e: MediaQueryListEvent) => setReduce(e.matches);
12
+ mq.addEventListener('change', on);
13
+ return () => mq.removeEventListener('change', on);
14
+ }, []);
15
+ return reduce;
16
+ }
17
+
18
+ /** A looping Web Animation on ref — none under reduced motion (the
19
+ * element keeps its still style) and none where the API is missing. */
20
+ export function useLoop(ref: RefObject<HTMLElement | null>, keyframes: Keyframe[], options: KeyframeAnimationOptions, on = true): void {
21
+ const reduce = useReducedMotion();
22
+ const key = JSON.stringify([keyframes, options]);
23
+ useEffect(() => {
24
+ const el = ref.current;
25
+ if (!on || reduce || !el || typeof el.animate !== 'function') return;
26
+ const a = el.animate(keyframes, { iterations: Infinity, ...options });
27
+ return () => a.cancel();
28
+ // eslint-disable-next-line react-hooks/exhaustive-deps
29
+ }, [on, reduce, key]);
30
+ }