@cosxai/ui 1.0.0-alpha.1 → 1.0.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/package.json +6 -1
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +48 -40
- 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 +27 -3
- package/src/components/Dropzone.tsx +187 -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/ListToolbar.tsx +177 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -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/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/SyncStatus.tsx +56 -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/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/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +38 -0
- package/src/components/core.test.tsx +0 -242
- package/src/components/forms.test.tsx +0 -280
- package/src/components/overlays.test.tsx +0 -245
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
import { useField } from './Field';
|
|
5
|
+
|
|
6
|
+
export type CodeInputProps = {
|
|
7
|
+
/** Number of cells. @default 6 */
|
|
8
|
+
length?: number | undefined;
|
|
9
|
+
value?: string | undefined;
|
|
10
|
+
onChange?: ((code: string) => void) | undefined;
|
|
11
|
+
/** Called once every cell is filled — typing the last digit or pasting the whole code. */
|
|
12
|
+
onComplete?: ((code: string) => void) | undefined;
|
|
13
|
+
/** Digits only (default) or letters and digits. */
|
|
14
|
+
mode?: 'numeric' | 'alphanumeric' | undefined;
|
|
15
|
+
invalid?: boolean | undefined;
|
|
16
|
+
disabled?: boolean | undefined;
|
|
17
|
+
/** Seconds until the code can be sent again; the countdown runs here. */
|
|
18
|
+
resendIn?: number | undefined;
|
|
19
|
+
onResend?: (() => void) | undefined;
|
|
20
|
+
/** @default (s) => `Resend in ${s} s` */
|
|
21
|
+
formatCountdown?: ((seconds: number) => ReactNode) | undefined;
|
|
22
|
+
/** @default "Send a new code" */
|
|
23
|
+
resendLabel?: string | undefined;
|
|
24
|
+
/** Name of the group for screen readers when there is no Field label. @default "Verification code" */
|
|
25
|
+
'aria-label'?: string | undefined;
|
|
26
|
+
className?: string | undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CodeInput — one cell per character of a one-time code. Typing moves on,
|
|
31
|
+
* Backspace moves back, arrows move; pasting the whole code fills every
|
|
32
|
+
* cell and completes. The first cell carries autocomplete=one-time-code
|
|
33
|
+
* so phones offer the code from the message.
|
|
34
|
+
*/
|
|
35
|
+
export function CodeInput({
|
|
36
|
+
length = 6,
|
|
37
|
+
value,
|
|
38
|
+
onChange,
|
|
39
|
+
onComplete,
|
|
40
|
+
mode = 'numeric',
|
|
41
|
+
invalid,
|
|
42
|
+
disabled,
|
|
43
|
+
resendIn,
|
|
44
|
+
onResend,
|
|
45
|
+
formatCountdown = (s) => `Resend in ${s} s`,
|
|
46
|
+
resendLabel = 'Send a new code',
|
|
47
|
+
'aria-label': ariaLabel = 'Verification code',
|
|
48
|
+
className,
|
|
49
|
+
}: CodeInputProps) {
|
|
50
|
+
const field = useField();
|
|
51
|
+
const [own, setOwn] = useState('');
|
|
52
|
+
const code = (value ?? own).slice(0, length);
|
|
53
|
+
const cells = useRef<Array<HTMLInputElement | null>>([]);
|
|
54
|
+
const allowed = mode === 'numeric' ? /\d/ : /[a-z0-9]/i;
|
|
55
|
+
const bad = Boolean(invalid || field?.invalid);
|
|
56
|
+
const off = Boolean(disabled || field?.disabled);
|
|
57
|
+
|
|
58
|
+
const [left, setLeft] = useState(resendIn ?? 0);
|
|
59
|
+
useEffect(() => setLeft(resendIn ?? 0), [resendIn]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (left <= 0) return;
|
|
62
|
+
const t = setTimeout(() => setLeft((s) => s - 1), 1000);
|
|
63
|
+
return () => clearTimeout(t);
|
|
64
|
+
}, [left]);
|
|
65
|
+
|
|
66
|
+
const set = (next: string) => {
|
|
67
|
+
const clean = next.slice(0, length);
|
|
68
|
+
if (value === undefined) setOwn(clean);
|
|
69
|
+
onChange?.(clean);
|
|
70
|
+
if (clean.length === length && !clean.includes(' ')) onComplete?.(clean);
|
|
71
|
+
};
|
|
72
|
+
const focus = (i: number) => cells.current[Math.max(0, Math.min(length - 1, i))]?.focus();
|
|
73
|
+
const chars = Array.from({ length }, (_, i) => code[i] ?? '');
|
|
74
|
+
|
|
75
|
+
const put = (i: number, raw: string) => {
|
|
76
|
+
const typed = [...raw].filter((c) => allowed.test(c)).map((c) => c.toUpperCase());
|
|
77
|
+
if (!typed.length) return;
|
|
78
|
+
const next = [...chars];
|
|
79
|
+
typed.forEach((c, k) => {
|
|
80
|
+
if (i + k < length) next[i + k] = c;
|
|
81
|
+
});
|
|
82
|
+
// Empty cells are a space inside; the value has no trailing spaces.
|
|
83
|
+
set(next.map((c) => c || ' ').join('').trimEnd());
|
|
84
|
+
focus(i + typed.length);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className={cn('flex flex-col gap-2 font-sans', className)}>
|
|
89
|
+
<div role="group" aria-label={field?.labelId ? undefined : ariaLabel} aria-labelledby={field?.labelId} className="flex gap-2">
|
|
90
|
+
{chars.map((c, i) => (
|
|
91
|
+
<input
|
|
92
|
+
key={i}
|
|
93
|
+
ref={(el) => {
|
|
94
|
+
cells.current[i] = el;
|
|
95
|
+
}}
|
|
96
|
+
id={i === 0 ? field?.id : undefined}
|
|
97
|
+
value={c.trim()}
|
|
98
|
+
inputMode={mode === 'numeric' ? 'numeric' : 'text'}
|
|
99
|
+
autoComplete={i === 0 ? 'one-time-code' : 'off'}
|
|
100
|
+
maxLength={length}
|
|
101
|
+
disabled={off}
|
|
102
|
+
aria-label={`${i + 1} of ${length}`}
|
|
103
|
+
aria-invalid={bad || undefined}
|
|
104
|
+
aria-describedby={field?.describedBy}
|
|
105
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
106
|
+
onChange={(e) => put(i, e.target.value)}
|
|
107
|
+
onPaste={(e) => {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
put(0, e.clipboardData.getData('text'));
|
|
110
|
+
}}
|
|
111
|
+
onKeyDown={(e) => {
|
|
112
|
+
if (e.key === 'Backspace') {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
const next = [...chars];
|
|
115
|
+
const at = c.trim() ? i : i - 1;
|
|
116
|
+
if (at < 0) return;
|
|
117
|
+
next[at] = ' ';
|
|
118
|
+
set(next.map((ch) => ch || ' ').join('').trimEnd());
|
|
119
|
+
focus(at);
|
|
120
|
+
} else if (e.key === 'ArrowLeft') {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
focus(i - 1);
|
|
123
|
+
} else if (e.key === 'ArrowRight') {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
focus(i + 1);
|
|
126
|
+
}
|
|
127
|
+
}}
|
|
128
|
+
className={cn(
|
|
129
|
+
'h-[52px] w-11 rounded-md border bg-sunk text-center font-sans text-[20px] font-medium text-fg tabular-nums outline-none',
|
|
130
|
+
'transition-[border-color] duration-[120ms] ease-standard focus:border-fg focus:shadow-(--focus-ring)',
|
|
131
|
+
bad ? 'border-transparent bg-error-wash focus:border-error' : 'border-transparent',
|
|
132
|
+
off && 'cursor-not-allowed bg-well text-fg-secondary',
|
|
133
|
+
)}
|
|
134
|
+
/>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
{resendIn !== undefined && (
|
|
138
|
+
<div className="text-meta text-fg-secondary" aria-live="polite">
|
|
139
|
+
{left > 0 ? (
|
|
140
|
+
formatCountdown(left)
|
|
141
|
+
) : (
|
|
142
|
+
<button type="button" onClick={onResend} className="cursor-pointer rounded-xs font-medium text-fg underline underline-offset-3 outline-none focus-visible:shadow-(--focus-ring)">
|
|
143
|
+
{resendLabel}
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
2
|
+
import { Command } from 'cmdk';
|
|
3
|
+
import { Search } from 'lucide-react';
|
|
4
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn';
|
|
7
|
+
import { Spinner } from './Spinner';
|
|
8
|
+
|
|
9
|
+
export type CommandItem = {
|
|
10
|
+
id: string;
|
|
11
|
+
title: ReactNode;
|
|
12
|
+
/** Where it lives: "Series A / Legal". */
|
|
13
|
+
subtitle?: ReactNode;
|
|
14
|
+
/** Right-aligned: a page ("p. 14"), a shortcut. */
|
|
15
|
+
meta?: ReactNode;
|
|
16
|
+
/** A line of matched text (full-text results). */
|
|
17
|
+
excerpt?: ReactNode;
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
onSelect: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type CommandSource = {
|
|
23
|
+
id: string;
|
|
24
|
+
/** Section heading: "Documents · titles", "Full text". */
|
|
25
|
+
label: string;
|
|
26
|
+
/** Results for the query. Aborted when the query changes. Throwing marks the source failed (it stops counting as pending). */
|
|
27
|
+
search: (query: string, signal: AbortSignal) => Promise<CommandItem[]>;
|
|
28
|
+
/** Characters needed before it runs. @default 1 */
|
|
29
|
+
minQuery?: number | undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type CommandPaletteLabels = {
|
|
33
|
+
/** @default "Search or run an action" */
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
/** @default (q) => `Nothing matches “${q}”.` */
|
|
36
|
+
empty?: (query: string) => ReactNode;
|
|
37
|
+
/** Heading hint while a slower source has not returned. @default "arriving" */
|
|
38
|
+
arriving?: string;
|
|
39
|
+
/** @default "Actions" */
|
|
40
|
+
actions?: string;
|
|
41
|
+
/** Accessible name of the palette. @default "Command palette" */
|
|
42
|
+
title?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type CommandPaletteProps = {
|
|
46
|
+
open: boolean;
|
|
47
|
+
onOpenChange: (open: boolean) => void;
|
|
48
|
+
/** Searched in parallel; each section appears as its source returns, in this order. */
|
|
49
|
+
sources: CommandSource[];
|
|
50
|
+
/** Actions for the query ("Ask the Agent about “cap table”"), always listed after the results. */
|
|
51
|
+
actions?: ((query: string) => CommandItem[]) | undefined;
|
|
52
|
+
labels?: CommandPaletteLabels | undefined;
|
|
53
|
+
/** ms after typing before sources run. @default 150 */
|
|
54
|
+
debounce?: number | undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const GROUP = '[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:pt-2.5 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-meta [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-fg-secondary';
|
|
58
|
+
|
|
59
|
+
type SourceState = { status: 'pending' | 'done' | 'failed'; items: CommandItem[] };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* CommandPalette — ⌘K. Fast sources (titles) show at once, slower ones
|
|
63
|
+
* (full text with page and excerpt) fill in below; a spinner shows while
|
|
64
|
+
* any is out, and "nothing matches" only once every source has returned.
|
|
65
|
+
* Arrows, Enter, Esc (cmdk + Radix Dialog). Pair with useCommandPaletteHotkey.
|
|
66
|
+
*/
|
|
67
|
+
export function CommandPalette({ open, onOpenChange, sources, actions, labels = {}, debounce = 150 }: CommandPaletteProps) {
|
|
68
|
+
const [query, setQuery] = useState('');
|
|
69
|
+
const [results, setResults] = useState<Record<string, SourceState>>({});
|
|
70
|
+
// The highlighted row. Follows the first result as sections arrive,
|
|
71
|
+
// until the person moves with the arrows.
|
|
72
|
+
const [selected, setSelected] = useState('');
|
|
73
|
+
const moved = useRef(false);
|
|
74
|
+
const sourcesRef = useRef(sources);
|
|
75
|
+
sourcesRef.current = sources;
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!open) {
|
|
79
|
+
setQuery('');
|
|
80
|
+
setResults({});
|
|
81
|
+
}
|
|
82
|
+
}, [open]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
moved.current = false;
|
|
85
|
+
}, [query]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const q = query.trim();
|
|
89
|
+
const active = sourcesRef.current.filter((s) => q.length >= (s.minQuery ?? 1));
|
|
90
|
+
if (!q || active.length === 0) {
|
|
91
|
+
setResults({});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setResults(Object.fromEntries(active.map((s) => [s.id, { status: 'pending', items: [] } satisfies SourceState])));
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
for (const s of active) {
|
|
98
|
+
s.search(q, controller.signal).then(
|
|
99
|
+
(items) => !controller.signal.aborted && setResults((r) => ({ ...r, [s.id]: { status: 'done', items } })),
|
|
100
|
+
() => !controller.signal.aborted && setResults((r) => ({ ...r, [s.id]: { status: 'failed', items: [] } })),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}, debounce);
|
|
104
|
+
return () => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
controller.abort();
|
|
107
|
+
};
|
|
108
|
+
}, [query, debounce]);
|
|
109
|
+
|
|
110
|
+
const q = query.trim();
|
|
111
|
+
const states = Object.values(results);
|
|
112
|
+
const pending = states.some((s) => s.status === 'pending');
|
|
113
|
+
const found = states.some((s) => s.items.length > 0);
|
|
114
|
+
const actionItems = q && actions ? actions(q) : [];
|
|
115
|
+
const order = [
|
|
116
|
+
...sourcesRef.current.flatMap((s) => (results[s.id]?.items ?? []).map((i) => `${s.id}:${i.id}`)),
|
|
117
|
+
...actionItems.map((i) => `action:${i.id}`),
|
|
118
|
+
];
|
|
119
|
+
const first = order[0] ?? '';
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!moved.current) setSelected(first);
|
|
122
|
+
}, [first]);
|
|
123
|
+
const run = (item: CommandItem) => {
|
|
124
|
+
onOpenChange(false);
|
|
125
|
+
item.onSelect();
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
|
130
|
+
<DialogPrimitive.Portal>
|
|
131
|
+
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-scrim" />
|
|
132
|
+
<DialogPrimitive.Content
|
|
133
|
+
aria-describedby={undefined}
|
|
134
|
+
className="fixed top-[12vh] left-1/2 z-50 w-[calc(100%-32px)] max-w-[640px] -translate-x-1/2 overflow-hidden rounded-lg border border-rule bg-page font-sans text-fg outline-none"
|
|
135
|
+
>
|
|
136
|
+
<DialogPrimitive.Title className="sr-only">{labels.title ?? 'Command palette'}</DialogPrimitive.Title>
|
|
137
|
+
<Command
|
|
138
|
+
shouldFilter={false}
|
|
139
|
+
loop
|
|
140
|
+
label={labels.title ?? 'Command palette'}
|
|
141
|
+
value={selected}
|
|
142
|
+
onValueChange={setSelected}
|
|
143
|
+
onKeyDown={(e) => {
|
|
144
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End') moved.current = true;
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<div className="flex items-center gap-2.5 border-b border-rule px-4">
|
|
148
|
+
<Search size={16} aria-hidden className="shrink-0 text-fg-secondary" />
|
|
149
|
+
<Command.Input
|
|
150
|
+
value={query}
|
|
151
|
+
onValueChange={setQuery}
|
|
152
|
+
placeholder={labels.placeholder ?? 'Search or run an action'}
|
|
153
|
+
className="h-12 min-w-0 flex-1 border-0 bg-transparent font-sans text-[15px] text-fg outline-none placeholder:text-fg-secondary"
|
|
154
|
+
/>
|
|
155
|
+
{pending && <Spinner label="Searching" className="text-fg-secondary" />}
|
|
156
|
+
</div>
|
|
157
|
+
<Command.List className="max-h-[min(420px,60vh)] overflow-y-auto p-1.5">
|
|
158
|
+
{sourcesRef.current.map((s) => {
|
|
159
|
+
const st = results[s.id];
|
|
160
|
+
if (!st || (st.status !== 'pending' && st.items.length === 0)) return null;
|
|
161
|
+
return (
|
|
162
|
+
<Command.Group
|
|
163
|
+
key={s.id}
|
|
164
|
+
className={GROUP}
|
|
165
|
+
heading={
|
|
166
|
+
<span className="flex items-center gap-1.5">
|
|
167
|
+
{s.label}
|
|
168
|
+
{st.status === 'pending' && <span className="font-normal">· {labels.arriving ?? 'arriving'}</span>}
|
|
169
|
+
</span>
|
|
170
|
+
}
|
|
171
|
+
>
|
|
172
|
+
{st.items.map((item) => (
|
|
173
|
+
<PaletteItem key={`${s.id}:${item.id}`} value={`${s.id}:${item.id}`} item={item} onRun={run} />
|
|
174
|
+
))}
|
|
175
|
+
</Command.Group>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
{q && !pending && !found && (
|
|
179
|
+
<div role="status" className="px-3 py-3 text-ui text-fg-secondary">
|
|
180
|
+
{labels.empty ? labels.empty(q) : `Nothing matches “${q}”.`}
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
{actionItems.length > 0 && (
|
|
184
|
+
<Command.Group className={GROUP} heading={labels.actions ?? 'Actions'}>
|
|
185
|
+
{actionItems.map((item) => (
|
|
186
|
+
<PaletteItem key={`action:${item.id}`} value={`action:${item.id}`} item={item} onRun={run} />
|
|
187
|
+
))}
|
|
188
|
+
</Command.Group>
|
|
189
|
+
)}
|
|
190
|
+
</Command.List>
|
|
191
|
+
</Command>
|
|
192
|
+
</DialogPrimitive.Content>
|
|
193
|
+
</DialogPrimitive.Portal>
|
|
194
|
+
</DialogPrimitive.Root>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function PaletteItem({ item, value, onRun }: { item: CommandItem; value: string; onRun: (item: CommandItem) => void }) {
|
|
199
|
+
return (
|
|
200
|
+
<Command.Item
|
|
201
|
+
value={value}
|
|
202
|
+
onSelect={() => onRun(item)}
|
|
203
|
+
className={cn('flex cursor-pointer items-start gap-2.5 rounded-md px-3 py-2 outline-none', 'data-[selected=true]:bg-hover')}
|
|
204
|
+
>
|
|
205
|
+
{item.icon && <span className="mt-0.5 grid size-4 shrink-0 place-items-center text-fg-secondary">{item.icon}</span>}
|
|
206
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
207
|
+
<span className="truncate text-ui">{item.title}</span>
|
|
208
|
+
{item.subtitle && <span className="truncate text-meta text-fg-secondary">{item.subtitle}</span>}
|
|
209
|
+
{item.excerpt && <span className="line-clamp-2 text-small text-fg-secondary">{item.excerpt}</span>}
|
|
210
|
+
</span>
|
|
211
|
+
{item.meta && <span className="mt-0.5 shrink-0 text-meta text-fg-secondary tabular-nums">{item.meta}</span>}
|
|
212
|
+
</Command.Item>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** ⌘K / Ctrl+K toggles the palette (also from inside inputs). */
|
|
217
|
+
export function useCommandPaletteHotkey(setOpen: (update: (open: boolean) => boolean) => void) {
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const onKey = (e: KeyboardEvent) => {
|
|
220
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') {
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
setOpen((o) => !o);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
document.addEventListener('keydown', onKey);
|
|
226
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
227
|
+
}, [setOpen]);
|
|
228
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { KeyRound } from 'lucide-react';
|
|
2
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
import { Dialog, DialogClose, DialogContent } from './Dialog';
|
|
6
|
+
import { Icon } from './Icon';
|
|
7
|
+
import { Input } from './Input';
|
|
8
|
+
import { useButtonAction } from './useButtonAction';
|
|
9
|
+
|
|
10
|
+
function errorText(e: unknown): string {
|
|
11
|
+
if (e instanceof Error) return e.message;
|
|
12
|
+
return typeof e === 'string' ? e : 'Something went wrong. Try again.';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Base = {
|
|
16
|
+
open: boolean;
|
|
17
|
+
onOpenChange: (open: boolean) => void;
|
|
18
|
+
/** A question or an action: "Revoke this share and 4 forwards?" */
|
|
19
|
+
title: ReactNode;
|
|
20
|
+
/** The consequence, in plain words: "Anna and the 4 people she forwarded it to lose access now." */
|
|
21
|
+
description?: ReactNode;
|
|
22
|
+
/** @default "Cancel" */
|
|
23
|
+
cancelLabel?: string | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ConfirmDialogProps = Base & {
|
|
27
|
+
/** The primary repeats the verb: "Revoke share", never "OK". */
|
|
28
|
+
confirmLabel: string;
|
|
29
|
+
/** Destructive: the primary is the red button. */
|
|
30
|
+
destructive?: boolean | undefined;
|
|
31
|
+
/** Type-to-confirm: the primary stays off until this word is typed (for what can't be undone at scale). */
|
|
32
|
+
confirmWord?: string | undefined;
|
|
33
|
+
/** Label of the type-to-confirm input. @default `Type ${confirmWord} to confirm` */
|
|
34
|
+
confirmWordLabel?: ReactNode;
|
|
35
|
+
/** Runs on confirm. While it runs the primary is busy; on success the dialog closes; a failure is shown in place. */
|
|
36
|
+
onConfirm: () => Promise<unknown> | unknown;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ConfirmDialog — a destructive or weighty action, confirmed before it
|
|
41
|
+
* happens (never undone after the fact). The primary names the action;
|
|
42
|
+
* with confirmWord the person types it first. Failures stay in the dialog.
|
|
43
|
+
*/
|
|
44
|
+
export function ConfirmDialog({
|
|
45
|
+
open,
|
|
46
|
+
onOpenChange,
|
|
47
|
+
title,
|
|
48
|
+
description,
|
|
49
|
+
cancelLabel = 'Cancel',
|
|
50
|
+
confirmLabel,
|
|
51
|
+
destructive = false,
|
|
52
|
+
confirmWord,
|
|
53
|
+
confirmWordLabel,
|
|
54
|
+
onConfirm,
|
|
55
|
+
}: ConfirmDialogProps) {
|
|
56
|
+
const [typed, setTyped] = useState('');
|
|
57
|
+
const action = useButtonAction(async () => {
|
|
58
|
+
await onConfirm();
|
|
59
|
+
onOpenChange(false);
|
|
60
|
+
});
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!open) setTyped('');
|
|
63
|
+
}, [open]);
|
|
64
|
+
const armed = !confirmWord || typed.trim().toLowerCase() === confirmWord.toLowerCase();
|
|
65
|
+
return (
|
|
66
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
67
|
+
<DialogContent
|
|
68
|
+
size="sm"
|
|
69
|
+
title={title}
|
|
70
|
+
description={description}
|
|
71
|
+
footer={
|
|
72
|
+
<>
|
|
73
|
+
<DialogClose asChild>
|
|
74
|
+
<Button variant="secondary">{cancelLabel}</Button>
|
|
75
|
+
</DialogClose>
|
|
76
|
+
<Button
|
|
77
|
+
variant={destructive ? 'danger' : 'primary'}
|
|
78
|
+
disabled={!armed}
|
|
79
|
+
state={action.state === 'done' ? 'busy' : action.state}
|
|
80
|
+
onClick={() => void action.run()}
|
|
81
|
+
>
|
|
82
|
+
{confirmLabel}
|
|
83
|
+
</Button>
|
|
84
|
+
</>
|
|
85
|
+
}
|
|
86
|
+
>
|
|
87
|
+
{confirmWord && (
|
|
88
|
+
<Input
|
|
89
|
+
label={confirmWordLabel ?? `Type ${confirmWord} to confirm`}
|
|
90
|
+
value={typed}
|
|
91
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
92
|
+
autoComplete="off"
|
|
93
|
+
spellCheck={false}
|
|
94
|
+
autoFocus
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
{action.error !== null && (
|
|
98
|
+
<p role="alert" className="mt-3 mb-0 text-small text-error-text">
|
|
99
|
+
{errorText(action.error)}
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
</DialogContent>
|
|
103
|
+
</Dialog>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type PromptDialogProps = Base & {
|
|
108
|
+
/** The field's label: "Folder name". */
|
|
109
|
+
label: ReactNode;
|
|
110
|
+
defaultValue?: string | undefined;
|
|
111
|
+
/** "Rename", "Create folder". */
|
|
112
|
+
confirmLabel: string;
|
|
113
|
+
/** Return the problem in words, or null when fine. Checked on submit and then as they type. */
|
|
114
|
+
validate?: ((value: string) => string | null) | undefined;
|
|
115
|
+
/** Receives the trimmed value. Busy while it runs; closes on success; a failure shows under the field. */
|
|
116
|
+
onSubmit: (value: string) => Promise<unknown> | unknown;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** PromptDialog — one value asked for (rename, new folder). The current
|
|
120
|
+
* value is selected on open so typing replaces it; Enter submits. */
|
|
121
|
+
export function PromptDialog({ open, onOpenChange, title, description, cancelLabel = 'Cancel', label, defaultValue = '', confirmLabel, validate, onSubmit }: PromptDialogProps) {
|
|
122
|
+
const [value, setValue] = useState(defaultValue);
|
|
123
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
124
|
+
const [touched, setTouched] = useState(false);
|
|
125
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
126
|
+
const formId = useId();
|
|
127
|
+
const action = useButtonAction(async (v: string) => {
|
|
128
|
+
await onSubmit(v);
|
|
129
|
+
onOpenChange(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (open) {
|
|
134
|
+
setValue(defaultValue);
|
|
135
|
+
setProblem(null);
|
|
136
|
+
setTouched(false);
|
|
137
|
+
}
|
|
138
|
+
}, [open, defaultValue]);
|
|
139
|
+
|
|
140
|
+
const check = (v: string) => validate?.(v.trim()) ?? (v.trim() ? null : 'Enter a name.');
|
|
141
|
+
const shownError = problem ?? (action.error !== null ? errorText(action.error) : null);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
145
|
+
<DialogContent
|
|
146
|
+
size="sm"
|
|
147
|
+
title={title}
|
|
148
|
+
description={description}
|
|
149
|
+
onOpenAutoFocus={(e) => {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
input.current?.focus();
|
|
152
|
+
input.current?.select();
|
|
153
|
+
}}
|
|
154
|
+
footer={
|
|
155
|
+
<>
|
|
156
|
+
<DialogClose asChild>
|
|
157
|
+
<Button variant="secondary">{cancelLabel}</Button>
|
|
158
|
+
</DialogClose>
|
|
159
|
+
<Button type="submit" form={formId} state={action.state === 'done' ? 'busy' : action.state}>
|
|
160
|
+
{confirmLabel}
|
|
161
|
+
</Button>
|
|
162
|
+
</>
|
|
163
|
+
}
|
|
164
|
+
>
|
|
165
|
+
<form
|
|
166
|
+
id={formId}
|
|
167
|
+
noValidate
|
|
168
|
+
onSubmit={(e) => {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
setTouched(true);
|
|
171
|
+
const p = check(value);
|
|
172
|
+
setProblem(p);
|
|
173
|
+
if (!p) void action.run(value.trim());
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
<Input
|
|
177
|
+
ref={input}
|
|
178
|
+
label={label}
|
|
179
|
+
value={value}
|
|
180
|
+
error={shownError ?? undefined}
|
|
181
|
+
onChange={(e) => {
|
|
182
|
+
setValue(e.target.value);
|
|
183
|
+
if (touched) setProblem(check(e.target.value));
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
</form>
|
|
187
|
+
</DialogContent>
|
|
188
|
+
</Dialog>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type StepUpDialogProps = Omit<Base, 'title'> & {
|
|
193
|
+
/** @default "Confirm it's you" */
|
|
194
|
+
title?: ReactNode;
|
|
195
|
+
/** @default "Use passkey" */
|
|
196
|
+
passkeyLabel?: string | undefined;
|
|
197
|
+
/** @default "Use authenticator code instead" */
|
|
198
|
+
codeLabel?: string | undefined;
|
|
199
|
+
/** @default "Authenticator code" */
|
|
200
|
+
codeFieldLabel?: ReactNode;
|
|
201
|
+
/** @default "Confirm" */
|
|
202
|
+
confirmLabel?: string | undefined;
|
|
203
|
+
/** Starts the passkey ceremony; resolves when verified (the dialog closes), throws to show why not. */
|
|
204
|
+
onPasskey: () => Promise<unknown>;
|
|
205
|
+
/** Checks a 6-digit authenticator code; resolves when verified, throws to show why not. Omit when there is no authenticator. */
|
|
206
|
+
onCode?: ((code: string) => Promise<unknown>) | undefined;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** StepUpDialog — a fresh check before a sensitive action (deleting a
|
|
210
|
+
* workspace): passkey first, an authenticator code as the alternative.
|
|
211
|
+
* UI only — the ceremony is the caller's. */
|
|
212
|
+
export function StepUpDialog({
|
|
213
|
+
open,
|
|
214
|
+
onOpenChange,
|
|
215
|
+
title = "Confirm it's you",
|
|
216
|
+
description,
|
|
217
|
+
cancelLabel = 'Cancel',
|
|
218
|
+
passkeyLabel = 'Use passkey',
|
|
219
|
+
codeLabel = 'Use authenticator code instead',
|
|
220
|
+
codeFieldLabel = 'Authenticator code',
|
|
221
|
+
confirmLabel = 'Confirm',
|
|
222
|
+
onPasskey,
|
|
223
|
+
onCode,
|
|
224
|
+
}: StepUpDialogProps) {
|
|
225
|
+
const [useCode, setUseCode] = useState(false);
|
|
226
|
+
const [code, setCode] = useState('');
|
|
227
|
+
const passkey = useButtonAction(async () => {
|
|
228
|
+
await onPasskey();
|
|
229
|
+
onOpenChange(false);
|
|
230
|
+
});
|
|
231
|
+
const byCode = useButtonAction(async (c: string) => {
|
|
232
|
+
await onCode?.(c);
|
|
233
|
+
onOpenChange(false);
|
|
234
|
+
});
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!open) {
|
|
237
|
+
setUseCode(false);
|
|
238
|
+
setCode('');
|
|
239
|
+
}
|
|
240
|
+
}, [open]);
|
|
241
|
+
const err = useCode ? byCode.error : passkey.error;
|
|
242
|
+
return (
|
|
243
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
244
|
+
<DialogContent
|
|
245
|
+
size="sm"
|
|
246
|
+
title={title}
|
|
247
|
+
description={description}
|
|
248
|
+
footer={
|
|
249
|
+
<>
|
|
250
|
+
<DialogClose asChild>
|
|
251
|
+
<Button variant="secondary">{cancelLabel}</Button>
|
|
252
|
+
</DialogClose>
|
|
253
|
+
{useCode ? (
|
|
254
|
+
<Button disabled={!/^\d{6}$/.test(code)} state={byCode.state === 'done' ? 'busy' : byCode.state} onClick={() => void byCode.run(code)}>
|
|
255
|
+
{confirmLabel}
|
|
256
|
+
</Button>
|
|
257
|
+
) : (
|
|
258
|
+
<Button iconLeft={<Icon icon={KeyRound} />} state={passkey.state === 'done' ? 'busy' : passkey.state} onClick={() => void passkey.run()}>
|
|
259
|
+
{passkeyLabel}
|
|
260
|
+
</Button>
|
|
261
|
+
)}
|
|
262
|
+
</>
|
|
263
|
+
}
|
|
264
|
+
>
|
|
265
|
+
{useCode ? (
|
|
266
|
+
<Input
|
|
267
|
+
label={codeFieldLabel}
|
|
268
|
+
value={code}
|
|
269
|
+
inputMode="numeric"
|
|
270
|
+
autoComplete="one-time-code"
|
|
271
|
+
maxLength={6}
|
|
272
|
+
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
273
|
+
autoFocus
|
|
274
|
+
/>
|
|
275
|
+
) : (
|
|
276
|
+
onCode && (
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={() => setUseCode(true)}
|
|
280
|
+
className="cursor-pointer border-0 bg-transparent p-0 font-sans text-small text-fg underline underline-offset-3 outline-none focus-visible:shadow-(--focus-ring)"
|
|
281
|
+
>
|
|
282
|
+
{codeLabel}
|
|
283
|
+
</button>
|
|
284
|
+
)
|
|
285
|
+
)}
|
|
286
|
+
{err !== null && (
|
|
287
|
+
<p role="alert" className="mt-3 mb-0 text-small text-error-text">
|
|
288
|
+
{errorText(err)}
|
|
289
|
+
</p>
|
|
290
|
+
)}
|
|
291
|
+
</DialogContent>
|
|
292
|
+
</Dialog>
|
|
293
|
+
);
|
|
294
|
+
}
|