@cosxai/ui 0.9.0 → 0.10.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 +1 -1
- package/src/actionbar/ActionBar.tsx +103 -4
- package/src/actionbar/types.ts +13 -0
package/package.json
CHANGED
|
@@ -78,6 +78,25 @@ function loadCollapsed(storageKey: string): boolean {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function loadAdminMode(storageKey: string): boolean {
|
|
82
|
+
if (typeof window === "undefined") return false;
|
|
83
|
+
try {
|
|
84
|
+
return window.localStorage.getItem(`${storageKey}:admin`) === "1";
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function persistAdminMode(storageKey: string, on: boolean) {
|
|
91
|
+
if (typeof window === "undefined") return;
|
|
92
|
+
try {
|
|
93
|
+
if (on) window.localStorage.setItem(`${storageKey}:admin`, "1");
|
|
94
|
+
else window.localStorage.removeItem(`${storageKey}:admin`);
|
|
95
|
+
} catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
interface BuildEntry {
|
|
82
101
|
kind: "flat" | "group";
|
|
83
102
|
expansionKey: string;
|
|
@@ -175,6 +194,26 @@ export function ActionBar({
|
|
|
175
194
|
return () => window.removeEventListener("resize", reclamp);
|
|
176
195
|
}, []);
|
|
177
196
|
|
|
197
|
+
// ----- Admin mode toggle (any registered item with adminOnly=true) -----
|
|
198
|
+
// Admin items stay hidden by default; a small toggle button (rendered
|
|
199
|
+
// between the grip and the first item) flips this to reveal them.
|
|
200
|
+
// Persisted per storageKey so admin users stay in the mode they
|
|
201
|
+
// last chose across route navigations. When no consumer registers
|
|
202
|
+
// admin items, the toggle button doesn't render.
|
|
203
|
+
const [adminMode, setAdminMode] = useState<boolean>(() => loadAdminMode(storageKey));
|
|
204
|
+
const toggleAdminMode = useCallback(() => {
|
|
205
|
+
setAdminMode((prev) => {
|
|
206
|
+
const next = !prev;
|
|
207
|
+
persistAdminMode(storageKey, next);
|
|
208
|
+
return next;
|
|
209
|
+
});
|
|
210
|
+
}, [storageKey]);
|
|
211
|
+
const hasAdminItems = useMemo(() => items.some((it) => it.adminOnly === true), [items]);
|
|
212
|
+
const visibleItems = useMemo(
|
|
213
|
+
() => (adminMode ? items : items.filter((it) => it.adminOnly !== true)),
|
|
214
|
+
[items, adminMode],
|
|
215
|
+
);
|
|
216
|
+
|
|
178
217
|
// ----- Collapsed state (phone only) -----
|
|
179
218
|
const [collapsed, setCollapsed] = useState<boolean>(() => loadCollapsed(storageKey));
|
|
180
219
|
useEffect(() => {
|
|
@@ -241,14 +280,14 @@ export function ActionBar({
|
|
|
241
280
|
const { leadingEntries, trailingEntries } = useMemo(() => {
|
|
242
281
|
const leading: ActionBarItem[] = [];
|
|
243
282
|
const trailing: ActionBarItem[] = [];
|
|
244
|
-
for (const it of
|
|
283
|
+
for (const it of visibleItems) {
|
|
245
284
|
(it.slot === "trailing" ? trailing : leading).push(it);
|
|
246
285
|
}
|
|
247
286
|
return {
|
|
248
287
|
leadingEntries: buildEntries(leading),
|
|
249
288
|
trailingEntries: buildEntries(trailing),
|
|
250
289
|
};
|
|
251
|
-
}, [
|
|
290
|
+
}, [visibleItems]);
|
|
252
291
|
const entries = useMemo(
|
|
253
292
|
() => [...leadingEntries, ...trailingEntries],
|
|
254
293
|
[leadingEntries, trailingEntries],
|
|
@@ -359,12 +398,21 @@ export function ActionBar({
|
|
|
359
398
|
alignItems: "center",
|
|
360
399
|
gap: 4,
|
|
361
400
|
zIndex: 80,
|
|
362
|
-
|
|
363
|
-
|
|
401
|
+
// Subtle accent tint when admin mode is active so the bar reads
|
|
402
|
+
// as "elevated privileges on" without a full colour swap.
|
|
403
|
+
background: adminMode
|
|
404
|
+
? "color-mix(in oklab, var(--ck-accent, #4f46e5) 8%, var(--ck-bg-surface))"
|
|
405
|
+
: "var(--ck-bg-surface)",
|
|
406
|
+
border: `1px solid ${
|
|
407
|
+
adminMode
|
|
408
|
+
? "var(--ck-accent, #4f46e5)"
|
|
409
|
+
: "var(--ck-border-subtle)"
|
|
410
|
+
}`,
|
|
364
411
|
borderRadius: 999,
|
|
365
412
|
boxShadow: "var(--ck-shadow-3)",
|
|
366
413
|
fontFamily: "var(--ck-font-sans)",
|
|
367
414
|
color: "var(--ck-text-primary)",
|
|
415
|
+
transition: "background 180ms ease-out, border-color 180ms ease-out",
|
|
368
416
|
...style,
|
|
369
417
|
}}
|
|
370
418
|
>
|
|
@@ -411,6 +459,57 @@ export function ActionBar({
|
|
|
411
459
|
</button>
|
|
412
460
|
) : null}
|
|
413
461
|
|
|
462
|
+
{/* Admin-mode toggle. Only renders when at least one registered
|
|
463
|
+
item has adminOnly=true. When active: filled accent circle;
|
|
464
|
+
when inactive: outlined muted circle. Uses a small shield
|
|
465
|
+
glyph so the affordance reads as "elevated privileges" at a
|
|
466
|
+
glance, distinct from a normal action button. */}
|
|
467
|
+
{hasAdminItems && (
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
onClick={toggleAdminMode}
|
|
471
|
+
aria-label={adminMode ? "Exit admin mode" : "Enter admin mode"}
|
|
472
|
+
aria-pressed={adminMode}
|
|
473
|
+
title={adminMode ? "Admin mode · click to exit" : "Enter admin mode"}
|
|
474
|
+
data-ck-actionbar-admin-toggle
|
|
475
|
+
data-active={adminMode ? "1" : "0"}
|
|
476
|
+
style={{
|
|
477
|
+
display: "inline-flex",
|
|
478
|
+
alignItems: "center",
|
|
479
|
+
justifyContent: "center",
|
|
480
|
+
width: 28,
|
|
481
|
+
height: 28,
|
|
482
|
+
padding: 0,
|
|
483
|
+
marginLeft: 2,
|
|
484
|
+
marginRight: 2,
|
|
485
|
+
borderRadius: 999,
|
|
486
|
+
background: adminMode ? "var(--ck-accent, #4f46e5)" : "transparent",
|
|
487
|
+
color: adminMode
|
|
488
|
+
? "var(--ck-accent-fg, #fff)"
|
|
489
|
+
: "var(--ck-text-secondary, #666)",
|
|
490
|
+
border: adminMode
|
|
491
|
+
? "1px solid var(--ck-accent, #4f46e5)"
|
|
492
|
+
: "1px solid var(--ck-border-subtle, #eee)",
|
|
493
|
+
cursor: "pointer",
|
|
494
|
+
transition: "background 160ms ease-out, color 160ms ease-out, border-color 160ms ease-out",
|
|
495
|
+
}}
|
|
496
|
+
>
|
|
497
|
+
<svg
|
|
498
|
+
width="14"
|
|
499
|
+
height="14"
|
|
500
|
+
viewBox="0 0 24 24"
|
|
501
|
+
fill="none"
|
|
502
|
+
stroke="currentColor"
|
|
503
|
+
strokeWidth="2"
|
|
504
|
+
strokeLinecap="round"
|
|
505
|
+
strokeLinejoin="round"
|
|
506
|
+
aria-hidden
|
|
507
|
+
>
|
|
508
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
509
|
+
</svg>
|
|
510
|
+
</button>
|
|
511
|
+
)}
|
|
512
|
+
|
|
414
513
|
{/* Balancing leading spacer — only present when the right side
|
|
415
514
|
holds ONLY a status dot (no trailing items). With a left
|
|
416
515
|
and right spacer of equal flex weight, the leading items
|
package/src/actionbar/types.ts
CHANGED
|
@@ -47,6 +47,19 @@ export interface ActionBarItem {
|
|
|
47
47
|
// a flex spacer so they pin to the right edge regardless of
|
|
48
48
|
// registration order — system status indicators belong here.
|
|
49
49
|
slot?: ActionBarItemSlot;
|
|
50
|
+
// Marks this item as admin-only. Hidden until the ActionBar's
|
|
51
|
+
// admin-mode toggle is switched on. The toggle button auto-appears
|
|
52
|
+
// on the bar whenever at least one registered item has adminOnly=
|
|
53
|
+
// true; when no consumer registers such items, the toggle stays
|
|
54
|
+
// hidden and the bar looks exactly as it always did.
|
|
55
|
+
//
|
|
56
|
+
// Use for elevated-privilege actions that would confuse regular
|
|
57
|
+
// users (version history, retry render, delete forever, etc.).
|
|
58
|
+
// Consumer decides eligibility upstream — typically by only
|
|
59
|
+
// registering the item when the caller has `capabilities.manage`
|
|
60
|
+
// (or an equivalent gate) === true. The kit does not check
|
|
61
|
+
// permissions; it only handles the toggle UX.
|
|
62
|
+
adminOnly?: boolean;
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
// Category definition — declared at the provider level. Drives
|