@cosxai/ui 0.2.10 → 0.3.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 +1 -1
- package/src/actionbar/ActionBar.tsx +157 -44
- package/src/actionbar/ActionBarProvider.tsx +5 -2
- package/src/actionbar/actionbar-context.ts +10 -5
- package/src/actionbar/index.ts +3 -0
- package/src/actionbar/types.ts +44 -0
- package/src/actionbar/useActionBarStatusDot.ts +49 -0
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useRef,
|
|
7
7
|
useState,
|
|
8
8
|
type CSSProperties,
|
|
9
|
+
type ReactNode,
|
|
9
10
|
} from "react";
|
|
10
11
|
import { ActionBarContext } from "./actionbar-context";
|
|
11
12
|
import { ActionBarButton } from "./ActionBarButton";
|
|
@@ -145,7 +146,7 @@ export function ActionBar({
|
|
|
145
146
|
if (!ctx) {
|
|
146
147
|
throw new Error("<ActionBar> must be inside <ActionBarProvider>");
|
|
147
148
|
}
|
|
148
|
-
const { items, categories, expandedKey, setExpandedKey } = ctx;
|
|
149
|
+
const { items, categories, expandedKey, setExpandedKey, statusDot } = ctx;
|
|
149
150
|
const vp = useViewport();
|
|
150
151
|
const isPhone = vp.isPhone;
|
|
151
152
|
|
|
@@ -229,8 +230,29 @@ export function ActionBar({
|
|
|
229
230
|
setPos({ type: "default" });
|
|
230
231
|
}, [storageKey]);
|
|
231
232
|
|
|
232
|
-
// ----- Entries (flat vs group) -----
|
|
233
|
-
|
|
233
|
+
// ----- Entries (flat vs group), partitioned by slot -----
|
|
234
|
+
// Items default to the leading slot. Trailing items render after a
|
|
235
|
+
// flex spacer so they pin to the right edge regardless of
|
|
236
|
+
// registration order — system status indicators (sync, identity,
|
|
237
|
+
// connection) belong here, where page items registering later
|
|
238
|
+
// can't shuffle them. buildEntries runs per slot so category
|
|
239
|
+
// grouping stays slot-local (a category split across slots is an
|
|
240
|
+
// edge case we intentionally don't fold across the spacer).
|
|
241
|
+
const { leadingEntries, trailingEntries } = useMemo(() => {
|
|
242
|
+
const leading: ActionBarItem[] = [];
|
|
243
|
+
const trailing: ActionBarItem[] = [];
|
|
244
|
+
for (const it of items) {
|
|
245
|
+
(it.slot === "trailing" ? trailing : leading).push(it);
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
leadingEntries: buildEntries(leading),
|
|
249
|
+
trailingEntries: buildEntries(trailing),
|
|
250
|
+
};
|
|
251
|
+
}, [items]);
|
|
252
|
+
const entries = useMemo(
|
|
253
|
+
() => [...leadingEntries, ...trailingEntries],
|
|
254
|
+
[leadingEntries, trailingEntries],
|
|
255
|
+
);
|
|
234
256
|
|
|
235
257
|
// Cleanup stale expansion if the corresponding group disappears.
|
|
236
258
|
useEffect(() => {
|
|
@@ -377,49 +399,94 @@ export function ActionBar({
|
|
|
377
399
|
</button>
|
|
378
400
|
) : null}
|
|
379
401
|
|
|
380
|
-
{/*
|
|
381
|
-
{
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
402
|
+
{/* Leading entries — flat items + group heads with disclosure regions. */}
|
|
403
|
+
{leadingEntries.map(renderEntry)}
|
|
404
|
+
|
|
405
|
+
{/* Spacer pushes whatever is right-aligned (trailing entries +/or
|
|
406
|
+
the bar-intrinsic status dot) to the right edge. Only rendered
|
|
407
|
+
when there IS something on the right, so the bar still hugs
|
|
408
|
+
its content tightly when neither is in use. */}
|
|
409
|
+
{(trailingEntries.length > 0 || statusDot) && (
|
|
410
|
+
<span
|
|
411
|
+
aria-hidden
|
|
412
|
+
data-ck-actionbar-spacer
|
|
413
|
+
style={{ flex: "1 1 auto", minWidth: 12 }}
|
|
414
|
+
/>
|
|
415
|
+
)}
|
|
416
|
+
|
|
417
|
+
{/* Trailing entries — pin to the right edge regardless of
|
|
418
|
+
registration order. Same rendering rules as leading. */}
|
|
419
|
+
{trailingEntries.map(renderEntry)}
|
|
420
|
+
|
|
421
|
+
{/* Status dot — bar-intrinsic chrome at the right edge,
|
|
422
|
+
mirroring the left-edge drag grip. Consumer-controlled via
|
|
423
|
+
`useActionBarStatusDot()`. Visual: a small coloured dot in
|
|
424
|
+
a 28×BAR_HEIGHT slot. Renders as a button when an onClick
|
|
425
|
+
is provided; otherwise a plain span (no interactive
|
|
426
|
+
affordances). */}
|
|
427
|
+
{statusDot &&
|
|
428
|
+
(statusDot.onClick ? (
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
onClick={statusDot.onClick}
|
|
432
|
+
aria-label={statusDot.title ?? "Status"}
|
|
433
|
+
title={statusDot.title ?? "Status"}
|
|
434
|
+
data-ck-actionbar-status-dot
|
|
435
|
+
style={rightEdgeButtonStyle}
|
|
436
|
+
>
|
|
437
|
+
<StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
|
|
438
|
+
</button>
|
|
439
|
+
) : (
|
|
440
|
+
<span
|
|
441
|
+
aria-label={statusDot.title ?? "Status"}
|
|
442
|
+
title={statusDot.title ?? "Status"}
|
|
443
|
+
data-ck-actionbar-status-dot
|
|
444
|
+
style={rightEdgeButtonStyle}
|
|
445
|
+
>
|
|
446
|
+
<StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
|
|
447
|
+
</span>
|
|
448
|
+
))}
|
|
421
449
|
</div>
|
|
422
450
|
);
|
|
451
|
+
|
|
452
|
+
function renderEntry(entry: BuildEntry): ReactNode {
|
|
453
|
+
if (entry.kind === "flat" && entry.item) {
|
|
454
|
+
return (
|
|
455
|
+
<ActionBarButton
|
|
456
|
+
key={entry.expansionKey}
|
|
457
|
+
icon={entry.item.icon}
|
|
458
|
+
label={entry.item.label}
|
|
459
|
+
title={entry.item.title}
|
|
460
|
+
active={entry.item.active}
|
|
461
|
+
disabled={entry.item.disabled}
|
|
462
|
+
variant={entry.item.variant}
|
|
463
|
+
hint={entry.item.hint}
|
|
464
|
+
onClick={entry.item.onClick}
|
|
465
|
+
/>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
const cat = entry.category!;
|
|
469
|
+
const catDef = categories[cat];
|
|
470
|
+
const hasActiveChild = entry.groupItems!.some((it) => it.active);
|
|
471
|
+
const isOpen = expandedKey === entry.expansionKey;
|
|
472
|
+
return (
|
|
473
|
+
<ActionBarMenuGroup
|
|
474
|
+
key={entry.expansionKey}
|
|
475
|
+
label={catDef?.label ?? cat}
|
|
476
|
+
icon={catDef?.icon}
|
|
477
|
+
hasActiveChild={hasActiveChild}
|
|
478
|
+
isOpen={isOpen}
|
|
479
|
+
onToggle={() => setExpandedKey(isOpen ? null : entry.expansionKey)}
|
|
480
|
+
items={entry.groupItems!}
|
|
481
|
+
onItemClicked={(it) => {
|
|
482
|
+
it.onClick();
|
|
483
|
+
if (!it.keepGroupOpenOnClick) {
|
|
484
|
+
setExpandedKey(null);
|
|
485
|
+
}
|
|
486
|
+
}}
|
|
487
|
+
/>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
423
490
|
}
|
|
424
491
|
|
|
425
492
|
const leftEdgeButtonStyle: CSSProperties = {
|
|
@@ -434,3 +501,49 @@ const leftEdgeButtonStyle: CSSProperties = {
|
|
|
434
501
|
color: "var(--ck-text-tertiary)",
|
|
435
502
|
borderRadius: 0,
|
|
436
503
|
};
|
|
504
|
+
|
|
505
|
+
// Right-edge button — mirrors leftEdgeButtonStyle in dimensions, used
|
|
506
|
+
// for the status dot. Cursor stays default for the non-interactive
|
|
507
|
+
// span variant; the interactive (onClick) path is a real <button>
|
|
508
|
+
// that picks up :hover / :focus styles from base.css.
|
|
509
|
+
const rightEdgeButtonStyle: CSSProperties = {
|
|
510
|
+
...leftEdgeButtonStyle,
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// StatusDotGlyph — the actual coloured circle, sized to read at par
|
|
514
|
+
// with the grip's six-dot icon. Pulse via a shared keyframe injected
|
|
515
|
+
// at module scope (one stylesheet entry; idempotent across mounts).
|
|
516
|
+
interface StatusDotGlyphProps {
|
|
517
|
+
color: string;
|
|
518
|
+
pulse?: boolean | undefined;
|
|
519
|
+
}
|
|
520
|
+
function StatusDotGlyph({ color, pulse }: StatusDotGlyphProps) {
|
|
521
|
+
return (
|
|
522
|
+
<span
|
|
523
|
+
aria-hidden
|
|
524
|
+
style={{
|
|
525
|
+
display: "inline-block",
|
|
526
|
+
width: 8,
|
|
527
|
+
height: 8,
|
|
528
|
+
borderRadius: "50%",
|
|
529
|
+
background: color,
|
|
530
|
+
animation: pulse ? "ck-actionbar-status-pulse 1.4s ease-in-out infinite" : undefined,
|
|
531
|
+
}}
|
|
532
|
+
/>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// One global stylesheet entry for the pulse keyframe. Injected once
|
|
537
|
+
// on first import. Idempotent — re-imports check by id.
|
|
538
|
+
if (typeof document !== "undefined") {
|
|
539
|
+
const STYLE_ID = "ck-actionbar-status-pulse";
|
|
540
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
541
|
+
const el = document.createElement("style");
|
|
542
|
+
el.id = STYLE_ID;
|
|
543
|
+
el.textContent = `@keyframes ck-actionbar-status-pulse {
|
|
544
|
+
0%, 100% { opacity: 1; }
|
|
545
|
+
50% { opacity: 0.45; }
|
|
546
|
+
}`;
|
|
547
|
+
document.head.appendChild(el);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
@@ -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 } from "./types";
|
|
3
|
+
import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot } 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
|
|
@@ -23,6 +23,7 @@ export function ActionBarProvider({
|
|
|
23
23
|
}: ActionBarProviderProps) {
|
|
24
24
|
const [sources, setSources] = useState<Record<string, ActionBarItem[]>>({});
|
|
25
25
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
|
26
|
+
const [statusDot, setStatusDot] = useState<ActionBarStatusDot | null>(null);
|
|
26
27
|
|
|
27
28
|
const register = useCallback((sourceKey: string, items: ActionBarItem[]) => {
|
|
28
29
|
setSources((prev) => {
|
|
@@ -64,8 +65,10 @@ export function ActionBarProvider({
|
|
|
64
65
|
categories,
|
|
65
66
|
expandedKey,
|
|
66
67
|
setExpandedKey,
|
|
68
|
+
statusDot,
|
|
69
|
+
setStatusDot,
|
|
67
70
|
}),
|
|
68
|
-
[register, unregister, items, categories, expandedKey],
|
|
71
|
+
[register, unregister, items, categories, expandedKey, statusDot],
|
|
69
72
|
);
|
|
70
73
|
|
|
71
74
|
return (
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createContext } from "react";
|
|
2
|
-
import type { ActionBarItem, ActionBarCategories } from "./types";
|
|
2
|
+
import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot } from "./types";
|
|
3
3
|
|
|
4
|
-
// Registry + expansion + category catalog. Items are
|
|
5
|
-
// pages through useActionBarItems();
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// Registry + expansion + category catalog + status-dot slot. Items are
|
|
5
|
+
// pushed by pages through useActionBarItems(); the status dot is
|
|
6
|
+
// pushed by a separate hook (useActionBarStatusDot) because it's
|
|
7
|
+
// bar-intrinsic chrome, not page-level content. expandedKey tracks
|
|
8
|
+
// which group is currently open (one at a time).
|
|
8
9
|
|
|
9
10
|
export interface ActionBarContextValue {
|
|
10
11
|
register: (sourceKey: string, items: ActionBarItem[]) => void;
|
|
@@ -18,6 +19,10 @@ export interface ActionBarContextValue {
|
|
|
18
19
|
// Which group head is currently expanded. null = none.
|
|
19
20
|
expandedKey: string | null;
|
|
20
21
|
setExpandedKey: (key: string | null) => void;
|
|
22
|
+
// Bar-intrinsic right-edge status indicator. null when no consumer
|
|
23
|
+
// has registered one. Last call to setStatusDot wins.
|
|
24
|
+
statusDot: ActionBarStatusDot | null;
|
|
25
|
+
setStatusDot: (dot: ActionBarStatusDot | null) => void;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export const ActionBarContext = createContext<ActionBarContextValue | null>(null);
|
package/src/actionbar/index.ts
CHANGED
|
@@ -5,9 +5,12 @@ export type { ActionBarProps } from "./ActionBar";
|
|
|
5
5
|
export { ActionBarButton } from "./ActionBarButton";
|
|
6
6
|
export type { ActionBarButtonProps } from "./ActionBarButton";
|
|
7
7
|
export { useActionBarItems, useActionBarItemsContext } from "./useActionBarItems";
|
|
8
|
+
export { useActionBarStatusDot } from "./useActionBarStatusDot";
|
|
8
9
|
export type {
|
|
9
10
|
ActionBarItem,
|
|
10
11
|
ActionBarItemVariant,
|
|
12
|
+
ActionBarItemSlot,
|
|
13
|
+
ActionBarStatusDot,
|
|
11
14
|
ActionBarCategory,
|
|
12
15
|
ActionBarCategories,
|
|
13
16
|
} from "./types";
|
package/src/actionbar/types.ts
CHANGED
|
@@ -7,6 +7,16 @@ import type { ReactNode } from "react";
|
|
|
7
7
|
|
|
8
8
|
export type ActionBarItemVariant = "ghost" | "primary" | "soft";
|
|
9
9
|
|
|
10
|
+
// Slot anchor — which edge of the bar the item gravitates toward.
|
|
11
|
+
// `leading` (default) keeps existing behavior: items pack from the
|
|
12
|
+
// drag grip outward in registration order. `trailing` pins the item
|
|
13
|
+
// to the right edge with a flex spacer separating it from the
|
|
14
|
+
// leading group, regardless of registration order. Use trailing for
|
|
15
|
+
// system status indicators (sync, connection, identity) that need a
|
|
16
|
+
// stable visual home and shouldn't be shuffled by page items
|
|
17
|
+
// registering after them.
|
|
18
|
+
export type ActionBarItemSlot = "leading" | "trailing";
|
|
19
|
+
|
|
10
20
|
export interface ActionBarItem {
|
|
11
21
|
// React key for the rendered button. Stable across rerenders.
|
|
12
22
|
key: string;
|
|
@@ -33,6 +43,10 @@ export interface ActionBarItem {
|
|
|
33
43
|
// (e.g. for items whose post-click state changes the label
|
|
34
44
|
// and should stay visible).
|
|
35
45
|
keepGroupOpenOnClick?: boolean;
|
|
46
|
+
// Slot anchor. Defaults to `leading`. Trailing items render after
|
|
47
|
+
// a flex spacer so they pin to the right edge regardless of
|
|
48
|
+
// registration order — system status indicators belong here.
|
|
49
|
+
slot?: ActionBarItemSlot;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
// Category definition — declared at the provider level. Drives
|
|
@@ -48,3 +62,33 @@ export interface ActionBarSource {
|
|
|
48
62
|
sourceKey: string;
|
|
49
63
|
items: ActionBarItem[];
|
|
50
64
|
}
|
|
65
|
+
|
|
66
|
+
// Status dot — bar-intrinsic chrome rendered at the right edge,
|
|
67
|
+
// mirroring the left-edge drag grip. Distinct from `ActionBarItem`
|
|
68
|
+
// (which is page-level content registered through the items
|
|
69
|
+
// registry): a status dot is a fixed system affordance —
|
|
70
|
+
// connection / sync / identity health. The grip on the left says
|
|
71
|
+
// "you can move this thing"; the status dot on the right says
|
|
72
|
+
// "here's how the system is doing."
|
|
73
|
+
//
|
|
74
|
+
// Visual: a small coloured dot, optionally pulsing. Hover shows the
|
|
75
|
+
// title as a native tooltip. Click fires `onClick` — the bar owns
|
|
76
|
+
// no popover behaviour; consumers render their own popover anchored
|
|
77
|
+
// to body / wherever fits their UX.
|
|
78
|
+
//
|
|
79
|
+
// Registered via `useActionBarStatusDot()`. One source of truth at
|
|
80
|
+
// any time (last call wins). Pass `null` to clear.
|
|
81
|
+
export interface ActionBarStatusDot {
|
|
82
|
+
// Colour of the dot. Any CSS colour string — typically a token
|
|
83
|
+
// like `var(--ck-status-success)`. The bar applies no defaulting,
|
|
84
|
+
// so omit the entry (pass null to the hook) to render no dot.
|
|
85
|
+
color: string;
|
|
86
|
+
// Hover tooltip + aria-label. Falls back to "Status".
|
|
87
|
+
title?: string;
|
|
88
|
+
// Click handler. When omitted the dot renders as a non-interactive
|
|
89
|
+
// span (no focus ring, no cursor change, no role=button).
|
|
90
|
+
onClick?: () => void;
|
|
91
|
+
// When true, the dot pulses (CSS animation). Use sparingly —
|
|
92
|
+
// pulsing reads as attention-grabbing.
|
|
93
|
+
pulse?: boolean;
|
|
94
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useContext, useEffect } from "react";
|
|
2
|
+
import { ActionBarContext } from "./actionbar-context";
|
|
3
|
+
import type { ActionBarStatusDot } from "./types";
|
|
4
|
+
|
|
5
|
+
// Push a status-dot config into the action bar's right-edge slot.
|
|
6
|
+
// The bar renders the dot as bar-intrinsic chrome (mirroring the
|
|
7
|
+
// left-edge drag grip), separate from the items registry.
|
|
8
|
+
//
|
|
9
|
+
// Pass `null` to clear. Last call wins — there's no source-key
|
|
10
|
+
// fan-out as with items, because the status dot is a single
|
|
11
|
+
// system-level affordance (sync / connection / identity) and
|
|
12
|
+
// stacking multiple makes no UX sense.
|
|
13
|
+
//
|
|
14
|
+
// On unmount, the dot is cleared automatically.
|
|
15
|
+
//
|
|
16
|
+
// **Important**: pass a stable config object (typically via
|
|
17
|
+
// `useMemo`), or omit fields you don't change between renders.
|
|
18
|
+
// The effect re-runs when individual fields change — colour
|
|
19
|
+
// transitions or pulse toggles re-render the dot but don't thrash
|
|
20
|
+
// the bar.
|
|
21
|
+
export function useActionBarStatusDot(config: ActionBarStatusDot | null) {
|
|
22
|
+
const ctx = useContext(ActionBarContext);
|
|
23
|
+
if (!ctx) {
|
|
24
|
+
throw new Error("useActionBarStatusDot must be used within <ActionBarProvider>");
|
|
25
|
+
}
|
|
26
|
+
const { setStatusDot } = ctx;
|
|
27
|
+
// Project config to primitive deps so a fresh-but-equivalent
|
|
28
|
+
// config object doesn't trigger thrash. onClick identity changes
|
|
29
|
+
// are tolerated through a manual ref capture in the bar render.
|
|
30
|
+
const color = config?.color;
|
|
31
|
+
const title = config?.title;
|
|
32
|
+
const onClick = config?.onClick;
|
|
33
|
+
const pulse = config?.pulse;
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (color === undefined) {
|
|
36
|
+
setStatusDot(null);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setStatusDot({
|
|
40
|
+
color,
|
|
41
|
+
...(title !== undefined ? { title } : {}),
|
|
42
|
+
...(onClick !== undefined ? { onClick } : {}),
|
|
43
|
+
...(pulse !== undefined ? { pulse } : {}),
|
|
44
|
+
});
|
|
45
|
+
return () => {
|
|
46
|
+
setStatusDot(null);
|
|
47
|
+
};
|
|
48
|
+
}, [setStatusDot, color, title, onClick, pulse]);
|
|
49
|
+
}
|