@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +109 -0
- package/package.json +36 -6
- 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/AuthLayout.tsx +127 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/BrandLoader.tsx +122 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +186 -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/ThemeSwitch.tsx +99 -0
- package/src/components/Toast.tsx +196 -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/WorkspaceRow.tsx +80 -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 +73 -2
- package/src/modes.css +10 -0
- package/src/styles.css +2 -0
- package/src/theme-base.css +123 -0
- package/src/theme-reset.css +14 -0
- package/src/theme.css +2 -114
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { ChevronDown, ChevronsLeft, ChevronsRight, GripVertical, X } from 'lucide-react';
|
|
2
|
+
import type { LucideIcon } from 'lucide-react';
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
Fragment,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useLayoutEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from 'react';
|
|
16
|
+
|
|
17
|
+
import { cn } from '../lib/cn';
|
|
18
|
+
import { Menu, MenuContent, MenuItem, MenuTrigger } from './Menu';
|
|
19
|
+
import { Tooltip } from './Tooltip';
|
|
20
|
+
|
|
21
|
+
// ─── Types ────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type ActionBarAction = {
|
|
24
|
+
/** Stable id (per registering page). */
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
icon?: LucideIcon | undefined;
|
|
28
|
+
/** The key that runs it, even while the bar is folded or hidden: "n", "shift+s", "mod+a". Shown as N, ⇧S, ⌘A. */
|
|
29
|
+
shortcut?: string | undefined;
|
|
30
|
+
onSelect: () => void;
|
|
31
|
+
/** A toggle that is on: lit with the brand colour — the only colour on the bar. */
|
|
32
|
+
active?: boolean | undefined;
|
|
33
|
+
disabled?: boolean | undefined;
|
|
34
|
+
/** Why it is unavailable (disables it; shown on hover). */
|
|
35
|
+
disabledReason?: string | undefined;
|
|
36
|
+
/** Actions of a kind: more than two sharing a group fold into one menu. */
|
|
37
|
+
group?: string | undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ActionBarSelection = {
|
|
41
|
+
/** How many items are selected; 0 returns the bar to idle. */
|
|
42
|
+
count: number;
|
|
43
|
+
/** Only actions valid for every selected item (folders included). */
|
|
44
|
+
actions: ActionBarAction[];
|
|
45
|
+
/** Clear the selection (Cancel, Esc). */
|
|
46
|
+
onClear: () => void;
|
|
47
|
+
/** @default (n) => `${n} selected` */
|
|
48
|
+
countLabel?: ((count: number) => string) | undefined;
|
|
49
|
+
/** @default "Cancel" */
|
|
50
|
+
clearLabel?: string | undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ActionBarMode = {
|
|
54
|
+
/** "Comment", "Translate". */
|
|
55
|
+
name: string;
|
|
56
|
+
actions: ActionBarAction[];
|
|
57
|
+
/** Leave the mode (Done, Esc). */
|
|
58
|
+
onDone: () => void;
|
|
59
|
+
/** @default "Done" */
|
|
60
|
+
doneLabel?: string | undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type ActionBarState = 'idle' | 'selection' | 'mode';
|
|
64
|
+
/** full: icon, label, shortcut · labels: no shortcut text · icons: icons only · folded: the left-edge handle. */
|
|
65
|
+
export type ActionBarPresentation = 'full' | 'labels' | 'icons' | 'folded';
|
|
66
|
+
|
|
67
|
+
type Stored = { folded: boolean; x: number; y: number };
|
|
68
|
+
|
|
69
|
+
type Ctx = {
|
|
70
|
+
version: number;
|
|
71
|
+
setItems: (source: string, actions: ActionBarAction[] | null) => void;
|
|
72
|
+
setSelection: (source: string, sel: ActionBarSelection | null) => void;
|
|
73
|
+
setMode: (source: string, mode: ActionBarMode | null) => void;
|
|
74
|
+
setHidden: (source: string, hidden: boolean) => void;
|
|
75
|
+
setActivity: (source: string, label: string | null) => void;
|
|
76
|
+
read: () => {
|
|
77
|
+
idle: ActionBarAction[];
|
|
78
|
+
selection: ActionBarSelection | null;
|
|
79
|
+
mode: ActionBarMode | null;
|
|
80
|
+
hidden: boolean;
|
|
81
|
+
activity: string | null;
|
|
82
|
+
};
|
|
83
|
+
prefs: Stored;
|
|
84
|
+
setPrefs: (next: Partial<Stored>) => void;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const ActionBarContext = createContext<Ctx | null>(null);
|
|
88
|
+
|
|
89
|
+
// ─── Keys ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
92
|
+
|
|
93
|
+
/** "shift+s" → "⇧S", "mod+a" → "⌘A" (Ctrl+A off Apple). */
|
|
94
|
+
export function formatShortcut(shortcut: string): string {
|
|
95
|
+
return shortcut
|
|
96
|
+
.split('+')
|
|
97
|
+
.map((p) => {
|
|
98
|
+
const k = p.trim().toLowerCase();
|
|
99
|
+
if (k === 'mod') return isMac ? '⌘' : 'Ctrl+';
|
|
100
|
+
if (k === 'shift') return '⇧';
|
|
101
|
+
if (k === 'alt') return isMac ? '⌥' : 'Alt+';
|
|
102
|
+
if (k === 'escape' || k === 'esc') return 'Esc';
|
|
103
|
+
return k.length === 1 ? k.toUpperCase() : k[0]!.toUpperCase() + k.slice(1);
|
|
104
|
+
})
|
|
105
|
+
.join('');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function matches(shortcut: string, e: KeyboardEvent): boolean {
|
|
109
|
+
const parts = shortcut.toLowerCase().split('+').map((p) => p.trim());
|
|
110
|
+
const key = parts[parts.length - 1]!;
|
|
111
|
+
const mod = parts.includes('mod');
|
|
112
|
+
const wantMod = isMac ? e.metaKey : e.ctrlKey;
|
|
113
|
+
if (mod !== wantMod) return false;
|
|
114
|
+
if (parts.includes('shift') !== e.shiftKey) return false;
|
|
115
|
+
if (parts.includes('alt') !== e.altKey) return false;
|
|
116
|
+
if (!mod && (e.metaKey || e.ctrlKey)) return false;
|
|
117
|
+
return e.key.toLowerCase() === key || e.code.toLowerCase() === `key${key}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function editable(t: EventTarget | null): boolean {
|
|
121
|
+
const el = t as HTMLElement | null;
|
|
122
|
+
if (!el || !el.tagName) return false;
|
|
123
|
+
return el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const dialogOpen = () => Boolean(document.querySelector('[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'));
|
|
127
|
+
|
|
128
|
+
const signature = (a: ActionBarAction[] | null | undefined) =>
|
|
129
|
+
a ? a.map((x) => [x.id, x.label, x.shortcut, x.active, x.disabled, x.disabledReason, x.group, x.icon?.displayName].join('\u0001')).join('\u0002') : '';
|
|
130
|
+
|
|
131
|
+
// ─── Provider ─────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export type ActionBarProviderProps = {
|
|
134
|
+
children: ReactNode;
|
|
135
|
+
/** localStorage key for position and fold state; null keeps them in memory. @default "cosx-action-bar" */
|
|
136
|
+
storageKey?: string | null | undefined;
|
|
137
|
+
/** Start folded on first visit (the viewer, signing and presenting default to folded). */
|
|
138
|
+
defaultFolded?: boolean | undefined;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* ActionBarProvider — the registry behind the one global action bar.
|
|
143
|
+
* Pages register actions (useActionBarItems) and withdraw them on unmount;
|
|
144
|
+
* a selection (useActionBarSelection) or a mode (useActionBarMode) swaps
|
|
145
|
+
* the bar's contents as a whole. Shortcuts of the current state work even
|
|
146
|
+
* when the bar is folded or hidden; Esc steps back mode → selection → idle;
|
|
147
|
+
* \ folds and unfolds. Handlers are read at call time, so registering
|
|
148
|
+
* inline arrays is safe.
|
|
149
|
+
*/
|
|
150
|
+
export function ActionBarProvider({ children, storageKey = 'cosx-action-bar', defaultFolded = false }: ActionBarProviderProps) {
|
|
151
|
+
const [version, setVersion] = useState(0);
|
|
152
|
+
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
|
153
|
+
const items = useRef(new Map<string, ActionBarAction[]>());
|
|
154
|
+
const selections = useRef(new Map<string, ActionBarSelection>());
|
|
155
|
+
const modes = useRef(new Map<string, ActionBarMode>());
|
|
156
|
+
const hidden = useRef(new Set<string>());
|
|
157
|
+
const activity = useRef(new Map<string, string>());
|
|
158
|
+
const sigs = useRef(new Map<string, string>());
|
|
159
|
+
|
|
160
|
+
const changed = (key: string, sig: string) => {
|
|
161
|
+
if (sigs.current.get(key) === sig) return false;
|
|
162
|
+
sigs.current.set(key, sig);
|
|
163
|
+
return true;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const ctxFns = useMemo(() => {
|
|
167
|
+
const put = <T,>(map: Map<string, T>, kind: string, source: string, v: T | null, sig: string) => {
|
|
168
|
+
if (v === null) map.delete(source);
|
|
169
|
+
else map.set(source, v);
|
|
170
|
+
if (changed(`${kind}:${source}`, v === null ? '∅' : sig)) bump();
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
setItems: (s: string, a: ActionBarAction[] | null) => put(items.current, 'i', s, a, signature(a)),
|
|
174
|
+
setSelection: (s: string, v: ActionBarSelection | null) =>
|
|
175
|
+
put(selections.current, 's', s, v, v ? `${v.count}|${v.clearLabel}|${signature(v.actions)}` : ''),
|
|
176
|
+
setMode: (s: string, v: ActionBarMode | null) => put(modes.current, 'm', s, v, v ? `${v.name}|${v.doneLabel}|${signature(v.actions)}` : ''),
|
|
177
|
+
setHidden: (s: string, h: boolean) => {
|
|
178
|
+
const had = hidden.current.has(s);
|
|
179
|
+
if (h) hidden.current.add(s);
|
|
180
|
+
else hidden.current.delete(s);
|
|
181
|
+
if (had !== h) bump();
|
|
182
|
+
},
|
|
183
|
+
setActivity: (s: string, label: string | null) => put(activity.current, 'a', s, label, label ?? ''),
|
|
184
|
+
read: () => {
|
|
185
|
+
const last = <T,>(m: Map<string, T>) => [...m.values()].at(-1) ?? null;
|
|
186
|
+
const sel = [...selections.current.values()].filter((x) => x.count > 0).at(-1) ?? null;
|
|
187
|
+
return {
|
|
188
|
+
idle: [...items.current.values()].flat(),
|
|
189
|
+
selection: sel,
|
|
190
|
+
mode: last(modes.current),
|
|
191
|
+
hidden: hidden.current.size > 0,
|
|
192
|
+
activity: last(activity.current),
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}, [bump]);
|
|
197
|
+
|
|
198
|
+
const [prefs, setPrefsState] = useState<Stored>(() => {
|
|
199
|
+
const fallback = { folded: defaultFolded, x: 0, y: 0 };
|
|
200
|
+
if (!storageKey) return fallback;
|
|
201
|
+
try {
|
|
202
|
+
const raw = localStorage.getItem(storageKey);
|
|
203
|
+
return raw ? { ...fallback, ...(JSON.parse(raw) as Partial<Stored>) } : fallback;
|
|
204
|
+
} catch {
|
|
205
|
+
return fallback;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
const setPrefs = useCallback(
|
|
209
|
+
(next: Partial<Stored>) =>
|
|
210
|
+
setPrefsState((cur) => {
|
|
211
|
+
const merged = { ...cur, ...next };
|
|
212
|
+
if (storageKey) {
|
|
213
|
+
try {
|
|
214
|
+
localStorage.setItem(storageKey, JSON.stringify(merged));
|
|
215
|
+
} catch {
|
|
216
|
+
/* storage unavailable */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return merged;
|
|
220
|
+
}),
|
|
221
|
+
[storageKey],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Keys: the current state's shortcuts, Esc stepping back, \ folding.
|
|
225
|
+
const readRef = useRef(ctxFns.read);
|
|
226
|
+
readRef.current = ctxFns.read;
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
const onKey = (e: KeyboardEvent) => {
|
|
229
|
+
if (e.defaultPrevented) return;
|
|
230
|
+
const { idle, selection, mode } = readRef.current();
|
|
231
|
+
if (e.key === 'Escape') {
|
|
232
|
+
if (dialogOpen() || editable(e.target)) return;
|
|
233
|
+
if (mode) {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
mode.onDone();
|
|
236
|
+
} else if (selection) {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
selection.onClear();
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (editable(e.target) || dialogOpen()) return;
|
|
243
|
+
if (e.key === '\\' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
setPrefsState((cur) => {
|
|
246
|
+
const merged = { ...cur, folded: !cur.folded };
|
|
247
|
+
if (storageKey) {
|
|
248
|
+
try {
|
|
249
|
+
localStorage.setItem(storageKey, JSON.stringify(merged));
|
|
250
|
+
} catch {
|
|
251
|
+
/* storage unavailable */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return merged;
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const list = mode ? mode.actions : selection ? selection.actions : idle;
|
|
259
|
+
const hit = list.find((a) => a.shortcut && !a.disabled && !a.disabledReason && matches(a.shortcut, e));
|
|
260
|
+
if (hit) {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
hit.onSelect();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
document.addEventListener('keydown', onKey);
|
|
266
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
267
|
+
}, [storageKey]);
|
|
268
|
+
|
|
269
|
+
const value = useMemo(() => ({ version, ...ctxFns, prefs, setPrefs }), [version, ctxFns, prefs, setPrefs]);
|
|
270
|
+
return <ActionBarContext.Provider value={value}>{children}</ActionBarContext.Provider>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function useCtx(): Ctx {
|
|
274
|
+
const ctx = useContext(ActionBarContext);
|
|
275
|
+
if (!ctx) throw new Error('Action bar hooks need an <ActionBarProvider>');
|
|
276
|
+
return ctx;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Register the page's standing actions (usually 3–5: New, Upload, Select, Comment); withdrawn on unmount. */
|
|
280
|
+
export function useActionBarItems(source: string, actions: ActionBarAction[]) {
|
|
281
|
+
const { setItems } = useCtx();
|
|
282
|
+
useLayoutEffect(() => {
|
|
283
|
+
setItems(source, actions);
|
|
284
|
+
});
|
|
285
|
+
useEffect(() => () => setItems(source, null), [setItems, source]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Put the bar in the selection state while count > 0; pass null when nothing can be selected. */
|
|
289
|
+
export function useActionBarSelection(source: string, selection: ActionBarSelection | null) {
|
|
290
|
+
const { setSelection } = useCtx();
|
|
291
|
+
useLayoutEffect(() => {
|
|
292
|
+
setSelection(source, selection);
|
|
293
|
+
});
|
|
294
|
+
useEffect(() => () => setSelection(source, null), [setSelection, source]);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Put the bar in a mode (Comment, Translate); null leaves it. */
|
|
298
|
+
export function useActionBarMode(source: string, mode: ActionBarMode | null) {
|
|
299
|
+
const { setMode } = useCtx();
|
|
300
|
+
useLayoutEffect(() => {
|
|
301
|
+
setMode(source, mode);
|
|
302
|
+
});
|
|
303
|
+
useEffect(() => () => setMode(source, null), [setMode, source]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Hide the bar (signing, presenting, full-screen viewing). Shortcuts keep working. */
|
|
307
|
+
export function useActionBarHidden(source: string, hidden: boolean) {
|
|
308
|
+
const { setHidden } = useCtx();
|
|
309
|
+
useLayoutEffect(() => {
|
|
310
|
+
setHidden(source, hidden);
|
|
311
|
+
});
|
|
312
|
+
useEffect(() => () => setHidden(source, false), [setHidden, source]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Background work or the Agent running: the folded handle carries a status dot, labelled with this. */
|
|
316
|
+
export function useActionBarActivity(source: string, label: string | null) {
|
|
317
|
+
const { setActivity } = useCtx();
|
|
318
|
+
useLayoutEffect(() => {
|
|
319
|
+
setActivity(source, label);
|
|
320
|
+
});
|
|
321
|
+
useEffect(() => () => setActivity(source, null), [setActivity, source]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─── Bar ──────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
function presentationFor(width: number): ActionBarPresentation {
|
|
327
|
+
if (width >= 1280) return 'full';
|
|
328
|
+
if (width >= 1024) return 'labels';
|
|
329
|
+
if (width >= 768) return 'icons';
|
|
330
|
+
return 'folded';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function useViewportWidth() {
|
|
334
|
+
const [w, setW] = useState(() => (typeof window === 'undefined' ? 1280 : window.innerWidth));
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
const on = () => setW(window.innerWidth);
|
|
337
|
+
window.addEventListener('resize', on);
|
|
338
|
+
return () => window.removeEventListener('resize', on);
|
|
339
|
+
}, []);
|
|
340
|
+
return w;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
type Entry = { kind: 'action'; action: ActionBarAction } | { kind: 'group'; label: string; actions: ActionBarAction[] };
|
|
344
|
+
|
|
345
|
+
/** Fold groups of more than two, then everything past maxActions into More. */
|
|
346
|
+
export function arrange(actions: ActionBarAction[], maxActions: number, moreLabel: string): Entry[] {
|
|
347
|
+
const counts = new Map<string, number>();
|
|
348
|
+
for (const a of actions) if (a.group) counts.set(a.group, (counts.get(a.group) ?? 0) + 1);
|
|
349
|
+
const entries: Entry[] = [];
|
|
350
|
+
const seen = new Set<string>();
|
|
351
|
+
for (const a of actions) {
|
|
352
|
+
if (a.group && (counts.get(a.group) ?? 0) > 2) {
|
|
353
|
+
if (seen.has(a.group)) continue;
|
|
354
|
+
seen.add(a.group);
|
|
355
|
+
entries.push({ kind: 'group', label: a.group, actions: actions.filter((x) => x.group === a.group) });
|
|
356
|
+
} else entries.push({ kind: 'action', action: a });
|
|
357
|
+
}
|
|
358
|
+
if (entries.length <= maxActions) return entries;
|
|
359
|
+
const keep = entries.slice(0, maxActions - 1);
|
|
360
|
+
const rest = entries.slice(maxActions - 1).flatMap((e) => (e.kind === 'action' ? [e.action] : e.actions));
|
|
361
|
+
return [...keep, { kind: 'group', label: moreLabel, actions: rest }];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export type ActionBarLabels = {
|
|
365
|
+
/** @default "Actions" */
|
|
366
|
+
bar?: string;
|
|
367
|
+
/** @default "Move" */
|
|
368
|
+
grip?: string;
|
|
369
|
+
/** @default "Fold the action bar" */
|
|
370
|
+
fold?: string;
|
|
371
|
+
/** @default "Show the action bar" */
|
|
372
|
+
unfold?: string;
|
|
373
|
+
/** @default "More" */
|
|
374
|
+
more?: string;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
export type ActionBarProps = {
|
|
378
|
+
/** Force a presentation (a demo, a narrow host); unset follows the window width and the fold state. */
|
|
379
|
+
presentation?: ActionBarPresentation | undefined;
|
|
380
|
+
/** Hide regardless of pages (e.g. while presenting). Shortcuts keep working. */
|
|
381
|
+
hidden?: boolean | undefined;
|
|
382
|
+
/** Actions per state before the rest fold into More. @default 6 */
|
|
383
|
+
maxActions?: number | undefined;
|
|
384
|
+
labels?: ActionBarLabels | undefined;
|
|
385
|
+
/** Lay it out in place (documentation, a demo) instead of floating: no fixed position, no dragging. */
|
|
386
|
+
inline?: boolean | undefined;
|
|
387
|
+
className?: string | undefined;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const itemCls = cn(
|
|
391
|
+
'inline-flex h-9 shrink-0 cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2.5 font-sans text-ui font-medium text-inherit outline-none',
|
|
392
|
+
'hover:bg-white/10 ink:hover:bg-ink/8 focus-visible:shadow-[0_0_0_2px_var(--yellow-accent)] ink:focus-visible:shadow-[0_0_0_2px_var(--ink)]',
|
|
393
|
+
'disabled:cursor-not-allowed disabled:opacity-40 aria-disabled:cursor-not-allowed aria-disabled:opacity-40',
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* ActionBar — the ink strip floating at the bottom centre: the page's most
|
|
398
|
+
* used actions, or the selection's, or a mode's. The highest-contrast
|
|
399
|
+
* element on the page; the brand colour lights only an active item.
|
|
400
|
+
* Narrowing drops shortcuts, then labels, then folds to a handle on the
|
|
401
|
+
* left edge. Drag by the grip; double-click it to reset.
|
|
402
|
+
*/
|
|
403
|
+
export function ActionBar({ presentation, hidden: hiddenProp, maxActions = 6, labels = {}, inline = false, className }: ActionBarProps) {
|
|
404
|
+
const ctx = useCtx();
|
|
405
|
+
const { idle, selection, mode, hidden, activity } = ctx.read();
|
|
406
|
+
const { prefs, setPrefs } = ctx;
|
|
407
|
+
const width = useViewportWidth();
|
|
408
|
+
const bar = useRef<HTMLDivElement | null>(null);
|
|
409
|
+
const drag = useRef<{ px: number; py: number; x: number; y: number } | null>(null);
|
|
410
|
+
|
|
411
|
+
const state: ActionBarState = mode ? 'mode' : selection ? 'selection' : 'idle';
|
|
412
|
+
const auto = presentation ?? presentationFor(width);
|
|
413
|
+
// Entering a selection or a mode unfolds it; back in idle the user's fold state returns.
|
|
414
|
+
const folded = state === 'idle' && (auto === 'folded' || (presentation === undefined && prefs.folded));
|
|
415
|
+
const shown: Exclude<ActionBarPresentation, 'folded'> = auto === 'folded' ? 'icons' : auto;
|
|
416
|
+
|
|
417
|
+
// Pull the bar back into view when the window shrinks.
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
const el = bar.current;
|
|
420
|
+
if (!el || (prefs.x === 0 && prefs.y === 0)) return;
|
|
421
|
+
const r = el.getBoundingClientRect();
|
|
422
|
+
let { x, y } = prefs;
|
|
423
|
+
if (r.left < 8) x += 8 - r.left;
|
|
424
|
+
if (r.right > window.innerWidth - 8) x -= r.right - (window.innerWidth - 8);
|
|
425
|
+
if (r.top < 8) y += 8 - r.top;
|
|
426
|
+
if (r.bottom > window.innerHeight - 8) y -= r.bottom - (window.innerHeight - 8);
|
|
427
|
+
if (x !== prefs.x || y !== prefs.y) setPrefs({ x, y });
|
|
428
|
+
}, [width, prefs, setPrefs]);
|
|
429
|
+
|
|
430
|
+
const list = mode ? mode.actions : selection ? selection.actions : idle;
|
|
431
|
+
if (hiddenProp || hidden || (state === 'idle' && list.length === 0)) return null;
|
|
432
|
+
|
|
433
|
+
if (folded) {
|
|
434
|
+
return (
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
onClick={() => setPrefs({ folded: false })}
|
|
438
|
+
aria-label={activity ? `${labels.unfold ?? 'Show the action bar'} · ${activity}` : (labels.unfold ?? 'Show the action bar')}
|
|
439
|
+
title={activity ?? labels.unfold ?? 'Show the action bar'}
|
|
440
|
+
className={cn(
|
|
441
|
+
inline ? 'relative' : 'fixed bottom-6 left-0 z-40',
|
|
442
|
+
'inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-r-lg border-0 bg-ink pr-2.5 pl-2 text-linen outline-none',
|
|
443
|
+
'hover:bg-ink-raised focus-visible:shadow-(--focus-ring) ink:bg-linen ink:text-ink',
|
|
444
|
+
className,
|
|
445
|
+
)}
|
|
446
|
+
>
|
|
447
|
+
<ChevronsRight size={16} aria-hidden />
|
|
448
|
+
{activity && <span aria-hidden className="size-2 rounded-pill bg-brand-mark motion-safe:animate-pulse" />}
|
|
449
|
+
</button>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const entries = arrange(list, maxActions, labels.more ?? 'More');
|
|
454
|
+
|
|
455
|
+
const onToolbarKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
456
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') return;
|
|
457
|
+
const buttons = Array.from(bar.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') ?? []);
|
|
458
|
+
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
|
459
|
+
if (i < 0) return;
|
|
460
|
+
e.preventDefault();
|
|
461
|
+
const next = e.key === 'Home' ? 0 : e.key === 'End' ? buttons.length - 1 : (i + (e.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
|
|
462
|
+
buttons[next]?.focus();
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const renderAction = (a: ActionBarAction) => {
|
|
466
|
+
const keys = a.shortcut ? formatShortcut(a.shortcut) : null;
|
|
467
|
+
const Glyph = a.icon;
|
|
468
|
+
const iconOnly = shown === 'icons' && Glyph;
|
|
469
|
+
const button = (
|
|
470
|
+
<button
|
|
471
|
+
type="button"
|
|
472
|
+
aria-label={iconOnly ? a.label : undefined}
|
|
473
|
+
aria-pressed={a.active === undefined ? undefined : a.active}
|
|
474
|
+
aria-keyshortcuts={a.shortcut ? keys ?? undefined : undefined}
|
|
475
|
+
disabled={a.disabled && !a.disabledReason ? true : undefined}
|
|
476
|
+
aria-disabled={a.disabledReason ? true : undefined}
|
|
477
|
+
onClick={() => !a.disabledReason && a.onSelect()}
|
|
478
|
+
className={cn(itemCls, a.active && 'bg-brand-field text-ink hover:bg-brand-field ink:hover:bg-brand-field')}
|
|
479
|
+
>
|
|
480
|
+
{Glyph && <Glyph size={16} strokeWidth={1.75} aria-hidden />}
|
|
481
|
+
{!iconOnly && <span>{a.label}</span>}
|
|
482
|
+
{shown === 'full' && keys && <kbd className="font-sans text-meta font-medium opacity-55">{keys}</kbd>}
|
|
483
|
+
</button>
|
|
484
|
+
);
|
|
485
|
+
const hint = a.disabledReason ?? (shown !== 'full' ? [a.label, keys].filter(Boolean).join(' · ') : null);
|
|
486
|
+
return hint && (iconOnly || a.disabledReason || keys) ? (
|
|
487
|
+
<Tooltip key={a.id} content={hint}>
|
|
488
|
+
{button}
|
|
489
|
+
</Tooltip>
|
|
490
|
+
) : (
|
|
491
|
+
<Fragment key={a.id}>{button}</Fragment>
|
|
492
|
+
);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<div
|
|
497
|
+
ref={bar}
|
|
498
|
+
role="toolbar"
|
|
499
|
+
aria-label={labels.bar ?? 'Actions'}
|
|
500
|
+
onKeyDown={onToolbarKey}
|
|
501
|
+
data-state={state}
|
|
502
|
+
data-presentation={shown}
|
|
503
|
+
style={inline ? undefined : { transform: `translate(calc(-50% + ${prefs.x}px), ${prefs.y}px)` }}
|
|
504
|
+
className={cn(
|
|
505
|
+
'flex max-w-[calc(100vw-16px)] items-center gap-0.5 overflow-x-auto rounded-lg bg-ink p-1.5 font-sans text-linen',
|
|
506
|
+
'ink:bg-linen ink:text-ink',
|
|
507
|
+
inline
|
|
508
|
+
? 'relative w-fit'
|
|
509
|
+
: 'fixed bottom-6 left-1/2 z-40 max-md:bottom-0 max-md:left-0 max-md:w-full max-md:max-w-none max-md:translate-x-0! max-md:rounded-none',
|
|
510
|
+
className,
|
|
511
|
+
)}
|
|
512
|
+
>
|
|
513
|
+
<span
|
|
514
|
+
aria-hidden
|
|
515
|
+
title={labels.grip ?? 'Move'}
|
|
516
|
+
onPointerDown={(e) => {
|
|
517
|
+
if (inline) return;
|
|
518
|
+
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
|
519
|
+
drag.current = { px: e.clientX, py: e.clientY, x: prefs.x, y: prefs.y };
|
|
520
|
+
}}
|
|
521
|
+
onPointerMove={(e) => {
|
|
522
|
+
const d = drag.current;
|
|
523
|
+
if (!d || !bar.current) return;
|
|
524
|
+
bar.current.style.transform = `translate(calc(-50% + ${d.x + e.clientX - d.px}px), ${d.y + e.clientY - d.py}px)`;
|
|
525
|
+
}}
|
|
526
|
+
onPointerUp={(e) => {
|
|
527
|
+
const d = drag.current;
|
|
528
|
+
drag.current = null;
|
|
529
|
+
if (!d) return;
|
|
530
|
+
const x = d.x + e.clientX - d.px;
|
|
531
|
+
const y = d.y + e.clientY - d.py;
|
|
532
|
+
// Dragged to the left edge: fold.
|
|
533
|
+
if (e.clientX < 24) setPrefs({ folded: true, x: 0, y: 0 });
|
|
534
|
+
else setPrefs({ x, y });
|
|
535
|
+
}}
|
|
536
|
+
onDoubleClick={() => setPrefs({ x: 0, y: 0 })}
|
|
537
|
+
className={cn('grid h-9 w-5 shrink-0 touch-none place-items-center opacity-50 hover:opacity-100 max-md:hidden', inline ? 'cursor-default' : 'cursor-grab')}
|
|
538
|
+
>
|
|
539
|
+
<GripVertical size={14} />
|
|
540
|
+
</span>
|
|
541
|
+
|
|
542
|
+
{selection && !mode && (
|
|
543
|
+
<span aria-live="polite" className="px-2.5 text-ui font-medium whitespace-nowrap">
|
|
544
|
+
{selection.countLabel ? selection.countLabel(selection.count) : `${selection.count} selected`}
|
|
545
|
+
</span>
|
|
546
|
+
)}
|
|
547
|
+
{/* The mode is named, not badged: the brand colour lights only an active item. */}
|
|
548
|
+
{mode && <span className="px-2.5 text-ui font-semibold whitespace-nowrap">{mode.name}</span>}
|
|
549
|
+
|
|
550
|
+
{entries.map((entry) =>
|
|
551
|
+
entry.kind === 'action' ? (
|
|
552
|
+
renderAction(entry.action)
|
|
553
|
+
) : (
|
|
554
|
+
<Menu key={`g:${entry.label}`}>
|
|
555
|
+
<MenuTrigger asChild>
|
|
556
|
+
<button type="button" className={itemCls}>
|
|
557
|
+
<span>{entry.label}</span>
|
|
558
|
+
<ChevronDown size={14} aria-hidden />
|
|
559
|
+
</button>
|
|
560
|
+
</MenuTrigger>
|
|
561
|
+
<MenuContent side="top">
|
|
562
|
+
{entry.actions.map((a) => (
|
|
563
|
+
<MenuItem
|
|
564
|
+
key={a.id}
|
|
565
|
+
icon={a.icon ? <a.icon size={16} strokeWidth={1.75} aria-hidden /> : undefined}
|
|
566
|
+
shortcut={a.shortcut ? formatShortcut(a.shortcut) : undefined}
|
|
567
|
+
disabled={Boolean(a.disabled)}
|
|
568
|
+
disabledReason={a.disabledReason}
|
|
569
|
+
onSelect={() => a.onSelect()}
|
|
570
|
+
>
|
|
571
|
+
{a.label}
|
|
572
|
+
</MenuItem>
|
|
573
|
+
))}
|
|
574
|
+
</MenuContent>
|
|
575
|
+
</Menu>
|
|
576
|
+
),
|
|
577
|
+
)}
|
|
578
|
+
|
|
579
|
+
{selection && !mode && (
|
|
580
|
+
<>
|
|
581
|
+
<span aria-hidden className="mx-1 h-5 w-px shrink-0 bg-white/20 ink:bg-ink/20" />
|
|
582
|
+
<button type="button" onClick={selection.onClear} className={itemCls}>
|
|
583
|
+
<X size={16} aria-hidden />
|
|
584
|
+
<span>{selection.clearLabel ?? 'Cancel'}</span>
|
|
585
|
+
{shown === 'full' && <kbd className="font-sans text-meta font-medium opacity-55">Esc</kbd>}
|
|
586
|
+
</button>
|
|
587
|
+
</>
|
|
588
|
+
)}
|
|
589
|
+
{mode && (
|
|
590
|
+
<>
|
|
591
|
+
<span aria-hidden className="mx-1 h-5 w-px shrink-0 bg-white/20 ink:bg-ink/20" />
|
|
592
|
+
<button type="button" onClick={mode.onDone} className={cn(itemCls, 'font-semibold')}>
|
|
593
|
+
{mode.doneLabel ?? 'Done'}
|
|
594
|
+
{shown === 'full' && <kbd className="font-sans text-meta font-medium opacity-55">Esc</kbd>}
|
|
595
|
+
</button>
|
|
596
|
+
</>
|
|
597
|
+
)}
|
|
598
|
+
{state === 'idle' && (
|
|
599
|
+
<Tooltip content={`${labels.fold ?? 'Fold the action bar'} · \\`}>
|
|
600
|
+
<button type="button" aria-label={labels.fold ?? 'Fold the action bar'} onClick={() => setPrefs({ folded: true })} className={cn(itemCls, 'px-2 opacity-60 hover:opacity-100 max-md:hidden')}>
|
|
601
|
+
<ChevronsLeft size={16} aria-hidden />
|
|
602
|
+
</button>
|
|
603
|
+
</Tooltip>
|
|
604
|
+
)}
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type ActivityEvent = {
|
|
6
|
+
id: string;
|
|
7
|
+
/** Who did it. Initials default from the name (first letters of up to two words; a single CJK name → its first character). */
|
|
8
|
+
actor: { name: string; initials?: string | undefined };
|
|
9
|
+
/** What they did, without the name: "viewed Cap table.xlsx for 6 min". */
|
|
10
|
+
action: ReactNode;
|
|
11
|
+
/** Shown time ("14:02"); `dateTime` makes it a <time>. */
|
|
12
|
+
time: ReactNode;
|
|
13
|
+
dateTime?: string | undefined;
|
|
14
|
+
/** Extra detail after the time: "pages 2–4 most". */
|
|
15
|
+
detail?: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ActivityGroup = { label: ReactNode; events: ActivityEvent[] };
|
|
19
|
+
|
|
20
|
+
export type ActivityTimelineProps = Omit<ComponentProps<'section'>, 'children'> & {
|
|
21
|
+
/** Grouped by time, newest first: Today, Yesterday, then dates. */
|
|
22
|
+
groups: ActivityGroup[];
|
|
23
|
+
/** Above the groups: the scope switch ("This share / Whole chain"). */
|
|
24
|
+
scope?: ReactNode;
|
|
25
|
+
/** Heading level of the group labels. @default 3 */
|
|
26
|
+
level?: 2 | 3 | 4 | undefined;
|
|
27
|
+
/** When there is nothing yet. */
|
|
28
|
+
empty?: ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function initialsOf(name: string): string {
|
|
32
|
+
const t = name.trim();
|
|
33
|
+
if (!t) return '?';
|
|
34
|
+
if (/[㐀-鿿]/.test(t[0]!)) return t[0]!;
|
|
35
|
+
return t
|
|
36
|
+
.split(/\s+/)
|
|
37
|
+
.slice(0, 2)
|
|
38
|
+
.map((w) => w[0]!.toUpperCase())
|
|
39
|
+
.join('');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** ActivityTimeline — who did what, grouped by time. */
|
|
43
|
+
export function ActivityTimeline({ groups, scope, level = 3, empty, className, ...rest }: ActivityTimelineProps) {
|
|
44
|
+
const H = `h${level}` as const;
|
|
45
|
+
const any = groups.some((g) => g.events.length > 0);
|
|
46
|
+
return (
|
|
47
|
+
<section className={cn('flex flex-col gap-3', className)} {...rest}>
|
|
48
|
+
{scope}
|
|
49
|
+
{!any && empty}
|
|
50
|
+
{groups
|
|
51
|
+
.filter((g) => g.events.length > 0)
|
|
52
|
+
.map((g, gi) => (
|
|
53
|
+
<div key={gi} className="flex flex-col gap-2">
|
|
54
|
+
<H className="m-0 text-meta font-medium text-fg-secondary">{g.label}</H>
|
|
55
|
+
<ol className="m-0 flex list-none flex-col gap-3 p-0">
|
|
56
|
+
{g.events.map((e) => (
|
|
57
|
+
<li key={e.id} className="flex gap-2.5">
|
|
58
|
+
<span aria-hidden className="grid size-6 shrink-0 place-items-center rounded-pill bg-sunk text-[10px] font-semibold text-fg">
|
|
59
|
+
{e.actor.initials ?? initialsOf(e.actor.name)}
|
|
60
|
+
</span>
|
|
61
|
+
<div className="flex min-w-0 flex-col">
|
|
62
|
+
<span className="text-ui text-fg">
|
|
63
|
+
<span className="font-medium">{e.actor.name}</span> {e.action}
|
|
64
|
+
</span>
|
|
65
|
+
<span className="text-meta text-fg-secondary">
|
|
66
|
+
{e.dateTime ? <time dateTime={e.dateTime}>{e.time}</time> : e.time}
|
|
67
|
+
{e.detail && <> · {e.detail}</>}
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
</li>
|
|
71
|
+
))}
|
|
72
|
+
</ol>
|
|
73
|
+
</div>
|
|
74
|
+
))}
|
|
75
|
+
</section>
|
|
76
|
+
);
|
|
77
|
+
}
|