@cosxai/ui 0.17.1 → 0.18.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.17.1",
3
+ "version": "0.18.0",
4
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",
@@ -155,7 +155,7 @@ export function ActionBar({
155
155
  if (!ctx) {
156
156
  throw new Error("<ActionBar> must be inside <ActionBarProvider>");
157
157
  }
158
- const { items, categories, expandedKey, setExpandedKey, statusDot, panel, toast } = ctx;
158
+ const { items, categories, expandedKey, setExpandedKey, statusDot, panel, setPanelHost, toastActive, setToastHost } = ctx;
159
159
  const vp = useViewport();
160
160
  const isPhone = vp.isPhone;
161
161
 
@@ -355,9 +355,26 @@ export function ActionBar({
355
355
  };
356
356
  }, [panelOpen, panelClose]);
357
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
+
358
376
  // Horizontal clamp: centred on the bar unless that would push the
359
377
  // panel past a viewport edge — then shift just enough to fit.
360
- const panelRef = useRef<HTMLDivElement | null>(null);
361
378
  useLayoutEffect(() => {
362
379
  if (!panelOpen) return;
363
380
  const el = panelRef.current;
@@ -478,11 +495,13 @@ export function ActionBar({
478
495
  >
479
496
  {/* Panel slot (design#13) — the bar positions + the chrome
480
497
  themes style it (.ck-actionbar-panel); consumers own only
481
- the content. Rendered inside the bar root so it follows
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
482
501
  drags natively. */}
483
502
  {panel?.open && (
484
503
  <div
485
- ref={panelRef}
504
+ ref={panelHostRef}
486
505
  className="ck-actionbar-panel"
487
506
  role="region"
488
507
  aria-label={panel.ariaLabel ?? "Panel"}
@@ -495,12 +514,11 @@ export function ActionBar({
495
514
  maxWidth: "calc(100vw - 16px)",
496
515
  cursor: "default",
497
516
  }}
498
- >
499
- {panel.content}
500
- </div>
517
+ />
501
518
  )}
502
- {toast != null && !panel?.open && (
519
+ {toastActive && !panel?.open && (
503
520
  <div
521
+ ref={toastHostRef}
504
522
  className="ck-actionbar-toast"
505
523
  role="status"
506
524
  style={{
@@ -510,9 +528,7 @@ export function ActionBar({
510
528
  transform: "translateX(-50%)",
511
529
  whiteSpace: "nowrap",
512
530
  }}
513
- >
514
- {toast}
515
- </div>
531
+ />
516
532
  )}
517
533
  {/* Left-edge button. Phone = collapse; desktop = drag grip. */}
518
534
  {isPhone && collapsibleOnPhone ? (
@@ -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, ActionBarPanel } from "./types";
3
+ import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot, ActionBarPanelMeta } 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,8 +24,10 @@ 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
+ const [panel, setPanel] = useState<ActionBarPanelMeta | null>(null);
28
+ const [panelHost, setPanelHost] = useState<HTMLDivElement | null>(null);
29
+ const [toastActive, setToastActive] = useState(false);
30
+ const [toastHost, setToastHost] = useState<HTMLDivElement | null>(null);
29
31
 
30
32
  const register = useCallback((sourceKey: string, items: ActionBarItem[]) => {
31
33
  setSources((prev) => {
@@ -71,10 +73,14 @@ export function ActionBarProvider({
71
73
  setStatusDot,
72
74
  panel,
73
75
  setPanel,
74
- toast,
75
- setToast,
76
+ panelHost,
77
+ setPanelHost,
78
+ toastActive,
79
+ setToastActive,
80
+ toastHost,
81
+ setToastHost,
76
82
  }),
77
- [register, unregister, items, categories, expandedKey, statusDot, panel, toast],
83
+ [register, unregister, items, categories, expandedKey, statusDot, panel, panelHost, toastActive, toastHost],
78
84
  );
79
85
 
80
86
  return (
@@ -1,6 +1,5 @@
1
1
  import { createContext } from "react";
2
- import type { ReactNode } from "react";
3
- import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot, ActionBarPanel } from "./types";
2
+ import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot, ActionBarPanelMeta } from "./types";
4
3
 
5
4
  // Registry + expansion + category catalog + status-dot slot. Items are
6
5
  // pushed by pages through useActionBarItems(); the status dot is
@@ -24,13 +23,23 @@ export interface ActionBarContextValue {
24
23
  // has registered one. Last call to setStatusDot wins.
25
24
  statusDot: ActionBarStatusDot | null;
26
25
  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
+ // Bar-intrinsic popover slot (design#13). useActionBarPanel
27
+ // registers META only (open/onOpenChange/width/ariaLabel); the bar
28
+ // renders an empty themed container and publishes its DOM node via
29
+ // panelHost, into which the hook PORTALS the consumer's content.
30
+ // Content never enters context state a fresh JSX identity per
31
+ // consumer render would otherwise re-render-loop through setPanel.
32
+ panel: ActionBarPanelMeta | null;
33
+ setPanel: (panel: ActionBarPanelMeta | null) => void;
34
+ panelHost: HTMLDivElement | null;
35
+ setPanelHost: (el: HTMLDivElement | null) => void;
36
+ // Transient bubble above the bar (completion toasts etc.) — same
37
+ // portal-host pattern: consumers register only "active", content
38
+ // portals into toastHost.
39
+ toastActive: boolean;
40
+ setToastActive: (active: boolean) => void;
41
+ toastHost: HTMLDivElement | null;
42
+ setToastHost: (el: HTMLDivElement | null) => void;
34
43
  }
35
44
 
36
45
  export const ActionBarContext = createContext<ActionBarContextValue | null>(null);
@@ -22,6 +22,7 @@ export type {
22
22
  ActionBarItemSlot,
23
23
  ActionBarStatusDot,
24
24
  ActionBarPanel,
25
+ ActionBarPanelMeta,
25
26
  ActionBarCategory,
26
27
  ActionBarCategories,
27
28
  } from "./types";
@@ -129,10 +129,19 @@ export interface ActionBarPanel {
129
129
  // own affordance (typically the status dot's onClick).
130
130
  open: boolean;
131
131
  onOpenChange: (open: boolean) => void;
132
- // Content subtree. Rendered inside the themed panel container.
132
+ // Content subtree. Portalled into the themed panel container the
133
+ // bar renders — it stays in the CONSUMER's React tree (fresh JSX
134
+ // identity per render is fine; content never flows through
135
+ // context state, which would re-render-loop).
133
136
  content: ReactNode;
134
137
  // Panel width in px. Default 320; always viewport-clamped.
135
138
  width?: number;
136
139
  // aria-label for the panel region. Default "Panel".
137
140
  ariaLabel?: string;
138
141
  }
142
+
143
+ // What the context actually stores for the panel slot: the config
144
+ // minus `content`. Only stable primitives + the onOpenChange
145
+ // callback go through context state, so the registration effect
146
+ // re-runs on real changes only.
147
+ export type ActionBarPanelMeta = Omit<ActionBarPanel, "content">;
@@ -1,6 +1,8 @@
1
1
  import { useContext, useEffect } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import { ActionBarContext } from "./actionbar-context";
3
4
  import type { ActionBarPanel } from "./types";
5
+ import type { ReactNode } from "react";
4
6
 
5
7
  // Push a panel config into the action bar's popover slot (design#13).
6
8
  //
@@ -12,19 +14,26 @@ import type { ActionBarPanel } from "./types";
12
14
  // own background/border/shadow. The consumer owns only the content
13
15
  // subtree and the `open` state.
14
16
  //
17
+ // RETURNS a ReactNode the consumer MUST render (a portal into the
18
+ // bar's panel container, or null while closed). Content stays in the
19
+ // consumer's own React tree — a fresh JSX identity every render is
20
+ // fine because only the meta (open/onOpenChange/width/ariaLabel)
21
+ // flows through context state; content in state would re-render-loop
22
+ // (setPanel → context change → consumer re-render → new JSX →
23
+ // setPanel → …).
24
+ //
15
25
  // The bar calls `onOpenChange(false)` on outside-click and Escape.
16
26
  // Pass `null` to clear (also cleared automatically on unmount).
17
27
  // Last call wins — one panel at a time, same contract as the status
18
28
  // dot.
19
- export function useActionBarPanel(config: ActionBarPanel | null) {
29
+ export function useActionBarPanel(config: ActionBarPanel | null): ReactNode {
20
30
  const ctx = useContext(ActionBarContext);
21
31
  if (!ctx) {
22
32
  throw new Error("useActionBarPanel must be used within <ActionBarProvider>");
23
33
  }
24
- const { setPanel } = ctx;
34
+ const { setPanel, panelHost } = ctx;
25
35
  const open = config?.open;
26
36
  const onOpenChange = config?.onOpenChange;
27
- const content = config?.content;
28
37
  const width = config?.width;
29
38
  const ariaLabel = config?.ariaLabel;
30
39
  useEffect(() => {
@@ -35,12 +44,13 @@ export function useActionBarPanel(config: ActionBarPanel | null) {
35
44
  setPanel({
36
45
  open,
37
46
  onOpenChange,
38
- content,
39
47
  ...(width !== undefined ? { width } : {}),
40
48
  ...(ariaLabel !== undefined ? { ariaLabel } : {}),
41
49
  });
42
50
  return () => {
43
51
  setPanel(null);
44
52
  };
45
- }, [setPanel, open, onOpenChange, content, width, ariaLabel]);
53
+ }, [setPanel, open, onOpenChange, width, ariaLabel]);
54
+ if (!config?.open || panelHost === null || config.content == null) return null;
55
+ return createPortal(config.content, panelHost);
46
56
  }
@@ -1,23 +1,33 @@
1
1
  import { useContext, useEffect } from "react";
2
- import type { ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
3
  import { ActionBarContext } from "./actionbar-context";
4
+ import type { ReactNode } from "react";
4
5
 
5
6
  // Push a transient bubble into the bar's toast slot — rendered above
6
7
  // 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-
8
+ // `.ck-actionbar-toast`, suppressed while the panel is open. The
9
+ // CONSUMER owns the lifetime: pass the node to show, `null` to hide
10
+ // (typically driven by a setTimeout in the consumer).
11
+ //
12
+ // RETURNS a ReactNode the consumer MUST render (a portal into the
13
+ // bar's toast container, or null while hidden). Same portal-host
14
+ // contract as useActionBarPanel: only the boolean "active" flag goes
15
+ // through context state, so a fresh JSX identity per render can't
16
+ // re-render-loop. Pairs with useActionBarPanel for the notification-
10
17
  // center pattern (design#13).
11
- export function useActionBarToast(content: ReactNode | null) {
18
+ export function useActionBarToast(content: ReactNode | null): ReactNode {
12
19
  const ctx = useContext(ActionBarContext);
13
20
  if (!ctx) {
14
21
  throw new Error("useActionBarToast must be used within <ActionBarProvider>");
15
22
  }
16
- const { setToast } = ctx;
23
+ const { setToastActive, toastHost } = ctx;
24
+ const active = content != null;
17
25
  useEffect(() => {
18
- setToast(content ?? null);
26
+ setToastActive(active);
19
27
  return () => {
20
- setToast(null);
28
+ setToastActive(false);
21
29
  };
22
- }, [setToast, content]);
30
+ }, [setToastActive, active]);
31
+ if (!active || toastHost === null) return null;
32
+ return createPortal(content, toastHost);
23
33
  }