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