@cosxai/ui 0.3.0 → 0.3.2

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.3.0",
3
+ "version": "0.3.2",
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",
@@ -146,7 +146,7 @@ export function ActionBar({
146
146
  if (!ctx) {
147
147
  throw new Error("<ActionBar> must be inside <ActionBarProvider>");
148
148
  }
149
- const { items, categories, expandedKey, setExpandedKey } = ctx;
149
+ const { items, categories, expandedKey, setExpandedKey, statusDot } = ctx;
150
150
  const vp = useViewport();
151
151
  const isPhone = vp.isPhone;
152
152
 
@@ -276,7 +276,11 @@ export function ActionBar({
276
276
  }, [expandedKey, setExpandedKey]);
277
277
 
278
278
  // ----- Empty state -----
279
- if (items.length === 0) return null;
279
+ // Bar renders when EITHER any page item is registered OR the
280
+ // status dot is set. Without the statusDot check, an app whose
281
+ // only consumer is `useActionBarStatusDot` (e.g. a system-status-
282
+ // only surface) would never see the bar at all.
283
+ if (items.length === 0 && !statusDot) return null;
280
284
 
281
285
  // ----- Collapsed handle (phone only) -----
282
286
  if (isPhone && collapsibleOnPhone && collapsed) {
@@ -402,11 +406,11 @@ export function ActionBar({
402
406
  {/* Leading entries — flat items + group heads with disclosure regions. */}
403
407
  {leadingEntries.map(renderEntry)}
404
408
 
405
- {/* Spacer pushes trailing entries to the right edge of the bar.
406
- Only rendered when there's at least one trailing entry, so the
407
- bar still hugs its content tightly when no trailing slot is in
408
- use. */}
409
- {trailingEntries.length > 0 && (
409
+ {/* Spacer pushes whatever is right-aligned (trailing entries +/or
410
+ the bar-intrinsic status dot) to the right edge. Only rendered
411
+ when there IS something on the right, so the bar still hugs
412
+ its content tightly when neither is in use. */}
413
+ {(trailingEntries.length > 0 || statusDot) && (
410
414
  <span
411
415
  aria-hidden
412
416
  data-ck-actionbar-spacer
@@ -417,6 +421,35 @@ export function ActionBar({
417
421
  {/* Trailing entries — pin to the right edge regardless of
418
422
  registration order. Same rendering rules as leading. */}
419
423
  {trailingEntries.map(renderEntry)}
424
+
425
+ {/* Status dot — bar-intrinsic chrome at the right edge,
426
+ mirroring the left-edge drag grip. Consumer-controlled via
427
+ `useActionBarStatusDot()`. Visual: a small coloured dot in
428
+ a 28×BAR_HEIGHT slot. Renders as a button when an onClick
429
+ is provided; otherwise a plain span (no interactive
430
+ affordances). */}
431
+ {statusDot &&
432
+ (statusDot.onClick ? (
433
+ <button
434
+ type="button"
435
+ onClick={statusDot.onClick}
436
+ aria-label={statusDot.title ?? "Status"}
437
+ title={statusDot.title ?? "Status"}
438
+ data-ck-actionbar-status-dot
439
+ style={rightEdgeButtonStyle}
440
+ >
441
+ <StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
442
+ </button>
443
+ ) : (
444
+ <span
445
+ aria-label={statusDot.title ?? "Status"}
446
+ title={statusDot.title ?? "Status"}
447
+ data-ck-actionbar-status-dot
448
+ style={rightEdgeButtonStyle}
449
+ >
450
+ <StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
451
+ </span>
452
+ ))}
420
453
  </div>
421
454
  );
422
455
 
@@ -472,3 +505,49 @@ const leftEdgeButtonStyle: CSSProperties = {
472
505
  color: "var(--ck-text-tertiary)",
473
506
  borderRadius: 0,
474
507
  };
508
+
509
+ // Right-edge button — mirrors leftEdgeButtonStyle in dimensions, used
510
+ // for the status dot. Cursor stays default for the non-interactive
511
+ // span variant; the interactive (onClick) path is a real <button>
512
+ // that picks up :hover / :focus styles from base.css.
513
+ const rightEdgeButtonStyle: CSSProperties = {
514
+ ...leftEdgeButtonStyle,
515
+ };
516
+
517
+ // StatusDotGlyph — the actual coloured circle, sized to read at par
518
+ // with the grip's six-dot icon. Pulse via a shared keyframe injected
519
+ // at module scope (one stylesheet entry; idempotent across mounts).
520
+ interface StatusDotGlyphProps {
521
+ color: string;
522
+ pulse?: boolean | undefined;
523
+ }
524
+ function StatusDotGlyph({ color, pulse }: StatusDotGlyphProps) {
525
+ return (
526
+ <span
527
+ aria-hidden
528
+ style={{
529
+ display: "inline-block",
530
+ width: 8,
531
+ height: 8,
532
+ borderRadius: "50%",
533
+ background: color,
534
+ animation: pulse ? "ck-actionbar-status-pulse 1.4s ease-in-out infinite" : undefined,
535
+ }}
536
+ />
537
+ );
538
+ }
539
+
540
+ // One global stylesheet entry for the pulse keyframe. Injected once
541
+ // on first import. Idempotent — re-imports check by id.
542
+ if (typeof document !== "undefined") {
543
+ const STYLE_ID = "ck-actionbar-status-pulse";
544
+ if (!document.getElementById(STYLE_ID)) {
545
+ const el = document.createElement("style");
546
+ el.id = STYLE_ID;
547
+ el.textContent = `@keyframes ck-actionbar-status-pulse {
548
+ 0%, 100% { opacity: 1; }
549
+ 50% { opacity: 0.45; }
550
+ }`;
551
+ document.head.appendChild(el);
552
+ }
553
+ }
@@ -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,10 +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,
11
12
  ActionBarItemSlot,
13
+ ActionBarStatusDot,
12
14
  ActionBarCategory,
13
15
  ActionBarCategories,
14
16
  } from "./types";
@@ -62,3 +62,33 @@ export interface ActionBarSource {
62
62
  sourceKey: string;
63
63
  items: ActionBarItem[];
64
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
+ }