@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/package.json +32 -3
  3. package/src/brand.css +13 -0
  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/Badge.tsx +68 -0
  8. package/src/components/Breadcrumb.tsx +129 -0
  9. package/src/components/Button.tsx +193 -0
  10. package/src/components/Card.tsx +42 -0
  11. package/src/components/Checkbox.tsx +67 -0
  12. package/src/components/ChoiceCards.tsx +68 -0
  13. package/src/components/CodeInput.tsx +150 -0
  14. package/src/components/CommandPalette.tsx +228 -0
  15. package/src/components/ConfirmDialog.tsx +294 -0
  16. package/src/components/CopyField.tsx +104 -0
  17. package/src/components/DateInput.tsx +366 -0
  18. package/src/components/Dialog.tsx +135 -0
  19. package/src/components/Dropzone.tsx +187 -0
  20. package/src/components/Field.tsx +66 -0
  21. package/src/components/Figure.tsx +30 -0
  22. package/src/components/FileCard.tsx +233 -0
  23. package/src/components/FileRow.tsx +159 -0
  24. package/src/components/FolderTree.tsx +377 -0
  25. package/src/components/Icon.tsx +32 -0
  26. package/src/components/IconButton.tsx +55 -0
  27. package/src/components/Input.tsx +74 -0
  28. package/src/components/ListToolbar.tsx +177 -0
  29. package/src/components/Logo.tsx +62 -0
  30. package/src/components/Marker.tsx +26 -0
  31. package/src/components/MentionInput.tsx +233 -0
  32. package/src/components/Menu.tsx +109 -0
  33. package/src/components/MetaLabel.tsx +38 -0
  34. package/src/components/PageHeader.tsx +62 -0
  35. package/src/components/PageState.tsx +84 -0
  36. package/src/components/Progress.tsx +97 -0
  37. package/src/components/PulseDot.tsx +31 -0
  38. package/src/components/Radio.tsx +72 -0
  39. package/src/components/RecipientsInput.tsx +252 -0
  40. package/src/components/SearchField.tsx +137 -0
  41. package/src/components/SegmentedControl.tsx +95 -0
  42. package/src/components/Select.tsx +351 -0
  43. package/src/components/SidePanel.tsx +130 -0
  44. package/src/components/Skeleton.tsx +73 -0
  45. package/src/components/Spinner.tsx +20 -0
  46. package/src/components/StageProgress.tsx +51 -0
  47. package/src/components/Steps.tsx +58 -0
  48. package/src/components/Switch.tsx +67 -0
  49. package/src/components/SyncStatus.tsx +56 -0
  50. package/src/components/Table.tsx +196 -0
  51. package/src/components/Tabs.tsx +124 -0
  52. package/src/components/Tag.tsx +35 -0
  53. package/src/components/Textarea.tsx +46 -0
  54. package/src/components/ThemeProvider.tsx +80 -0
  55. package/src/components/Toast.tsx +159 -0
  56. package/src/components/Tooltip.tsx +57 -0
  57. package/src/components/UploadCheck.tsx +118 -0
  58. package/src/components/UploadList.tsx +176 -0
  59. package/src/components/VirtualList.tsx +90 -0
  60. package/src/components/WindowDrop.tsx +131 -0
  61. package/src/components/forms-control.ts +36 -0
  62. package/src/components/inputs-aria.ts +5 -0
  63. package/src/components/inputs-date.ts +92 -0
  64. package/src/components/inputs-listbox.tsx +154 -0
  65. package/src/components/inputs-search.ts +60 -0
  66. package/src/components/logo-art.ts +4 -0
  67. package/src/components/status-files.ts +124 -0
  68. package/src/components/status-motion.ts +30 -0
  69. package/src/components/useButtonAction.ts +57 -0
  70. package/src/components/useSelection.ts +142 -0
  71. package/src/index.ts +67 -2
  72. package/src/modes.css +10 -0
  73. package/src/styles.css +2 -0
  74. package/src/theme.css +11 -0
