@cosxai/ui 0.1.0

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 (109) hide show
  1. package/package.json +38 -0
  2. package/src/actionbar/ActionBar.tsx +436 -0
  3. package/src/actionbar/ActionBarButton.tsx +110 -0
  4. package/src/actionbar/ActionBarMenuGroup.tsx +106 -0
  5. package/src/actionbar/ActionBarProvider.tsx +76 -0
  6. package/src/actionbar/actionbar-context.ts +23 -0
  7. package/src/actionbar/index.ts +13 -0
  8. package/src/actionbar/types.ts +50 -0
  9. package/src/actionbar/useActionBarItems.ts +47 -0
  10. package/src/ambient/AmbientBackdrop.tsx +74 -0
  11. package/src/ambient/CommandInput.tsx +107 -0
  12. package/src/ambient/SuperbarStrip.tsx +36 -0
  13. package/src/ambient/index.ts +6 -0
  14. package/src/bento/BentoCell.tsx +66 -0
  15. package/src/bento/BentoGrid.tsx +42 -0
  16. package/src/bento/index.ts +2 -0
  17. package/src/command/CommandPalette.tsx +277 -0
  18. package/src/command/CommandProvider.tsx +57 -0
  19. package/src/command/command-context.ts +12 -0
  20. package/src/command/index.ts +6 -0
  21. package/src/command/rank.ts +45 -0
  22. package/src/command/types.ts +26 -0
  23. package/src/command/useCommandSource.ts +37 -0
  24. package/src/dialogs/DialogsProvider.tsx +216 -0
  25. package/src/dialogs/Modal.tsx +204 -0
  26. package/src/dialogs/Toast.tsx +85 -0
  27. package/src/dialogs/dialogs-context.ts +6 -0
  28. package/src/dialogs/index.ts +10 -0
  29. package/src/dialogs/types.ts +37 -0
  30. package/src/dialogs/useDialogs.ts +8 -0
  31. package/src/editorial/EditorialSpotlight.tsx +63 -0
  32. package/src/editorial/Folio.tsx +52 -0
  33. package/src/editorial/PlateMarker.tsx +33 -0
  34. package/src/editorial/RomanSection.tsx +65 -0
  35. package/src/editorial/RunningMarginalia.tsx +65 -0
  36. package/src/editorial/index.ts +10 -0
  37. package/src/frutiger/GlossyOrb.tsx +79 -0
  38. package/src/frutiger/SkyBackdrop.tsx +114 -0
  39. package/src/frutiger/index.ts +2 -0
  40. package/src/hooks/index.ts +5 -0
  41. package/src/hooks/useKeyboardHotkey.ts +80 -0
  42. package/src/hooks/useReducedMotion.ts +20 -0
  43. package/src/hooks/useViewport.ts +61 -0
  44. package/src/index.ts +26 -0
  45. package/src/layout/Breadcrumb.tsx +74 -0
  46. package/src/layout/LeftNavRail.tsx +126 -0
  47. package/src/layout/MobileTabBar.tsx +101 -0
  48. package/src/layout/NavItem.tsx +128 -0
  49. package/src/layout/NavSearchTrigger.tsx +88 -0
  50. package/src/layout/NavSection.tsx +40 -0
  51. package/src/layout/RightSidebarPanel.tsx +111 -0
  52. package/src/layout/Shell.tsx +91 -0
  53. package/src/layout/StickyBanner.tsx +83 -0
  54. package/src/layout/Topbar.tsx +68 -0
  55. package/src/layout/index.ts +22 -0
  56. package/src/layout/useNavRailState.ts +69 -0
  57. package/src/lib/cn.ts +7 -0
  58. package/src/lib/time-utils.ts +44 -0
  59. package/src/neobrutalism/Marquee.tsx +81 -0
  60. package/src/neobrutalism/Sticker.tsx +71 -0
  61. package/src/neobrutalism/index.ts +4 -0
  62. package/src/primitives/Avatar.tsx +53 -0
  63. package/src/primitives/Button.tsx +30 -0
  64. package/src/primitives/Card.tsx +41 -0
  65. package/src/primitives/Checkbox.tsx +78 -0
  66. package/src/primitives/CountBadge.tsx +50 -0
  67. package/src/primitives/Input.tsx +71 -0
  68. package/src/primitives/Kbd.tsx +45 -0
  69. package/src/primitives/PageHeader.tsx +77 -0
  70. package/src/primitives/Tag.tsx +56 -0
  71. package/src/primitives/Textarea.tsx +62 -0
  72. package/src/primitives/ToggleSwitch.tsx +79 -0
  73. package/src/primitives/Tooltip.tsx +171 -0
  74. package/src/primitives/index.ts +24 -0
  75. package/src/pwa/InstallPromptBanner.tsx +132 -0
  76. package/src/pwa/index.ts +4 -0
  77. package/src/pwa/manifest.template.json +20 -0
  78. package/src/pwa/registerSW.ts +55 -0
  79. package/src/riso/Halftone.tsx +85 -0
  80. package/src/riso/Misregister.tsx +63 -0
  81. package/src/riso/RisoStamp.tsx +76 -0
  82. package/src/riso/index.ts +3 -0
  83. package/src/sketch/HandUnderline.tsx +53 -0
  84. package/src/sketch/RoughArrow.tsx +91 -0
  85. package/src/sketch/RoughBox.tsx +73 -0
  86. package/src/sketch/StickyNote.tsx +56 -0
  87. package/src/sketch/index.ts +4 -0
  88. package/src/styles/base.css +80 -0
  89. package/src/styles/chrome-ambient.css +222 -0
  90. package/src/styles/chrome-bento.css +184 -0
  91. package/src/styles/chrome-editorial.css +145 -0
  92. package/src/styles/chrome-frutiger.css +364 -0
  93. package/src/styles/chrome-neobrutalism.css +315 -0
  94. package/src/styles/chrome-riso.css +328 -0
  95. package/src/styles/chrome-sketch.css +351 -0
  96. package/src/styles/chrome-swiss.css +232 -0
  97. package/src/styles/chrome-terminal.css +235 -0
  98. package/src/styles/fonts.css +22 -0
  99. package/src/styles/index.css +198 -0
  100. package/src/styles/tokens.css +976 -0
  101. package/src/terminal/AsciiBox.tsx +65 -0
  102. package/src/terminal/BrailleSpinner.tsx +46 -0
  103. package/src/terminal/index.ts +4 -0
  104. package/src/theme/ThemeProvider.tsx +93 -0
  105. package/src/theme/index.ts +5 -0
  106. package/src/theme/inline-script.ts +36 -0
  107. package/src/theme/theme-context.ts +7 -0
  108. package/src/theme/types.ts +22 -0
  109. package/src/theme/useTheme.ts +8 -0
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@cosxai/ui",
3
+ "version": "0.1.0",
4
+ "description": "COSX design system — React 19 component primitives shared across product-meta and other consumers",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./styles.css": "./src/styles/index.css"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/cosxai/product-design.git",
20
+ "directory": "packages/ui"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "peerDependencies": {
27
+ "react": "^19.0.0",
28
+ "react-dom": "^19.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/react": "^19.0.0",
32
+ "@types/react-dom": "^19.0.0",
33
+ "typescript": "^5.6.0"
34
+ },
35
+ "scripts": {
36
+ "typecheck": "tsc --noEmit"
37
+ }
38
+ }
@@ -0,0 +1,436 @@
1
+ import {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ } from "react";
10
+ import { ActionBarContext } from "./actionbar-context";
11
+ import { ActionBarButton } from "./ActionBarButton";
12
+ import { ActionBarMenuGroup } from "./ActionBarMenuGroup";
13
+ import { useViewport } from "../hooks/useViewport";
14
+ import type { ActionBarItem } from "./types";
15
+
16
+ // Floating action bar. Reads items from the registry, groups items
17
+ // sharing a category (2+) into inline-expandable menus, supports:
18
+ // - drag-to-reposition on desktop (left grip; double-click resets)
19
+ // - phone-only collapsed handle (left-edge peek tab)
20
+ // - localStorage persistence of position + collapsed state
21
+ // - spring entry animation at the default centred position
22
+ // - ESC + outside-click dismissal of expanded groups
23
+ // - label hiding below md breakpoint (icons + tooltips only)
24
+ //
25
+ // Bar is purely a renderer. Pages register items via useActionBarItems().
26
+
27
+ const BAR_HEIGHT = 48;
28
+ const BAR_WIDTH_FALLBACK = 440;
29
+ const VIEWPORT_MARGIN = 8;
30
+ const DEFAULT_BOTTOM_GUTTER = 24;
31
+ const COLLAPSED_HANDLE_W = 22;
32
+ const COLLAPSED_HANDLE_H = 56;
33
+
34
+ type Pos = { type: "default" } | { type: "custom"; left: number; bottom: number };
35
+
36
+ function clampCustom(
37
+ p: { left: number; bottom: number },
38
+ barWidth: number,
39
+ ): { left: number; bottom: number } {
40
+ if (typeof window === "undefined") return p;
41
+ return {
42
+ left: Math.max(
43
+ VIEWPORT_MARGIN,
44
+ Math.min(window.innerWidth - VIEWPORT_MARGIN - barWidth, p.left),
45
+ ),
46
+ bottom: Math.max(
47
+ VIEWPORT_MARGIN,
48
+ Math.min(window.innerHeight - VIEWPORT_MARGIN - BAR_HEIGHT, p.bottom),
49
+ ),
50
+ };
51
+ }
52
+
53
+ function loadPos(storageKey: string): Pos {
54
+ if (typeof window === "undefined") return { type: "default" };
55
+ try {
56
+ const raw = window.localStorage.getItem(`${storageKey}:pos`);
57
+ if (!raw) return { type: "default" };
58
+ const parsed = JSON.parse(raw);
59
+ if (parsed?.type === "default") return { type: "default" };
60
+ if (
61
+ parsed?.type === "custom" &&
62
+ typeof parsed.left === "number" &&
63
+ typeof parsed.bottom === "number"
64
+ ) {
65
+ return { type: "custom", left: parsed.left, bottom: parsed.bottom };
66
+ }
67
+ } catch {}
68
+ return { type: "default" };
69
+ }
70
+
71
+ function loadCollapsed(storageKey: string): boolean {
72
+ if (typeof window === "undefined") return false;
73
+ try {
74
+ return window.localStorage.getItem(`${storageKey}:collapsed`) === "1";
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ interface BuildEntry {
81
+ kind: "flat" | "group";
82
+ expansionKey: string;
83
+ item?: ActionBarItem;
84
+ category?: string;
85
+ groupItems?: ActionBarItem[];
86
+ }
87
+
88
+ // Build render entries: a category with 2+ items collapses into one
89
+ // group head at the position of its first item; later items in that
90
+ // category are folded into the group, not re-emitted. A category
91
+ // with one item just renders flat in place. No-category items always
92
+ // render flat.
93
+ function buildEntries(items: ActionBarItem[]): BuildEntry[] {
94
+ const countByCat: Record<string, number> = {};
95
+ for (const it of items) {
96
+ if (it.category) countByCat[it.category] = (countByCat[it.category] ?? 0) + 1;
97
+ }
98
+ const result: BuildEntry[] = [];
99
+ const consumed = new Set<string>();
100
+ for (let i = 0; i < items.length; i++) {
101
+ const it = items[i]!;
102
+ const cat = it.category;
103
+ if (cat && (countByCat[cat] ?? 0) >= 2) {
104
+ if (consumed.has(cat)) continue;
105
+ const groupItems = items.filter((x) => x.category === cat);
106
+ result.push({
107
+ kind: "group",
108
+ expansionKey: `group:${cat}`,
109
+ category: cat,
110
+ groupItems,
111
+ });
112
+ consumed.add(cat);
113
+ continue;
114
+ }
115
+ result.push({
116
+ kind: "flat",
117
+ expansionKey: it.key ?? `flat-${i}`,
118
+ item: it,
119
+ });
120
+ }
121
+ return result;
122
+ }
123
+
124
+ export interface ActionBarProps {
125
+ // localStorage namespace for position + collapsed state.
126
+ // Override per-app so multiple bars don't collide.
127
+ storageKey?: string;
128
+ // Enable desktop drag-to-reposition + double-click-to-reset.
129
+ // Default true.
130
+ draggable?: boolean;
131
+ // Enable phone-only collapsed handle. Default true.
132
+ collapsibleOnPhone?: boolean;
133
+ className?: string;
134
+ style?: CSSProperties;
135
+ }
136
+
137
+ export function ActionBar({
138
+ storageKey = "ck-actionbar",
139
+ draggable = true,
140
+ collapsibleOnPhone = true,
141
+ className,
142
+ style,
143
+ }: ActionBarProps) {
144
+ const ctx = useContext(ActionBarContext);
145
+ if (!ctx) {
146
+ throw new Error("<ActionBar> must be inside <ActionBarProvider>");
147
+ }
148
+ const { items, categories, expandedKey, setExpandedKey } = ctx;
149
+ const vp = useViewport();
150
+ const isPhone = vp.isPhone;
151
+
152
+ // ----- Position state -----
153
+ const [pos, setPos] = useState<Pos>(() => loadPos(storageKey));
154
+ const barRef = useRef<HTMLDivElement | null>(null);
155
+ useEffect(() => {
156
+ try {
157
+ window.localStorage.setItem(`${storageKey}:pos`, JSON.stringify(pos));
158
+ } catch {}
159
+ }, [pos, storageKey]);
160
+
161
+ // Re-clamp on width resize so the bar tracks the viewport as
162
+ // the window resizes. Initial clamp also runs after first paint
163
+ // to handle saved positions from a wider window.
164
+ useEffect(() => {
165
+ const reclamp = () => {
166
+ setPos((p) => {
167
+ if (p.type !== "custom") return p;
168
+ const w = barRef.current?.offsetWidth ?? BAR_WIDTH_FALLBACK;
169
+ return { type: "custom", ...clampCustom(p, w) };
170
+ });
171
+ };
172
+ reclamp();
173
+ window.addEventListener("resize", reclamp);
174
+ return () => window.removeEventListener("resize", reclamp);
175
+ }, []);
176
+
177
+ // ----- Collapsed state (phone only) -----
178
+ const [collapsed, setCollapsed] = useState<boolean>(() => loadCollapsed(storageKey));
179
+ useEffect(() => {
180
+ try {
181
+ window.localStorage.setItem(
182
+ `${storageKey}:collapsed`,
183
+ collapsed ? "1" : "0",
184
+ );
185
+ } catch {}
186
+ }, [collapsed, storageKey]);
187
+
188
+ // ----- Drag handlers (desktop) -----
189
+ const dragRef = useRef<{ active: boolean; offsetX: number; offsetY: number }>({
190
+ active: false,
191
+ offsetX: 0,
192
+ offsetY: 0,
193
+ });
194
+ const onGripDown = useCallback((e: React.MouseEvent) => {
195
+ const rect = barRef.current?.getBoundingClientRect();
196
+ if (!rect) return;
197
+ const currentLeft = rect.left;
198
+ const currentBottom = window.innerHeight - rect.bottom;
199
+ dragRef.current = {
200
+ active: true,
201
+ offsetX: e.clientX - currentLeft,
202
+ offsetY: e.clientY - (window.innerHeight - currentBottom - BAR_HEIGHT),
203
+ };
204
+ e.preventDefault();
205
+ }, []);
206
+ useEffect(() => {
207
+ const onMove = (e: MouseEvent) => {
208
+ if (!dragRef.current.active) return;
209
+ const w = barRef.current?.offsetWidth ?? BAR_WIDTH_FALLBACK;
210
+ const left = e.clientX - dragRef.current.offsetX;
211
+ const topY = e.clientY - dragRef.current.offsetY;
212
+ const bottom = window.innerHeight - topY - BAR_HEIGHT;
213
+ setPos({ type: "custom", ...clampCustom({ left, bottom }, w) });
214
+ };
215
+ const onUp = () => {
216
+ dragRef.current.active = false;
217
+ };
218
+ window.addEventListener("mousemove", onMove);
219
+ window.addEventListener("mouseup", onUp);
220
+ return () => {
221
+ window.removeEventListener("mousemove", onMove);
222
+ window.removeEventListener("mouseup", onUp);
223
+ };
224
+ }, []);
225
+ const onGripDoubleClick = useCallback(() => {
226
+ try {
227
+ window.localStorage.removeItem(`${storageKey}:pos`);
228
+ } catch {}
229
+ setPos({ type: "default" });
230
+ }, [storageKey]);
231
+
232
+ // ----- Entries (flat vs group) -----
233
+ const entries = useMemo(() => buildEntries(items), [items]);
234
+
235
+ // Cleanup stale expansion if the corresponding group disappears.
236
+ useEffect(() => {
237
+ if (expandedKey === null) return;
238
+ if (!entries.some((e) => e.kind === "group" && e.expansionKey === expandedKey)) {
239
+ setExpandedKey(null);
240
+ }
241
+ }, [expandedKey, entries, setExpandedKey]);
242
+
243
+ // ESC closes any open group.
244
+ useEffect(() => {
245
+ if (expandedKey === null) return;
246
+ const onKey = (e: KeyboardEvent) => {
247
+ if (e.key === "Escape") {
248
+ e.preventDefault();
249
+ setExpandedKey(null);
250
+ }
251
+ };
252
+ window.addEventListener("keydown", onKey);
253
+ return () => window.removeEventListener("keydown", onKey);
254
+ }, [expandedKey, setExpandedKey]);
255
+
256
+ // ----- Empty state -----
257
+ if (items.length === 0) return null;
258
+
259
+ // ----- Collapsed handle (phone only) -----
260
+ if (isPhone && collapsibleOnPhone && collapsed) {
261
+ return (
262
+ <button
263
+ type="button"
264
+ onClick={() => setCollapsed(false)}
265
+ aria-label="Expand action bar"
266
+ title="Tap to expand"
267
+ className="ck-action-handle-enter"
268
+ style={{
269
+ position: "fixed",
270
+ left: 0,
271
+ bottom: `calc(${DEFAULT_BOTTOM_GUTTER}px + var(--ck-tabbar-height, 0px) + env(safe-area-inset-bottom, 0px))`,
272
+ width: COLLAPSED_HANDLE_W,
273
+ height: COLLAPSED_HANDLE_H,
274
+ padding: 0,
275
+ paddingLeft: 4,
276
+ display: "inline-flex",
277
+ alignItems: "center",
278
+ justifyContent: "center",
279
+ zIndex: 80,
280
+ background: "var(--ck-bg-surface)",
281
+ border: "1px solid var(--ck-border-subtle)",
282
+ borderLeft: 0,
283
+ borderTopLeftRadius: 0,
284
+ borderBottomLeftRadius: 0,
285
+ borderTopRightRadius: 999,
286
+ borderBottomRightRadius: 999,
287
+ boxShadow: "3px 0 12px rgba(0,0,0,0.18)",
288
+ cursor: "pointer",
289
+ color: "var(--ck-text-secondary)",
290
+ }}
291
+ >
292
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
293
+ <path d="M12 2 L13.6 8.4 L20 10 L13.6 11.6 L12 18 L10.4 11.6 L4 10 L10.4 8.4 Z" />
294
+ <path
295
+ d="M19 16 L19.6 18.4 L22 19 L19.6 19.6 L19 22 L18.4 19.6 L16 19 L18.4 18.4 Z"
296
+ opacity="0.55"
297
+ />
298
+ </svg>
299
+ </button>
300
+ );
301
+ }
302
+
303
+ // ----- Full bar -----
304
+ const isDefault = pos.type === "default";
305
+ return (
306
+ <div
307
+ ref={barRef}
308
+ data-ck-actionbar
309
+ className={`ck-actionbar${isDefault ? " ck-actionbar-enter" : ""}${className ? ` ${className}` : ""}`}
310
+ style={{
311
+ position: "fixed",
312
+ ...(isDefault
313
+ ? {
314
+ left: "50%",
315
+ transform: "translateX(-50%)",
316
+ bottom: `calc(${DEFAULT_BOTTOM_GUTTER}px + var(--ck-tabbar-height, 0px) + env(safe-area-inset-bottom, 0px))`,
317
+ }
318
+ : {
319
+ left: pos.left,
320
+ bottom: pos.bottom,
321
+ }),
322
+ height: BAR_HEIGHT,
323
+ padding: "0 6px 0 0",
324
+ display: "flex",
325
+ alignItems: "center",
326
+ gap: 4,
327
+ zIndex: 80,
328
+ background: "var(--ck-bg-surface)",
329
+ border: "1px solid var(--ck-border-subtle)",
330
+ borderRadius: 999,
331
+ boxShadow: "var(--ck-shadow-3)",
332
+ fontFamily: "var(--ck-font-sans)",
333
+ color: "var(--ck-text-primary)",
334
+ ...style,
335
+ }}
336
+ >
337
+ {/* Left-edge button. Phone = collapse; desktop = drag grip. */}
338
+ {isPhone && collapsibleOnPhone ? (
339
+ <button
340
+ type="button"
341
+ onClick={() => setCollapsed(true)}
342
+ aria-label="Collapse action bar"
343
+ title="Collapse"
344
+ style={leftEdgeButtonStyle}
345
+ >
346
+ <svg
347
+ width="14"
348
+ height="14"
349
+ viewBox="0 0 24 24"
350
+ fill="none"
351
+ stroke="currentColor"
352
+ strokeWidth="1.8"
353
+ strokeLinecap="round"
354
+ strokeLinejoin="round"
355
+ aria-hidden
356
+ >
357
+ <polyline points="15 18 9 12 15 6" />
358
+ </svg>
359
+ </button>
360
+ ) : draggable ? (
361
+ <button
362
+ type="button"
363
+ onMouseDown={onGripDown}
364
+ onDoubleClick={onGripDoubleClick}
365
+ aria-label="Drag action bar (double-click to reset)"
366
+ title="Drag · double-click to reset"
367
+ style={{ ...leftEdgeButtonStyle, cursor: "grab" }}
368
+ >
369
+ <svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden>
370
+ <circle cx="2" cy="2" r="1" />
371
+ <circle cx="2" cy="7" r="1" />
372
+ <circle cx="2" cy="12" r="1" />
373
+ <circle cx="8" cy="2" r="1" />
374
+ <circle cx="8" cy="7" r="1" />
375
+ <circle cx="8" cy="12" r="1" />
376
+ </svg>
377
+ </button>
378
+ ) : null}
379
+
380
+ {/* Entries: flat items + group heads with disclosure regions. */}
381
+ {entries.map((entry) => {
382
+ if (entry.kind === "flat" && entry.item) {
383
+ return (
384
+ <ActionBarButton
385
+ key={entry.expansionKey}
386
+ icon={entry.item.icon}
387
+ label={entry.item.label}
388
+ title={entry.item.title}
389
+ active={entry.item.active}
390
+ disabled={entry.item.disabled}
391
+ variant={entry.item.variant}
392
+ hint={entry.item.hint}
393
+ onClick={entry.item.onClick}
394
+ />
395
+ );
396
+ }
397
+ const cat = entry.category!;
398
+ const catDef = categories[cat];
399
+ const hasActiveChild = entry.groupItems!.some((it) => it.active);
400
+ const isOpen = expandedKey === entry.expansionKey;
401
+ return (
402
+ <ActionBarMenuGroup
403
+ key={entry.expansionKey}
404
+ label={catDef?.label ?? cat}
405
+ icon={catDef?.icon}
406
+ hasActiveChild={hasActiveChild}
407
+ isOpen={isOpen}
408
+ onToggle={() =>
409
+ setExpandedKey(isOpen ? null : entry.expansionKey)
410
+ }
411
+ items={entry.groupItems!}
412
+ onItemClicked={(it) => {
413
+ it.onClick();
414
+ if (!it.keepGroupOpenOnClick) {
415
+ setExpandedKey(null);
416
+ }
417
+ }}
418
+ />
419
+ );
420
+ })}
421
+ </div>
422
+ );
423
+ }
424
+
425
+ const leftEdgeButtonStyle: CSSProperties = {
426
+ width: 28,
427
+ height: BAR_HEIGHT,
428
+ padding: 0,
429
+ display: "inline-flex",
430
+ alignItems: "center",
431
+ justifyContent: "center",
432
+ border: "none",
433
+ background: "transparent",
434
+ color: "var(--ck-text-tertiary)",
435
+ borderRadius: 0,
436
+ };
@@ -0,0 +1,110 @@
1
+ import type { ReactNode, CSSProperties } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ // Single button inside the action bar — used both for top-level
5
+ // items and for entries inside expanded group menus.
6
+ //
7
+ // Three visual variants:
8
+ // - ghost → default. Transparent bg, neutral text. Active
9
+ // state promotes to accent muted bg.
10
+ // - primary → solid accent fill, white text. For items whose
11
+ // "this mode is on" signal should beat the "this
12
+ // group is open" signal (e.g. an editing-mode toggle
13
+ // nested in a Manage group, while Manage is open).
14
+ // - soft → transparent bg, accent-coloured text. Used for
15
+ // open group HEADS where the wrapper supplies a
16
+ // muted-accent bg; the head just needs the
17
+ // foreground to read as accent.
18
+
19
+ // Optional props use `| undefined` (rather than bare `T?`) so that callers
20
+ // can forward potentially-undefined values from upstream optional sources
21
+ // (e.g. `ActionBarItem.active`, which is itself optional). Compatible with
22
+ // `exactOptionalPropertyTypes: true` consumers.
23
+ export interface ActionBarButtonProps {
24
+ icon?: ReactNode | undefined;
25
+ label: string;
26
+ onClick: () => void;
27
+ active?: boolean | undefined;
28
+ title?: string | undefined;
29
+ variant?: "ghost" | "primary" | "soft" | undefined;
30
+ disabled?: boolean | undefined;
31
+ hint?: string | undefined;
32
+ // Optional chevron — used by group heads to signal disclosure.
33
+ chevron?: "right" | undefined;
34
+ // 180° rotation when true (▸ becomes ◂ — reads as "close").
35
+ chevronRotated?: boolean | undefined;
36
+ // Hide label below md viewport (768 px). Default true.
37
+ responsiveLabel?: boolean | undefined;
38
+ style?: CSSProperties | undefined;
39
+ }
40
+
41
+ export function ActionBarButton({
42
+ icon,
43
+ label,
44
+ onClick,
45
+ active,
46
+ title,
47
+ variant = "ghost",
48
+ disabled,
49
+ hint,
50
+ chevron,
51
+ chevronRotated,
52
+ responsiveLabel = true,
53
+ style,
54
+ }: ActionBarButtonProps) {
55
+ const isPrimary = active || variant === "primary";
56
+ const isSoft = !isPrimary && variant === "soft";
57
+ return (
58
+ <button
59
+ type="button"
60
+ onClick={onClick}
61
+ disabled={disabled}
62
+ title={title ?? label}
63
+ className={cn(
64
+ "ck-actionbar-btn",
65
+ isPrimary && "ck-actionbar-btn--primary",
66
+ !isPrimary && "ck-actionbar-btn--ghost",
67
+ )}
68
+ style={{
69
+ ...(isSoft ? { color: "var(--ck-accent)" } : undefined),
70
+ ...style,
71
+ }}
72
+ >
73
+ {icon}
74
+ <span className={responsiveLabel ? "ck-actionbar-label" : undefined}>
75
+ {label}
76
+ </span>
77
+ {hint && (
78
+ <span
79
+ style={{
80
+ color: isPrimary ? "rgba(255,255,255,0.7)" : "var(--ck-text-tertiary)",
81
+ font: "500 11px/1 var(--ck-font-mono)",
82
+ marginLeft: 4,
83
+ }}
84
+ >
85
+ {hint}
86
+ </span>
87
+ )}
88
+ {chevron && (
89
+ <svg
90
+ width="10"
91
+ height="10"
92
+ viewBox="0 0 24 24"
93
+ fill="none"
94
+ stroke="currentColor"
95
+ strokeWidth="2.2"
96
+ strokeLinecap="round"
97
+ strokeLinejoin="round"
98
+ style={{
99
+ flexShrink: 0,
100
+ opacity: 0.7,
101
+ transform: chevronRotated ? "rotate(180deg)" : "rotate(0deg)",
102
+ transition: "transform 220ms cubic-bezier(0.4, 0, 0.2, 1)",
103
+ }}
104
+ >
105
+ <polyline points="9 6 15 12 9 18" />
106
+ </svg>
107
+ )}
108
+ </button>
109
+ );
110
+ }
@@ -0,0 +1,106 @@
1
+ import { useEffect, useRef, type ReactNode } from "react";
2
+ import { ActionBarButton } from "./ActionBarButton";
3
+ import type { ActionBarItem } from "./types";
4
+
5
+ // Inline-expandable group inside the action bar. Head button +
6
+ // disclosure region of child items. When open:
7
+ // - head gets a "soft" foreground (or "primary" if a child is
8
+ // active), wrapped in a muted-accent pill that extends across
9
+ // the children too
10
+ // - children animate in via max-width + opacity transition
11
+ // (no mount/unmount so the open/close has motion)
12
+ // - ESC + outside-click both close
13
+
14
+ export interface ActionBarMenuGroupProps {
15
+ label: string;
16
+ icon?: ReactNode;
17
+ hasActiveChild: boolean;
18
+ isOpen: boolean;
19
+ onToggle: () => void;
20
+ items: ActionBarItem[];
21
+ onItemClicked: (item: ActionBarItem) => void;
22
+ }
23
+
24
+ export function ActionBarMenuGroup({
25
+ label,
26
+ icon,
27
+ hasActiveChild,
28
+ isOpen,
29
+ onToggle,
30
+ items,
31
+ onItemClicked,
32
+ }: ActionBarMenuGroupProps) {
33
+ const headVariant: "ghost" | "primary" | "soft" = hasActiveChild
34
+ ? "primary"
35
+ : isOpen
36
+ ? "soft"
37
+ : "ghost";
38
+
39
+ // Outside-click closes the group via a capture-phase listener so
40
+ // the menu dismisses before the click reaches any other handler.
41
+ const wrapperRef = useRef<HTMLDivElement>(null);
42
+ useEffect(() => {
43
+ if (!isOpen) return;
44
+ const onMouseDown = (e: MouseEvent) => {
45
+ if (!wrapperRef.current) return;
46
+ if (wrapperRef.current.contains(e.target as Node)) return;
47
+ onToggle();
48
+ };
49
+ document.addEventListener("mousedown", onMouseDown, true);
50
+ return () => document.removeEventListener("mousedown", onMouseDown, true);
51
+ }, [isOpen, onToggle]);
52
+
53
+ return (
54
+ <div
55
+ ref={wrapperRef}
56
+ style={{
57
+ display: "inline-flex",
58
+ alignItems: "center",
59
+ gap: 6,
60
+ background: isOpen ? "var(--ck-accent-muted)" : "transparent",
61
+ borderRadius: 999,
62
+ transition: "background 200ms ease",
63
+ }}
64
+ >
65
+ <ActionBarButton
66
+ icon={icon}
67
+ label={label}
68
+ title={isOpen ? `Close ${label}` : label}
69
+ variant={headVariant}
70
+ chevron="right"
71
+ chevronRotated={isOpen}
72
+ onClick={onToggle}
73
+ />
74
+ <div
75
+ aria-hidden={!isOpen}
76
+ style={{
77
+ display: "flex",
78
+ alignItems: "center",
79
+ gap: 6,
80
+ overflow: "hidden",
81
+ maxWidth: isOpen ? 1000 : 0,
82
+ opacity: isOpen ? 1 : 0,
83
+ marginLeft: isOpen ? 0 : -6,
84
+ paddingRight: isOpen ? 4 : 0,
85
+ pointerEvents: isOpen ? "auto" : "none",
86
+ transition:
87
+ "max-width 240ms cubic-bezier(0.4, 0, 0.2, 1), opacity 180ms ease, margin-left 240ms cubic-bezier(0.4, 0, 0.2, 1), padding-right 240ms cubic-bezier(0.4, 0, 0.2, 1)",
88
+ }}
89
+ >
90
+ {items.map((it) => (
91
+ <ActionBarButton
92
+ key={it.key}
93
+ icon={it.icon}
94
+ label={it.label}
95
+ title={it.title}
96
+ active={it.active}
97
+ disabled={it.disabled || !isOpen}
98
+ variant={it.variant}
99
+ hint={it.hint}
100
+ onClick={() => onItemClicked(it)}
101
+ />
102
+ ))}
103
+ </div>
104
+ </div>
105
+ );
106
+ }