@cosxai/ui 0.17.0 → 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 +64 -46
- 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
|
|
|
@@ -332,6 +332,61 @@ export function ActionBar({
|
|
|
332
332
|
return () => window.removeEventListener("keydown", onKey);
|
|
333
333
|
}, [expandedKey, setExpandedKey]);
|
|
334
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
|
+
// 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
|
+
|
|
376
|
+
// Horizontal clamp: centred on the bar unless that would push the
|
|
377
|
+
// panel past a viewport edge — then shift just enough to fit.
|
|
378
|
+
useLayoutEffect(() => {
|
|
379
|
+
if (!panelOpen) return;
|
|
380
|
+
const el = panelRef.current;
|
|
381
|
+
if (!el) return;
|
|
382
|
+
el.style.marginLeft = "0px";
|
|
383
|
+
const rect = el.getBoundingClientRect();
|
|
384
|
+
let shift = 0;
|
|
385
|
+
if (rect.left < 8) shift = 8 - rect.left;
|
|
386
|
+
else if (rect.right > window.innerWidth - 8) shift = window.innerWidth - 8 - rect.right;
|
|
387
|
+
if (shift !== 0) el.style.marginLeft = `${Math.round(shift)}px`;
|
|
388
|
+
}, [panelOpen, pos]);
|
|
389
|
+
|
|
335
390
|
// ----- Empty state -----
|
|
336
391
|
// Bar renders when EITHER any page item is registered OR the
|
|
337
392
|
// status dot is set. Without the statusDot check, an app whose
|
|
@@ -383,42 +438,6 @@ export function ActionBar({
|
|
|
383
438
|
);
|
|
384
439
|
}
|
|
385
440
|
|
|
386
|
-
// ----- Panel slot (design#13): outside-click + Escape close. -----
|
|
387
|
-
const panelOpen = Boolean(panel?.open);
|
|
388
|
-
const panelClose = panel?.onOpenChange;
|
|
389
|
-
useEffect(() => {
|
|
390
|
-
if (!panelOpen || !panelClose) return;
|
|
391
|
-
const onDown = (e: MouseEvent) => {
|
|
392
|
-
const t = e.target as Node | null;
|
|
393
|
-
if (t && barRef.current?.contains(t)) return;
|
|
394
|
-
panelClose(false);
|
|
395
|
-
};
|
|
396
|
-
const onKey = (e: KeyboardEvent) => {
|
|
397
|
-
if (e.key === "Escape") panelClose(false);
|
|
398
|
-
};
|
|
399
|
-
window.addEventListener("mousedown", onDown);
|
|
400
|
-
window.addEventListener("keydown", onKey);
|
|
401
|
-
return () => {
|
|
402
|
-
window.removeEventListener("mousedown", onDown);
|
|
403
|
-
window.removeEventListener("keydown", onKey);
|
|
404
|
-
};
|
|
405
|
-
}, [panelOpen, panelClose]);
|
|
406
|
-
|
|
407
|
-
// Horizontal clamp: centred on the bar unless that would push the
|
|
408
|
-
// panel past a viewport edge — then shift just enough to fit.
|
|
409
|
-
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
410
|
-
useLayoutEffect(() => {
|
|
411
|
-
if (!panelOpen) return;
|
|
412
|
-
const el = panelRef.current;
|
|
413
|
-
if (!el) return;
|
|
414
|
-
el.style.marginLeft = "0px";
|
|
415
|
-
const rect = el.getBoundingClientRect();
|
|
416
|
-
let shift = 0;
|
|
417
|
-
if (rect.left < 8) shift = 8 - rect.left;
|
|
418
|
-
else if (rect.right > window.innerWidth - 8) shift = window.innerWidth - 8 - rect.right;
|
|
419
|
-
if (shift !== 0) el.style.marginLeft = `${Math.round(shift)}px`;
|
|
420
|
-
}, [panelOpen, pos]);
|
|
421
|
-
|
|
422
441
|
// ----- Full bar -----
|
|
423
442
|
const isDefault = pos.type === "default";
|
|
424
443
|
return (
|
|
@@ -476,11 +495,13 @@ export function ActionBar({
|
|
|
476
495
|
>
|
|
477
496
|
{/* Panel slot (design#13) — the bar positions + the chrome
|
|
478
497
|
themes style it (.ck-actionbar-panel); consumers own only
|
|
479
|
-
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
|
|
480
501
|
drags natively. */}
|
|
481
502
|
{panel?.open && (
|
|
482
503
|
<div
|
|
483
|
-
ref={
|
|
504
|
+
ref={panelHostRef}
|
|
484
505
|
className="ck-actionbar-panel"
|
|
485
506
|
role="region"
|
|
486
507
|
aria-label={panel.ariaLabel ?? "Panel"}
|
|
@@ -493,12 +514,11 @@ export function ActionBar({
|
|
|
493
514
|
maxWidth: "calc(100vw - 16px)",
|
|
494
515
|
cursor: "default",
|
|
495
516
|
}}
|
|
496
|
-
|
|
497
|
-
{panel.content}
|
|
498
|
-
</div>
|
|
517
|
+
/>
|
|
499
518
|
)}
|
|
500
|
-
{
|
|
519
|
+
{toastActive && !panel?.open && (
|
|
501
520
|
<div
|
|
521
|
+
ref={toastHostRef}
|
|
502
522
|
className="ck-actionbar-toast"
|
|
503
523
|
role="status"
|
|
504
524
|
style={{
|
|
@@ -508,9 +528,7 @@ export function ActionBar({
|
|
|
508
528
|
transform: "translateX(-50%)",
|
|
509
529
|
whiteSpace: "nowrap",
|
|
510
530
|
}}
|
|
511
|
-
|
|
512
|
-
{toast}
|
|
513
|
-
</div>
|
|
531
|
+
/>
|
|
514
532
|
)}
|
|
515
533
|
{/* Left-edge button. Phone = collapse; desktop = drag grip. */}
|
|
516
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
|
}
|