@@ -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
+ }
@@ -0,0 +1,104 @@
1
+ import { Check, Copy } from 'lucide-react';
2
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { Field, useField } from './Field';
6
+ import { controlFrame } from './forms-control';
7
+
8
+ export type CopyFieldProps = {
9
+ /** The text shown and copied: a share link, a token. */
10
+ value: string;
11
+ label?: ReactNode;
12
+ hint?: ReactNode;
13
+ /** @default "Copy" */
14
+ copyLabel?: string | undefined;
15
+ /** @default "Copied" */
16
+ copiedLabel?: string | undefined;
17
+ /** Shown when the clipboard refuses and the text is selected instead. @default "Press ⌘C to copy" (Ctrl+C off the Mac) */
18
+ manualHint?: ReactNode;
19
+ /** ms the confirmation stays. @default 1600 */
20
+ copiedFor?: number | undefined;
21
+ onCopied?: ((value: string) => void) | undefined;
22
+ size?: 'sm' | 'md' | 'lg' | undefined;
23
+ id?: string | undefined;
24
+ className?: string | undefined;
25
+ };
26
+
27
+ const isMac = () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
28
+
29
+ /**
30
+ * CopyField — a read-only value with a copy button. The confirmation stays
31
+ * in place; if copying fails the text is selected and the hint says how to
32
+ * copy it by hand.
33
+ */
34
+ export function CopyField({ label, hint, ...rest }: CopyFieldProps) {
35
+ const [manual, setManual] = useState(false);
36
+ const shown = manual ? (rest.manualHint ?? (isMac() ? 'Press ⌘C to copy' : 'Press Ctrl+C to copy')) : hint;
37
+ if (label !== undefined || shown !== undefined) {
38
+ return (
39
+ <Field label={label} hint={shown} id={rest.id}>
40
+ <Control {...rest} onManual={setManual} />
41
+ </Field>
42
+ );
43
+ }
44
+ return <Control {...rest} onManual={setManual} />;
45
+ }
46
+
47
+ function Control({
48
+ value,
49
+ copyLabel = 'Copy',
50
+ copiedLabel = 'Copied',
51
+ copiedFor = 1600,
52
+ onCopied,
53
+ size,
54
+ id,
55
+ className,
56
+ onManual,
57
+ }: Omit<CopyFieldProps, 'label' | 'hint' | 'manualHint'> & { onManual: (on: boolean) => void }) {
58
+ const field = useField();
59
+ const [copied, setCopied] = useState(false);
60
+ const input = useRef<HTMLInputElement | null>(null);
61
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
62
+ useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []);
63
+
64
+ const selectAll = () => {
65
+ input.current?.focus();
66
+ input.current?.select();
67
+ };
68
+ const copy = async () => {
69
+ try {
70
+ if (!navigator.clipboard?.writeText) throw new Error('no clipboard');
71
+ await navigator.clipboard.writeText(value);
72
+ onManual(false);
73
+ setCopied(true);
74
+ onCopied?.(value);
75
+ if (timer.current) clearTimeout(timer.current);
76
+ timer.current = setTimeout(() => setCopied(false), copiedFor);
77
+ } catch {
78
+ selectAll();
79
+ onManual(true);
80
+ }
81
+ };
82
+
83
+ return (
84
+ <div className={cn(controlFrame({ size, readOnly: true }), 'pr-1', className)}>
85
+ <input
86
+ ref={input}
87
+ id={id ?? field?.id}
88
+ readOnly
89
+ value={value}
90
+ onFocus={(e) => e.currentTarget.select()}
91
+ aria-describedby={field?.describedBy}
92
+ className="min-w-0 flex-1 truncate border-none bg-transparent p-0 font-sans text-inherit outline-none"
93
+ />
94
+ <button
95
+ type="button"
96
+ onClick={() => void copy()}
97
+ className="inline-flex h-[calc(100%-8px)] shrink-0 cursor-pointer items-center gap-1.5 rounded-sm bg-page px-2.5 text-meta font-semibold text-fg outline-none hover:bg-well focus-visible:shadow-(--focus-ring)"
98
+ >
99
+ {copied ? <Check size={13} strokeWidth={2} aria-hidden /> : <Copy size={13} aria-hidden />}
100
+ <span aria-live="polite">{copied ? copiedLabel : copyLabel}</span>
101
+ </button>
102
+ </div>
103
+ );
104
+ }