@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.2.10",
3
+ "version": "0.3.1",
4
4
  "description": "COSX design system — React 19 component primitives shared across product-meta and other consumers",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -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
- const entries = useMemo(() => buildEntries(items), [items]);
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
- {/* Entries: flat items + group heads with disclosure regions. */}
381
- {entries.map((entry) => {
382
- if (entry.kind === "flat" && entry.item) {
383
- return (
384
- <ActionBarButton
385
- key={entry.expansionKey}
386
- icon={entry.item.icon}
387
- label={entry.item.label}
388
- title={entry.item.title}
389
- active={entry.item.active}
390
- disabled={entry.item.disabled}
391
- variant={entry.item.variant}
392
- hint={entry.item.hint}
393
- onClick={entry.item.onClick}
394
- />
395
- );
396
- }
397
- const cat = entry.category!;
398
- const catDef = categories[cat];
399
- const hasActiveChild = entry.groupItems!.some((it) => it.active);
400
- const isOpen = expandedKey === entry.expansionKey;
401
- return (
402
- <ActionBarMenuGroup
403
- key={entry.expansionKey}
404
- label={catDef?.label ?? cat}
405
- icon={catDef?.icon}
406
- hasActiveChild={hasActiveChild}
407
- isOpen={isOpen}
408
- onToggle={() =>
409
- setExpandedKey(isOpen ? null : entry.expansionKey)
410
- }
411
- items={entry.groupItems!}
412
- onItemClicked={(it) => {
413
- it.onClick();
414
- if (!it.keepGroupOpenOnClick) {
415
- setExpandedKey(null);
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 pushed by
5
- // pages through useActionBarItems(); categories are declared at
6
- // the provider level (passed as a prop). expandedKey tracks which
7
- // group is currently open (one at a time).
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);
@@ -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";
@@ -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
+ }