@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 +1 -1
- package/src/actionbar/ActionBar.tsx +27 -11
- package/src/actionbar/ActionBarProvider.tsx +12 -6
- package/src/actionbar/actionbar-context.ts +18 -9
- package/src/actionbar/index.ts +1 -0
- package/src/actionbar/types.ts +10 -1
- package/src/actionbar/useActionBarPanel.ts +15 -5
- package/src/actionbar/useActionBarToast.ts +19 -9
package/package.json
CHANGED
|
@@ -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,
|
|
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
|
|
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={
|
|
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
|
-
{
|
|
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,
|
|
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<
|
|
28
|
-
const [
|
|
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
|
-
|
|
75
|
-
|
|
76
|
+
panelHost,
|
|
77
|
+
setPanelHost,
|
|
78
|
+
toastActive,
|
|
79
|
+
setToastActive,
|
|
80
|
+
toastHost,
|
|
81
|
+
setToastHost,
|
|
76
82
|
}),
|
|
77
|
-
[register, unregister, items, categories, expandedKey, statusDot, panel,
|
|
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 {
|
|
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).
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
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);
|
package/src/actionbar/index.ts
CHANGED
package/src/actionbar/types.ts
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
|
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
|
|
8
|
-
// node to show, `null` to hide
|
|
9
|
-
//
|
|
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 {
|
|
23
|
+
const { setToastActive, toastHost } = ctx;
|
|
24
|
+
const active = content != null;
|
|
17
25
|
useEffect(() => {
|
|
18
|
-
|
|
26
|
+
setToastActive(active);
|
|
19
27
|
return () => {
|
|
20
|
-
|
|
28
|
+
setToastActive(false);
|
|
21
29
|
};
|
|
22
|
-
}, [
|
|
30
|
+
}, [setToastActive, active]);
|
|
31
|
+
if (!active || toastHost === null) return null;
|
|
32
|
+
return createPortal(content, toastHost);
|
|
23
33
|
}
|