@cosxai/ui 0.8.4 → 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
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
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { forwardRef, type CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
// ActionBarModeHandle — the top-edge peek button that lets a user
|
|
4
|
+
// switch the ActionBar between two content sets ("modes"). Sits
|
|
5
|
+
// above the ActionBar's centred bottom position, showing only a
|
|
6
|
+
// slim 6px sliver at rest; grows on hover / focus to a full 28px
|
|
7
|
+
// tappable disc with a tooltip beneath the pointer.
|
|
8
|
+
//
|
|
9
|
+
// Compose with: `useActionBarMode` hook (or your own state) to swap
|
|
10
|
+
// the items registered via useActionBarItems when the handle fires
|
|
11
|
+
// onClick. Gate visibility on a permission bit (e.g.
|
|
12
|
+
// `document.capabilities.manage`) — passing `visible={false}` hides
|
|
13
|
+
// the handle entirely so non-privileged users never see the
|
|
14
|
+
// affordance.
|
|
15
|
+
//
|
|
16
|
+
// Visual: fixed positioning centred at the bottom of the viewport,
|
|
17
|
+
// stacked ABOVE the ActionBar (which lives at `bottom: 24px +
|
|
18
|
+
// tabbar-height`). The handle sits ~6px above the ActionBar's top
|
|
19
|
+
// edge at rest and animates upward on hover.
|
|
20
|
+
//
|
|
21
|
+
// Positioning contract: consumers may pass `bottom` to override the
|
|
22
|
+
// default 66px (24 gutter + 48 bar height - 6 overlap). Otherwise
|
|
23
|
+
// this matches the default ActionBar's centred bottom placement.
|
|
24
|
+
//
|
|
25
|
+
// Forwards ref to the underlying <button>.
|
|
26
|
+
export interface ActionBarModeHandleProps {
|
|
27
|
+
/**
|
|
28
|
+
* Called when the handle is clicked / activated. Consumer should
|
|
29
|
+
* flip the active mode state here (which drives
|
|
30
|
+
* useActionBarItems(sourceKey, items) with a different sourceKey).
|
|
31
|
+
*/
|
|
32
|
+
onClick: () => void;
|
|
33
|
+
/**
|
|
34
|
+
* Human-readable label describing what the click will do
|
|
35
|
+
* ("Switch to manage mode"). Used as both aria-label and the
|
|
36
|
+
* hover tooltip.
|
|
37
|
+
*/
|
|
38
|
+
label: string;
|
|
39
|
+
/**
|
|
40
|
+
* When false, the handle renders nothing. Consumers wire this to
|
|
41
|
+
* their capability check (e.g. `capabilities.manage === true`)
|
|
42
|
+
* so principals without the permission never see the affordance.
|
|
43
|
+
* Default: true.
|
|
44
|
+
*/
|
|
45
|
+
visible?: boolean | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Optional bottom offset in px (default: matches the ActionBar's
|
|
48
|
+
* default centred position — 66px = 24 gutter + 48 bar height - 6
|
|
49
|
+
* overlap, plus the same tabbar + safe-area terms). Override when
|
|
50
|
+
* the ActionBar is docked non-default.
|
|
51
|
+
*/
|
|
52
|
+
bottom?: number | string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Extra className appended to the handle's root class. Handy for
|
|
55
|
+
* tests + one-off styling. Default className: `ck-actionbar-handle`.
|
|
56
|
+
*/
|
|
57
|
+
className?: string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Extra inline styles merged onto the root. Only use for one-off
|
|
60
|
+
* z-index / offset overrides; the visual language should stay
|
|
61
|
+
* consistent across surfaces.
|
|
62
|
+
*/
|
|
63
|
+
style?: CSSProperties | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const HANDLE_REST_WIDTH = 44;
|
|
67
|
+
const HANDLE_REST_HEIGHT = 6;
|
|
68
|
+
const HANDLE_HOVER_WIDTH = 68;
|
|
69
|
+
const HANDLE_HOVER_HEIGHT = 24;
|
|
70
|
+
// Overlap the ActionBar's top edge by 4px so the handle looks
|
|
71
|
+
// tethered to the bar rather than floating above it.
|
|
72
|
+
const HANDLE_BOTTOM_DEFAULT = "calc(24px + 48px - 4px + var(--ck-tabbar-height, 0px) + env(safe-area-inset-bottom, 0px))";
|
|
73
|
+
|
|
74
|
+
export const ActionBarModeHandle = forwardRef<HTMLButtonElement, ActionBarModeHandleProps>(
|
|
75
|
+
function ActionBarModeHandle(
|
|
76
|
+
{
|
|
77
|
+
onClick,
|
|
78
|
+
label,
|
|
79
|
+
visible = true,
|
|
80
|
+
bottom,
|
|
81
|
+
className,
|
|
82
|
+
style,
|
|
83
|
+
},
|
|
84
|
+
ref,
|
|
85
|
+
) {
|
|
86
|
+
if (!visible) return null;
|
|
87
|
+
const resolvedBottom =
|
|
88
|
+
typeof bottom === "number"
|
|
89
|
+
? `${bottom}px`
|
|
90
|
+
: typeof bottom === "string"
|
|
91
|
+
? bottom
|
|
92
|
+
: HANDLE_BOTTOM_DEFAULT;
|
|
93
|
+
return (
|
|
94
|
+
<button
|
|
95
|
+
ref={ref}
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onClick}
|
|
98
|
+
aria-label={label}
|
|
99
|
+
title={label}
|
|
100
|
+
className={`ck-actionbar-handle${className ? ` ${className}` : ""}`}
|
|
101
|
+
style={{
|
|
102
|
+
position: "fixed",
|
|
103
|
+
left: "50%",
|
|
104
|
+
bottom: resolvedBottom,
|
|
105
|
+
transform: "translateX(-50%)",
|
|
106
|
+
// Rest state: narrow slim strip at the top edge of the bar.
|
|
107
|
+
width: `var(--ck-actionbar-handle-w, ${HANDLE_REST_WIDTH}px)`,
|
|
108
|
+
height: `var(--ck-actionbar-handle-h, ${HANDLE_REST_HEIGHT}px)`,
|
|
109
|
+
padding: 0,
|
|
110
|
+
margin: 0,
|
|
111
|
+
border: 0,
|
|
112
|
+
borderRadius: 999,
|
|
113
|
+
background: "var(--ck-accent, #4f46e5)",
|
|
114
|
+
color: "var(--ck-accent-fg, #fff)",
|
|
115
|
+
fontSize: 11,
|
|
116
|
+
fontWeight: 500,
|
|
117
|
+
letterSpacing: 0.2,
|
|
118
|
+
lineHeight: 1,
|
|
119
|
+
overflow: "hidden",
|
|
120
|
+
whiteSpace: "nowrap",
|
|
121
|
+
cursor: "pointer",
|
|
122
|
+
// Sit above the ActionBar (z-index 80 in ActionBar.tsx).
|
|
123
|
+
zIndex: 85,
|
|
124
|
+
boxShadow: "0 2px 8px rgba(0,0,0,0.14)",
|
|
125
|
+
transition:
|
|
126
|
+
"width 220ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
|
|
127
|
+
"height 220ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
|
|
128
|
+
"box-shadow 220ms ease-out",
|
|
129
|
+
display: "inline-flex",
|
|
130
|
+
alignItems: "center",
|
|
131
|
+
justifyContent: "center",
|
|
132
|
+
...style,
|
|
133
|
+
}}
|
|
134
|
+
// Inline hover/focus expansion — pure CSS on the same element
|
|
135
|
+
// via CSSProperties gives us the animation without a global
|
|
136
|
+
// stylesheet dependency in the kit.
|
|
137
|
+
onMouseEnter={(e) => {
|
|
138
|
+
const el = e.currentTarget;
|
|
139
|
+
el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_HOVER_WIDTH}px`);
|
|
140
|
+
el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_HOVER_HEIGHT}px`);
|
|
141
|
+
el.style.boxShadow = "0 4px 14px rgba(0,0,0,0.22)";
|
|
142
|
+
}}
|
|
143
|
+
onMouseLeave={(e) => {
|
|
144
|
+
const el = e.currentTarget;
|
|
145
|
+
el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_REST_WIDTH}px`);
|
|
146
|
+
el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_REST_HEIGHT}px`);
|
|
147
|
+
el.style.boxShadow = "0 2px 8px rgba(0,0,0,0.14)";
|
|
148
|
+
}}
|
|
149
|
+
onFocus={(e) => {
|
|
150
|
+
const el = e.currentTarget;
|
|
151
|
+
el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_HOVER_WIDTH}px`);
|
|
152
|
+
el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_HOVER_HEIGHT}px`);
|
|
153
|
+
el.style.boxShadow = "0 4px 14px rgba(0,0,0,0.22)";
|
|
154
|
+
}}
|
|
155
|
+
onBlur={(e) => {
|
|
156
|
+
const el = e.currentTarget;
|
|
157
|
+
el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_REST_WIDTH}px`);
|
|
158
|
+
el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_REST_HEIGHT}px`);
|
|
159
|
+
el.style.boxShadow = "0 2px 8px rgba(0,0,0,0.14)";
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{/* Only visible when expanded — CSS `overflow: hidden` on the
|
|
163
|
+
button clips it at rest. Kept as visible text (not aria-
|
|
164
|
+
hidden) so screen readers can still hear it via title. */}
|
|
165
|
+
<span aria-hidden style={{ opacity: 0.95 }}>
|
|
166
|
+
{label}
|
|
167
|
+
</span>
|
|
168
|
+
</button>
|
|
169
|
+
);
|
|
170
|
+
},
|
|
171
|
+
);
|
package/src/actionbar/index.ts
CHANGED
|
@@ -6,6 +6,14 @@ export { ActionBarButton } from "./ActionBarButton";
|
|
|
6
6
|
export type { ActionBarButtonProps } from "./ActionBarButton";
|
|
7
7
|
export { useActionBarItems, useActionBarItemsContext } from "./useActionBarItems";
|
|
8
8
|
export { useActionBarStatusDot } from "./useActionBarStatusDot";
|
|
9
|
+
export { ActionBarModeHandle } from "./ActionBarModeHandle";
|
|
10
|
+
export type { ActionBarModeHandleProps } from "./ActionBarModeHandle";
|
|
11
|
+
export { useActionBarMode } from "./useActionBarMode";
|
|
12
|
+
export type {
|
|
13
|
+
ActionBarModeConfig,
|
|
14
|
+
UseActionBarModeOptions,
|
|
15
|
+
UseActionBarModeResult,
|
|
16
|
+
} from "./useActionBarMode";
|
|
9
17
|
export type {
|
|
10
18
|
ActionBarItem,
|
|
11
19
|
ActionBarItemVariant,
|
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
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useActionBarItems } from "./useActionBarItems";
|
|
3
|
+
import type { ActionBarItem } from "./types";
|
|
4
|
+
|
|
5
|
+
// useActionBarMode — one hook that manages a small set of named
|
|
6
|
+
// "modes" (each with its own item list) + registers the active mode's
|
|
7
|
+
// items into the ActionBar registry. Consumer combines this with
|
|
8
|
+
// <ActionBarModeHandle> to give privileged users a peek-out affordance
|
|
9
|
+
// that flips between the modes.
|
|
10
|
+
//
|
|
11
|
+
// Design intent (M4.5 block_doc surface):
|
|
12
|
+
// - Default mode: standard viewer actions (comment / download / share)
|
|
13
|
+
// - Manage mode: admin-only shortcuts (preview draft / manage share /
|
|
14
|
+
// view activity), gated behind `capabilities.manage === true`
|
|
15
|
+
// - Draft mode (optional third): appears once the user enters draft
|
|
16
|
+
// preview; distinct action set (publish / discard / raw JSON /
|
|
17
|
+
// return)
|
|
18
|
+
//
|
|
19
|
+
// The mode name is the ActionBar's source key, so switching modes
|
|
20
|
+
// unregisters the previous set and registers the new set atomically
|
|
21
|
+
// (useActionBarItems handles the cleanup).
|
|
22
|
+
//
|
|
23
|
+
// State persistence: pass `storageKey` for localStorage-backed
|
|
24
|
+
// stickiness across reloads. Nil-safe when window is unavailable
|
|
25
|
+
// (SSR).
|
|
26
|
+
|
|
27
|
+
export type ActionBarModeConfig<K extends string> = {
|
|
28
|
+
[key in K]: ActionBarItem[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface UseActionBarModeOptions<K extends string> {
|
|
32
|
+
/**
|
|
33
|
+
* Map of mode keys → items. Each mode key becomes the source key
|
|
34
|
+
* passed to useActionBarItems, so it must be a stable string
|
|
35
|
+
* (e.g. "viewer", "manage") — mode registrations will not
|
|
36
|
+
* "cross-contaminate" the item set of another mode.
|
|
37
|
+
*/
|
|
38
|
+
modes: ActionBarModeConfig<K>;
|
|
39
|
+
/**
|
|
40
|
+
* Which mode to start in. Overridden by localStorage when
|
|
41
|
+
* `storageKey` is set AND localStorage has a valid value.
|
|
42
|
+
*/
|
|
43
|
+
defaultMode: K;
|
|
44
|
+
/**
|
|
45
|
+
* Optional localStorage key. When set, the current mode is
|
|
46
|
+
* persisted + rehydrated across reloads.
|
|
47
|
+
*/
|
|
48
|
+
storageKey?: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface UseActionBarModeResult<K extends string> {
|
|
52
|
+
/** The currently active mode key. */
|
|
53
|
+
mode: K;
|
|
54
|
+
/** Set the mode explicitly (typically wired to the mode Handle). */
|
|
55
|
+
setMode: (next: K) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Toggle between the FIRST two modes in the config's iteration
|
|
58
|
+
* order. Convenience for the common two-mode case; a
|
|
59
|
+
* three-mode consumer uses setMode directly.
|
|
60
|
+
*/
|
|
61
|
+
toggle: () => void;
|
|
62
|
+
/**
|
|
63
|
+
* All configured mode keys in registration order — useful for
|
|
64
|
+
* building a cycle-through-modes button.
|
|
65
|
+
*/
|
|
66
|
+
allModes: readonly K[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadPersistedMode<K extends string>(
|
|
70
|
+
storageKey: string,
|
|
71
|
+
allModes: readonly K[],
|
|
72
|
+
): K | null {
|
|
73
|
+
if (typeof window === "undefined") return null;
|
|
74
|
+
try {
|
|
75
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
76
|
+
if (raw && (allModes as readonly string[]).includes(raw)) {
|
|
77
|
+
return raw as K;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function persistMode(storageKey: string, mode: string) {
|
|
86
|
+
if (typeof window === "undefined") return;
|
|
87
|
+
try {
|
|
88
|
+
window.localStorage.setItem(storageKey, mode);
|
|
89
|
+
} catch {
|
|
90
|
+
/* ignore */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function useActionBarMode<K extends string>(
|
|
95
|
+
opts: UseActionBarModeOptions<K>,
|
|
96
|
+
): UseActionBarModeResult<K> {
|
|
97
|
+
const { modes, defaultMode, storageKey } = opts;
|
|
98
|
+
const allModes = useMemo(() => Object.keys(modes) as K[], [modes]);
|
|
99
|
+
const [mode, setModeState] = useState<K>(() => {
|
|
100
|
+
if (storageKey) {
|
|
101
|
+
const persisted = loadPersistedMode<K>(storageKey, allModes);
|
|
102
|
+
if (persisted) return persisted;
|
|
103
|
+
}
|
|
104
|
+
return defaultMode;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// If the modes config changes (dev / hot reload) so the current
|
|
108
|
+
// mode key is no longer valid, drop back to the default.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!(allModes as readonly string[]).includes(mode)) {
|
|
111
|
+
setModeState(defaultMode);
|
|
112
|
+
}
|
|
113
|
+
}, [mode, allModes, defaultMode]);
|
|
114
|
+
|
|
115
|
+
const setMode = useCallback(
|
|
116
|
+
(next: K) => {
|
|
117
|
+
setModeState(next);
|
|
118
|
+
if (storageKey) persistMode(storageKey, next);
|
|
119
|
+
},
|
|
120
|
+
[storageKey],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const toggle = useCallback(() => {
|
|
124
|
+
if (allModes.length < 2) return;
|
|
125
|
+
const idx = allModes.indexOf(mode);
|
|
126
|
+
// Cycle to the NEXT mode; wrap-around handles both the "toggle
|
|
127
|
+
// between 2" and "cycle through 3+" cases.
|
|
128
|
+
const nextIdx = (idx + 1) % allModes.length;
|
|
129
|
+
const next = allModes[nextIdx];
|
|
130
|
+
if (next !== undefined) {
|
|
131
|
+
setMode(next);
|
|
132
|
+
}
|
|
133
|
+
}, [allModes, mode, setMode]);
|
|
134
|
+
|
|
135
|
+
// Register the active mode's items with the ActionBar. Uses the
|
|
136
|
+
// mode key as the source key, so switching modes atomically
|
|
137
|
+
// unregisters the previous set. Items themselves must be stable
|
|
138
|
+
// (useMemo'd) — the ActionBar deduplicates by reference, so passing
|
|
139
|
+
// `modes[mode]` re-uses the caller's memoized array.
|
|
140
|
+
const activeItems = modes[mode];
|
|
141
|
+
useActionBarItems(mode, activeItems);
|
|
142
|
+
|
|
143
|
+
return { mode, setMode, toggle, allModes };
|
|
144
|
+
}
|