@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,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,4 @@
1
+ // Generated by scripts/gen-logo.mjs from the COSX Design System 3.0
2
+ // logo artwork. Do not edit — regenerate.
3
+ export const WORDMARK = {"viewBox":"32 113 225 62","paths":["M242.79,170.43L228.93,152.02L215.07,170.43L204.43,170.43L223.66,145.02L204.29,119.29L214.99,119.29L229.06,137.89L243.14,119.29L253.58,119.29L234.34,144.85L253.7,170.43L242.79,170.43Z","M121.49,172.05a27.37,27.37,0,0,1-25.91-18.53l-1.17-3.42L84.33,161.86l-.49.58-.62.71A27.39,27.39,0,1,1,82,125l-5.92,5.89A18.25,18.25,0,0,0,63,125.36c-10.31,0-18.71,8.65-18.71,19.28s8.4,19.28,18.71,19.28A18.6,18.6,0,0,0,77.22,157c2.12-2.47,4.1-5.09,6-7.63,1-1.27,2-2.69,3.08-4l12.09-15.45.07-.09a25,25,0,0,1,2.91-3.48l.36-.38a27.07,27.07,0,0,1,19.74-8.69,27.38,27.38,0,1,1,0,54.76Zm0-46.69c-10.32,0-18.71,8.65-18.71,19.28s8.39,19.28,18.71,19.28,18.71-8.65,18.71-19.28S131.81,125.36,121.49,125.36Z","M177.23,171.65a34.27,34.27,0,0,1-22.3-8.3l3.65-7.11c5.58,4.82,12.57,7.68,18.81,7.68,6.85,0,10.93-2.65,10.93-7.08,0-5.71-6.22-7.48-12.82-9.35-8.16-2.31-17.41-4.94-17.41-15.42,0-8.94,7.83-14.72,19.94-14.72a36.71,36.71,0,0,1,19,5.31L193.51,130c-5.34-3.06-11.36-4.87-16.2-4.87-6,0-9.57,2.29-9.57,6.14,0,5.56,6.13,7.36,12.63,9.28,8.23,2.43,17.56,5.18,17.52,15.87C197.89,165.51,189.58,171.65,177.23,171.65Z"]} as const;
4
+ export const LOOP = {"viewBox":"0 0 289.34 289.34","paths":["M193.32,99.1a45.06,45.06,0,0,0-32.85,14.46l-.59.64A41.46,41.46,0,0,0,155,120l-.1.16-20.13,25.7c-1.74,2.22-3.53,4.58-5.12,6.69-3.2,4.22-6.5,8.59-10,12.7-6.31,7.39-14.71,11.46-23.63,11.46-17.17,0-31.13-14.39-31.13-32.08h0c0-17.69,14-32.08,31.13-32.08a30.37,30.37,0,0,1,21.8,9.19l9.85-9.81a45.55,45.55,0,1,0,2,63.51l1-1.19.83-1,16.76-19.56,1.95,5.69a45.57,45.57,0,1,0,43.12-60.3Zm0,77.6c-17.17,0-31.14-14.39-31.14-32.08s14-32.08,31.14-32.08,31.13,14.39,31.13,32.08S210.49,176.7,193.32,176.7Z"]} as const;
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ import type { ButtonState } from './Button';
4
+
5
+ export type ButtonAction<A extends unknown[]> = {
6
+ /** Pass to <Button state>. */
7
+ state: ButtonState;
8
+ /** The last failure, to explain beside the button (with a Retry). Cleared on the next run. */
9
+ error: unknown;
10
+ /** Run the action: busy → done for `doneFor` ms → idle; on failure → idle with `error` set. */
11
+ run: (...args: A) => Promise<void>;
12
+ };
13
+
14
+ /**
15
+ * useButtonAction — the async button of the spec: busy while the action
16
+ * runs, the result confirmed in place for 1.6s, a failure kept for the
17
+ * caller to explain in place. Repeat runs while busy are ignored.
18
+ */
19
+ export function useButtonAction<A extends unknown[]>(action: (...args: A) => Promise<unknown>, { doneFor = 1600 }: { doneFor?: number } = {}): ButtonAction<A> {
20
+ const [state, setState] = useState<ButtonState>('idle');
21
+ const [error, setError] = useState<unknown>(null);
22
+ const busy = useRef(false);
23
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
24
+ const alive = useRef(true);
25
+
26
+ useEffect(
27
+ () => () => {
28
+ alive.current = false;
29
+ if (timer.current) clearTimeout(timer.current);
30
+ },
31
+ [],
32
+ );
33
+
34
+ const run = useCallback(
35
+ async (...args: A) => {
36
+ if (busy.current) return;
37
+ busy.current = true;
38
+ setError(null);
39
+ setState('busy');
40
+ try {
41
+ await action(...args);
42
+ if (!alive.current) return;
43
+ setState('done');
44
+ timer.current = setTimeout(() => alive.current && setState('idle'), doneFor);
45
+ } catch (e) {
46
+ if (!alive.current) return;
47
+ setError(e);
48
+ setState('idle');
49
+ } finally {
50
+ busy.current = false;
51
+ }
52
+ },
53
+ [action, doneFor],
54
+ );
55
+
56
+ return { state, error, run };
57
+ }
@@ -0,0 +1,142 @@
1
+ import { useCallback, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react';
2
+
3
+ export type SelectionOptions = {
4
+ /** Every selectable id, in display order (ranges follow this order). */
5
+ ids: readonly string[];
6
+ /** Controlled selection. */
7
+ selected?: ReadonlySet<string> | readonly string[] | undefined;
8
+ /** Uncontrolled starting selection. */
9
+ defaultSelected?: readonly string[] | undefined;
10
+ onChange?: ((selected: Set<string>) => void) | undefined;
11
+ /**
12
+ * Selection mode: a plain click toggles instead of opening. Unset: on
13
+ * while anything is selected (ticking one box enters it, clearing leaves).
14
+ */
15
+ selectionMode?: boolean | undefined;
16
+ };
17
+
18
+ export type Selection = {
19
+ selected: Set<string>;
20
+ count: number;
21
+ isSelected: (id: string) => boolean;
22
+ /** True when a plain click toggles (see SelectionOptions.selectionMode). */
23
+ selectionMode: boolean;
24
+ toggle: (id: string) => void;
25
+ /** Select from the anchor (last clicked) to id, keeping what is selected. */
26
+ selectRange: (id: string) => void;
27
+ selectAll: () => void;
28
+ clear: () => void;
29
+ set: (ids: Iterable<string>) => void;
30
+ /**
31
+ * The click on an item: ⇧ selects a range, ⌘/Ctrl toggles, a plain click
32
+ * toggles in selection mode. Returns true when it handled the click —
33
+ * otherwise the caller opens the item.
34
+ */
35
+ handleClick: (id: string, event: Pick<MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>) => boolean;
36
+ /** For the list container: ⌘/Ctrl-A selects all, Esc clears. */
37
+ onKeyDown: (event: KeyboardEvent) => void;
38
+ };
39
+
40
+ function toSet(v: ReadonlySet<string> | readonly string[] | undefined): Set<string> | undefined {
41
+ if (v === undefined) return undefined;
42
+ return new Set(v);
43
+ }
44
+
45
+ /**
46
+ * useSelection — the selection model every list and grid shares: click,
47
+ * ⇧-click range, ⌘/Ctrl-click toggle, ⌘A, Esc. In selection mode the whole
48
+ * card or row is the checkbox.
49
+ */
50
+ export function useSelection({ ids, selected, defaultSelected, onChange, selectionMode }: SelectionOptions): Selection {
51
+ const [inner, setInner] = useState<Set<string>>(() => new Set(defaultSelected ?? []));
52
+ const controlled = toSet(selected);
53
+ const current = useMemo(() => controlled ?? inner, [controlled, inner]);
54
+ const anchor = useRef<string | null>(null);
55
+
56
+ const commit = useCallback(
57
+ (next: Set<string>) => {
58
+ if (!controlled) setInner(next);
59
+ onChange?.(next);
60
+ },
61
+ [controlled, onChange],
62
+ );
63
+
64
+ const toggle = useCallback(
65
+ (id: string) => {
66
+ const next = new Set(current);
67
+ if (next.has(id)) next.delete(id);
68
+ else next.add(id);
69
+ anchor.current = id;
70
+ commit(next);
71
+ },
72
+ [current, commit],
73
+ );
74
+
75
+ const selectRange = useCallback(
76
+ (id: string) => {
77
+ const from = anchor.current === null ? -1 : ids.indexOf(anchor.current);
78
+ const to = ids.indexOf(id);
79
+ if (to < 0) return;
80
+ const next = new Set(current);
81
+ if (from < 0) next.add(id);
82
+ else for (let i = Math.min(from, to); i <= Math.max(from, to); i++) next.add(ids[i]!);
83
+ anchor.current = id;
84
+ commit(next);
85
+ },
86
+ [ids, current, commit],
87
+ );
88
+
89
+ const selectAll = useCallback(() => commit(new Set(ids)), [ids, commit]);
90
+ const clear = useCallback(() => {
91
+ anchor.current = null;
92
+ commit(new Set());
93
+ }, [commit]);
94
+ const set = useCallback((next: Iterable<string>) => commit(new Set(next)), [commit]);
95
+
96
+ const mode = selectionMode ?? current.size > 0;
97
+
98
+ const handleClick = useCallback<Selection['handleClick']>(
99
+ (id, e) => {
100
+ if (e.shiftKey) {
101
+ selectRange(id);
102
+ return true;
103
+ }
104
+ if (e.metaKey || e.ctrlKey || mode) {
105
+ toggle(id);
106
+ return true;
107
+ }
108
+ anchor.current = id;
109
+ return false;
110
+ },
111
+ [mode, selectRange, toggle],
112
+ );
113
+
114
+ const onKeyDown = useCallback(
115
+ (e: KeyboardEvent) => {
116
+ const target = e.target as HTMLElement | null;
117
+ const typing = target?.closest('input, textarea, [contenteditable="true"]');
118
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a' && !typing) {
119
+ e.preventDefault();
120
+ selectAll();
121
+ } else if (e.key === 'Escape' && current.size > 0) {
122
+ e.preventDefault();
123
+ clear();
124
+ }
125
+ },
126
+ [selectAll, clear, current.size],
127
+ );
128
+
129
+ return {
130
+ selected: current,
131
+ count: current.size,
132
+ isSelected: (id) => current.has(id),
133
+ selectionMode: mode,
134
+ toggle,
135
+ selectRange,
136
+ selectAll,
137
+ clear,
138
+ set,
139
+ handleClick,
140
+ onKeyDown,
141
+ };
142
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,72 @@
2
2
  * @cosxai/ui 1.x — the COSX Design System 3.0 in React.
3
3
  *
4
4
  * Foundations: import "@cosxai/ui/styles.css" (tokens + fonts) and, in a
5
- * Tailwind v4 app, "@cosxai/ui/theme.css". Components arrive in the 1.0
6
- * alphas (docs/workdocs/2026-09-29_feature-ui-1.0).
5
+ * Tailwind v4 app, "@cosxai/ui/theme.css". Plan and status:
6
+ * docs/workdocs/2026-09-29_feature-ui-1.0.
7
7
  */
8
8
  export { cn } from './lib/cn';
9
+
10
+ export { Badge, type BadgeAppearance, type BadgeProps, type BadgeStatus } from './components/Badge';
11
+ export { Button, formatCount, type ButtonGround, type ButtonProps, type ButtonState, type ButtonVariant } from './components/Button';
12
+ export { Card, type CardProps } from './components/Card';
13
+ export { Figure, type FigureProps } from './components/Figure';
14
+ export { Icon, type IconProps } from './components/Icon';
15
+ export { IconButton, type IconButtonProps } from './components/IconButton';
16
+ export { Logo, type LogoProps } from './components/Logo';
17
+ export { Marker, type MarkerProps } from './components/Marker';
18
+ export { MetaLabel, type MetaLabelProps } from './components/MetaLabel';
19
+ export { Spinner, type SpinnerProps } from './components/Spinner';
20
+ export { Tag, type TagProps } from './components/Tag';
21
+ export { ThemeProvider, useTheme, type ThemeMode, type ThemeProviderProps } from './components/ThemeProvider';
22
+ export { useButtonAction, type ButtonAction } from './components/useButtonAction';
23
+
24
+ export { Checkbox, type CheckboxProps } from './components/Checkbox';
25
+ export { Dialog, DialogClose, DialogContent, DialogTrigger, type DialogContentProps } from './components/Dialog';
26
+ export { Field, useField, type FieldContextValue, type FieldProps } from './components/Field';
27
+ export { Input, type InputProps } from './components/Input';
28
+ export { RadioGroup, type RadioGroupProps, type RadioOption } from './components/Radio';
29
+ export { Select, type SelectOption, type SelectProps } from './components/Select';
30
+ export { Switch, type SwitchProps } from './components/Switch';
31
+ export { Table, type TableColumn, type TableProps, type TableRowBase, type TableSort, type TableSortDirection } from './components/Table';
32
+ export { Tab, TabList, TabPanel, Tabs, type TabItem, type TabListProps, type TabPanelProps, type TabProps, type TabsProps, type TabsVariant } from './components/Tabs';
33
+ export { Textarea, type TextareaProps } from './components/Textarea';
34
+ export { Toaster, toast, type ToasterProps, type ToastLabels, type ToastOptions, type ToastStatus } from './components/Toast';
35
+ export { Tooltip, TooltipProvider, type TooltipProps } from './components/Tooltip';
36
+
37
+ // Stage 2 — product patterns
38
+ export { ActionBar, ActionBarProvider, arrange, formatShortcut, useActionBarActivity, useActionBarHidden, useActionBarItems, useActionBarMode, useActionBarSelection, type ActionBarAction, type ActionBarLabels, type ActionBarMode, type ActionBarPresentation, type ActionBarProps, type ActionBarProviderProps, type ActionBarSelection, type ActionBarState } from './components/ActionBar';
39
+ export { ActivityTimeline, initialsOf, type ActivityEvent, type ActivityGroup, type ActivityTimelineProps } from './components/ActivityTimeline';
40
+ export { AsyncSelect, type AsyncSelectProps } from './components/AsyncSelect';
41
+ export { Breadcrumb, type BreadcrumbItem, type BreadcrumbProps } from './components/Breadcrumb';
42
+ export { ChoiceCards, type Choice, type ChoiceCardsProps } from './components/ChoiceCards';
43
+ export { CodeInput, type CodeInputProps } from './components/CodeInput';
44
+ export { CommandPalette, useCommandPaletteHotkey, type CommandItem, type CommandPaletteLabels, type CommandPaletteProps, type CommandSource } from './components/CommandPalette';
45
+ export { ConfirmDialog, PromptDialog, StepUpDialog, type ConfirmDialogProps, type PromptDialogProps, type StepUpDialogProps } from './components/ConfirmDialog';
46
+ export { CopyField, type CopyFieldProps } from './components/CopyField';
47
+ export { DateInput, FuzzyDateInput, type DateInputProps, type DatePrecision, type FuzzyDateInputProps } from './components/DateInput';
48
+ export { Dropzone, refusedText, type DropzoneLabels, type DropzoneProps } from './components/Dropzone';
49
+ export { FileCard, type FileCardProps, type FileCardState } from './components/FileCard';
50
+ export { FileList, FileRow, type FileListProps, type FileRowProps } from './components/FileRow';
51
+ export { FolderTree, type FolderTreeProps, type TreeNode } from './components/FolderTree';
52
+ export { type ListItem } from './components/inputs-listbox';
53
+ export { ListToolbar, type ListFilter, type ListToolbarLabels, type ListToolbarProps, type ListView, type SearchScope } from './components/ListToolbar';
54
+ export { MentionInput, mentionAt, type Mention, type MentionInputProps } from './components/MentionInput';
55
+ export { Menu, MenuCheckboxItem, MenuContent, MenuGroup, MenuItem, MenuSeparator, MenuSub, MenuSubContent, MenuSubTrigger, MenuTrigger, type MenuCheckboxItemProps, type MenuContentProps, type MenuItemProps, type MenuSubTriggerProps } from './components/Menu';
56
+ export { PageHeader, type PageHeaderFigure, type PageHeaderProps } from './components/PageHeader';
57
+ export { PageNotice, PageState, type PageNoticeProps, type PageStateKind, type PageStateProps } from './components/PageState';
58
+ export { Progress, progressStep, type ProgressProps, type ProgressSegment } from './components/Progress';
59
+ export { PulseDot, type PulseDotProps } from './components/PulseDot';
60
+ export { RecipientsInput, type Recipient, type RecipientStatus, type RecipientsInputProps } from './components/RecipientsInput';
61
+ export { SearchField, type SearchFieldProps } from './components/SearchField';
62
+ export { SegmentedControl, type Segment, type SegmentedControlProps } from './components/SegmentedControl';
63
+ export { SidePanel, SidePanelProvider, SidePanelSlot, useSidePanel, type SidePanelProps } from './components/SidePanel';
64
+ export { Skeleton, SkeletonCards, SkeletonList, SkeletonViewer, type SkeletonProps, type SkeletonViewerProps } from './components/Skeleton';
65
+ export { StageProgress, type Stage, type StageProgressProps } from './components/StageProgress';
66
+ export { checkFiles, formatBytes, hasFiles, readDataTransfer, type DroppedFile, type FileRules, type Rejected, type RejectReason } from './components/status-files';
67
+ export { Steps, type Step, type StepState, type StepsProps } from './components/Steps';
68
+ export { SyncStatus, type SyncState, type SyncStatusProps } from './components/SyncStatus';
69
+ export { UploadCheck, groupByFolder, type UploadCheckLabels, type UploadCheckProps } from './components/UploadCheck';
70
+ export { UploadFolderRow, UploadList, UploadRow, useBeforeUnloadWhile, type UploadItem, type UploadRowLabels, type UploadRowProps, type UploadState } from './components/UploadList';
71
+ export { useSelection, type Selection, type SelectionOptions } from './components/useSelection';
72
+ export { InfiniteLoader, VirtualList, type InfiniteLoaderProps, type VirtualListProps } from './components/VirtualList';
73
+ export { WindowDrop, useWindowDrop, type WindowDropProps } from './components/WindowDrop';
package/src/modes.css ADDED
@@ -0,0 +1,10 @@
1
+ /* @cosxai/ui — ink mode, completed.
2
+ *
3
+ * 3.0 switches its colours on either data-mode="ink" (root) or .ink-mode
4
+ * (subtree) — colors.css — but its marker rules (typography.css,
5
+ * typography-cjk.css) only know .ink-mode. Same rules for data-mode, so a
6
+ * page in ink mode gets the ink marker: no band, the finding in accent
7
+ * yellow. Reported to the design system; drop this when 3.0 covers it.
8
+ */
9
+ [data-mode="ink"] .marker { color: var(--yellow-accent); background: none; padding: 0; margin: 0; border-radius: 0; }
10
+ [data-mode="ink"] :lang(zh) .marker, :lang(zh)[data-mode="ink"] .marker { background: none; }
package/src/styles.css CHANGED
@@ -14,4 +14,6 @@
14
14
  @import "./tokens/elevation.css";
15
15
  @import "./tokens/motion.css";
16
16
  @import "./tokens/compat.css";
17
+ @import "./brand.css";
18
+ @import "./modes.css";
17
19
  @import "./fonts.css";