@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.
- package/package.json +38 -0
- package/src/actionbar/ActionBar.tsx +436 -0
- package/src/actionbar/ActionBarButton.tsx +110 -0
- package/src/actionbar/ActionBarMenuGroup.tsx +106 -0
- package/src/actionbar/ActionBarProvider.tsx +76 -0
- package/src/actionbar/actionbar-context.ts +23 -0
- package/src/actionbar/index.ts +13 -0
- package/src/actionbar/types.ts +50 -0
- package/src/actionbar/useActionBarItems.ts +47 -0
- package/src/ambient/AmbientBackdrop.tsx +74 -0
- package/src/ambient/CommandInput.tsx +107 -0
- package/src/ambient/SuperbarStrip.tsx +36 -0
- package/src/ambient/index.ts +6 -0
- package/src/bento/BentoCell.tsx +66 -0
- package/src/bento/BentoGrid.tsx +42 -0
- package/src/bento/index.ts +2 -0
- package/src/command/CommandPalette.tsx +277 -0
- package/src/command/CommandProvider.tsx +57 -0
- package/src/command/command-context.ts +12 -0
- package/src/command/index.ts +6 -0
- package/src/command/rank.ts +45 -0
- package/src/command/types.ts +26 -0
- package/src/command/useCommandSource.ts +37 -0
- package/src/dialogs/DialogsProvider.tsx +216 -0
- package/src/dialogs/Modal.tsx +204 -0
- package/src/dialogs/Toast.tsx +85 -0
- package/src/dialogs/dialogs-context.ts +6 -0
- package/src/dialogs/index.ts +10 -0
- package/src/dialogs/types.ts +37 -0
- package/src/dialogs/useDialogs.ts +8 -0
- package/src/editorial/EditorialSpotlight.tsx +63 -0
- package/src/editorial/Folio.tsx +52 -0
- package/src/editorial/PlateMarker.tsx +33 -0
- package/src/editorial/RomanSection.tsx +65 -0
- package/src/editorial/RunningMarginalia.tsx +65 -0
- package/src/editorial/index.ts +10 -0
- package/src/frutiger/GlossyOrb.tsx +79 -0
- package/src/frutiger/SkyBackdrop.tsx +114 -0
- package/src/frutiger/index.ts +2 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useKeyboardHotkey.ts +80 -0
- package/src/hooks/useReducedMotion.ts +20 -0
- package/src/hooks/useViewport.ts +61 -0
- package/src/index.ts +26 -0
- package/src/layout/Breadcrumb.tsx +74 -0
- package/src/layout/LeftNavRail.tsx +126 -0
- package/src/layout/MobileTabBar.tsx +101 -0
- package/src/layout/NavItem.tsx +128 -0
- package/src/layout/NavSearchTrigger.tsx +88 -0
- package/src/layout/NavSection.tsx +40 -0
- package/src/layout/RightSidebarPanel.tsx +111 -0
- package/src/layout/Shell.tsx +91 -0
- package/src/layout/StickyBanner.tsx +83 -0
- package/src/layout/Topbar.tsx +68 -0
- package/src/layout/index.ts +22 -0
- package/src/layout/useNavRailState.ts +69 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/time-utils.ts +44 -0
- package/src/neobrutalism/Marquee.tsx +81 -0
- package/src/neobrutalism/Sticker.tsx +71 -0
- package/src/neobrutalism/index.ts +4 -0
- package/src/primitives/Avatar.tsx +53 -0
- package/src/primitives/Button.tsx +30 -0
- package/src/primitives/Card.tsx +41 -0
- package/src/primitives/Checkbox.tsx +78 -0
- package/src/primitives/CountBadge.tsx +50 -0
- package/src/primitives/Input.tsx +71 -0
- package/src/primitives/Kbd.tsx +45 -0
- package/src/primitives/PageHeader.tsx +77 -0
- package/src/primitives/Tag.tsx +56 -0
- package/src/primitives/Textarea.tsx +62 -0
- package/src/primitives/ToggleSwitch.tsx +79 -0
- package/src/primitives/Tooltip.tsx +171 -0
- package/src/primitives/index.ts +24 -0
- package/src/pwa/InstallPromptBanner.tsx +132 -0
- package/src/pwa/index.ts +4 -0
- package/src/pwa/manifest.template.json +20 -0
- package/src/pwa/registerSW.ts +55 -0
- package/src/riso/Halftone.tsx +85 -0
- package/src/riso/Misregister.tsx +63 -0
- package/src/riso/RisoStamp.tsx +76 -0
- package/src/riso/index.ts +3 -0
- package/src/sketch/HandUnderline.tsx +53 -0
- package/src/sketch/RoughArrow.tsx +91 -0
- package/src/sketch/RoughBox.tsx +73 -0
- package/src/sketch/StickyNote.tsx +56 -0
- package/src/sketch/index.ts +4 -0
- package/src/styles/base.css +80 -0
- package/src/styles/chrome-ambient.css +222 -0
- package/src/styles/chrome-bento.css +184 -0
- package/src/styles/chrome-editorial.css +145 -0
- package/src/styles/chrome-frutiger.css +364 -0
- package/src/styles/chrome-neobrutalism.css +315 -0
- package/src/styles/chrome-riso.css +328 -0
- package/src/styles/chrome-sketch.css +351 -0
- package/src/styles/chrome-swiss.css +232 -0
- package/src/styles/chrome-terminal.css +235 -0
- package/src/styles/fonts.css +22 -0
- package/src/styles/index.css +198 -0
- package/src/styles/tokens.css +976 -0
- package/src/terminal/AsciiBox.tsx +65 -0
- package/src/terminal/BrailleSpinner.tsx +46 -0
- package/src/terminal/index.ts +4 -0
- package/src/theme/ThemeProvider.tsx +93 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/inline-script.ts +36 -0
- package/src/theme/theme-context.ts +7 -0
- package/src/theme/types.ts +22 -0
- 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
|
+
}
|