@cosxai/ui 0.10.3 → 0.11.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "COSX design system — React 19 component primitives shared across product-meta and other consumers",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -78,24 +78,13 @@ 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
- }
81
+ // Admin mode is session-only — deliberately NOT persisted. Two
82
+ // reasons: (1) cross-page reload persistence would surface an
83
+ // elevated-privileges state on a fresh session without explicit
84
+ // user opt-in every time — a small surprise factor with real
85
+ // consequences on admin surfaces. (2) The reset-on-context-change
86
+ // effect below relies on the state living in React memory alone,
87
+ // so we can watch for admin-item-set changes and clear cleanly.
99
88
 
100
89
  interface BuildEntry {
101
90
  kind: "flat" | "group";
@@ -197,17 +186,34 @@ export function ActionBar({
197
186
  // ----- Admin mode toggle (any registered item with adminOnly=true) -----
198
187
  // Admin items stay hidden by default; a small toggle button (rendered
199
188
  // 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));
189
+ // Session-only: never persisted (see comment on loadAdminMode's
190
+ // removal above). Additionally auto-resets when the set of
191
+ // adminOnly item keys changes so a user who turned admin on for
192
+ // Doc A doesn't accidentally land in admin mode on Doc B.
193
+ const [adminMode, setAdminMode] = useState<boolean>(false);
204
194
  const toggleAdminMode = useCallback(() => {
205
- setAdminMode((prev) => {
206
- const next = !prev;
207
- persistAdminMode(storageKey, next);
208
- return next;
209
- });
210
- }, [storageKey]);
195
+ setAdminMode((prev) => !prev);
196
+ }, []);
197
+ const adminItemKeys = useMemo(
198
+ () =>
199
+ items
200
+ .filter((it) => it.adminOnly === true)
201
+ .map((it) => it.key)
202
+ .sort()
203
+ .join(","),
204
+ [items],
205
+ );
206
+ const prevAdminKeysRef = useRef(adminItemKeys);
207
+ useEffect(() => {
208
+ if (prevAdminKeysRef.current !== adminItemKeys) {
209
+ prevAdminKeysRef.current = adminItemKeys;
210
+ // Context changed (typically: user navigated to a different
211
+ // page whose useActionBarItems registered a different set of
212
+ // admin actions). Reset admin mode so the elevated state is
213
+ // never carried across viewer sessions implicitly.
214
+ setAdminMode(false);
215
+ }
216
+ }, [adminItemKeys]);
211
217
  const hasAdminItems = useMemo(() => items.some((it) => it.adminOnly === true), [items]);
212
218
  // Filter semantics:
213
219
  // admin OFF → hide items marked adminOnly (the toggle would reveal them)
@@ -0,0 +1,316 @@
1
+ import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ // SidePanel — right-docked overlay-less panel for admin surfaces
5
+ // (doc editors, activity feeds, share managers, …). Slides in from
6
+ // the right and pushes the main content left via
7
+ // `--ck-sidepanel-width`, which Shell / topbar / scroll containers
8
+ // can read to inset themselves. No backdrop — the reader keeps
9
+ // interacting with the doc while the panel is open, mirroring
10
+ // Notion / Linear / Slack right-panel UX.
11
+ //
12
+ // Distinct from `<RightSidebarPanel>` (this bucket's other right-
13
+ // side surface):
14
+ // - RightSidebarPanel = scoped floating card (bordered, offset
15
+ // from viewport edges); good for lightweight ephemeral lists.
16
+ // - SidePanel = full-height slide-in that reshapes layout;
17
+ // good for editors + long panels the user will linger in.
18
+ //
19
+ // Companion CSS variable:
20
+ // `--ck-sidepanel-width` — stamped on `:root` while `open` is true.
21
+ // Layout consumers (Shell, main scroll containers, watermarks)
22
+ // should sum this into their right inset:
23
+ // right: calc(var(--ck-rightrail-width, 0px)
24
+ // + var(--ck-sidepanel-width, 0px));
25
+ // The variable is DELIBERATELY separate from `--ck-rightrail-width`
26
+ // so the SidePanel can push content-region chrome without
27
+ // narrowing the topbar (workspace switcher, avatar, notification
28
+ // pills belong to the topbar and shouldn't slide around when a
29
+ // doc-scoped panel opens).
30
+ //
31
+ // Anatomy:
32
+ //
33
+ // ┌─ header (sticky) ─────────────┐
34
+ // │ Title × │
35
+ // ├───────────────────────────────┤
36
+ // │ │
37
+ // │ body (scrollable + flex col) │
38
+ // │ │
39
+ // ├─ footer (sticky, optional) ───┤
40
+ // │ Cancel Save │
41
+ // └───────────────────────────────┘
42
+ //
43
+ // Non-goals:
44
+ // - Multiple concurrent panels. Only one at a time — a second would
45
+ // double the marginRight the shell reads and the second panel would
46
+ // render off-viewport. Callers coordinate open state.
47
+ // - Focus trap. The main content stays interactive; trapping focus
48
+ // here would break the "keep working while the panel is open"
49
+ // promise. ESC still closes.
50
+ // - Resize handle. Fixed width per open; iterate if callers need it.
51
+ //
52
+ // Phone: renders the same panel but at `width: min(100vw, width)`
53
+ // so it simply covers the viewport. The push-left effect degrades
54
+ // to a full-screen overlay — acceptable since these surfaces are
55
+ // desktop-first anyway.
56
+ //
57
+ // Body is a flex column so children can opt into `flex: 1` +
58
+ // `minHeight: 0` and grow to fill the panel (e.g. a textarea that
59
+ // should reach the footer). `overflowY: auto` stays as a safety
60
+ // net for content that genuinely exceeds the panel height.
61
+
62
+ export interface SidePanelProps {
63
+ open: boolean;
64
+ onClose: () => void;
65
+ /** Text shown in the header. Icons / node also accepted. */
66
+ title: ReactNode;
67
+ /**
68
+ * Panel width in CSS px. Default 480 — the sweet spot for an
69
+ * editor with a couple of fields. Callers override for wider
70
+ * content (e.g. Activity timeline).
71
+ */
72
+ width?: number | undefined;
73
+ /**
74
+ * Optional sticky footer — usually action buttons (Cancel / Save).
75
+ * Omit for read-only surfaces (Activity feed, share reader).
76
+ */
77
+ footer?: ReactNode | undefined;
78
+ /**
79
+ * Top inset. Defaults to
80
+ * `calc(var(--ck-topbar-height, 64px) + env(safe-area-inset-top, 0px))`
81
+ * so the panel docks under the app's topbar. Consumers with a
82
+ * different topbar height stamp `--ck-topbar-height` on `:root`
83
+ * OR override this prop directly.
84
+ */
85
+ topOffset?: string | undefined;
86
+ /**
87
+ * Bottom inset. Defaults to `var(--ck-tabbar-height, 0px)` so
88
+ * the panel doesn't cover the mobile tab bar. Override for
89
+ * consumers with different bottom chrome.
90
+ */
91
+ bottomOffset?: string | undefined;
92
+ /**
93
+ * z-index. Default 25 — sits above main content, below the
94
+ * standard Modal (100) and typical fixed topbar (30).
95
+ */
96
+ zIndex?: number | undefined;
97
+ children: ReactNode;
98
+ }
99
+
100
+ const DEFAULT_WIDTH = 480;
101
+ const CSS_VAR_SIDEPANEL_WIDTH = '--ck-sidepanel-width';
102
+ const DEFAULT_TOP_OFFSET =
103
+ 'calc(var(--ck-topbar-height, 64px) + env(safe-area-inset-top, 0px))';
104
+ const DEFAULT_BOTTOM_OFFSET = 'var(--ck-tabbar-height, 0px)';
105
+ const DEFAULT_Z_INDEX = 25;
106
+
107
+ export function SidePanel({
108
+ open,
109
+ onClose,
110
+ title,
111
+ width,
112
+ footer,
113
+ topOffset,
114
+ bottomOffset,
115
+ zIndex,
116
+ children,
117
+ }: SidePanelProps) {
118
+ const panelWidth = width ?? DEFAULT_WIDTH;
119
+ const resolvedTop = topOffset ?? DEFAULT_TOP_OFFSET;
120
+ const resolvedBottom = bottomOffset ?? DEFAULT_BOTTOM_OFFSET;
121
+ const resolvedZ = zIndex ?? DEFAULT_Z_INDEX;
122
+
123
+ // Stamp / clear `--ck-sidepanel-width` on document root while open.
124
+ // Consumers that want their layout to shift left when a panel opens
125
+ // read this var and inset their right edge accordingly. The kit
126
+ // itself doesn't force any layout to read it — it's an opt-in
127
+ // contract between the panel and layout consumers.
128
+ useEffect(() => {
129
+ if (!open) return;
130
+ const root = document.documentElement;
131
+ const prev = root.style.getPropertyValue(CSS_VAR_SIDEPANEL_WIDTH);
132
+ root.style.setProperty(CSS_VAR_SIDEPANEL_WIDTH, `${panelWidth}px`);
133
+ return () => {
134
+ // Restore whatever value was there before us — could be empty
135
+ // (fall through to token default) or a previous panel's width
136
+ // if two panels overlap mid-transition.
137
+ if (prev) {
138
+ root.style.setProperty(CSS_VAR_SIDEPANEL_WIDTH, prev);
139
+ } else {
140
+ root.style.removeProperty(CSS_VAR_SIDEPANEL_WIDTH);
141
+ }
142
+ };
143
+ }, [open, panelWidth]);
144
+
145
+ // ESC to close. Global listener so users don't have to focus into
146
+ // the panel first. Only mounted while open so it doesn't eat ESC
147
+ // on the main surface.
148
+ useEffect(() => {
149
+ if (!open) return;
150
+ const onKey = (e: KeyboardEvent) => {
151
+ if (e.key === 'Escape') {
152
+ e.stopPropagation();
153
+ onClose();
154
+ }
155
+ };
156
+ window.addEventListener('keydown', onKey);
157
+ return () => window.removeEventListener('keydown', onKey);
158
+ }, [open, onClose]);
159
+
160
+ // Focus the close button on open so keyboard users have a
161
+ // predictable landing target + can Tab into the body. Not a focus
162
+ // trap — Tab from the last body input walks back out into the
163
+ // main surface, which is the whole point of the pattern.
164
+ const closeButtonRef = useRef<HTMLButtonElement | null>(null);
165
+ useEffect(() => {
166
+ if (!open) return;
167
+ // Run after transition frame so the button is actually
168
+ // interactive by the time focus lands.
169
+ const raf = requestAnimationFrame(() => {
170
+ closeButtonRef.current?.focus();
171
+ });
172
+ return () => cancelAnimationFrame(raf);
173
+ }, [open]);
174
+
175
+ const containerStyle: CSSProperties = {
176
+ position: 'fixed',
177
+ top: resolvedTop,
178
+ right: 0,
179
+ bottom: resolvedBottom,
180
+ width: `min(100vw, ${panelWidth}px)`,
181
+ background: 'var(--ck-bg-surface, #fff)',
182
+ borderLeft: '1px solid var(--ck-border-subtle, #eee)',
183
+ boxShadow: open ? '-4px 0 20px rgba(0, 0, 0, 0.06)' : 'none',
184
+ transform: open ? 'translateX(0)' : 'translateX(100%)',
185
+ transition:
186
+ 'transform var(--ck-dur-fast, 180ms) var(--ck-ease, cubic-bezier(0.22, 1, 0.36, 1))',
187
+ display: 'flex',
188
+ flexDirection: 'column',
189
+ zIndex: resolvedZ,
190
+ // Prevent the closed panel from stealing pointer events on the
191
+ // main surface (translated off-screen but still in the DOM).
192
+ pointerEvents: open ? 'auto' : 'none',
193
+ };
194
+
195
+ // Portal to <body> so the panel escapes any `transform` /
196
+ // `will-change: transform` / `filter` / `contain: paint` ancestor
197
+ // — those establish a new containing block for `position: fixed`,
198
+ // meaning the panel would anchor to that ancestor instead of the
199
+ // viewport (e.g. inside a ZoomLayer it would land mid-page rather
200
+ // than on the right edge). The body has no such ancestor, so
201
+ // `right: 0` + `top: <offset>` genuinely means "the viewport
202
+ // corner".
203
+ //
204
+ // `inert` (React 19+) instead of `aria-hidden` for the closed
205
+ // state: aria-hidden triggers a runtime warning when a focused
206
+ // descendant exists (the close button focuses on open + keeps
207
+ // focus briefly after close), and `inert` is the modern correct
208
+ // API — removes the subtree from the tab sequence + AT tree +
209
+ // pointer targeting in one attribute.
210
+ return createPortal(
211
+ <aside
212
+ role="complementary"
213
+ inert={!open}
214
+ aria-label={typeof title === 'string' ? title : undefined}
215
+ style={containerStyle}
216
+ >
217
+ <header
218
+ style={{
219
+ display: 'flex',
220
+ alignItems: 'center',
221
+ justifyContent: 'space-between',
222
+ padding: '12px 16px',
223
+ borderBottom: '1px solid var(--ck-border-subtle, #eee)',
224
+ gap: 12,
225
+ flex: '0 0 auto',
226
+ }}
227
+ >
228
+ <div
229
+ style={{
230
+ font: '600 14px/1.4 var(--ck-font-sans)',
231
+ color: 'var(--ck-text-primary, #111)',
232
+ overflow: 'hidden',
233
+ textOverflow: 'ellipsis',
234
+ whiteSpace: 'nowrap',
235
+ }}
236
+ >
237
+ {title}
238
+ </div>
239
+ <button
240
+ ref={closeButtonRef}
241
+ type="button"
242
+ onClick={onClose}
243
+ aria-label="Close panel"
244
+ style={{
245
+ display: 'inline-flex',
246
+ alignItems: 'center',
247
+ justifyContent: 'center',
248
+ width: 28,
249
+ height: 28,
250
+ borderRadius: 6,
251
+ border: 'none',
252
+ background: 'transparent',
253
+ color: 'var(--ck-text-secondary, #666)',
254
+ cursor: 'pointer',
255
+ transition: 'background 120ms ease',
256
+ }}
257
+ onMouseEnter={(e) => {
258
+ e.currentTarget.style.background = 'var(--ck-bg-hover, #f4f4f5)';
259
+ }}
260
+ onMouseLeave={(e) => {
261
+ e.currentTarget.style.background = 'transparent';
262
+ }}
263
+ >
264
+ <CloseIcon />
265
+ </button>
266
+ </header>
267
+ <div
268
+ style={{
269
+ flex: 1,
270
+ minHeight: 0,
271
+ overflowY: 'auto',
272
+ padding: 16,
273
+ display: 'flex',
274
+ flexDirection: 'column',
275
+ }}
276
+ >
277
+ {children}
278
+ </div>
279
+ {footer ? (
280
+ <footer
281
+ style={{
282
+ display: 'flex',
283
+ justifyContent: 'flex-end',
284
+ alignItems: 'center',
285
+ gap: 8,
286
+ padding: '12px 16px',
287
+ borderTop: '1px solid var(--ck-border-subtle, #eee)',
288
+ flex: '0 0 auto',
289
+ }}
290
+ >
291
+ {footer}
292
+ </footer>
293
+ ) : null}
294
+ </aside>,
295
+ document.body,
296
+ );
297
+ }
298
+
299
+ function CloseIcon(): ReactNode {
300
+ return (
301
+ <svg
302
+ width="16"
303
+ height="16"
304
+ viewBox="0 0 24 24"
305
+ fill="none"
306
+ stroke="currentColor"
307
+ strokeWidth="2"
308
+ strokeLinecap="round"
309
+ strokeLinejoin="round"
310
+ aria-hidden="true"
311
+ >
312
+ <line x1="18" y1="6" x2="6" y2="18" />
313
+ <line x1="6" y1="6" x2="18" y2="18" />
314
+ </svg>
315
+ );
316
+ }
@@ -12,6 +12,8 @@ export { MobileTabBar } from "./MobileTabBar";
12
12
  export type { MobileTabBarProps, MobileTab } from "./MobileTabBar";
13
13
  export { RightSidebarPanel } from "./RightSidebarPanel";
14
14
  export type { RightSidebarPanelProps } from "./RightSidebarPanel";
15
+ export { SidePanel } from "./SidePanel";
16
+ export type { SidePanelProps } from "./SidePanel";
15
17
  export { StickyBanner } from "./StickyBanner";
16
18
  export type { StickyBannerProps } from "./StickyBanner";
17
19
  export { NavSection } from "./NavSection";