@cosxai/ui 0.25.0 → 1.0.0-alpha.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/CHANGELOG.md +577 -0
- package/NOTICE +11 -0
- package/README.md +52 -0
- package/package.json +33 -7
- package/src/fonts.css +20 -0
- package/src/index.ts +8 -25
- package/src/lib/cn.ts +23 -6
- package/src/styles.css +17 -0
- package/src/theme.css +132 -0
- package/src/tokens/colors.css +112 -0
- package/src/tokens/compat.css +36 -0
- package/src/tokens/elevation.css +14 -0
- package/src/tokens/motion.css +42 -0
- package/src/tokens/spacing.css +55 -0
- package/src/tokens/typography-cjk.css +42 -0
- package/src/tokens/typography.css +81 -0
- package/src/actionbar/ActionBar.tsx +0 -837
- package/src/actionbar/ActionBarButton.tsx +0 -111
- package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
- package/src/actionbar/ActionBarModeHandle.tsx +0 -171
- package/src/actionbar/ActionBarProvider.tsx +0 -91
- package/src/actionbar/actionbar-context.ts +0 -45
- package/src/actionbar/index.ts +0 -28
- package/src/actionbar/types.ts +0 -147
- package/src/actionbar/useActionBarItems.ts +0 -60
- package/src/actionbar/useActionBarMode.ts +0 -144
- package/src/actionbar/useActionBarPanel.ts +0 -56
- package/src/actionbar/useActionBarStatusDot.ts +0 -49
- package/src/actionbar/useActionBarToast.ts +0 -33
- package/src/ambient/AmbientBackdrop.tsx +0 -74
- package/src/ambient/CommandInput.tsx +0 -107
- package/src/ambient/SuperbarStrip.tsx +0 -36
- package/src/ambient/index.ts +0 -6
- package/src/bento/BentoCell.tsx +0 -66
- package/src/bento/BentoGrid.tsx +0 -42
- package/src/bento/index.ts +0 -2
- package/src/command/CommandPalette.tsx +0 -329
- package/src/command/CommandProvider.tsx +0 -57
- package/src/command/command-context.ts +0 -12
- package/src/command/index.ts +0 -6
- package/src/command/rank.ts +0 -50
- package/src/command/types.ts +0 -37
- package/src/command/useCommandSource.ts +0 -37
- package/src/dialogs/DialogsProvider.tsx +0 -216
- package/src/dialogs/Modal.tsx +0 -260
- package/src/dialogs/Toast.tsx +0 -85
- package/src/dialogs/dialogs-context.ts +0 -6
- package/src/dialogs/index.ts +0 -10
- package/src/dialogs/types.ts +0 -37
- package/src/dialogs/useDialogs.ts +0 -8
- package/src/editorial/EditorialSpotlight.tsx +0 -63
- package/src/editorial/Folio.tsx +0 -52
- package/src/editorial/PlateMarker.tsx +0 -33
- package/src/editorial/RomanSection.tsx +0 -65
- package/src/editorial/RunningMarginalia.tsx +0 -65
- package/src/editorial/index.ts +0 -10
- package/src/hooks/index.ts +0 -5
- package/src/hooks/useKeyboardHotkey.ts +0 -93
- package/src/hooks/useReducedMotion.ts +0 -20
- package/src/hooks/useViewport.ts +0 -61
- package/src/layout/Breadcrumb.tsx +0 -74
- package/src/layout/LeftNavRail.tsx +0 -139
- package/src/layout/MobileTabBar.tsx +0 -101
- package/src/layout/NavItem.tsx +0 -128
- package/src/layout/NavSearchTrigger.tsx +0 -88
- package/src/layout/NavSection.tsx +0 -40
- package/src/layout/RightSidebarPanel.tsx +0 -111
- package/src/layout/Shell.tsx +0 -91
- package/src/layout/SidePanel.tsx +0 -316
- package/src/layout/StickyBanner.tsx +0 -83
- package/src/layout/Topbar.tsx +0 -68
- package/src/layout/index.ts +0 -24
- package/src/layout/useNavRailState.ts +0 -69
- package/src/lib/time-utils.ts +0 -44
- package/src/neobrutalism/Marquee.tsx +0 -81
- package/src/neobrutalism/Sticker.tsx +0 -71
- package/src/neobrutalism/index.ts +0 -4
- package/src/primitives/Avatar.tsx +0 -53
- package/src/primitives/Button.tsx +0 -67
- package/src/primitives/Card.tsx +0 -41
- package/src/primitives/Checkbox.tsx +0 -87
- package/src/primitives/Chip.tsx +0 -191
- package/src/primitives/Combobox.tsx +0 -507
- package/src/primitives/CopyField.tsx +0 -139
- package/src/primitives/CountBadge.tsx +0 -50
- package/src/primitives/Input.tsx +0 -204
- package/src/primitives/Kbd.tsx +0 -45
- package/src/primitives/MentionCombobox.tsx +0 -612
- package/src/primitives/MultiCombobox.tsx +0 -327
- package/src/primitives/PageHeader.tsx +0 -77
- package/src/primitives/Radio.tsx +0 -131
- package/src/primitives/SegmentedControl.tsx +0 -135
- package/src/primitives/Select.tsx +0 -779
- package/src/primitives/SignaturePad.tsx +0 -461
- package/src/primitives/Tag.tsx +0 -56
- package/src/primitives/Textarea.tsx +0 -62
- package/src/primitives/ToggleSwitch.tsx +0 -79
- package/src/primitives/Tooltip.tsx +0 -213
- package/src/primitives/index.ts +0 -53
- package/src/pwa/InstallPromptBanner.tsx +0 -132
- package/src/pwa/index.ts +0 -4
- package/src/pwa/manifest.template.json +0 -20
- package/src/pwa/registerSW.ts +0 -55
- package/src/riso/Halftone.tsx +0 -85
- package/src/riso/Misregister.tsx +0 -63
- package/src/riso/RisoStamp.tsx +0 -76
- package/src/riso/index.ts +0 -3
- package/src/sketch/HandUnderline.tsx +0 -53
- package/src/sketch/RoughArrow.tsx +0 -91
- package/src/sketch/RoughBox.tsx +0 -73
- package/src/sketch/StickyNote.tsx +0 -56
- package/src/sketch/index.ts +0 -4
- package/src/styles/base.css +0 -186
- package/src/styles/chrome-ambient.css +0 -235
- package/src/styles/chrome-bento.css +0 -192
- package/src/styles/chrome-editorial.css +0 -168
- package/src/styles/chrome-neobrutalism.css +0 -326
- package/src/styles/chrome-riso.css +0 -339
- package/src/styles/chrome-sketch.css +0 -362
- package/src/styles/chrome-swiss.css +0 -265
- package/src/styles/chrome-terminal.css +0 -272
- package/src/styles/fonts.css +0 -1034
- package/src/styles/index.css +0 -343
- package/src/styles/tokens.css +0 -941
- package/src/terminal/AsciiBox.tsx +0 -65
- package/src/terminal/BrailleSpinner.tsx +0 -46
- package/src/terminal/index.ts +0 -4
- package/src/theme/ThemeProvider.tsx +0 -99
- package/src/theme/index.ts +0 -6
- package/src/theme/inline-script.ts +0 -40
- package/src/theme/theme-context.ts +0 -7
- package/src/theme/types.ts +0 -39
- package/src/theme/useTheme.ts +0 -8
|
@@ -1,837 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useCallback,
|
|
3
|
-
useContext,
|
|
4
|
-
useLayoutEffect,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
type CSSProperties,
|
|
10
|
-
type ReactNode,
|
|
11
|
-
} from "react";
|
|
12
|
-
import { ActionBarContext } from "./actionbar-context";
|
|
13
|
-
import { ActionBarButton } from "./ActionBarButton";
|
|
14
|
-
import { ActionBarMenuGroup } from "./ActionBarMenuGroup";
|
|
15
|
-
import { useViewport } from "../hooks/useViewport";
|
|
16
|
-
import type { ActionBarItem } from "./types";
|
|
17
|
-
|
|
18
|
-
// Floating action bar. Reads items from the registry, groups items
|
|
19
|
-
// sharing a category (2+) into inline-expandable menus, supports:
|
|
20
|
-
// - drag-to-reposition on desktop (left grip; double-click resets)
|
|
21
|
-
// - phone-only collapsed handle (left-edge peek tab)
|
|
22
|
-
// - localStorage persistence of position + collapsed state
|
|
23
|
-
// - spring entry animation at the default centred position
|
|
24
|
-
// - ESC + outside-click dismissal of expanded groups
|
|
25
|
-
// - label hiding below md breakpoint (icons + tooltips only)
|
|
26
|
-
//
|
|
27
|
-
// Bar is purely a renderer. Pages register items via useActionBarItems().
|
|
28
|
-
|
|
29
|
-
const BAR_HEIGHT = 48;
|
|
30
|
-
const BAR_WIDTH_FALLBACK = 440;
|
|
31
|
-
const VIEWPORT_MARGIN = 8;
|
|
32
|
-
const DEFAULT_BOTTOM_GUTTER = 24;
|
|
33
|
-
const COLLAPSED_HANDLE_W = 22;
|
|
34
|
-
const COLLAPSED_HANDLE_H = 56;
|
|
35
|
-
|
|
36
|
-
type Pos = { type: "default" } | { type: "custom"; left: number; bottom: number };
|
|
37
|
-
|
|
38
|
-
function clampCustom(
|
|
39
|
-
p: { left: number; bottom: number },
|
|
40
|
-
barWidth: number,
|
|
41
|
-
): { left: number; bottom: number } {
|
|
42
|
-
if (typeof window === "undefined") return p;
|
|
43
|
-
return {
|
|
44
|
-
left: Math.max(
|
|
45
|
-
VIEWPORT_MARGIN,
|
|
46
|
-
Math.min(window.innerWidth - VIEWPORT_MARGIN - barWidth, p.left),
|
|
47
|
-
),
|
|
48
|
-
bottom: Math.max(
|
|
49
|
-
VIEWPORT_MARGIN,
|
|
50
|
-
Math.min(window.innerHeight - VIEWPORT_MARGIN - BAR_HEIGHT, p.bottom),
|
|
51
|
-
),
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function loadPos(storageKey: string): Pos {
|
|
56
|
-
if (typeof window === "undefined") return { type: "default" };
|
|
57
|
-
try {
|
|
58
|
-
const raw = window.localStorage.getItem(`${storageKey}:pos`);
|
|
59
|
-
if (!raw) return { type: "default" };
|
|
60
|
-
const parsed = JSON.parse(raw);
|
|
61
|
-
if (parsed?.type === "default") return { type: "default" };
|
|
62
|
-
if (
|
|
63
|
-
parsed?.type === "custom" &&
|
|
64
|
-
typeof parsed.left === "number" &&
|
|
65
|
-
typeof parsed.bottom === "number"
|
|
66
|
-
) {
|
|
67
|
-
return { type: "custom", left: parsed.left, bottom: parsed.bottom };
|
|
68
|
-
}
|
|
69
|
-
} catch {}
|
|
70
|
-
return { type: "default" };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function loadCollapsed(storageKey: string): boolean {
|
|
74
|
-
if (typeof window === "undefined") return false;
|
|
75
|
-
try {
|
|
76
|
-
return window.localStorage.getItem(`${storageKey}:collapsed`) === "1";
|
|
77
|
-
} catch {
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Admin mode is session-only — deliberately NOT persisted. Two
|
|
83
|
-
// reasons: (1) cross-page reload persistence would surface an
|
|
84
|
-
// elevated-privileges state on a fresh session without explicit
|
|
85
|
-
// user opt-in every time — a small surprise factor with real
|
|
86
|
-
// consequences on admin surfaces. (2) The reset-on-context-change
|
|
87
|
-
// effect below relies on the state living in React memory alone,
|
|
88
|
-
// so we can watch for admin-item-set changes and clear cleanly.
|
|
89
|
-
|
|
90
|
-
interface BuildEntry {
|
|
91
|
-
kind: "flat" | "group";
|
|
92
|
-
expansionKey: string;
|
|
93
|
-
item?: ActionBarItem;
|
|
94
|
-
category?: string;
|
|
95
|
-
groupItems?: ActionBarItem[];
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Build render entries: a category with 2+ items collapses into one
|
|
99
|
-
// group head at the position of its first item; later items in that
|
|
100
|
-
// category are folded into the group, not re-emitted. A category
|
|
101
|
-
// with one item just renders flat in place. No-category items always
|
|
102
|
-
// render flat.
|
|
103
|
-
function buildEntries(items: ActionBarItem[]): BuildEntry[] {
|
|
104
|
-
const countByCat: Record<string, number> = {};
|
|
105
|
-
for (const it of items) {
|
|
106
|
-
if (it.category) countByCat[it.category] = (countByCat[it.category] ?? 0) + 1;
|
|
107
|
-
}
|
|
108
|
-
const result: BuildEntry[] = [];
|
|
109
|
-
const consumed = new Set<string>();
|
|
110
|
-
for (let i = 0; i < items.length; i++) {
|
|
111
|
-
const it = items[i]!;
|
|
112
|
-
const cat = it.category;
|
|
113
|
-
if (cat && (countByCat[cat] ?? 0) >= 2) {
|
|
114
|
-
if (consumed.has(cat)) continue;
|
|
115
|
-
const groupItems = items.filter((x) => x.category === cat);
|
|
116
|
-
result.push({
|
|
117
|
-
kind: "group",
|
|
118
|
-
expansionKey: `group:${cat}`,
|
|
119
|
-
category: cat,
|
|
120
|
-
groupItems,
|
|
121
|
-
});
|
|
122
|
-
consumed.add(cat);
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
result.push({
|
|
126
|
-
kind: "flat",
|
|
127
|
-
expansionKey: it.key ?? `flat-${i}`,
|
|
128
|
-
item: it,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
return result;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export interface ActionBarProps {
|
|
135
|
-
// localStorage namespace for position + collapsed state.
|
|
136
|
-
// Override per-app so multiple bars don't collide.
|
|
137
|
-
storageKey?: string;
|
|
138
|
-
// Enable desktop drag-to-reposition + double-click-to-reset.
|
|
139
|
-
// Default true.
|
|
140
|
-
draggable?: boolean;
|
|
141
|
-
// Enable phone-only collapsed handle. Default true.
|
|
142
|
-
collapsibleOnPhone?: boolean;
|
|
143
|
-
className?: string;
|
|
144
|
-
style?: CSSProperties;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function ActionBar({
|
|
148
|
-
storageKey = "ck-actionbar",
|
|
149
|
-
draggable = true,
|
|
150
|
-
collapsibleOnPhone = true,
|
|
151
|
-
className,
|
|
152
|
-
style,
|
|
153
|
-
}: ActionBarProps) {
|
|
154
|
-
const ctx = useContext(ActionBarContext);
|
|
155
|
-
if (!ctx) {
|
|
156
|
-
throw new Error("<ActionBar> must be inside <ActionBarProvider>");
|
|
157
|
-
}
|
|
158
|
-
const { items, categories, expandedKey, setExpandedKey, statusDot, panel, setPanelHost, toastActive, setToastHost } = ctx;
|
|
159
|
-
const vp = useViewport();
|
|
160
|
-
const isPhone = vp.isPhone;
|
|
161
|
-
|
|
162
|
-
// ----- Position state -----
|
|
163
|
-
const [pos, setPos] = useState<Pos>(() => loadPos(storageKey));
|
|
164
|
-
const barRef = useRef<HTMLDivElement | null>(null);
|
|
165
|
-
useEffect(() => {
|
|
166
|
-
try {
|
|
167
|
-
window.localStorage.setItem(`${storageKey}:pos`, JSON.stringify(pos));
|
|
168
|
-
} catch {}
|
|
169
|
-
}, [pos, storageKey]);
|
|
170
|
-
|
|
171
|
-
// Re-clamp on width resize so the bar tracks the viewport as
|
|
172
|
-
// the window resizes. Initial clamp also runs after first paint
|
|
173
|
-
// to handle saved positions from a wider window.
|
|
174
|
-
useEffect(() => {
|
|
175
|
-
const reclamp = () => {
|
|
176
|
-
setPos((p) => {
|
|
177
|
-
if (p.type !== "custom") return p;
|
|
178
|
-
const w = barRef.current?.offsetWidth ?? BAR_WIDTH_FALLBACK;
|
|
179
|
-
return { type: "custom", ...clampCustom(p, w) };
|
|
180
|
-
});
|
|
181
|
-
};
|
|
182
|
-
reclamp();
|
|
183
|
-
window.addEventListener("resize", reclamp);
|
|
184
|
-
return () => window.removeEventListener("resize", reclamp);
|
|
185
|
-
}, []);
|
|
186
|
-
|
|
187
|
-
// ----- Admin mode toggle (any registered item with adminOnly=true) -----
|
|
188
|
-
// Admin items stay hidden by default; a small toggle button (rendered
|
|
189
|
-
// between the grip and the first item) flips this to reveal them.
|
|
190
|
-
// Session-only: never persisted (see comment on loadAdminMode's
|
|
191
|
-
// removal above). Additionally auto-resets when the set of
|
|
192
|
-
// adminOnly item keys changes so a user who turned admin on for
|
|
193
|
-
// Doc A doesn't accidentally land in admin mode on Doc B.
|
|
194
|
-
const [adminMode, setAdminMode] = useState<boolean>(false);
|
|
195
|
-
const toggleAdminMode = useCallback(() => {
|
|
196
|
-
setAdminMode((prev) => !prev);
|
|
197
|
-
}, []);
|
|
198
|
-
const adminItemKeys = useMemo(
|
|
199
|
-
() =>
|
|
200
|
-
items
|
|
201
|
-
.filter((it) => it.adminOnly === true)
|
|
202
|
-
.map((it) => it.key)
|
|
203
|
-
.sort()
|
|
204
|
-
.join(","),
|
|
205
|
-
[items],
|
|
206
|
-
);
|
|
207
|
-
const prevAdminKeysRef = useRef(adminItemKeys);
|
|
208
|
-
useEffect(() => {
|
|
209
|
-
if (prevAdminKeysRef.current !== adminItemKeys) {
|
|
210
|
-
prevAdminKeysRef.current = adminItemKeys;
|
|
211
|
-
// Context changed (typically: user navigated to a different
|
|
212
|
-
// page whose useActionBarItems registered a different set of
|
|
213
|
-
// admin actions). Reset admin mode so the elevated state is
|
|
214
|
-
// never carried across viewer sessions implicitly.
|
|
215
|
-
setAdminMode(false);
|
|
216
|
-
}
|
|
217
|
-
}, [adminItemKeys]);
|
|
218
|
-
const hasAdminItems = useMemo(() => items.some((it) => it.adminOnly === true), [items]);
|
|
219
|
-
// Filter semantics:
|
|
220
|
-
// admin OFF → hide items marked adminOnly (the toggle would reveal them)
|
|
221
|
-
// admin ON → hide items marked hiddenInAdmin (page opted them out of admin view)
|
|
222
|
-
// Together, adminOnly + hiddenInAdmin let pages choose:
|
|
223
|
-
// - additive: normal items unmarked → visible in both modes;
|
|
224
|
-
// admin items marked adminOnly → visible only in admin
|
|
225
|
-
// - exclusive: normal items marked hiddenInAdmin → hidden in admin;
|
|
226
|
-
// admin items marked adminOnly → visible only in admin
|
|
227
|
-
const visibleItems = useMemo(
|
|
228
|
-
() =>
|
|
229
|
-
adminMode
|
|
230
|
-
? items.filter((it) => it.hiddenInAdmin !== true)
|
|
231
|
-
: items.filter((it) => it.adminOnly !== true),
|
|
232
|
-
[items, adminMode],
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
// ----- Collapsed state (phone only) -----
|
|
236
|
-
const [collapsed, setCollapsed] = useState<boolean>(() => loadCollapsed(storageKey));
|
|
237
|
-
useEffect(() => {
|
|
238
|
-
try {
|
|
239
|
-
window.localStorage.setItem(
|
|
240
|
-
`${storageKey}:collapsed`,
|
|
241
|
-
collapsed ? "1" : "0",
|
|
242
|
-
);
|
|
243
|
-
} catch {}
|
|
244
|
-
}, [collapsed, storageKey]);
|
|
245
|
-
|
|
246
|
-
// ----- Drag handlers (desktop) -----
|
|
247
|
-
const dragRef = useRef<{ active: boolean; offsetX: number; offsetY: number }>({
|
|
248
|
-
active: false,
|
|
249
|
-
offsetX: 0,
|
|
250
|
-
offsetY: 0,
|
|
251
|
-
});
|
|
252
|
-
const onGripDown = useCallback((e: React.MouseEvent) => {
|
|
253
|
-
const rect = barRef.current?.getBoundingClientRect();
|
|
254
|
-
if (!rect) return;
|
|
255
|
-
const currentLeft = rect.left;
|
|
256
|
-
const currentBottom = window.innerHeight - rect.bottom;
|
|
257
|
-
dragRef.current = {
|
|
258
|
-
active: true,
|
|
259
|
-
offsetX: e.clientX - currentLeft,
|
|
260
|
-
offsetY: e.clientY - (window.innerHeight - currentBottom - BAR_HEIGHT),
|
|
261
|
-
};
|
|
262
|
-
e.preventDefault();
|
|
263
|
-
}, []);
|
|
264
|
-
useEffect(() => {
|
|
265
|
-
const onMove = (e: MouseEvent) => {
|
|
266
|
-
if (!dragRef.current.active) return;
|
|
267
|
-
const w = barRef.current?.offsetWidth ?? BAR_WIDTH_FALLBACK;
|
|
268
|
-
const left = e.clientX - dragRef.current.offsetX;
|
|
269
|
-
const topY = e.clientY - dragRef.current.offsetY;
|
|
270
|
-
const bottom = window.innerHeight - topY - BAR_HEIGHT;
|
|
271
|
-
setPos({ type: "custom", ...clampCustom({ left, bottom }, w) });
|
|
272
|
-
};
|
|
273
|
-
const onUp = () => {
|
|
274
|
-
dragRef.current.active = false;
|
|
275
|
-
};
|
|
276
|
-
window.addEventListener("mousemove", onMove);
|
|
277
|
-
window.addEventListener("mouseup", onUp);
|
|
278
|
-
return () => {
|
|
279
|
-
window.removeEventListener("mousemove", onMove);
|
|
280
|
-
window.removeEventListener("mouseup", onUp);
|
|
281
|
-
};
|
|
282
|
-
}, []);
|
|
283
|
-
const onGripDoubleClick = useCallback(() => {
|
|
284
|
-
try {
|
|
285
|
-
window.localStorage.removeItem(`${storageKey}:pos`);
|
|
286
|
-
} catch {}
|
|
287
|
-
setPos({ type: "default" });
|
|
288
|
-
}, [storageKey]);
|
|
289
|
-
|
|
290
|
-
// ----- Entries (flat vs group), partitioned by slot -----
|
|
291
|
-
// Items default to the leading slot. Trailing items render after a
|
|
292
|
-
// flex spacer so they pin to the right edge regardless of
|
|
293
|
-
// registration order — system status indicators (sync, identity,
|
|
294
|
-
// connection) belong here, where page items registering later
|
|
295
|
-
// can't shuffle them. buildEntries runs per slot so category
|
|
296
|
-
// grouping stays slot-local (a category split across slots is an
|
|
297
|
-
// edge case we intentionally don't fold across the spacer).
|
|
298
|
-
const { leadingEntries, trailingEntries } = useMemo(() => {
|
|
299
|
-
const leading: ActionBarItem[] = [];
|
|
300
|
-
const trailing: ActionBarItem[] = [];
|
|
301
|
-
for (const it of visibleItems) {
|
|
302
|
-
(it.slot === "trailing" ? trailing : leading).push(it);
|
|
303
|
-
}
|
|
304
|
-
return {
|
|
305
|
-
leadingEntries: buildEntries(leading),
|
|
306
|
-
trailingEntries: buildEntries(trailing),
|
|
307
|
-
};
|
|
308
|
-
}, [visibleItems]);
|
|
309
|
-
const entries = useMemo(
|
|
310
|
-
() => [...leadingEntries, ...trailingEntries],
|
|
311
|
-
[leadingEntries, trailingEntries],
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
// Cleanup stale expansion if the corresponding group disappears.
|
|
315
|
-
useEffect(() => {
|
|
316
|
-
if (expandedKey === null) return;
|
|
317
|
-
if (!entries.some((e) => e.kind === "group" && e.expansionKey === expandedKey)) {
|
|
318
|
-
setExpandedKey(null);
|
|
319
|
-
}
|
|
320
|
-
}, [expandedKey, entries, setExpandedKey]);
|
|
321
|
-
|
|
322
|
-
// ESC closes any open group.
|
|
323
|
-
useEffect(() => {
|
|
324
|
-
if (expandedKey === null) return;
|
|
325
|
-
const onKey = (e: KeyboardEvent) => {
|
|
326
|
-
if (e.key === "Escape") {
|
|
327
|
-
e.preventDefault();
|
|
328
|
-
setExpandedKey(null);
|
|
329
|
-
}
|
|
330
|
-
};
|
|
331
|
-
window.addEventListener("keydown", onKey);
|
|
332
|
-
return () => window.removeEventListener("keydown", onKey);
|
|
333
|
-
}, [expandedKey, setExpandedKey]);
|
|
334
|
-
|
|
335
|
-
// ----- Panel slot (design#13): outside-click + Escape close. -----
|
|
336
|
-
// Hooks live ABOVE the early returns below — hook order must not
|
|
337
|
-
// change when the bar switches between empty/collapsed/full.
|
|
338
|
-
const panelOpen = Boolean(panel?.open);
|
|
339
|
-
const panelClose = panel?.onOpenChange;
|
|
340
|
-
useEffect(() => {
|
|
341
|
-
if (!panelOpen || !panelClose) return;
|
|
342
|
-
const onDown = (e: MouseEvent) => {
|
|
343
|
-
const t = e.target as Node | null;
|
|
344
|
-
if (t && barRef.current?.contains(t)) return;
|
|
345
|
-
panelClose(false);
|
|
346
|
-
};
|
|
347
|
-
const onKey = (e: KeyboardEvent) => {
|
|
348
|
-
if (e.key === "Escape") panelClose(false);
|
|
349
|
-
};
|
|
350
|
-
window.addEventListener("mousedown", onDown);
|
|
351
|
-
window.addEventListener("keydown", onKey);
|
|
352
|
-
return () => {
|
|
353
|
-
window.removeEventListener("mousedown", onDown);
|
|
354
|
-
window.removeEventListener("keydown", onKey);
|
|
355
|
-
};
|
|
356
|
-
}, [panelOpen, panelClose]);
|
|
357
|
-
|
|
358
|
-
// Host refs — stable callbacks publishing the container DOM nodes
|
|
359
|
-
// through context so the consumer hooks can portal content in.
|
|
360
|
-
// useCallback keeps React from detach/re-attach churn per render.
|
|
361
|
-
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
362
|
-
const panelHostRef = useCallback(
|
|
363
|
-
(el: HTMLDivElement | null) => {
|
|
364
|
-
panelRef.current = el;
|
|
365
|
-
setPanelHost(el);
|
|
366
|
-
},
|
|
367
|
-
[setPanelHost],
|
|
368
|
-
);
|
|
369
|
-
const toastHostRef = useCallback(
|
|
370
|
-
(el: HTMLDivElement | null) => {
|
|
371
|
-
setToastHost(el);
|
|
372
|
-
},
|
|
373
|
-
[setToastHost],
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
// Horizontal clamp: centred on the bar unless that would push the
|
|
377
|
-
// panel past a viewport edge — then shift just enough to fit.
|
|
378
|
-
useLayoutEffect(() => {
|
|
379
|
-
if (!panelOpen) return;
|
|
380
|
-
const el = panelRef.current;
|
|
381
|
-
if (!el) return;
|
|
382
|
-
el.style.marginLeft = "0px";
|
|
383
|
-
const rect = el.getBoundingClientRect();
|
|
384
|
-
let shift = 0;
|
|
385
|
-
if (rect.left < 8) shift = 8 - rect.left;
|
|
386
|
-
else if (rect.right > window.innerWidth - 8) shift = window.innerWidth - 8 - rect.right;
|
|
387
|
-
if (shift !== 0) el.style.marginLeft = `${Math.round(shift)}px`;
|
|
388
|
-
}, [panelOpen, pos]);
|
|
389
|
-
|
|
390
|
-
// ----- Empty state -----
|
|
391
|
-
// Bar renders when EITHER any page item is registered OR the
|
|
392
|
-
// status dot is set. Without the statusDot check, an app whose
|
|
393
|
-
// only consumer is `useActionBarStatusDot` (e.g. a system-status-
|
|
394
|
-
// only surface) would never see the bar at all.
|
|
395
|
-
if (items.length === 0 && !statusDot) return null;
|
|
396
|
-
|
|
397
|
-
// ----- Collapsed handle (phone only) -----
|
|
398
|
-
if (isPhone && collapsibleOnPhone && collapsed) {
|
|
399
|
-
return (
|
|
400
|
-
<button
|
|
401
|
-
type="button"
|
|
402
|
-
onClick={() => setCollapsed(false)}
|
|
403
|
-
aria-label="Expand action bar"
|
|
404
|
-
title="Tap to expand"
|
|
405
|
-
className="ck-action-handle-enter"
|
|
406
|
-
style={{
|
|
407
|
-
position: "fixed",
|
|
408
|
-
left: 0,
|
|
409
|
-
bottom: `calc(${DEFAULT_BOTTOM_GUTTER}px + var(--ck-tabbar-height, 0px) + env(safe-area-inset-bottom, 0px))`,
|
|
410
|
-
width: COLLAPSED_HANDLE_W,
|
|
411
|
-
height: COLLAPSED_HANDLE_H,
|
|
412
|
-
padding: 0,
|
|
413
|
-
paddingLeft: 4,
|
|
414
|
-
display: "inline-flex",
|
|
415
|
-
alignItems: "center",
|
|
416
|
-
justifyContent: "center",
|
|
417
|
-
zIndex: 80,
|
|
418
|
-
background: "var(--ck-bg-surface)",
|
|
419
|
-
border: "1px solid var(--ck-border-subtle)",
|
|
420
|
-
borderLeft: 0,
|
|
421
|
-
borderTopLeftRadius: 0,
|
|
422
|
-
borderBottomLeftRadius: 0,
|
|
423
|
-
borderTopRightRadius: 999,
|
|
424
|
-
borderBottomRightRadius: 999,
|
|
425
|
-
boxShadow: "3px 0 12px rgba(0,0,0,0.18)",
|
|
426
|
-
cursor: "pointer",
|
|
427
|
-
color: "var(--ck-text-secondary)",
|
|
428
|
-
}}
|
|
429
|
-
>
|
|
430
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
431
|
-
<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" />
|
|
432
|
-
<path
|
|
433
|
-
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"
|
|
434
|
-
opacity="0.55"
|
|
435
|
-
/>
|
|
436
|
-
</svg>
|
|
437
|
-
</button>
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// ----- Full bar -----
|
|
442
|
-
const isDefault = pos.type === "default";
|
|
443
|
-
return (
|
|
444
|
-
<div
|
|
445
|
-
ref={barRef}
|
|
446
|
-
data-ck-actionbar
|
|
447
|
-
className={`ck-actionbar${isDefault ? " ck-actionbar-enter" : ""}${className ? ` ${className}` : ""}`}
|
|
448
|
-
style={{
|
|
449
|
-
position: "fixed",
|
|
450
|
-
...(isDefault
|
|
451
|
-
? {
|
|
452
|
-
left: "50%",
|
|
453
|
-
transform: "translateX(-50%)",
|
|
454
|
-
bottom: `calc(${DEFAULT_BOTTOM_GUTTER}px + var(--ck-tabbar-height, 0px) + env(safe-area-inset-bottom, 0px))`,
|
|
455
|
-
}
|
|
456
|
-
: {
|
|
457
|
-
left: pos.left,
|
|
458
|
-
bottom: pos.bottom,
|
|
459
|
-
}),
|
|
460
|
-
height: BAR_HEIGHT,
|
|
461
|
-
// Symmetric horizontal padding so the bar's interior content
|
|
462
|
-
// area is centred. Previously only the right side had 6px
|
|
463
|
-
// padding (legacy from when the right edge held normal items
|
|
464
|
-
// and the left edge was an interactive grip), which made the
|
|
465
|
-
// grip sit flush against the left curve while the rightmost
|
|
466
|
-
// element (now the status dot) had visible breathing room.
|
|
467
|
-
// The asymmetry pushed the leading items a few pixels left of
|
|
468
|
-
// the bar's true centre.
|
|
469
|
-
padding: "0 6px",
|
|
470
|
-
display: "flex",
|
|
471
|
-
alignItems: "center",
|
|
472
|
-
gap: 4,
|
|
473
|
-
zIndex: 80,
|
|
474
|
-
// Bar background stays constant across modes — the earlier
|
|
475
|
-
// whole-bar tint composited under the item hover overlay
|
|
476
|
-
// (cool grey on tinted warm base) produced an awkward green
|
|
477
|
-
// cast on hover, and competed with the shield toggle for
|
|
478
|
-
// signal. Border colour DOES swap to accent when admin mode
|
|
479
|
-
// is on so the elevated state reads at a glance without a
|
|
480
|
-
// heavy surface change; the shield glyph adds a second cue
|
|
481
|
-
// (outline off → filled on) at the toggle itself.
|
|
482
|
-
background: "var(--ck-bg-surface)",
|
|
483
|
-
border: `1px solid ${
|
|
484
|
-
adminMode
|
|
485
|
-
? "var(--ck-accent, #4f46e5)"
|
|
486
|
-
: "var(--ck-border-subtle)"
|
|
487
|
-
}`,
|
|
488
|
-
borderRadius: 999,
|
|
489
|
-
boxShadow: "var(--ck-shadow-3)",
|
|
490
|
-
fontFamily: "var(--ck-font-sans)",
|
|
491
|
-
color: "var(--ck-text-primary)",
|
|
492
|
-
transition: "border-color 200ms var(--ck-ease, ease)",
|
|
493
|
-
...style,
|
|
494
|
-
}}
|
|
495
|
-
>
|
|
496
|
-
{/* Panel slot (design#13) — the bar positions + the chrome
|
|
497
|
-
themes style it (.ck-actionbar-panel); consumers own only
|
|
498
|
-
the content, PORTALLED in via useActionBarPanel (the div
|
|
499
|
-
here is an empty host — content never flows through
|
|
500
|
-
context state). Rendered inside the bar root so it follows
|
|
501
|
-
drags natively. */}
|
|
502
|
-
{panel?.open && (
|
|
503
|
-
<div
|
|
504
|
-
ref={panelHostRef}
|
|
505
|
-
className="ck-actionbar-panel"
|
|
506
|
-
role="region"
|
|
507
|
-
aria-label={panel.ariaLabel ?? "Panel"}
|
|
508
|
-
style={{
|
|
509
|
-
position: "absolute",
|
|
510
|
-
bottom: "calc(100% + 8px)",
|
|
511
|
-
left: "50%",
|
|
512
|
-
transform: "translateX(-50%)",
|
|
513
|
-
width: panel.width ?? 320,
|
|
514
|
-
maxWidth: "calc(100vw - 16px)",
|
|
515
|
-
cursor: "default",
|
|
516
|
-
}}
|
|
517
|
-
/>
|
|
518
|
-
)}
|
|
519
|
-
{toastActive && !panel?.open && (
|
|
520
|
-
<div
|
|
521
|
-
ref={toastHostRef}
|
|
522
|
-
className="ck-actionbar-toast"
|
|
523
|
-
role="status"
|
|
524
|
-
style={{
|
|
525
|
-
position: "absolute",
|
|
526
|
-
bottom: "calc(100% + 8px)",
|
|
527
|
-
left: "50%",
|
|
528
|
-
transform: "translateX(-50%)",
|
|
529
|
-
whiteSpace: "nowrap",
|
|
530
|
-
}}
|
|
531
|
-
/>
|
|
532
|
-
)}
|
|
533
|
-
{/* Left-edge button. Phone = collapse; desktop = drag grip. */}
|
|
534
|
-
{isPhone && collapsibleOnPhone ? (
|
|
535
|
-
<button
|
|
536
|
-
type="button"
|
|
537
|
-
onClick={() => setCollapsed(true)}
|
|
538
|
-
aria-label="Collapse action bar"
|
|
539
|
-
title="Collapse"
|
|
540
|
-
style={leftEdgeButtonStyle}
|
|
541
|
-
>
|
|
542
|
-
<svg
|
|
543
|
-
width="14"
|
|
544
|
-
height="14"
|
|
545
|
-
viewBox="0 0 24 24"
|
|
546
|
-
fill="none"
|
|
547
|
-
stroke="currentColor"
|
|
548
|
-
strokeWidth="1.8"
|
|
549
|
-
strokeLinecap="round"
|
|
550
|
-
strokeLinejoin="round"
|
|
551
|
-
aria-hidden
|
|
552
|
-
>
|
|
553
|
-
<polyline points="15 18 9 12 15 6" />
|
|
554
|
-
</svg>
|
|
555
|
-
</button>
|
|
556
|
-
) : draggable ? (
|
|
557
|
-
<button
|
|
558
|
-
type="button"
|
|
559
|
-
onMouseDown={onGripDown}
|
|
560
|
-
onDoubleClick={onGripDoubleClick}
|
|
561
|
-
aria-label="Drag action bar (double-click to reset)"
|
|
562
|
-
title="Drag · double-click to reset"
|
|
563
|
-
style={{ ...leftEdgeButtonStyle, cursor: "grab" }}
|
|
564
|
-
>
|
|
565
|
-
<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden>
|
|
566
|
-
<circle cx="2" cy="2" r="1" />
|
|
567
|
-
<circle cx="2" cy="7" r="1" />
|
|
568
|
-
<circle cx="2" cy="12" r="1" />
|
|
569
|
-
<circle cx="8" cy="2" r="1" />
|
|
570
|
-
<circle cx="8" cy="7" r="1" />
|
|
571
|
-
<circle cx="8" cy="12" r="1" />
|
|
572
|
-
</svg>
|
|
573
|
-
</button>
|
|
574
|
-
) : null}
|
|
575
|
-
|
|
576
|
-
{/* Admin-mode toggle. Only renders when at least one registered
|
|
577
|
-
item has adminOnly=true. Borderless, transparent bg — same
|
|
578
|
-
visual weight as the drag grip and the item icons around
|
|
579
|
-
it. State polarity lives on the glyph itself: outline shield
|
|
580
|
-
(off) → filled shield (on). No background pill, no whole-bar
|
|
581
|
-
tint — the icon is the whole affordance. */}
|
|
582
|
-
{hasAdminItems && (
|
|
583
|
-
<button
|
|
584
|
-
type="button"
|
|
585
|
-
onClick={toggleAdminMode}
|
|
586
|
-
aria-label={adminMode ? "Exit admin mode" : "Enter admin mode"}
|
|
587
|
-
aria-pressed={adminMode}
|
|
588
|
-
title={adminMode ? "Admin mode · click to exit" : "Enter admin mode"}
|
|
589
|
-
data-ck-actionbar-admin-toggle
|
|
590
|
-
data-active={adminMode ? "1" : "0"}
|
|
591
|
-
style={{
|
|
592
|
-
display: "inline-flex",
|
|
593
|
-
alignItems: "center",
|
|
594
|
-
justifyContent: "center",
|
|
595
|
-
width: 28,
|
|
596
|
-
height: 28,
|
|
597
|
-
padding: 0,
|
|
598
|
-
marginLeft: 2,
|
|
599
|
-
marginRight: 2,
|
|
600
|
-
borderRadius: 999,
|
|
601
|
-
background: "transparent",
|
|
602
|
-
color: adminMode
|
|
603
|
-
? "var(--ck-accent, #4f46e5)"
|
|
604
|
-
: "var(--ck-text-secondary, #666)",
|
|
605
|
-
border: "none",
|
|
606
|
-
cursor: "pointer",
|
|
607
|
-
transition: "color 200ms var(--ck-ease, ease)",
|
|
608
|
-
}}
|
|
609
|
-
>
|
|
610
|
-
<svg
|
|
611
|
-
width="16"
|
|
612
|
-
height="16"
|
|
613
|
-
viewBox="0 0 24 24"
|
|
614
|
-
// Outline when off, filled when on. Stroke stays on in
|
|
615
|
-
// both states so the silhouette is identical — only the
|
|
616
|
-
// interior fills — which reads as "same shape, different
|
|
617
|
-
// state" instead of two distinct glyphs.
|
|
618
|
-
fill={adminMode ? "currentColor" : "none"}
|
|
619
|
-
stroke="currentColor"
|
|
620
|
-
strokeWidth="1.8"
|
|
621
|
-
strokeLinecap="round"
|
|
622
|
-
strokeLinejoin="round"
|
|
623
|
-
aria-hidden
|
|
624
|
-
>
|
|
625
|
-
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
626
|
-
</svg>
|
|
627
|
-
</button>
|
|
628
|
-
)}
|
|
629
|
-
|
|
630
|
-
{/* Balancing leading spacer — only present when the right side
|
|
631
|
-
holds ONLY a status dot (no trailing items). With a left
|
|
632
|
-
and right spacer of equal flex weight, the leading items
|
|
633
|
-
centre between the grip and the status dot. Without this,
|
|
634
|
-
a solo leading item (e.g. just "Theme · Light") looks
|
|
635
|
-
left-anchored next to the grip with a visible gap before
|
|
636
|
-
the dot, which reads as unbalanced. When trailing items ARE
|
|
637
|
-
present, they take that role on the right and the leading
|
|
638
|
-
group goes back to its natural left-anchored packing. */}
|
|
639
|
-
{leadingEntries.length > 0 && trailingEntries.length === 0 && statusDot && (
|
|
640
|
-
<span
|
|
641
|
-
aria-hidden
|
|
642
|
-
data-ck-actionbar-spacer="leading"
|
|
643
|
-
style={{ flex: "1 1 auto", minWidth: 0 }}
|
|
644
|
-
/>
|
|
645
|
-
)}
|
|
646
|
-
|
|
647
|
-
{/* Leading entries — flat items + group heads with disclosure regions. */}
|
|
648
|
-
{leadingEntries.map(renderEntry)}
|
|
649
|
-
|
|
650
|
-
{/* Spacer pushes whatever is right-aligned (trailing entries +/or
|
|
651
|
-
the bar-intrinsic status dot) to the right edge. Only rendered
|
|
652
|
-
when there IS something on the right, so the bar still hugs
|
|
653
|
-
its content tightly when neither is in use. */}
|
|
654
|
-
{(trailingEntries.length > 0 || statusDot) && (
|
|
655
|
-
<span
|
|
656
|
-
aria-hidden
|
|
657
|
-
data-ck-actionbar-spacer
|
|
658
|
-
style={{ flex: "1 1 auto", minWidth: 0 }}
|
|
659
|
-
/>
|
|
660
|
-
)}
|
|
661
|
-
|
|
662
|
-
{/* Trailing entries — pin to the right edge regardless of
|
|
663
|
-
registration order. Same rendering rules as leading. */}
|
|
664
|
-
{trailingEntries.map(renderEntry)}
|
|
665
|
-
|
|
666
|
-
{/* Status dot — bar-intrinsic chrome at the right edge,
|
|
667
|
-
mirroring the left-edge drag grip. Consumer-controlled via
|
|
668
|
-
`useActionBarStatusDot()`. Visual: a small coloured dot in
|
|
669
|
-
a 28×BAR_HEIGHT slot. Renders as a button when an onClick
|
|
670
|
-
is provided; otherwise a plain span (no interactive
|
|
671
|
-
affordances). */}
|
|
672
|
-
{statusDot &&
|
|
673
|
-
(statusDot.onClick ? (
|
|
674
|
-
<button
|
|
675
|
-
type="button"
|
|
676
|
-
onClick={statusDot.onClick}
|
|
677
|
-
aria-label={statusDot.title ?? "Status"}
|
|
678
|
-
title={statusDot.title ?? "Status"}
|
|
679
|
-
data-ck-actionbar-status-dot
|
|
680
|
-
style={rightEdgeButtonStyle}
|
|
681
|
-
>
|
|
682
|
-
<StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
|
|
683
|
-
</button>
|
|
684
|
-
) : (
|
|
685
|
-
<span
|
|
686
|
-
aria-label={statusDot.title ?? "Status"}
|
|
687
|
-
title={statusDot.title ?? "Status"}
|
|
688
|
-
data-ck-actionbar-status-dot
|
|
689
|
-
style={rightEdgeButtonStyle}
|
|
690
|
-
>
|
|
691
|
-
<StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
|
|
692
|
-
</span>
|
|
693
|
-
))}
|
|
694
|
-
</div>
|
|
695
|
-
);
|
|
696
|
-
|
|
697
|
-
function renderEntry(entry: BuildEntry): ReactNode {
|
|
698
|
-
// Admin-only entries play a slide-in animation on mount so the
|
|
699
|
-
// reveal doesn't feel like items just popped in. Groups that
|
|
700
|
-
// contain any adminOnly items also get wrapped so the whole
|
|
701
|
-
// disclosure slides in together.
|
|
702
|
-
const isAdmin =
|
|
703
|
-
entry.item?.adminOnly === true ||
|
|
704
|
-
entry.groupItems?.some((it) => it.adminOnly === true) === true;
|
|
705
|
-
let node: ReactNode;
|
|
706
|
-
if (entry.kind === "flat" && entry.item) {
|
|
707
|
-
node = (
|
|
708
|
-
<ActionBarButton
|
|
709
|
-
key={entry.expansionKey}
|
|
710
|
-
icon={entry.item.icon}
|
|
711
|
-
label={entry.item.label}
|
|
712
|
-
title={entry.item.title}
|
|
713
|
-
active={entry.item.active}
|
|
714
|
-
disabled={entry.item.disabled}
|
|
715
|
-
variant={entry.item.variant}
|
|
716
|
-
hint={entry.item.hint}
|
|
717
|
-
onClick={entry.item.onClick}
|
|
718
|
-
/>
|
|
719
|
-
);
|
|
720
|
-
} else {
|
|
721
|
-
const cat = entry.category!;
|
|
722
|
-
const catDef = categories[cat];
|
|
723
|
-
const hasActiveChild = entry.groupItems!.some((it) => it.active);
|
|
724
|
-
const isOpen = expandedKey === entry.expansionKey;
|
|
725
|
-
node = (
|
|
726
|
-
<ActionBarMenuGroup
|
|
727
|
-
key={entry.expansionKey}
|
|
728
|
-
label={catDef?.label ?? cat}
|
|
729
|
-
icon={catDef?.icon}
|
|
730
|
-
hasActiveChild={hasActiveChild}
|
|
731
|
-
isOpen={isOpen}
|
|
732
|
-
onToggle={() => setExpandedKey(isOpen ? null : entry.expansionKey)}
|
|
733
|
-
items={entry.groupItems!}
|
|
734
|
-
onItemClicked={(it) => {
|
|
735
|
-
it.onClick();
|
|
736
|
-
if (!it.keepGroupOpenOnClick) {
|
|
737
|
-
setExpandedKey(null);
|
|
738
|
-
}
|
|
739
|
-
}}
|
|
740
|
-
/>
|
|
741
|
-
);
|
|
742
|
-
}
|
|
743
|
-
if (!isAdmin) return node;
|
|
744
|
-
// Wrapper carries the spring-in animation via keyframes injected
|
|
745
|
-
// at module load (see bottom of file). Key includes adminMode so
|
|
746
|
-
// React remounts the wrapper each time it flips, replaying the
|
|
747
|
-
// animation cleanly.
|
|
748
|
-
return (
|
|
749
|
-
<span
|
|
750
|
-
key={`${entry.expansionKey}-admin`}
|
|
751
|
-
className="ck-actionbar-admin-item"
|
|
752
|
-
style={{ display: "inline-flex" }}
|
|
753
|
-
>
|
|
754
|
-
{node}
|
|
755
|
-
</span>
|
|
756
|
-
);
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
const leftEdgeButtonStyle: CSSProperties = {
|
|
761
|
-
width: 28,
|
|
762
|
-
height: BAR_HEIGHT,
|
|
763
|
-
padding: 0,
|
|
764
|
-
display: "inline-flex",
|
|
765
|
-
alignItems: "center",
|
|
766
|
-
justifyContent: "center",
|
|
767
|
-
border: "none",
|
|
768
|
-
background: "transparent",
|
|
769
|
-
color: "var(--ck-text-tertiary)",
|
|
770
|
-
borderRadius: 0,
|
|
771
|
-
};
|
|
772
|
-
|
|
773
|
-
// Right-edge button — mirrors leftEdgeButtonStyle in dimensions, used
|
|
774
|
-
// for the status dot. Cursor stays default for the non-interactive
|
|
775
|
-
// span variant; the interactive (onClick) path is a real <button>
|
|
776
|
-
// that picks up :hover / :focus styles from base.css.
|
|
777
|
-
const rightEdgeButtonStyle: CSSProperties = {
|
|
778
|
-
...leftEdgeButtonStyle,
|
|
779
|
-
};
|
|
780
|
-
|
|
781
|
-
// StatusDotGlyph — the actual coloured circle, sized to read at par
|
|
782
|
-
// with the grip's six-dot icon. Pulse via a shared keyframe injected
|
|
783
|
-
// at module scope (one stylesheet entry; idempotent across mounts).
|
|
784
|
-
interface StatusDotGlyphProps {
|
|
785
|
-
color: string;
|
|
786
|
-
pulse?: boolean | undefined;
|
|
787
|
-
}
|
|
788
|
-
function StatusDotGlyph({ color, pulse }: StatusDotGlyphProps) {
|
|
789
|
-
return (
|
|
790
|
-
<span
|
|
791
|
-
aria-hidden
|
|
792
|
-
style={{
|
|
793
|
-
display: "inline-block",
|
|
794
|
-
width: 8,
|
|
795
|
-
height: 8,
|
|
796
|
-
borderRadius: "50%",
|
|
797
|
-
background: color,
|
|
798
|
-
animation: pulse ? "ck-actionbar-status-pulse 1.4s ease-in-out infinite" : undefined,
|
|
799
|
-
}}
|
|
800
|
-
/>
|
|
801
|
-
);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// One global stylesheet entry for the pulse keyframe + admin-item
|
|
805
|
-
// entrance animation. Injected once on first import. Idempotent —
|
|
806
|
-
// re-imports check by id.
|
|
807
|
-
if (typeof document !== "undefined") {
|
|
808
|
-
const STYLE_ID = "ck-actionbar-status-pulse";
|
|
809
|
-
if (!document.getElementById(STYLE_ID)) {
|
|
810
|
-
const el = document.createElement("style");
|
|
811
|
-
el.id = STYLE_ID;
|
|
812
|
-
el.textContent = `
|
|
813
|
-
@keyframes ck-actionbar-status-pulse {
|
|
814
|
-
0%, 100% { opacity: 1; }
|
|
815
|
-
50% { opacity: 0.45; }
|
|
816
|
-
}
|
|
817
|
-
@keyframes ck-actionbar-admin-enter {
|
|
818
|
-
0% {
|
|
819
|
-
opacity: 0;
|
|
820
|
-
transform: translateX(-6px) scale(0.92);
|
|
821
|
-
}
|
|
822
|
-
60% {
|
|
823
|
-
opacity: 1;
|
|
824
|
-
}
|
|
825
|
-
100% {
|
|
826
|
-
opacity: 1;
|
|
827
|
-
transform: translateX(0) scale(1);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
.ck-actionbar-admin-item {
|
|
831
|
-
animation: ck-actionbar-admin-enter 260ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
|
832
|
-
transform-origin: left center;
|
|
833
|
-
}
|
|
834
|
-
`;
|
|
835
|
-
document.head.appendChild(el);
|
|
836
|
-
}
|
|
837
|
-
}
|