@grafloria/element 0.4.26 → 0.4.27

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": "@grafloria/element",
3
- "version": "0.4.26",
3
+ "version": "0.4.27",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
package/src/index.d.ts CHANGED
@@ -80,7 +80,7 @@ export type { ErColumn, ErEntitySpec, ErRelationshipSpec, ErCardinality, ErSide,
80
80
  export { erTable, umlClass, erTables, umlClasses, CardHandle, ErTable, ErField, ErColumnList, UmlClass, UmlMemberList, } from './lib/diagram-kit/index.js';
81
81
  export type { HandleApi } from './lib/diagram-kit/index.js';
82
82
  export { bindDashboardGrid, normalizeCaption, captionReserve, captionBandHeight, paintCaptionBand, CAPTION_HEIGHT, CAPTION_HEIGHT_SUBTITLE, CAPTION_HEIGHT_TIGHT, CAPTION_PASS_THROUGH, rowHeightFor, boardHeightFor, columnUnitFor, cellToRect, pointToCell, sizeToSpan, gridItemFromCell, cellFromGridItem, buildCommitCommands, ensureDashboardKitStyles, DASHBOARD_KIT_STYLE_ID, dashboard, defaultWidgetRenderer, renderKpiWidget, renderLineWidget, renderBarWidget, renderDonutWidget, renderFunnelWidget, renderTableWidget, BUILT_IN_WIDGET_KINDS, } from './lib/dashboard-kit/index.js';
83
- export type { DashboardGridApi, DashboardGridOptions, DashboardGridHandle, CellRect, WorldRect, DashboardGridGeometry, TileDelta, DashboardOptions, DashboardSpec, DashboardSnapshot, DragHandleOption, DragGripOptions, DashboardHandle, DashboardViewSpec, DashboardWidgetSpec, SectionCaption, SectionCaptionOptions, SectionCaptionAction, SectionCaptionFont, WidgetHandle, WidgetRenderer, KpiWidgetData, LineWidgetData, LineSeries, BarWidgetData, DonutWidgetData, FunnelWidgetData, TableWidgetData, } from './lib/dashboard-kit/index.js';
83
+ export type { DashboardGridApi, DashboardGridOptions, DashboardGridHandle, CellRect, WorldRect, DashboardGridGeometry, TileDelta, DashboardOptions, DashboardSpec, DashboardSnapshot, DragHandleOption, DragGripOptions, DashboardHandle, DashboardViewSpec, DashboardWidgetSpec, SectionCaption, SectionCaptionOptions, TabsOptions, TabPage, SectionCaptionAction, SectionCaptionFont, WidgetHandle, WidgetRenderer, KpiWidgetData, LineWidgetData, LineSeries, BarWidgetData, DonutWidgetData, FunnelWidgetData, TableWidgetData, } from './lib/dashboard-kit/index.js';
84
84
  export { bindStencilPalette, bindShapeDataPanel, ensureStencilKitStyles } from './lib/stencil-kit/index.js';
85
85
  export type { StencilPaletteApi, StencilPaletteOptions, StencilPaletteHandle, ShapeDataPanelApi, ShapeDataPanelOptions, ShapeDataPanelHandle, } from './lib/stencil-kit/index.js';
86
86
  export * from '@grafloria/engine';
@@ -47,6 +47,7 @@ import { type DashboardGridHandle, type DashboardGridOptions, type DashboardResp
47
47
  import type { DragHandleOption } from './grid-binder.js';
48
48
  import type { SplitNode } from './split-layout.js';
49
49
  import type { SectionCaption } from './caption.js';
50
+ import { type TabsOptions } from './tabs.js';
50
51
  /** A widget, declared as data. */
51
52
  export interface DashboardWidgetSpec {
52
53
  id: string;
@@ -113,7 +114,16 @@ export interface DashboardWidgetSpec {
113
114
  * always covers the pane. Switch live with `setLayout(mode, containerId)`;
114
115
  * `toJSON()` writes it per container.
115
116
  */
116
- layout?: 'grid' | 'split';
117
+ layout?: 'grid' | 'split' | 'tabs';
118
+ /**
119
+ * TAB CONTAINER (`layout: 'tabs'`). Every child that carries `widgets` is a
120
+ * PAGE: one is visible at a time, and a strip of tabs across the top
121
+ * switches between them (DevExpress' Tab Container; VS Code's editor area
122
+ * is a split of these). `active` is the page showing, persisted by
123
+ * `toJSON()`; `tabs` styles the strip.
124
+ */
125
+ active?: string;
126
+ tabs?: TabsOptions;
117
127
  /**
118
128
  * Container only: a CAPTION painted by the kit on the section's slab —
119
129
  * `true` for the title, a string, or the full options (subtitle,
@@ -276,6 +286,8 @@ export interface DashboardOptions {
276
286
  renderCaption?: (widget: DashboardWidgetSpec, host: HTMLElement) => void;
277
287
  /** A press on a caption action (`caption.actions`): the section, the action id, the view. */
278
288
  onCaptionAction?: (sectionId: string, actionId: string, viewId: string) => void;
289
+ /** A tab container switched pages: the container, the page now showing, the view. */
290
+ onTabChange?: (containerId: string, pageId: string, viewId: string) => void;
279
291
  /** Fires after any committed gesture, with the view whose layout changed. */
280
292
  onLayoutChange?: (viewId: string, widgets: DashboardWidgetSpec[]) => void;
281
293
  /** Extra binder options, merged last (escape hatch to the layer below). */
@@ -342,7 +354,8 @@ export interface DashboardHandle {
342
354
  * saved document reopens in the layout it was left in.
343
355
  */
344
356
  setLayout(layout: 'grid' | 'split', viewId?: string): void;
345
- getLayout(viewId?: string): 'grid' | 'split';
357
+ /** A container built with `layout: 'tabs'` reports 'tabs'; views never do. */
358
+ getLayout(viewId?: string): 'grid' | 'split' | 'tabs';
346
359
  /**
347
360
  * Set a SECTION's caption live — `false` removes it, `true` is the title, a
348
361
  * string or the options. Repaints the band, gives the reserve back or takes
@@ -352,6 +365,14 @@ export interface DashboardHandle {
352
365
  setCaption(id: string, caption: SectionCaption): boolean;
353
366
  /** The caption as authored or last set; undefined when the section has none. */
354
367
  getCaption(id: string): SectionCaption | undefined;
368
+ /**
369
+ * Show a PAGE of a tab container (`layout: 'tabs'`). False when the
370
+ * container or the page is not one. Persisted, so a saved board reopens on
371
+ * the page it was left on.
372
+ */
373
+ activateTab(containerId: string, pageId: string): boolean;
374
+ /** The page showing in a tab container. */
375
+ getActiveTab(containerId: string): string | undefined;
355
376
  /** Live sizing/float switches — the two prototype toggles. */
356
377
  setSizing(mode: 'fit' | 'grow'): void;
357
378
  getSizing(): 'fit' | 'grow';
@@ -527,6 +548,10 @@ export interface DashboardApiRef {
527
548
  onChange?(listener: (state: unknown) => void): () => void;
528
549
  };
529
550
  }
551
+ export declare function attachTabsRuntime(ctx: DashboardHandleContext, model: {
552
+ getGroup(id: string): GroupModel | undefined;
553
+ runSystemWrite?(fn: () => void): void;
554
+ }, container: HTMLElement | null, handle: DashboardHandle): void;
530
555
  export interface DashboardHandleContext {
531
556
  /** The views — MUTATED in place by addWidget (push) and remove (filter). */
532
557
  views: DashboardViewSpec[];
@@ -555,7 +580,15 @@ export interface DashboardHandleContext {
555
580
  mode: 'fluid' | 'fixed';
556
581
  overflow: 'bounded' | 'scroll';
557
582
  /** See DashboardOptions.layout — per view. */
558
- layoutOf: Map<string, 'grid' | 'split'>;
583
+ layoutOf: Map<string, 'grid' | 'split' | 'tabs'>;
584
+ /** Tab containers: the page showing, the strip options, the strip element. */
585
+ activeTab: Map<string, string>;
586
+ tabsOf: Map<string, TabsOptions>;
587
+ tabStrips: Map<string, HTMLElement>;
588
+ /** Set by finalize: re-lay a tab container's pages and repaint its strip. */
589
+ syncTabs?: (containerId: string) => void;
590
+ /** Set by finalize: the user's `onTabChange`, so the handle can fire it. */
591
+ onTabChange?: (containerId: string, pageId: string, viewId: string) => void;
559
592
  /** Set by finalize: re-bind a VIEW's board under the given layout (setLayout). */
560
593
  rebindView?: (viewId: string, layout: 'grid' | 'split') => void;
561
594
  /**
@@ -48,6 +48,7 @@ import { bindDashboardGrid, } from './grid-binder.js';
48
48
  import { bindDashboardSplit, SPLIT_TREE_KEY } from './split-binder.js';
49
49
  import { gridItemFromCell } from './grid-mapping.js';
50
50
  import { ensureDashboardKitStyles } from './styles.js';
51
+ import { paintTabStrip, tabStripKey, tabStripReserve } from './tabs.js';
51
52
  import { defaultWidgetRenderer } from './widgets.js';
52
53
  /**
53
54
  * Add a widget node AND its board membership as ONE undoable step.
@@ -302,6 +303,117 @@ function assignCells(widgets, columns) {
302
303
  * reads and writes is the whole reason a second handle implementation, which
303
304
  * would silently drift, is not needed.
304
305
  */
306
+ /**
307
+ * TAB CONTAINER RUNTIME, shared by `dashboard()` and `fromDocument()`.
308
+ *
309
+ * A tab container binds no grid of its own: it positions its PAGES itself —
310
+ * one into its frame under the strip, the rest parked off-canvas, exactly as
311
+ * `showView` parks a view one level up — and paints the strip. Each page is an
312
+ * ordinary container with an ordinary binder, so a page can be a grid or a
313
+ * split, and the parent board still sees ONE member group to place and resize.
314
+ *
315
+ * Both entry points call this, so a board reloaded from a document behaves
316
+ * like one built from a literal (the first version lived inside `dashboard()`
317
+ * and `fromDocument` silently produced a tab container that never switched).
318
+ */
319
+ const cssEscape = (v) => typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(v) : v.replace(/"/g, '\\"');
320
+ export function attachTabsRuntime(ctx, model, container, handle) {
321
+ var _a, _b, _c;
322
+ const pagesOf = (id) => {
323
+ var _a;
324
+ const w = ctx.specById.get(id);
325
+ return ((_a = w === null || w === void 0 ? void 0 : w.widgets) !== null && _a !== void 0 ? _a : []).filter((c) => !!c.widgets).map((p) => { var _a; return ({ id: p.id, label: (_a = p.title) !== null && _a !== void 0 ? _a : p.id }); });
326
+ };
327
+ const isTabs = (id) => { var _a, _b; return ((_a = ctx.layoutOf.get(id)) !== null && _a !== void 0 ? _a : (_b = ctx.specById.get(id)) === null || _b === void 0 ? void 0 : _b.layout) === 'tabs' && pagesOf(id).length > 0; };
328
+ const paintStrip = (id, f, h, pages, active) => {
329
+ var _a, _b, _c;
330
+ const layer = container === null || container === void 0 ? void 0 : container.querySelector('.grafloria-html-layer');
331
+ if (!layer)
332
+ return;
333
+ let el = ctx.tabStrips.get(id);
334
+ if (!el || el.parentElement !== layer) {
335
+ el === null || el === void 0 ? void 0 : el.remove();
336
+ el = document.createElement('div');
337
+ el.setAttribute('data-tabs-id', id);
338
+ layer.appendChild(el);
339
+ ctx.tabStrips.set(id, el);
340
+ }
341
+ el.style.position = 'absolute';
342
+ el.style.left = `${f.x}px`;
343
+ el.style.top = `${f.y}px`;
344
+ el.style.width = `${f.width}px`;
345
+ el.style.height = `${h}px`;
346
+ const rtl = (_c = (_b = ctx.binders.get((_a = ctx.viewOfBoard.get(id)) !== null && _a !== void 0 ? _a : ctx.active)) === null || _b === void 0 ? void 0 : _b.getRtl()) !== null && _c !== void 0 ? _c : false;
347
+ const key = tabStripKey(pages, active, ctx.tabsOf.get(id), rtl);
348
+ if (el.getAttribute('data-key') === key)
349
+ return;
350
+ paintTabStrip(el, pages, active, ctx.tabsOf.get(id), rtl, (pid) => handle.activateTab(id, pid), () => handle.selectWidget(id));
351
+ el.setAttribute('data-key', key);
352
+ };
353
+ const sync = (id) => {
354
+ var _a, _b, _c, _d, _e, _f, _g, _h;
355
+ const cg = (_a = ctx.boardGroups.get(id)) !== null && _a !== void 0 ? _a : model.getGroup(id);
356
+ if (!cg || !isTabs(id))
357
+ return;
358
+ const pages = pagesOf(id);
359
+ const active = (_b = ctx.activeTab.get(id)) !== null && _b !== void 0 ? _b : pages[0].id;
360
+ const strip = tabStripReserve(ctx.tabsOf.get(id), pages.length);
361
+ const f = { x: cg.position.x, y: cg.position.y, width: (_d = (_c = cg.size) === null || _c === void 0 ? void 0 : _c.width) !== null && _d !== void 0 ? _d : 0, height: (_f = (_e = cg.size) === null || _e === void 0 ? void 0 : _e.height) !== null && _f !== void 0 ? _f : 0 };
362
+ const inner = { width: f.width, height: Math.max(0, f.height - strip) };
363
+ const write = (fn) => (model.runSystemWrite ? model.runSystemWrite(fn) : fn());
364
+ write(() => {
365
+ for (const p of pages) {
366
+ const pg = model.getGroup(p.id);
367
+ if (!pg)
368
+ continue;
369
+ // A PARKED page keeps its size, so its own board keeps its layout and
370
+ // comes back exactly as it was; only its x leaves the canvas.
371
+ pg.setFrame({ x: p.id === active ? f.x : OFFSCREEN_X, y: f.y + strip, width: inner.width, height: inner.height });
372
+ }
373
+ });
374
+ for (const p of pages)
375
+ (_g = ctx.binders.get(p.id)) === null || _g === void 0 ? void 0 : _g.sync();
376
+ // A PARKED page is off-canvas but still in the DOM: take its widgets out
377
+ // of the tab order and hide them from assistive tech, or a keyboard user
378
+ // tabs through pages nobody can see (the accessibility scenario counted
379
+ // five tab stops on a board with three of them hidden).
380
+ for (const p of pages) {
381
+ const pg = model.getGroup(p.id);
382
+ const parked = p.id !== active;
383
+ for (const m of (_h = pg === null || pg === void 0 ? void 0 : pg.members) !== null && _h !== void 0 ? _h : []) {
384
+ const host = container === null || container === void 0 ? void 0 : container.querySelector(`.grafloria-node-host[data-node-id="${cssEscape(m)}"]`);
385
+ if (!host)
386
+ continue;
387
+ if (parked) {
388
+ host.setAttribute('aria-hidden', 'true');
389
+ host.tabIndex = -1;
390
+ }
391
+ else
392
+ host.removeAttribute('aria-hidden');
393
+ }
394
+ }
395
+ paintStrip(id, f, strip, pages, active);
396
+ };
397
+ ctx.syncTabs = sync;
398
+ ctx.subscriptions = (_a = ctx.subscriptions) !== null && _a !== void 0 ? _a : [];
399
+ for (const [id, cg] of ctx.boardGroups) {
400
+ if (!isTabs(id))
401
+ continue;
402
+ const pages = pagesOf(id);
403
+ if (!ctx.activeTab.has(id)) {
404
+ const meta = cg.getMetadata('containerWidget');
405
+ const want = (_c = (_b = ctx.specById.get(id)) === null || _b === void 0 ? void 0 : _b.active) !== null && _c !== void 0 ? _c : meta === null || meta === void 0 ? void 0 : meta.active;
406
+ ctx.activeTab.set(id, want && pages.some((p) => p.id === want) ? want : pages[0].id);
407
+ if ((meta === null || meta === void 0 ? void 0 : meta.tabs) && !ctx.tabsOf.has(id))
408
+ ctx.tabsOf.set(id, meta.tabs);
409
+ }
410
+ // Re-lay the pages whenever the parent moves or resizes the container.
411
+ const off = cg.on('bounds:changed', (() => sync(id)));
412
+ if (typeof off === 'function')
413
+ ctx.subscriptions.push(off);
414
+ sync(id);
415
+ }
416
+ }
305
417
  /** `setCaption` as one history step: execute re-applies the value, undo the previous one. */
306
418
  class SetCaptionCommand extends Command {
307
419
  constructor(sectionId, before, after, apply) {
@@ -484,16 +596,27 @@ export function createDashboardHandle(ctx) {
484
596
  for (const v of handle.toJSON().views)
485
597
  lastReported.set(v.id, JSON.stringify(v.widgets));
486
598
  const onHistory = () => {
487
- var _a;
599
+ var _a, _b;
488
600
  if (!ctx.apiRef)
489
601
  return;
490
602
  const model = ctx.apiRef.getModel();
491
603
  for (const id of [...ctx.boardGroups.keys()]) {
604
+ // A TAB CONTAINER deliberately has no binder — it places its pages
605
+ // itself. "No binder" must not read as "needs rebinding", or every
606
+ // history event bound a GRID on it and laid the three pages out as
607
+ // cells side by side (measured: 2×2 of 216×200 after one resize).
608
+ if (ctx.layoutOf.get(id) === 'tabs')
609
+ continue;
492
610
  if (!binders.has(id) && model.getGroup(id))
493
611
  (_a = ctx.rebindContainer) === null || _a === void 0 ? void 0 : _a.call(ctx, id);
494
612
  }
495
613
  for (const b of binders.values())
496
614
  b.sync();
615
+ // …and undo/redo of a section resize must re-place the pages under the
616
+ // strip, which only the tabs runtime knows how to do.
617
+ for (const id of ctx.layoutOf.keys())
618
+ if (ctx.layoutOf.get(id) === 'tabs')
619
+ (_b = ctx.syncTabs) === null || _b === void 0 ? void 0 : _b.call(ctx, id);
497
620
  clampCamera();
498
621
  ctx.apiRef.renderNow();
499
622
  reportChanged();
@@ -630,6 +753,27 @@ export function createDashboardHandle(ctx) {
630
753
  return true;
631
754
  },
632
755
  getCaption: (id) => { var _a; return (_a = specById.get(id)) === null || _a === void 0 ? void 0 : _a.caption; },
756
+ activateTab(containerId, pageId) {
757
+ var _a, _b, _c, _d, _e, _f, _g;
758
+ const cg = ctx.boardGroups.get(containerId);
759
+ const w = specById.get(containerId);
760
+ if (!cg || !w || ((_a = ctx.layoutOf.get(containerId)) !== null && _a !== void 0 ? _a : w.layout) !== 'tabs')
761
+ return false;
762
+ if (!((_b = w.widgets) !== null && _b !== void 0 ? _b : []).some((p) => p.id === pageId && p.widgets))
763
+ return false;
764
+ if (ctx.activeTab.get(containerId) === pageId)
765
+ return true;
766
+ ctx.activeTab.set(containerId, pageId);
767
+ w.active = pageId;
768
+ const cw = (_c = cg.getMetadata('containerWidget')) !== null && _c !== void 0 ? _c : {};
769
+ cg.setMetadata('containerWidget', Object.assign(Object.assign({}, cw), { active: pageId }));
770
+ (_d = ctx.syncTabs) === null || _d === void 0 ? void 0 : _d.call(ctx, containerId);
771
+ (_e = ctx.apiRef) === null || _e === void 0 ? void 0 : _e.renderNow();
772
+ (_f = ctx.onTabChange) === null || _f === void 0 ? void 0 : _f.call(ctx, containerId, pageId, (_g = ctx.viewOfBoard.get(containerId)) !== null && _g !== void 0 ? _g : ctx.active);
773
+ reportChanged();
774
+ return true;
775
+ },
776
+ getActiveTab: (containerId) => ctx.activeTab.get(containerId),
633
777
  setSizing(mode) {
634
778
  var _a;
635
779
  for (const b of binders.values())
@@ -1061,6 +1205,9 @@ export function dashboard(options) {
1061
1205
  boardH,
1062
1206
  mode,
1063
1207
  overflow,
1208
+ activeTab: new Map(),
1209
+ tabsOf: new Map(),
1210
+ tabStrips: new Map(),
1064
1211
  layoutOf: new Map(views.map((v) => { var _a; return [v.id, (_a = v.layout) !== null && _a !== void 0 ? _a : layout]; })),
1065
1212
  optionsBase: options,
1066
1213
  active: (_p = (_o = views[0]) === null || _o === void 0 ? void 0 : _o.id) !== null && _p !== void 0 ? _p : 'main',
@@ -1079,7 +1226,7 @@ export function dashboard(options) {
1079
1226
  }, get handle() {
1080
1227
  return handle;
1081
1228
  }, finalize: (api) => {
1082
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
1229
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
1083
1230
  const a = api;
1084
1231
  if (!a)
1085
1232
  return;
@@ -1179,13 +1326,15 @@ export function dashboard(options) {
1179
1326
  };
1180
1327
  handle.showView(ctx.active);
1181
1328
  ctx.rebindContainer = (id) => {
1182
- var _a;
1329
+ var _a, _b;
1183
1330
  const g = model.getGroup(id);
1184
1331
  const w = specById.get(id);
1185
1332
  if (!g || !w || !w.widgets)
1186
1333
  return;
1334
+ if (((_a = ctx.layoutOf.get(id)) !== null && _a !== void 0 ? _a : w.layout) === 'tabs')
1335
+ return; // places its own pages
1187
1336
  ctx.boardGroups.set(id, g);
1188
- bindContainer(g, w, (_a = ctx.viewOfBoard.get(id)) !== null && _a !== void 0 ? _a : ctx.active);
1337
+ bindContainer(g, w, (_b = ctx.viewOfBoard.get(id)) !== null && _b !== void 0 ? _b : ctx.active);
1189
1338
  };
1190
1339
  /**
1191
1340
  * LIVE LAYOUT SWITCH ON A CONTAINER (item 7) — the view's contract one
@@ -1236,7 +1385,13 @@ export function dashboard(options) {
1236
1385
  else if (selected)
1237
1386
  (_f = binders.get(id)) === null || _f === void 0 ? void 0 : _f.selectWidget(selected);
1238
1387
  }
1239
- (_m = ctx.attachHistory) === null || _m === void 0 ? void 0 : _m.call(ctx);
1388
+ // (Statements must live BEFORE this return everything below it is a
1389
+ // hoisted function declaration, so an assignment placed there never
1390
+ // runs. That cost me a debugging round: `ctx.syncTabs` stayed undefined
1391
+ // and every tab switch silently did nothing.)
1392
+ ctx.onTabChange = options.onTabChange;
1393
+ attachTabsRuntime(ctx, model, (_m = a.container) !== null && _m !== void 0 ? _m : null, handle);
1394
+ (_o = ctx.attachHistory) === null || _o === void 0 ? void 0 : _o.call(ctx);
1240
1395
  return;
1241
1396
  /**
1242
1397
  * Mount one board's widgets into its group — and recurse for CONTAINERS.
@@ -1248,7 +1403,7 @@ export function dashboard(options) {
1248
1403
  * hit-testing and the height-escalation ratchet all apply unchanged.
1249
1404
  */
1250
1405
  function mountBoard(boardId, viewId, widgets, boardGroup) {
1251
- var _a, _b, _c, _d, _e, _f, _g;
1406
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1252
1407
  for (const w of widgets) {
1253
1408
  if (w.widgets) {
1254
1409
  const innerColumns = innerColumnsOf(w);
@@ -1285,6 +1440,21 @@ export function dashboard(options) {
1285
1440
  boardGroup.addMember(w.id);
1286
1441
  ctx.boardGroups.set(w.id, cg);
1287
1442
  mountBoard(w.id, viewId, w.widgets, cg);
1443
+ if (w.layout === 'tabs') {
1444
+ // A TAB CONTAINER lays its pages out itself — one visible, the
1445
+ // rest parked — so it binds no grid of its own. Its pages are
1446
+ // ordinary containers with ordinary binders. The runtime that
1447
+ // positions them is attached once, after every board is mounted.
1448
+ const pages = ((_f = w.widgets) !== null && _f !== void 0 ? _f : []).filter((c) => !!c.widgets);
1449
+ const active = w.active && pages.some((p) => p.id === w.active) ? w.active : (_g = pages[0]) === null || _g === void 0 ? void 0 : _g.id;
1450
+ if (active) {
1451
+ ctx.activeTab.set(w.id, active);
1452
+ w.active = active;
1453
+ }
1454
+ cg.setMetadata('containerWidget', Object.assign(Object.assign(Object.assign(Object.assign({}, cg.getMetadata('containerWidget')), { layout: 'tabs' }), (active ? { active } : {})), (w.tabs ? { tabs: w.tabs } : {})));
1455
+ ctx.tabsOf.set(w.id, (_h = w.tabs) !== null && _h !== void 0 ? _h : {});
1456
+ continue;
1457
+ }
1288
1458
  bindContainer(cg, w, viewId);
1289
1459
  continue;
1290
1460
  }
@@ -1299,7 +1469,7 @@ export function dashboard(options) {
1299
1469
  // and made toJSON() → dashboard() NOT round-trip (a saved layout
1300
1470
  // rebuilt back into its declaration order rather than its cells).
1301
1471
  if (w.x !== undefined && w.y !== undefined) {
1302
- n.setGridItem(gridItemFromCell({ x: w.x, y: w.y, w: (_f = w.span) !== null && _f !== void 0 ? _f : 3, h: (_g = w.rows) !== null && _g !== void 0 ? _g : 1 }));
1472
+ n.setGridItem(gridItemFromCell({ x: w.x, y: w.y, w: (_j = w.span) !== null && _j !== void 0 ? _j : 3, h: (_k = w.rows) !== null && _k !== void 0 ? _k : 1 }));
1303
1473
  }
1304
1474
  if (w.pinned)
1305
1475
  n.setState({ locked: true });
@@ -114,6 +114,9 @@ export function ownsPress(container, diagram, ev, hit) {
114
114
  // anything the caption's `passThrough` names) is the content's, not any
115
115
  // tool's: no selection, no drag, no resize — the DOM handles it.
116
116
  if (typeof Element !== 'undefined' && t instanceof Element) {
117
+ // A TAB STRIP is content, never a board press: its tabs are real buttons.
118
+ if (t.closest('.axdb-tabs'))
119
+ return false;
117
120
  const band = t.closest('.axdb-slab > .axdb-slab-h');
118
121
  const sid = (_b = band === null || band === void 0 ? void 0 : band.parentElement) === null || _b === void 0 ? void 0 : _b.getAttribute('data-slab-id');
119
122
  if (band && sid) {
@@ -2274,10 +2277,20 @@ export function bindDashboardGrid(api, group, options = {}) {
2274
2277
  // A press on one of OUR sections' caption bands is ours by the DOM: a
2275
2278
  // 'tab' band sits above the frame, over the gap or the tile above,
2276
2279
  // where the geometry says otherwise.
2277
- const bandTarget = (_c = (_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) === null || _b === void 0 ? void 0 : _b.closest) === null || _c === void 0 ? void 0 : _c.call(_b, '.axdb-slab > .axdb-slab-h');
2278
- const bandId = (_d = bandTarget === null || bandTarget === void 0 ? void 0 : bandTarget.parentElement) === null || _d === void 0 ? void 0 : _d.getAttribute('data-slab-id');
2279
- if (bandId && ((_e = group.members) !== null && _e !== void 0 ? _e : new Set()).has(bandId))
2280
+ const chrome = (_c = (_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) === null || _b === void 0 ? void 0 : _b.closest) === null || _c === void 0 ? void 0 : _c.call(_b, '.axdb-slab > .axdb-slab-h, .axdb-slab > .axdb-rs');
2281
+ const chromeId = (_d = chrome === null || chrome === void 0 ? void 0 : chrome.parentElement) === null || _d === void 0 ? void 0 : _d.getAttribute('data-slab-id');
2282
+ const mine = !!chromeId && ((_e = group.members) !== null && _e !== void 0 ? _e : new Set()).has(chromeId);
2283
+ if (mine)
2280
2284
  return true;
2285
+ // Someone else's SECTION HANDLE is never ours to claim. A tab
2286
+ // container's pages register before the parent board, so a page's tool
2287
+ // won the tie for the CONTAINER's corner handle and, finding no peer to
2288
+ // hand it to, just cleared the selection — the container could not be
2289
+ // resized by hand at all while the API resized it fine. (Only the
2290
+ // handle: declining every foreign band broke the press that reaches the
2291
+ // content under a hidden `show: 'hover'` caption.)
2292
+ if (chromeId && (chrome === null || chrome === void 0 ? void 0 : chrome.classList.contains('axdb-rs')))
2293
+ return false;
2281
2294
  if (hit.node) {
2282
2295
  if (((_f = group.members) !== null && _f !== void 0 ? _f : new Set()).has(hit.node.id))
2283
2296
  return true;
@@ -3,6 +3,7 @@ export { bindDashboardSplit, SetSplitTreeCommand, SPLIT_TREE_KEY, type Dashboard
3
3
  export { projectSplit, dividersOf, addSplitLeaf, removeSplitLeaf, insertSplitLeaf, moveSplitDivider, splitFromCells, cellsFromSplit, splitLeaves, type SplitNode, type SplitGroup, type SplitLeaf, type SplitDir, type SplitSide, type SplitDivider, } from './split-layout.js';
4
4
  export { rowHeightFor, boardHeightFor, columnUnitFor, cellToRect, pointToCell, sizeToSpan, spanWidthPx, gridItemFromCell, cellFromGridItem, buildCommitCommands, type CellRect, type WorldRect, type DashboardGridGeometry, type TileDelta, } from './grid-mapping.js';
5
5
  export { ensureDashboardKitStyles, DASHBOARD_KIT_STYLE_ID } from './styles.js';
6
+ export { paintTabStrip, tabStripReserve, tabStripKey, TAB_STRIP_HEIGHT, type TabsOptions, type TabPage } from './tabs.js';
6
7
  export { normalizeCaption, captionReserve, captionBandHeight, paintCaptionBand, CAPTION_HEIGHT, CAPTION_HEIGHT_SUBTITLE, CAPTION_HEIGHT_TIGHT, CAPTION_PASS_THROUGH, type SectionCaption, type SectionCaptionOptions, type SectionCaptionAction, type SectionCaptionFont, } from './caption.js';
7
8
  export { dashboard, type DashboardOptions, type DashboardSpec, type DashboardSnapshot, type DashboardHandle, type DashboardViewSpec, type DashboardWidgetSpec, type WidgetHandle, } from './dashboard.js';
8
9
  export { defaultWidgetRenderer, renderKpiWidget, renderLineWidget, renderBarWidget, renderDonutWidget, renderFunnelWidget, renderTableWidget, BUILT_IN_WIDGET_KINDS, type WidgetRenderer, type KpiWidgetData, type LineWidgetData, type LineSeries, type BarWidgetData, type DonutWidgetData, type FunnelWidgetData, type TableWidgetData, } from './widgets.js';
@@ -3,6 +3,7 @@ export { bindDashboardSplit, SetSplitTreeCommand, SPLIT_TREE_KEY, } from './spli
3
3
  export { projectSplit, dividersOf, addSplitLeaf, removeSplitLeaf, insertSplitLeaf, moveSplitDivider, splitFromCells, cellsFromSplit, splitLeaves, } from './split-layout.js';
4
4
  export { rowHeightFor, boardHeightFor, columnUnitFor, cellToRect, pointToCell, sizeToSpan, spanWidthPx, gridItemFromCell, cellFromGridItem, buildCommitCommands, } from './grid-mapping.js';
5
5
  export { ensureDashboardKitStyles, DASHBOARD_KIT_STYLE_ID } from './styles.js';
6
+ export { paintTabStrip, tabStripReserve, tabStripKey, TAB_STRIP_HEIGHT } from './tabs.js';
6
7
  export { normalizeCaption, captionReserve, captionBandHeight, paintCaptionBand, CAPTION_HEIGHT, CAPTION_HEIGHT_SUBTITLE, CAPTION_HEIGHT_TIGHT, CAPTION_PASS_THROUGH, } from './caption.js';
7
8
  // The DATA-FIRST authoring API (the erDiagram/umlDiagram equivalent).
8
9
  export { dashboard, } from './dashboard.js';
@@ -442,6 +442,43 @@ const CSS = `
442
442
  .axdb-slab-h-action:hover { background: rgba(236, 238, 244, .1); }
443
443
  }
444
444
 
445
+ /* TAB CONTAINER (0.4.27): a strip of pages across the container's top. The
446
+ strip takes the pointer (the tabs are real buttons); the pages below it are
447
+ ordinary boards. */
448
+ .grafloria-html-layer > .axdb-tabs {
449
+ position: absolute; box-sizing: border-box; pointer-events: auto; z-index: 5;
450
+ display: flex; align-items: flex-end; gap: 2px; padding: 0 6px; overflow-x: auto; overflow-y: hidden;
451
+ background: var(--axdb-tabs-bg, rgba(31, 36, 48, .05));
452
+ border-bottom: 1px solid var(--axdb-tabs-line, rgba(31, 36, 48, .12));
453
+ border-radius: var(--axdb-rs-radius, 3px) var(--axdb-rs-radius, 3px) 0 0;
454
+ scrollbar-width: thin;
455
+ }
456
+ .axdb-tabs--center { justify-content: center; }
457
+ .axdb-tabs--end { justify-content: flex-end; }
458
+ .axdb-tabs--stretch > .axdb-tab { flex: 1 1 0; }
459
+ .axdb-tab {
460
+ all: unset; box-sizing: border-box; flex: 0 0 auto; max-width: 200px;
461
+ padding: 0 12px; height: calc(100% - 4px); display: inline-flex; align-items: center;
462
+ font: 600 12px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
463
+ color: var(--axdb-tabs-fg, #5a6478); cursor: pointer;
464
+ border-radius: var(--axdb-rs-radius, 3px) var(--axdb-rs-radius, 3px) 0 0;
465
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
466
+ }
467
+ .axdb-tab:hover { background: rgba(31, 36, 48, .06); }
468
+ .axdb-tab:focus-visible { outline: 2px solid var(--axdb-accent-ring, rgba(59, 82, 217, .55)); outline-offset: -2px; }
469
+ /* The active tab reads as the front page: the card's own ground, lifted. */
470
+ .axdb-tab.axdb-tab--on {
471
+ background: var(--axdb-tabs-on-bg, #fff);
472
+ color: var(--axdb-tabs-on-fg, #1f2430);
473
+ box-shadow: 0 -1px 0 var(--axdb-accent, #3b52d9) inset, 0 0 0 1px rgba(31, 36, 48, .1);
474
+ }
475
+ @media (prefers-color-scheme: dark) {
476
+ .grafloria-html-layer > .axdb-tabs { background: var(--axdb-tabs-bg, rgba(236, 238, 244, .06)); border-bottom-color: var(--axdb-tabs-line, rgba(236, 238, 244, .14)); }
477
+ .axdb-tab { color: var(--axdb-tabs-fg, #98a1b4); }
478
+ .axdb-tab:hover { background: rgba(236, 238, 244, .08); }
479
+ .axdb-tab.axdb-tab--on { background: var(--axdb-tabs-on-bg, #1a1d25); color: var(--axdb-tabs-on-fg, #eceef4); box-shadow: 0 -1px 0 var(--axdb-accent, #7d8ff0) inset, 0 0 0 1px rgba(236, 238, 244, .14); }
480
+ }
481
+
445
482
  /* legend chips, shared by line and donut */
446
483
  .axdb-lg { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 9px; }
447
484
  .axdb-lg--col { flex-direction: column; flex-wrap: nowrap; gap: 6px; margin-top: 0; }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * TAB CONTAINERS — a container whose children are PAGES, one visible at a
3
+ * time, with a strip of tabs across its top. DevExpress ships this as its Tab
4
+ * Container; VS Code's editor area is a split of them.
5
+ *
6
+ * This module owns the strip: its height, its geometry and its DOM. Who is
7
+ * active, where the pages sit and what a press means are the container's
8
+ * business (dashboard.ts), exactly as the caption band's press routing is the
9
+ * binder's.
10
+ */
11
+ export interface TabsOptions {
12
+ /** Where the strip sits. Only 'top' today; 'bottom' | 'start' | 'end' follow. */
13
+ position?: 'top';
14
+ /** Along the strip (default 'start', mirrored on RTL). */
15
+ align?: 'start' | 'center' | 'end';
16
+ /** Tabs share the strip's width equally. */
17
+ stretch?: boolean;
18
+ /** Strip height, px (default 30). */
19
+ height?: number;
20
+ /** Your class on the strip, for per-container theming. */
21
+ className?: string;
22
+ }
23
+ export interface TabPage {
24
+ id: string;
25
+ label: string;
26
+ }
27
+ export declare const TAB_STRIP_HEIGHT = 30;
28
+ /** Pixels the pages give up at the top of the container. */
29
+ export declare function tabStripReserve(o: TabsOptions | undefined, pageCount: number): number;
30
+ /**
31
+ * Paint the strip. Repainted only when its identity changes (the pages, the
32
+ * active one, the options, RTL) so a page switch is a class toggle, not a
33
+ * rebuild — and so a `click` listener survives between switches.
34
+ */
35
+ export declare function tabStripKey(pages: TabPage[], activeId: string, o: TabsOptions | undefined, rtl: boolean): string;
36
+ export declare function paintTabStrip(strip: HTMLElement, pages: TabPage[], activeId: string, o: TabsOptions | undefined, rtl: boolean, onPick: (id: string) => void, onSelectContainer?: () => void): void;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * TAB CONTAINERS — a container whose children are PAGES, one visible at a
3
+ * time, with a strip of tabs across its top. DevExpress ships this as its Tab
4
+ * Container; VS Code's editor area is a split of them.
5
+ *
6
+ * This module owns the strip: its height, its geometry and its DOM. Who is
7
+ * active, where the pages sit and what a press means are the container's
8
+ * business (dashboard.ts), exactly as the caption band's press routing is the
9
+ * binder's.
10
+ */
11
+ export const TAB_STRIP_HEIGHT = 30;
12
+ /** Pixels the pages give up at the top of the container. */
13
+ export function tabStripReserve(o, pageCount) {
14
+ var _a;
15
+ if (pageCount <= 0)
16
+ return 0;
17
+ return Math.max(18, (_a = o === null || o === void 0 ? void 0 : o.height) !== null && _a !== void 0 ? _a : TAB_STRIP_HEIGHT);
18
+ }
19
+ /**
20
+ * Paint the strip. Repainted only when its identity changes (the pages, the
21
+ * active one, the options, RTL) so a page switch is a class toggle, not a
22
+ * rebuild — and so a `click` listener survives between switches.
23
+ */
24
+ export function tabStripKey(pages, activeId, o, rtl) {
25
+ return JSON.stringify([pages, activeId, o !== null && o !== void 0 ? o : null, rtl]);
26
+ }
27
+ export function paintTabStrip(strip, pages, activeId, o, rtl, onPick, onSelectContainer) {
28
+ const doc = strip.ownerDocument;
29
+ strip.className = 'axdb-tabs';
30
+ if (o === null || o === void 0 ? void 0 : o.className)
31
+ for (const c of o.className.split(/\s+/).filter(Boolean))
32
+ strip.classList.add(c);
33
+ strip.classList.toggle('axdb-tabs--center', (o === null || o === void 0 ? void 0 : o.align) === 'center');
34
+ strip.classList.toggle('axdb-tabs--end', (o === null || o === void 0 ? void 0 : o.align) === 'end');
35
+ strip.classList.toggle('axdb-tabs--stretch', (o === null || o === void 0 ? void 0 : o.stretch) === true);
36
+ strip.setAttribute('dir', rtl ? 'rtl' : 'ltr');
37
+ strip.setAttribute('role', 'tablist');
38
+ strip.textContent = '';
39
+ // The strip's EMPTY space selects the container. A tab container's pages
40
+ // cover it completely, so without this there is nowhere to press to select
41
+ // it — and an unselected section shows no corner handle, which made the
42
+ // container impossible to resize by hand.
43
+ strip.onpointerdown = (e) => {
44
+ var _a;
45
+ if ((_a = e.target) === null || _a === void 0 ? void 0 : _a.closest('.axdb-tab'))
46
+ return;
47
+ onSelectContainer === null || onSelectContainer === void 0 ? void 0 : onSelectContainer();
48
+ };
49
+ for (const p of pages) {
50
+ const b = doc.createElement('button');
51
+ b.type = 'button';
52
+ b.className = 'axdb-tab';
53
+ b.setAttribute('role', 'tab');
54
+ b.setAttribute('data-tab-id', p.id);
55
+ b.setAttribute('aria-selected', String(p.id === activeId));
56
+ b.classList.toggle('axdb-tab--on', p.id === activeId);
57
+ // ONE tab stop for the whole strip, arrows move between tabs — the
58
+ // pattern every tablist uses, and it keeps the board's single tab stop
59
+ // rule intact.
60
+ b.tabIndex = p.id === activeId ? 0 : -1;
61
+ b.textContent = p.label;
62
+ b.title = p.label;
63
+ b.addEventListener('click', (e) => {
64
+ e.stopPropagation();
65
+ onPick(p.id);
66
+ });
67
+ b.addEventListener('keydown', (e) => {
68
+ var _a;
69
+ const k = e.key;
70
+ const step = k === 'ArrowRight' ? 1 : k === 'ArrowLeft' ? -1 : k === 'Home' ? -pages.length : k === 'End' ? pages.length : 0;
71
+ if (!step)
72
+ return;
73
+ e.preventDefault();
74
+ e.stopPropagation();
75
+ const i = pages.findIndex((x) => x.id === p.id);
76
+ const dir = rtl && (k === 'ArrowRight' || k === 'ArrowLeft') ? -step : step;
77
+ const next = Math.max(0, Math.min(pages.length - 1, i + dir));
78
+ onPick(pages[next].id);
79
+ (_a = strip.querySelector(`[data-tab-id="${pages[next].id}"]`)) === null || _a === void 0 ? void 0 : _a.focus();
80
+ });
81
+ strip.appendChild(b);
82
+ }
83
+ }
84
+ //# sourceMappingURL=tabs.js.map
package/src/lib/load.js CHANGED
@@ -72,7 +72,7 @@ import { gridItemFromCell } from './dashboard-kit/grid-mapping.js';
72
72
  import { cellFromGridItem } from './dashboard-kit/grid-mapping.js';
73
73
  import { ensureDashboardKitStyles } from './dashboard-kit/styles.js';
74
74
  import { defaultWidgetRenderer } from './dashboard-kit/widgets.js';
75
- import { createDashboardHandle, } from './dashboard-kit/dashboard.js';
75
+ import { createDashboardHandle, attachTabsRuntime } from './dashboard-kit/dashboard.js';
76
76
  /** True when the node is an ER entity card or a UML class card. */
77
77
  function isDiagramKitCard(node) {
78
78
  return node.getMetadata('kitEntity') !== undefined || node.getMetadata('kitClass') !== undefined;
@@ -206,6 +206,9 @@ export function fromDocument(document, options = {}) {
206
206
  // a fixed world — it stays one. Fluid is only what was saved fluid.
207
207
  mode: (firstBoard === null || firstBoard === void 0 ? void 0 : firstBoard.fluid) === true ? 'fluid' : 'fixed',
208
208
  overflow: (_p = firstBoard === null || firstBoard === void 0 ? void 0 : firstBoard.overflow) !== null && _p !== void 0 ? _p : 'bounded',
209
+ activeTab: new Map(),
210
+ tabsOf: new Map(),
211
+ tabStrips: new Map(),
209
212
  layoutOf: new Map(dashGroups.map((g) => { var _a, _b; return [g.id, ((_b = (_a = g.getMetadata('dashboardBoard')) === null || _a === void 0 ? void 0 : _a.layout) !== null && _b !== void 0 ? _b : 'grid')]; })),
210
213
  // responsive is NOT in the document (a runtime seam), so it is deliberately
211
214
  // absent from the round-trip; width/height/columns/gap/sizing/float/rtl are.
@@ -244,7 +247,7 @@ export function fromDocument(document, options = {}) {
244
247
  (_b = getNodeType(node.type)) === null || _b === void 0 ? void 0 : _b(node, host);
245
248
  };
246
249
  const finalize = (api) => {
247
- var _a, _b, _c, _d, _e;
250
+ var _a, _b, _c, _d, _e, _f;
248
251
  const a = api;
249
252
  if (!a)
250
253
  return;
@@ -289,8 +292,15 @@ export function fromDocument(document, options = {}) {
289
292
  const board = group.getMetadata('dashboardBoard');
290
293
  if (!board)
291
294
  continue;
295
+ // A TAB CONTAINER binds no board of its own — the runtime below places
296
+ // its pages and paints its strip.
297
+ if (board.layout === 'tabs')
298
+ continue;
292
299
  boards.set(group.id, bindBoard(group, board));
293
300
  }
301
+ // Tab containers, with the same runtime dashboard() uses — a reloaded
302
+ // document must switch pages exactly like a board built from a literal.
303
+ attachTabsRuntime(ctx, model, (_e = a.container) !== null && _e !== void 0 ? _e : null, handle);
294
304
  // LIVE LAYOUT SWITCH on a loaded document — the same contract dashboard()
295
305
  // finalize offers: cells persisted where the grid reads them, any tree and
296
306
  // column cache cleared, the board's `layout` flag flipped, a fresh binder.
@@ -337,7 +347,7 @@ export function fromDocument(document, options = {}) {
337
347
  }
338
348
  // A loaded board follows the history exactly as an authored one does: an
339
349
  // undo re-syncs every binder without the consumer calling refresh().
340
- (_e = ctx.attachHistory) === null || _e === void 0 ? void 0 : _e.call(ctx);
350
+ (_f = ctx.attachHistory) === null || _f === void 0 ? void 0 : _f.call(ctx);
341
351
  };
342
352
  return Object.assign({ nodes, edges: model.getLinks(), renderCustomNode,
343
353
  finalize,