@cosxai/ui 0.16.0 → 0.17.1

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.16.0",
4
- "description": "COSX design system React 19 component primitives shared across product-meta and other consumers",
3
+ "version": "0.17.1",
4
+ "description": "COSX design system \u2014 React 19 component primitives shared across product-meta and other consumers",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "main": "./src/index.ts",
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  useCallback,
3
3
  useContext,
4
+ useLayoutEffect,
4
5
  useEffect,
5
6
  useMemo,
6
7
  useRef,
@@ -154,7 +155,7 @@ export function ActionBar({
154
155
  if (!ctx) {
155
156
  throw new Error("<ActionBar> must be inside <ActionBarProvider>");
156
157
  }
157
- const { items, categories, expandedKey, setExpandedKey, statusDot } = ctx;
158
+ const { items, categories, expandedKey, setExpandedKey, statusDot, panel, toast } = ctx;
158
159
  const vp = useViewport();
159
160
  const isPhone = vp.isPhone;
160
161
 
@@ -331,6 +332,44 @@ export function ActionBar({
331
332
  return () => window.removeEventListener("keydown", onKey);
332
333
  }, [expandedKey, setExpandedKey]);
333
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
+ // Horizontal clamp: centred on the bar unless that would push the
359
+ // panel past a viewport edge — then shift just enough to fit.
360
+ const panelRef = useRef<HTMLDivElement | null>(null);
361
+ useLayoutEffect(() => {
362
+ if (!panelOpen) return;
363
+ const el = panelRef.current;
364
+ if (!el) return;
365
+ el.style.marginLeft = "0px";
366
+ const rect = el.getBoundingClientRect();
367
+ let shift = 0;
368
+ if (rect.left < 8) shift = 8 - rect.left;
369
+ else if (rect.right > window.innerWidth - 8) shift = window.innerWidth - 8 - rect.right;
370
+ if (shift !== 0) el.style.marginLeft = `${Math.round(shift)}px`;
371
+ }, [panelOpen, pos]);
372
+
334
373
  // ----- Empty state -----
335
374
  // Bar renders when EITHER any page item is registered OR the
336
375
  // status dot is set. Without the statusDot check, an app whose
@@ -437,6 +476,44 @@ export function ActionBar({
437
476
  ...style,
438
477
  }}
439
478
  >
479
+ {/* Panel slot (design#13) — the bar positions + the chrome
480
+ themes style it (.ck-actionbar-panel); consumers own only
481
+ the content. Rendered inside the bar root so it follows
482
+ drags natively. */}
483
+ {panel?.open && (
484
+ <div
485
+ ref={panelRef}
486
+ className="ck-actionbar-panel"
487
+ role="region"
488
+ aria-label={panel.ariaLabel ?? "Panel"}
489
+ style={{
490
+ position: "absolute",
491
+ bottom: "calc(100% + 8px)",
492
+ left: "50%",
493
+ transform: "translateX(-50%)",
494
+ width: panel.width ?? 320,
495
+ maxWidth: "calc(100vw - 16px)",
496
+ cursor: "default",
497
+ }}
498
+ >
499
+ {panel.content}
500
+ </div>
501
+ )}
502
+ {toast != null && !panel?.open && (
503
+ <div
504
+ className="ck-actionbar-toast"
505
+ role="status"
506
+ style={{
507
+ position: "absolute",
508
+ bottom: "calc(100% + 8px)",
509
+ left: "50%",
510
+ transform: "translateX(-50%)",
511
+ whiteSpace: "nowrap",
512
+ }}
513
+ >
514
+ {toast}
515
+ </div>
516
+ )}
440
517
  {/* Left-edge button. Phone = collapse; desktop = drag grip. */}
441
518
  {isPhone && collapsibleOnPhone ? (
442
519
  <button
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useMemo, useState, type ReactNode } from "react";
2
2
  import { ActionBarContext } from "./actionbar-context";
3
- import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot } from "./types";
3
+ import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot, ActionBarPanel } from "./types";
4
4
 
5
5
  // Provider for the action-bar registry + expansion state +
6
6
  // category catalog. Mount once near the app root, inside the
@@ -24,6 +24,8 @@ export function ActionBarProvider({
24
24
  const [sources, setSources] = useState<Record<string, ActionBarItem[]>>({});
25
25
  const [expandedKey, setExpandedKey] = useState<string | null>(null);
26
26
  const [statusDot, setStatusDot] = useState<ActionBarStatusDot | null>(null);
27
+ const [panel, setPanel] = useState<ActionBarPanel | null>(null);
28
+ const [toast, setToast] = useState<ReactNode | null>(null);
27
29
 
28
30
  const register = useCallback((sourceKey: string, items: ActionBarItem[]) => {
29
31
  setSources((prev) => {
@@ -67,8 +69,12 @@ export function ActionBarProvider({
67
69
  setExpandedKey,
68
70
  statusDot,
69
71
  setStatusDot,
72
+ panel,
73
+ setPanel,
74
+ toast,
75
+ setToast,
70
76
  }),
71
- [register, unregister, items, categories, expandedKey, statusDot],
77
+ [register, unregister, items, categories, expandedKey, statusDot, panel, toast],
72
78
  );
73
79
 
74
80
  return (
@@ -1,5 +1,6 @@
1
1
  import { createContext } from "react";
2
- import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot } from "./types";
2
+ import type { ReactNode } from "react";
3
+ import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot, ActionBarPanel } from "./types";
3
4
 
4
5
  // Registry + expansion + category catalog + status-dot slot. Items are
5
6
  // pushed by pages through useActionBarItems(); the status dot is
@@ -23,6 +24,13 @@ export interface ActionBarContextValue {
23
24
  // has registered one. Last call to setStatusDot wins.
24
25
  statusDot: ActionBarStatusDot | null;
25
26
  setStatusDot: (dot: ActionBarStatusDot | null) => void;
27
+ // Bar-intrinsic popover slot (design#13). Registered by
28
+ // useActionBarPanel; the bar positions + themes it.
29
+ panel: ActionBarPanel | null;
30
+ setPanel: (panel: ActionBarPanel | null) => void;
31
+ // Transient bubble above the bar (completion toasts etc.).
32
+ toast: ReactNode | null;
33
+ setToast: (toast: ReactNode | null) => void;
26
34
  }
27
35
 
28
36
  export const ActionBarContext = createContext<ActionBarContextValue | null>(null);
@@ -6,6 +6,8 @@ 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 { useActionBarPanel } from "./useActionBarPanel";
10
+ export { useActionBarToast } from "./useActionBarToast";
9
11
  export { ActionBarModeHandle } from "./ActionBarModeHandle";
10
12
  export type { ActionBarModeHandleProps } from "./ActionBarModeHandle";
11
13
  export { useActionBarMode } from "./useActionBarMode";
@@ -19,6 +21,7 @@ export type {
19
21
  ActionBarItemVariant,
20
22
  ActionBarItemSlot,
21
23
  ActionBarStatusDot,
24
+ ActionBarPanel,
22
25
  ActionBarCategory,
23
26
  ActionBarCategories,
24
27
  } from "./types";
@@ -117,3 +117,22 @@ export interface ActionBarStatusDot {
117
117
  // pulsing reads as attention-grabbing.
118
118
  pulse?: boolean;
119
119
  }
120
+
121
+ // Registered via `useActionBarPanel()` — the bar-intrinsic popover
122
+ // slot (design#13). The BAR owns positioning (docked above itself,
123
+ // following drag natively) and the chrome themes own the panel's
124
+ // visual identity via `.ck-actionbar-panel`; the consumer owns only
125
+ // the content subtree. One panel at a time (last call wins).
126
+ export interface ActionBarPanel {
127
+ // Controlled visibility. The bar calls onOpenChange(false) on
128
+ // outside-click and Escape; the consumer toggles open from its
129
+ // own affordance (typically the status dot's onClick).
130
+ open: boolean;
131
+ onOpenChange: (open: boolean) => void;
132
+ // Content subtree. Rendered inside the themed panel container.
133
+ content: ReactNode;
134
+ // Panel width in px. Default 320; always viewport-clamped.
135
+ width?: number;
136
+ // aria-label for the panel region. Default "Panel".
137
+ ariaLabel?: string;
138
+ }
@@ -0,0 +1,46 @@
1
+ import { useContext, useEffect } from "react";
2
+ import { ActionBarContext } from "./actionbar-context";
3
+ import type { ActionBarPanel } from "./types";
4
+
5
+ // Push a panel config into the action bar's popover slot (design#13).
6
+ //
7
+ // The BAR owns positioning: docked above itself, centered, following
8
+ // drag natively (it renders the panel inside its own fixed-position
9
+ // root — no polling, no rect maths in the consumer) and viewport-
10
+ // clamped at the edges. The chrome themes own the panel's visual
11
+ // identity via `.ck-actionbar-panel`; consumers must NOT paint their
12
+ // own background/border/shadow. The consumer owns only the content
13
+ // subtree and the `open` state.
14
+ //
15
+ // The bar calls `onOpenChange(false)` on outside-click and Escape.
16
+ // Pass `null` to clear (also cleared automatically on unmount).
17
+ // Last call wins — one panel at a time, same contract as the status
18
+ // dot.
19
+ export function useActionBarPanel(config: ActionBarPanel | null) {
20
+ const ctx = useContext(ActionBarContext);
21
+ if (!ctx) {
22
+ throw new Error("useActionBarPanel must be used within <ActionBarProvider>");
23
+ }
24
+ const { setPanel } = ctx;
25
+ const open = config?.open;
26
+ const onOpenChange = config?.onOpenChange;
27
+ const content = config?.content;
28
+ const width = config?.width;
29
+ const ariaLabel = config?.ariaLabel;
30
+ useEffect(() => {
31
+ if (open === undefined || onOpenChange === undefined) {
32
+ setPanel(null);
33
+ return;
34
+ }
35
+ setPanel({
36
+ open,
37
+ onOpenChange,
38
+ content,
39
+ ...(width !== undefined ? { width } : {}),
40
+ ...(ariaLabel !== undefined ? { ariaLabel } : {}),
41
+ });
42
+ return () => {
43
+ setPanel(null);
44
+ };
45
+ }, [setPanel, open, onOpenChange, content, width, ariaLabel]);
46
+ }
@@ -0,0 +1,23 @@
1
+ import { useContext, useEffect } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { ActionBarContext } from "./actionbar-context";
4
+
5
+ // Push a transient bubble into the bar's toast slot — rendered above
6
+ // the bar (following it when dragged), themed via
7
+ // `.ck-actionbar-toast`. The CONSUMER owns the lifetime: pass the
8
+ // node to show, `null` to hide (typically driven by a setTimeout in
9
+ // the consumer). Pairs with useActionBarPanel for the notification-
10
+ // center pattern (design#13).
11
+ export function useActionBarToast(content: ReactNode | null) {
12
+ const ctx = useContext(ActionBarContext);
13
+ if (!ctx) {
14
+ throw new Error("useActionBarToast must be used within <ActionBarProvider>");
15
+ }
16
+ const { setToast } = ctx;
17
+ useEffect(() => {
18
+ setToast(content ?? null);
19
+ return () => {
20
+ setToast(null);
21
+ };
22
+ }, [setToast, content]);
23
+ }
@@ -122,3 +122,30 @@ a:visited { color: var(--ck-accent); }
122
122
  :root {
123
123
  scrollbar-color: var(--ck-border-strong, rgba(0, 0, 0, 0.25)) transparent;
124
124
  }
125
+
126
+ /* ── ActionBar panel + toast slots (design#13) ─────────────────────
127
+ Base chrome for the bar-intrinsic popover (useActionBarPanel) and
128
+ transient bubble (useActionBarToast). Token-driven so every theme
129
+ reads correctly by default; chrome-*.css sharpens identity themes
130
+ (neobrutalism ink shadows, sketch wobble, terminal flatness). */
131
+ .ck-actionbar-panel {
132
+ background: var(--ck-bg-surface, #fff);
133
+ border: 1px solid var(--ck-border-subtle, #e5e7eb);
134
+ border-radius: 12px;
135
+ box-shadow: var(--ck-shadow-3, 0 16px 48px rgba(0, 0, 0, 0.12));
136
+ padding: 16px;
137
+ font-family: var(--ck-font-sans, system-ui, sans-serif);
138
+ color: var(--ck-text-primary);
139
+ user-select: none;
140
+ }
141
+ .ck-actionbar-toast {
142
+ background: var(--ck-bg-surface, #fff);
143
+ border: 1px solid var(--ck-border-subtle, #e5e7eb);
144
+ border-radius: 10px;
145
+ box-shadow: var(--ck-shadow-3, 0 16px 48px rgba(0, 0, 0, 0.12));
146
+ padding: 10px 14px;
147
+ font-size: 12px;
148
+ font-family: var(--ck-font-sans, system-ui, sans-serif);
149
+ color: var(--ck-text-primary);
150
+ user-select: none;
151
+ }
@@ -223,3 +223,13 @@ html[data-ck-chrome="ambient"] [role="dialog"] > div > div {
223
223
  transition: none !important;
224
224
  }
225
225
  }
226
+
227
+ html[data-ck-chrome="ambient"] .ck-actionbar-panel,
228
+ html[data-ck-chrome="ambient"] .ck-actionbar-toast {
229
+ background: var(--ck-bg-sidebar);
230
+ backdrop-filter: var(--ck-blur-overlay);
231
+ -webkit-backdrop-filter: var(--ck-blur-overlay);
232
+ border: 1px solid var(--ck-border-subtle);
233
+ border-radius: 20px;
234
+ box-shadow: var(--ck-shadow-2);
235
+ }
@@ -182,3 +182,11 @@ html[data-ck-chrome="bento"] [data-ck-bento-cell][data-interactive="true"]:hover
182
182
  transform: translateY(-3px);
183
183
  box-shadow: var(--ck-shadow-3);
184
184
  }
185
+
186
+ html[data-ck-chrome="bento"] .ck-actionbar-panel,
187
+ html[data-ck-chrome="bento"] .ck-actionbar-toast {
188
+ background: var(--ck-bg-surface);
189
+ border: 1px solid var(--ck-border-subtle);
190
+ border-radius: 16px;
191
+ box-shadow: var(--ck-shadow-2);
192
+ }
@@ -158,3 +158,11 @@ html[data-ck-chrome="editorial"] [role="dialog"] > div:first-child {
158
158
  [data-ck-spotlight] .ck-card .ck-tag {
159
159
  color: var(--ck-text-secondary);
160
160
  }
161
+
162
+ /* ActionBar panel/toast — flat paper, hairline, no drop glow. */
163
+ html[data-ck-chrome="editorial"] .ck-actionbar-panel,
164
+ html[data-ck-chrome="editorial"] .ck-actionbar-toast {
165
+ background: var(--ck-bg-surface);
166
+ border-color: var(--ck-border-subtle);
167
+ box-shadow: var(--ck-shadow-1, 0 2px 8px rgba(0, 0, 0, 0.06));
168
+ }
@@ -316,3 +316,11 @@ html[data-ck-chrome="neobrutalism"] [role="switch"] {
316
316
  box-shadow: 4px 4px 0 0 var(--neobrut-ink);
317
317
  }
318
318
  }
319
+
320
+ html[data-ck-chrome="neobrutalism"] .ck-actionbar-panel,
321
+ html[data-ck-chrome="neobrutalism"] .ck-actionbar-toast {
322
+ border: 2px solid var(--neobrut-ink);
323
+ border-radius: 5px;
324
+ box-shadow: 4px 4px 0 0 var(--neobrut-ink);
325
+ background: var(--ck-bg-surface);
326
+ }
@@ -329,3 +329,11 @@ html[data-ck-chrome="riso"] hr {
329
329
  }
330
330
  .ck-riso-marks::before { top: 4px; left: 8px; }
331
331
  .ck-riso-marks::after { bottom: 4px; right: 8px; }
332
+
333
+ html[data-ck-chrome="riso"] .ck-actionbar-panel,
334
+ html[data-ck-chrome="riso"] .ck-actionbar-toast {
335
+ background: var(--ck-bg-surface);
336
+ border: 2px solid var(--ck-text-primary);
337
+ border-radius: 4px;
338
+ box-shadow: 5px 5px 0 var(--ck-riso-blue);
339
+ }
@@ -352,3 +352,11 @@ html[data-ck-chrome="sketch"] hr {
352
352
  stroke-dasharray: none;
353
353
  }
354
354
  }
355
+
356
+ html[data-ck-chrome="sketch"] .ck-actionbar-panel,
357
+ html[data-ck-chrome="sketch"] .ck-actionbar-toast {
358
+ background: var(--ck-bg-surface);
359
+ border: 2px solid var(--ck-text-primary);
360
+ border-radius: 14px 18px 16px 20px / 18px 14px 20px 16px;
361
+ box-shadow: 4px 5px 0 var(--ck-text-primary);
362
+ }
@@ -255,3 +255,11 @@ html[data-ck-chrome="swiss"] a {
255
255
  text-underline-offset: 3px;
256
256
  text-decoration-thickness: 1px;
257
257
  }
258
+
259
+ html[data-ck-chrome="swiss"] .ck-actionbar-panel,
260
+ html[data-ck-chrome="swiss"] .ck-actionbar-toast {
261
+ background: var(--ck-bg-canvas);
262
+ border: 1px solid var(--ck-border-subtle);
263
+ border-radius: 0;
264
+ box-shadow: var(--ck-shadow-1, 0 2px 8px rgba(0, 0, 0, 0.08));
265
+ }
@@ -261,3 +261,12 @@ html[data-ck-chrome="terminal"] [role="dialog"] > div > div {
261
261
  box-shadow: none;
262
262
  font-family: var(--ck-font-mono);
263
263
  }
264
+
265
+ html[data-ck-chrome="terminal"] .ck-actionbar-panel,
266
+ html[data-ck-chrome="terminal"] .ck-actionbar-toast {
267
+ background: var(--ck-bg-surface);
268
+ border: 1px solid var(--ck-border-subtle);
269
+ border-radius: 2px;
270
+ box-shadow: none;
271
+ font-family: var(--ck-font-mono);
272
+ }