@cosxai/ui 0.2.9 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
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";
@@ -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,65 @@ 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 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 && (
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)}
421
420
  </div>
422
421
  );
422
+
423
+ function renderEntry(entry: BuildEntry): ReactNode {
424
+ if (entry.kind === "flat" && entry.item) {
425
+ return (
426
+ <ActionBarButton
427
+ key={entry.expansionKey}
428
+ icon={entry.item.icon}
429
+ label={entry.item.label}
430
+ title={entry.item.title}
431
+ active={entry.item.active}
432
+ disabled={entry.item.disabled}
433
+ variant={entry.item.variant}
434
+ hint={entry.item.hint}
435
+ onClick={entry.item.onClick}
436
+ />
437
+ );
438
+ }
439
+ const cat = entry.category!;
440
+ const catDef = categories[cat];
441
+ const hasActiveChild = entry.groupItems!.some((it) => it.active);
442
+ const isOpen = expandedKey === entry.expansionKey;
443
+ return (
444
+ <ActionBarMenuGroup
445
+ key={entry.expansionKey}
446
+ label={catDef?.label ?? cat}
447
+ icon={catDef?.icon}
448
+ hasActiveChild={hasActiveChild}
449
+ isOpen={isOpen}
450
+ onToggle={() => setExpandedKey(isOpen ? null : entry.expansionKey)}
451
+ items={entry.groupItems!}
452
+ onItemClicked={(it) => {
453
+ it.onClick();
454
+ if (!it.keepGroupOpenOnClick) {
455
+ setExpandedKey(null);
456
+ }
457
+ }}
458
+ />
459
+ );
460
+ }
423
461
  }
424
462
 
425
463
  const leftEdgeButtonStyle: CSSProperties = {
@@ -8,6 +8,7 @@ export { useActionBarItems, useActionBarItemsContext } from "./useActionBarItems
8
8
  export type {
9
9
  ActionBarItem,
10
10
  ActionBarItemVariant,
11
+ ActionBarItemSlot,
11
12
  ActionBarCategory,
12
13
  ActionBarCategories,
13
14
  } 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
@@ -33,6 +33,7 @@
33
33
  color: var(--ck-text-primary);
34
34
  cursor: pointer;
35
35
  white-space: nowrap;
36
+ text-decoration: none;
36
37
  transition: background var(--ck-dur-fast) var(--ck-ease), border-color var(--ck-dur-fast) var(--ck-ease);
37
38
  }
38
39