@grafloria/element 0.4.6 → 0.4.8

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.6",
3
+ "version": "0.4.8",
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, 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, DashboardHandle, DashboardViewSpec, DashboardWidgetSpec, 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, 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';
@@ -44,6 +44,7 @@
44
44
  */
45
45
  import { GroupModel, NodeModel, type GridColumnLayout } from '@grafloria/engine';
46
46
  import { type DashboardGridHandle, type DashboardGridOptions, type DashboardResponsiveOptions } from './grid-binder.js';
47
+ import type { DragHandleOption } from './grid-binder.js';
47
48
  import type { SplitNode } from './split-layout.js';
48
49
  /** A widget, declared as data. */
49
50
  export interface DashboardWidgetSpec {
@@ -189,13 +190,15 @@ export interface DashboardOptions {
189
190
  */
190
191
  static?: boolean;
191
192
  /**
192
- * DRAG HANDLE — DevExpress drags an item by its caption. `true`: a widget
193
- * moves only from its header, which shows a grip; a selector string: your
194
- * own handle inside the card; off (default): the whole card. Resize edges
195
- * and the keyboard are unaffected; the body stays interactive. Live:
196
- * `handle.setDragHandle()`.
193
+ * DRAG HANDLE — DevExpress drags an item by its caption. `true`: the caption
194
+ * strip is the only handle (the header shows grip dots); a selector string:
195
+ * your own element inside the card; `{ grip: true, position, placement }`:
196
+ * a painted grip, left / center / right along the top edge, `inside` the
197
+ * header band or `outside` as a tab above the card; off (default): the
198
+ * whole card. Resize edges and the keyboard are unaffected; the body stays
199
+ * interactive. Live: `handle.setDragHandle()`.
197
200
  */
198
- dragHandle?: boolean | string;
201
+ dragHandle?: DragHandleOption;
199
202
  /**
200
203
  * RIGHT-TO-LEFT boards: column x=0 renders at the RIGHT edge and columns run
201
204
  * leftwards. Cells are untouched — the same `widgets` array describes the
@@ -295,9 +298,9 @@ export interface DashboardHandle {
295
298
  /** Static (read-only for the pointer) mode, live — the viewer/designer switch. */
296
299
  setStatic(on: boolean): void;
297
300
  getStatic(): boolean;
298
- /** Drag-handle mode, live, every view: `true` = the header, a selector = your own handle, `false` = the whole card. */
299
- setDragHandle(v: boolean | string): void;
300
- getDragHandle(): boolean | string;
301
+ /** Drag-handle mode, live, every view: `true` = the caption strip, a selector = your own handle, `{ grip: true, … }` = a painted grip, `false` = the whole card. */
302
+ setDragHandle(v: DragHandleOption): void;
303
+ getDragHandle(): DragHandleOption;
301
304
  /**
302
305
  * Add a widget to a view. CREATES the node (you do not pre-build one), wires
303
306
  * its metadata, and commits node + membership as ONE undoable step.
@@ -214,19 +214,42 @@ export interface DashboardGridOptions {
214
214
  static?: boolean;
215
215
  /**
216
216
  * DRAG HANDLE (DevExpress drags an item by its caption; gridstack's
217
- * `handle`): `true` — a widget moves only from its header, which shows a
218
- * grip; a selector string — your own handle inside the host. Off (default):
219
- * the whole card is the handle. Resize edges are unaffected and the body
220
- * stays interactive (scroll a table, click a legend). Live: `setDragHandle`.
217
+ * `handle`). `true` — the CAPTION STRIP is the handle (the header shows grip
218
+ * dots); a selector string — your own element inside the host; a
219
+ * `{ grip: true }` object a dedicated GRIP the kit paints, positioned
220
+ * left / center / right and, for the top edge, `inside` the header band or
221
+ * `outside` as a tab above the card (the DevExpress item bar). Off
222
+ * (default): the whole card. Resize edges are unaffected and the body stays
223
+ * interactive (scroll a table, click a legend). Live: `setDragHandle`.
221
224
  */
222
- dragHandle?: boolean | string;
225
+ dragHandle?: DragHandleOption;
223
226
  }
224
- /** The selector a `dragHandle` value names `null` when the whole card is the handle. */
225
- export declare const dragHandleSelector: (v: boolean | string | undefined) => string | null;
226
- /** The public form of a stored handle selector (the inverse of `dragHandleSelector`). */
227
- export declare const dragHandleValue: (sel: string | null) => boolean | string;
228
- /** The container class that turns the header grip on. */
227
+ /** A painted grip: the only drag zone, placed along the card's top edge. */
228
+ export interface DragGripOptions {
229
+ grip: true;
230
+ /** Where along the top edge. Default 'left'. */
231
+ position?: 'left' | 'center' | 'right';
232
+ /** In the header band, or a tab above the card. Default 'inside'. */
233
+ placement?: 'inside' | 'outside';
234
+ }
235
+ export type DragHandleOption = boolean | string | DragGripOptions;
236
+ /** The class of the painted grip element (a child of the node host). */
237
+ export declare const GRIP_CLASS = "axdb-grip";
238
+ /** The container class that turns the caption strip's grip dots on. */
229
239
  export declare const DRAG_HANDLE_CLASS = "axdb-drag-handle";
240
+ /** A `dragHandle` value with its defaults filled in — the form the handle reports. */
241
+ export declare const normalizeDragHandle: (v: DragHandleOption | undefined) => DragHandleOption;
242
+ /** The selector a `dragHandle` value names — `null` when the whole card is the handle. */
243
+ export declare const dragHandleSelector: (v: DragHandleOption) => string | null;
244
+ /** The grip config of a value, or null when it paints no grip. */
245
+ export declare const gripOf: (v: DragHandleOption) => DragGripOptions | null;
246
+ /**
247
+ * Paint (or remove) the grip on one host, and stamp the host with the grip's
248
+ * placement so the header can make room for it. `movable` false = no grip.
249
+ */
250
+ export declare function syncGrip(host: HTMLElement, cfg: DragGripOptions | null, movable: boolean): void;
251
+ /** The member host a press on a painted grip belongs to (the grip may sit OUTSIDE the host's box). */
252
+ export declare function gripHostOf(target: Element | null): HTMLElement | null;
230
253
  /**
231
254
  * Did a press land on the drag handle? A press INSIDE the handle element
232
255
  * always does. The default handle is a CAPTION BAR the DevExpress way: the
@@ -266,9 +289,9 @@ export interface DashboardGridHandle {
266
289
  /** Static mode, live: pointer gestures off (and handles gone) or back on. */
267
290
  setStatic(on: boolean): void;
268
291
  getStatic(): boolean;
269
- /** Drag-handle mode, live: `true` = the header, a selector = your own handle, `false` = the whole card. */
270
- setDragHandle(v: boolean | string): void;
271
- getDragHandle(): boolean | string;
292
+ /** Drag-handle mode, live: `true` = the caption strip, a selector = your own handle, `{ grip: true, … }` = a painted grip, `false` = the whole card. */
293
+ setDragHandle(v: DragHandleOption): void;
294
+ getDragHandle(): DragHandleOption;
272
295
  /**
273
296
  * Move keyboard focus to a member (the roving tabindex lands on it). The
274
297
  * host takes DOM focus when it exists; returns false for a non-member.
@@ -295,8 +318,8 @@ export interface DashboardGridHandle {
295
318
  responsive: boolean;
296
319
  fluid: boolean;
297
320
  static: boolean;
298
- /** `true` = the header is the grip, a selector = a custom one, `false` = the whole card. */
299
- dragHandle: boolean | string;
321
+ /** `true` = the caption strip, a selector = a custom one, `{ grip: true, … }` = a painted grip, `false` = the whole card. */
322
+ dragHandle: DragHandleOption;
300
323
  /** Fit-mode row capacity (undefined when unbounded). */
301
324
  capacity: number | undefined;
302
325
  gap: number;
@@ -54,12 +54,49 @@ import { AddToGroupCommand, BatchCommand, Command, GridPackEngine, RemoveFromGro
54
54
  import { LiveRegionController, registerTool } from '@grafloria/renderer';
55
55
  import { buildCommitCommands, cellFromGridItem, cellToRect, columnUnitFor, gridItemFromCell, pointToCell, rowHeightFor, sizeToSpan, } from './grid-mapping.js';
56
56
  import { ensureDashboardKitStyles } from './styles.js';
57
- /** The selector a `dragHandle` value names `null` when the whole card is the handle. */
58
- export const dragHandleSelector = (v) => v === true ? '.axdb-widget-h' : typeof v === 'string' && v.length > 0 ? v : null;
59
- /** The public form of a stored handle selector (the inverse of `dragHandleSelector`). */
60
- export const dragHandleValue = (sel) => sel === null ? false : sel === '.axdb-widget-h' ? true : sel;
61
- /** The container class that turns the header grip on. */
57
+ /** The class of the painted grip element (a child of the node host). */
58
+ export const GRIP_CLASS = 'axdb-grip';
59
+ /** The container class that turns the caption strip's grip dots on. */
62
60
  export const DRAG_HANDLE_CLASS = 'axdb-drag-handle';
61
+ /** A `dragHandle` value with its defaults filled in — the form the handle reports. */
62
+ export const normalizeDragHandle = (v) => {
63
+ var _a, _b;
64
+ return typeof v === 'object' && v !== null && v.grip
65
+ ? { grip: true, position: (_a = v.position) !== null && _a !== void 0 ? _a : 'left', placement: (_b = v.placement) !== null && _b !== void 0 ? _b : 'inside' }
66
+ : v === true ? true : typeof v === 'string' && v.length > 0 ? v : false;
67
+ };
68
+ /** The selector a `dragHandle` value names — `null` when the whole card is the handle. */
69
+ export const dragHandleSelector = (v) => v === true ? '.axdb-widget-h' : typeof v === 'string' ? v : typeof v === 'object' ? '.' + GRIP_CLASS : null;
70
+ /** The grip config of a value, or null when it paints no grip. */
71
+ export const gripOf = (v) => (typeof v === 'object' ? v : null);
72
+ const sameDragHandle = (a, b) => JSON.stringify(a) === JSON.stringify(b);
73
+ /**
74
+ * Paint (or remove) the grip on one host, and stamp the host with the grip's
75
+ * placement so the header can make room for it. `movable` false = no grip.
76
+ */
77
+ export function syncGrip(host, cfg, movable) {
78
+ var _a, _b, _c, _d, _e;
79
+ const existing = host.querySelector(':scope > .' + GRIP_CLASS);
80
+ host.classList.remove('axdb-gp-inside', 'axdb-gp-outside', 'axdb-gp-left', 'axdb-gp-center', 'axdb-gp-right');
81
+ if (!cfg || !movable) {
82
+ existing === null || existing === void 0 ? void 0 : existing.remove();
83
+ return;
84
+ }
85
+ const el = (_a = existing) !== null && _a !== void 0 ? _a : host.ownerDocument.createElement('div');
86
+ if (!existing) {
87
+ el.setAttribute('aria-hidden', 'true');
88
+ el.setAttribute('title', 'Drag');
89
+ host.appendChild(el);
90
+ }
91
+ el.className = `${GRIP_CLASS} ${GRIP_CLASS}--${(_b = cfg.position) !== null && _b !== void 0 ? _b : 'left'} ${GRIP_CLASS}--${(_c = cfg.placement) !== null && _c !== void 0 ? _c : 'inside'}`;
92
+ host.classList.add(`axdb-gp-${(_d = cfg.placement) !== null && _d !== void 0 ? _d : 'inside'}`, `axdb-gp-${(_e = cfg.position) !== null && _e !== void 0 ? _e : 'left'}`);
93
+ }
94
+ /** The member host a press on a painted grip belongs to (the grip may sit OUTSIDE the host's box). */
95
+ export function gripHostOf(target) {
96
+ var _a, _b;
97
+ const grip = (_a = target === null || target === void 0 ? void 0 : target.closest) === null || _a === void 0 ? void 0 : _a.call(target, '.' + GRIP_CLASS);
98
+ return (_b = grip === null || grip === void 0 ? void 0 : grip.closest('.grafloria-node-host')) !== null && _b !== void 0 ? _b : null;
99
+ }
63
100
  /**
64
101
  * Did a press land on the drag handle? A press INSIDE the handle element
65
102
  * always does. The default handle is a CAPTION BAR the DevExpress way: the
@@ -208,8 +245,9 @@ export function bindDashboardGrid(api, group, options = {}) {
208
245
  const fluid = options.fluid === true;
209
246
  const overflow = (_h = options.overflow) !== null && _h !== void 0 ? _h : 'bounded';
210
247
  let isStatic = options.static === true;
211
- let dragHandle = dragHandleSelector(options.dragHandle);
212
- api.container.classList.toggle(DRAG_HANDLE_CLASS, dragHandle !== null);
248
+ let dragHandle = normalizeDragHandle(options.dragHandle);
249
+ let dragSel = dragHandleSelector(dragHandle);
250
+ api.container.classList.toggle(DRAG_HANDLE_CLASS, dragHandle === true);
213
251
  /** The member the ROVING TABINDEX rests on (one tab stop per board). */
214
252
  let focusedId;
215
253
  const live = liveRegionFor(api.container);
@@ -598,11 +636,18 @@ export function bindDashboardGrid(api, group, options = {}) {
598
636
  host.setAttribute('tabindex', id === stop ? '0' : '-1');
599
637
  }
600
638
  };
639
+ /**
640
+ * The chrome on every member host: the painted grip (or none) and the corner
641
+ * resize handle — ONE host lookup per member, which the host observer's
642
+ * budget counts (a repaint of one host must cost that host's lookup, not a
643
+ * second pass).
644
+ */
601
645
  const syncHandles = (only) => {
602
- var _a, _b, _c;
646
+ var _a, _b, _c, _d, _e;
603
647
  syncA11y(only);
604
- if (!wantHandles || disposed)
648
+ if (disposed)
605
649
  return;
650
+ const grip = gripOf(dragHandle);
606
651
  for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
607
652
  if (only && !only.has(id))
608
653
  continue;
@@ -612,8 +657,11 @@ export function bindDashboardGrid(api, group, options = {}) {
612
657
  const host = hostOf(id);
613
658
  if (!host)
614
659
  continue;
660
+ syncGrip(host, grip, ((_b = node.state) === null || _b === void 0 ? void 0 : _b.locked) !== true && !isStatic && ((_c = node.getMetadata) === null || _c === void 0 ? void 0 : _c.call(node, 'widgetMovable')) !== false);
661
+ if (!wantHandles)
662
+ continue;
615
663
  const existing = host.querySelector(':scope > .axdb-rs');
616
- if (((_b = node.state) === null || _b === void 0 ? void 0 : _b.locked) === true || isStatic || ((_c = node.getMetadata) === null || _c === void 0 ? void 0 : _c.call(node, 'widgetResizable')) === false) {
664
+ if (((_d = node.state) === null || _d === void 0 ? void 0 : _d.locked) === true || isStatic || ((_e = node.getMetadata) === null || _e === void 0 ? void 0 : _e.call(node, 'widgetResizable')) === false) {
617
665
  existing === null || existing === void 0 ? void 0 : existing.remove();
618
666
  continue;
619
667
  }
@@ -1626,29 +1674,35 @@ export function bindDashboardGrid(api, group, options = {}) {
1626
1674
  return insideMemberGroupFrame(ev.world.x, ev.world.y) || worldInsideBoard(ev.world.x, ev.world.y);
1627
1675
  },
1628
1676
  onPointerDown(ev, hit) {
1629
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1677
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
1630
1678
  if (gesture)
1631
1679
  return; // mid-palette
1632
- if (!hit.node) {
1680
+ const target = ((_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) !== null && _b !== void 0 ? _b : null);
1681
+ // A press on a painted grip names its widget by the DOM: an OUTSIDE tab
1682
+ // sits above the card's box, where the hit test sees the gap or the
1683
+ // neighbour above.
1684
+ const gripHost = gripHostOf(target);
1685
+ const gripId = (_c = gripHost === null || gripHost === void 0 ? void 0 : gripHost.getAttribute('data-node-id')) !== null && _c !== void 0 ? _c : null;
1686
+ const onGrip = !!gripId && ((_d = group.members) !== null && _d !== void 0 ? _d : new Set()).has(gripId);
1687
+ if (!hit.node && !onGrip) {
1633
1688
  // The board's own empty area: a void click. Nothing to drag, and the
1634
1689
  // selection clears exactly as a click outside any board would.
1635
- (_b = (_a = diagram).clearSelection) === null || _b === void 0 ? void 0 : _b.call(_a);
1690
+ (_f = (_e = diagram).clearSelection) === null || _f === void 0 ? void 0 : _f.call(_e);
1636
1691
  api.render();
1637
1692
  return;
1638
1693
  }
1639
- const node = diagram.getNode(hit.node.id);
1640
- if (!node || ((_c = node.state) === null || _c === void 0 ? void 0 : _c.locked) === true)
1694
+ const node = diagram.getNode(onGrip ? gripId : hit.node.id);
1695
+ if (!node || ((_g = node.state) === null || _g === void 0 ? void 0 : _g.locked) === true)
1641
1696
  return; // pinned: refuse; click still focuses
1642
1697
  if (isStatic)
1643
1698
  return; // a static board: claimed and deadened, click still focuses
1644
1699
  // Which edges did the press take? The corner handle names its own (s+e,
1645
1700
  // or s+w on RTL); a bare press within EDGE_GRIP of the tile's border
1646
- // takes that border; anywhere else is a move.
1647
- const target = ((_e = (_d = ev.source) === null || _d === void 0 ? void 0 : _d.target) !== null && _e !== void 0 ? _e : null);
1648
- const onHandle = !!((_f = target === null || target === void 0 ? void 0 : target.closest) === null || _f === void 0 ? void 0 : _f.call(target, '.axdb-rs'));
1701
+ // takes that border; anywhere else is a move. A grip press is a move.
1702
+ const onHandle = !!((_h = target === null || target === void 0 ? void 0 : target.closest) === null || _h === void 0 ? void 0 : _h.call(target, '.axdb-rs'));
1649
1703
  const hostEl = hostOf(node.id);
1650
- const resizable = ((_g = node.getMetadata) === null || _g === void 0 ? void 0 : _g.call(node, 'widgetResizable')) !== false;
1651
- const movable = ((_h = node.getMetadata) === null || _h === void 0 ? void 0 : _h.call(node, 'widgetMovable')) !== false;
1704
+ const resizable = ((_j = node.getMetadata) === null || _j === void 0 ? void 0 : _j.call(node, 'widgetResizable')) !== false;
1705
+ const movable = ((_k = node.getMetadata) === null || _k === void 0 ? void 0 : _k.call(node, 'widgetMovable')) !== false;
1652
1706
  // `ev.screen` is ELEMENT-LOCAL px; host rects are in client px. Compare
1653
1707
  // like with like — the source event's clientX/Y when there is one, else
1654
1708
  // the container's origin plus the local offset.
@@ -1657,7 +1711,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1657
1711
  const cx = typeof (src === null || src === void 0 ? void 0 : src.clientX) === 'number' ? src.clientX : cr.left + ev.screen.x;
1658
1712
  const cy = typeof (src === null || src === void 0 ? void 0 : src.clientY) === 'number' ? src.clientY : cr.top + ev.screen.y;
1659
1713
  let edges = NO_EDGES;
1660
- if (resizable) {
1714
+ if (resizable && !onGrip) {
1661
1715
  if (onHandle)
1662
1716
  edges = rtl ? { n: false, e: false, s: true, w: true } : { n: false, e: true, s: true, w: false };
1663
1717
  else if (hostEl)
@@ -1666,11 +1720,11 @@ export function bindDashboardGrid(api, group, options = {}) {
1666
1720
  const isResize = anyEdge(edges);
1667
1721
  if (!isResize && !movable)
1668
1722
  return; // a fixed tile: refuse the drag, click still focuses
1669
- // Drag-handle mode: only a press on the handle (the caption strip by
1670
- // default) moves the tile. Anywhere else the press is claimed (no group
1671
- // drag) but starts nothing, so the body keeps its own behaviour — a
1672
- // table scrolls, a legend clicks.
1673
- if (!isResize && dragHandle !== null && !pressOnDragHandle(dragHandle, target, hostEl, cx, cy))
1723
+ // Drag-handle mode: only a press on the handle (the caption strip, a
1724
+ // custom element, or the painted grip) moves the tile. Anywhere else the
1725
+ // press is claimed (no group drag) but starts nothing, so the body keeps
1726
+ // its own behaviour — a table scrolls, a legend clicks.
1727
+ if (!isResize && dragSel !== null && !pressOnDragHandle(dragSel, target, hostEl, cx, cy))
1674
1728
  return;
1675
1729
  // Arm the glide class NOW, a full task before any displacement can
1676
1730
  // happen: a transition defined in the same style recalc as the first
@@ -1696,7 +1750,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1696
1750
  startSize: { width: node.size.width, height: node.size.height },
1697
1751
  startPos: { x: node.position.x, y: node.position.y },
1698
1752
  edges,
1699
- spans: { w: (_j = it === null || it === void 0 ? void 0 : it.w) !== null && _j !== void 0 ? _j : 1, h: (_k = it === null || it === void 0 ? void 0 : it.h) !== null && _k !== void 0 ? _k : 1 },
1753
+ spans: { w: (_l = it === null || it === void 0 ? void 0 : it.w) !== null && _l !== void 0 ? _l : 1, h: (_m = it === null || it === void 0 ? void 0 : it.h) !== null && _m !== void 0 ? _m : 1 },
1700
1754
  removedFromBoard: false,
1701
1755
  leg: null,
1702
1756
  lastWorld: null,
@@ -2148,16 +2202,18 @@ export function bindDashboardGrid(api, group, options = {}) {
2148
2202
  },
2149
2203
  getStatic: () => isStatic,
2150
2204
  setDragHandle(v) {
2151
- const sel = dragHandleSelector(v);
2152
- if (sel === dragHandle)
2205
+ const next = normalizeDragHandle(v);
2206
+ if (sameDragHandle(next, dragHandle))
2153
2207
  return;
2154
- dragHandle = sel;
2208
+ dragHandle = next;
2209
+ dragSel = dragHandleSelector(next);
2155
2210
  if (gesture)
2156
2211
  cancelActiveGesture(false);
2157
- api.container.classList.toggle(DRAG_HANDLE_CLASS, dragHandle !== null);
2212
+ api.container.classList.toggle(DRAG_HANDLE_CLASS, dragHandle === true);
2213
+ syncHandles();
2158
2214
  api.renderNow();
2159
2215
  },
2160
- getDragHandle: () => dragHandleValue(dragHandle),
2216
+ getDragHandle: () => (typeof dragHandle === 'object' ? Object.assign({}, dragHandle) : dragHandle),
2161
2217
  saveLayout() {
2162
2218
  const saved = engine.saveLayout();
2163
2219
  return {
@@ -2198,7 +2254,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2198
2254
  responsive: !!responsive && !responsivePinned,
2199
2255
  fluid,
2200
2256
  static: isStatic,
2201
- dragHandle: dragHandleValue(dragHandle),
2257
+ dragHandle: typeof dragHandle === 'object' ? Object.assign({}, dragHandle) : dragHandle,
2202
2258
  capacity,
2203
2259
  gap,
2204
2260
  padding,
@@ -1,4 +1,4 @@
1
- export { bindDashboardGrid, type DashboardGridApi, type DashboardGridOptions, type DashboardGridHandle, type DashboardResponsiveOptions, } from './grid-binder.js';
1
+ export { bindDashboardGrid, type DashboardGridApi, type DashboardGridOptions, type DashboardGridHandle, type DashboardResponsiveOptions, type DragHandleOption, type DragGripOptions, normalizeDragHandle, } from './grid-binder.js';
2
2
  export { bindDashboardSplit, SetSplitTreeCommand, SPLIT_TREE_KEY, type DashboardSplitOptions, type DashboardSplitHandle, } from './split-binder.js';
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';
@@ -1,4 +1,4 @@
1
- export { bindDashboardGrid, } from './grid-binder.js';
1
+ export { bindDashboardGrid, normalizeDragHandle, } from './grid-binder.js';
2
2
  export { bindDashboardSplit, SetSplitTreeCommand, SPLIT_TREE_KEY, } from './split-binder.js';
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';
@@ -25,7 +25,7 @@
25
25
  import { __awaiter } from "tslib";
26
26
  import { Command } from '@grafloria/engine';
27
27
  import { LiveRegionController, registerTool } from '@grafloria/renderer';
28
- import { dragHandleSelector, dragHandleValue, pressOnDragHandle, DRAG_HANDLE_CLASS } from './grid-binder.js';
28
+ import { dragHandleSelector, gripHostOf, gripOf, normalizeDragHandle, pressOnDragHandle, syncGrip, DRAG_HANDLE_CLASS } from './grid-binder.js';
29
29
  import { cellFromGridItem } from './grid-mapping.js';
30
30
  import { addSplitLeaf, cellsFromSplit, cloneSplit, dividersOf, groupRectsOf, insertSplitLeaf, moveSplitDivider, normalizeSplit, pathToLeaf, projectSplit, removeSplitLeaf, splitFromCells, splitLeaves, } from './split-layout.js';
31
31
  import { ensureDashboardKitStyles } from './styles.js';
@@ -92,8 +92,9 @@ export function bindDashboardSplit(api, group, options = {}) {
92
92
  const fluid = options.fluid === true;
93
93
  let rtl = options.rtl === true;
94
94
  let isStatic = options.static === true;
95
- let dragHandle = dragHandleSelector(options.dragHandle);
96
- api.container.classList.toggle(DRAG_HANDLE_CLASS, dragHandle !== null);
95
+ let dragHandle = normalizeDragHandle(options.dragHandle);
96
+ let dragSel = dragHandleSelector(dragHandle);
97
+ api.container.classList.toggle(DRAG_HANDLE_CLASS, dragHandle === true);
97
98
  let designH = (_h = (_f = options.designHeight) !== null && _f !== void 0 ? _f : (_g = group.size) === null || _g === void 0 ? void 0 : _g.height) !== null && _h !== void 0 ? _h : 0;
98
99
  let designW = (_k = (_j = group.size) === null || _j === void 0 ? void 0 : _j.width) !== null && _k !== void 0 ? _k : 0;
99
100
  let disposed = false;
@@ -369,7 +370,7 @@ export function bindDashboardSplit(api, group, options = {}) {
369
370
  return `${Math.round((r.width / f.width) * 100)} percent wide, ${Math.round((r.height / f.height) * 100)} percent tall`;
370
371
  };
371
372
  const syncA11y = () => {
372
- var _a, _b;
373
+ var _a, _b, _c, _d;
373
374
  if (disposed)
374
375
  return;
375
376
  const order = splitLeaves(paintedTree()).filter((id) => !!diagram.getNode(id));
@@ -377,6 +378,13 @@ export function bindDashboardSplit(api, group, options = {}) {
377
378
  // that carried the grid's handle (a board switched live) sheds it here.
378
379
  for (const id of order)
379
380
  (_b = (_a = hostOf(id)) === null || _a === void 0 ? void 0 : _a.querySelector(':scope > .axdb-rs')) === null || _b === void 0 ? void 0 : _b.remove();
381
+ // The painted grip (or none), on every leaf host.
382
+ for (const id of order) {
383
+ const host = hostOf(id);
384
+ const node = diagram.getNode(id);
385
+ if (host && node)
386
+ syncGrip(host, gripOf(dragHandle), ((_c = node.state) === null || _c === void 0 ? void 0 : _c.locked) !== true && !isStatic && ((_d = node.getMetadata) === null || _d === void 0 ? void 0 : _d.call(node, 'widgetMovable')) !== false);
387
+ }
380
388
  if (focusedId && !order.includes(focusedId))
381
389
  focusedId = undefined;
382
390
  const stop = focusedId !== null && focusedId !== void 0 ? focusedId : order[0];
@@ -592,7 +600,7 @@ export function bindDashboardSplit(api, group, options = {}) {
592
600
  return worldInsideBoard(ev.world.x, ev.world.y);
593
601
  },
594
602
  onPointerDown(ev, hit) {
595
- var _a, _b, _c, _d, _e, _f, _g;
603
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
596
604
  if (gesture)
597
605
  return;
598
606
  const target = ((_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) !== null && _b !== void 0 ? _b : null);
@@ -623,24 +631,28 @@ export function bindDashboardSplit(api, group, options = {}) {
623
631
  api.container.style.cursor = d.dir === 'row' ? 'col-resize' : 'row-resize';
624
632
  return;
625
633
  }
626
- if (!hit.node) {
627
- (_e = (_d = diagram).clearSelection) === null || _e === void 0 ? void 0 : _e.call(_d);
634
+ // A press on a painted grip names its widget by the DOM (an outside tab
635
+ // sits above the card's box) see grid-binder.
636
+ const gripId = (_e = (_d = gripHostOf(target)) === null || _d === void 0 ? void 0 : _d.getAttribute('data-node-id')) !== null && _e !== void 0 ? _e : null;
637
+ const onGrip = !!gripId && ((_f = group.members) !== null && _f !== void 0 ? _f : new Set()).has(gripId);
638
+ if (!hit.node && !onGrip) {
639
+ (_h = (_g = diagram).clearSelection) === null || _h === void 0 ? void 0 : _h.call(_g);
628
640
  api.render();
629
641
  return;
630
642
  }
631
- const node = diagram.getNode(hit.node.id);
632
- if (!node || ((_f = node.state) === null || _f === void 0 ? void 0 : _f.locked) === true || isStatic)
643
+ const node = diagram.getNode(onGrip ? gripId : hit.node.id);
644
+ if (!node || ((_j = node.state) === null || _j === void 0 ? void 0 : _j.locked) === true || isStatic)
633
645
  return;
634
- if (((_g = node.getMetadata) === null || _g === void 0 ? void 0 : _g.call(node, 'widgetMovable')) === false)
646
+ if (((_k = node.getMetadata) === null || _k === void 0 ? void 0 : _k.call(node, 'widgetMovable')) === false)
635
647
  return;
636
- // Drag-handle mode: only the handle (the caption strip by default) lifts
637
- // a widget out — see pressOnDragHandle in grid-binder.
638
- if (dragHandle !== null) {
648
+ // Drag-handle mode: only the handle (the caption strip, a custom element
649
+ // or the painted grip) lifts a widget out — see pressOnDragHandle.
650
+ if (dragSel !== null) {
639
651
  const src = ev.source;
640
652
  const cr = api.container.getBoundingClientRect();
641
653
  const cx = typeof (src === null || src === void 0 ? void 0 : src.clientX) === 'number' ? src.clientX : cr.left + ev.screen.x;
642
654
  const cy = typeof (src === null || src === void 0 ? void 0 : src.clientY) === 'number' ? src.clientY : cr.top + ev.screen.y;
643
- if (!pressOnDragHandle(dragHandle, target, hostOf(node.id), cx, cy))
655
+ if (!pressOnDragHandle(dragSel, target, hostOf(node.id), cx, cy))
644
656
  return;
645
657
  }
646
658
  const tree = readTree();
@@ -950,15 +962,17 @@ export function bindDashboardSplit(api, group, options = {}) {
950
962
  },
951
963
  getStatic: () => isStatic,
952
964
  setDragHandle(v) {
953
- const sel = dragHandleSelector(v);
954
- if (sel === dragHandle)
965
+ const next = normalizeDragHandle(v);
966
+ if (JSON.stringify(next) === JSON.stringify(dragHandle))
955
967
  return;
956
- dragHandle = sel;
968
+ dragHandle = next;
969
+ dragSel = dragHandleSelector(next);
957
970
  cancelActiveGesture();
958
- api.container.classList.toggle(DRAG_HANDLE_CLASS, dragHandle !== null);
971
+ api.container.classList.toggle(DRAG_HANDLE_CLASS, dragHandle === true);
972
+ syncA11y();
959
973
  api.renderNow();
960
974
  },
961
- getDragHandle: () => dragHandleValue(dragHandle),
975
+ getDragHandle: () => (typeof dragHandle === 'object' ? Object.assign({}, dragHandle) : dragHandle),
962
976
  focusWidget(id) {
963
977
  var _a, _b, _c;
964
978
  if (!((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !diagram.getNode(id))
@@ -981,7 +995,7 @@ export function bindDashboardSplit(api, group, options = {}) {
981
995
  responsive: false,
982
996
  fluid,
983
997
  static: isStatic,
984
- dragHandle: dragHandleValue(dragHandle),
998
+ dragHandle: typeof dragHandle === 'object' ? Object.assign({}, dragHandle) : dragHandle,
985
999
  capacity: undefined,
986
1000
  gap,
987
1001
  padding,
@@ -185,6 +185,28 @@ const CSS = `
185
185
  content: ''; flex: none; width: 8px; height: 12px; opacity: .55;
186
186
  background: radial-gradient(circle, currentColor 1.1px, transparent 1.5px) 0 0 / 4px 4px;
187
187
  }
188
+ /* A PAINTED GRIP (dragHandle: { grip: true }): the only drag zone, a small
189
+ dotted tab along the card's top edge. Inside sits in the header band and
190
+ the header makes room for it; outside is a tab above the card, the
191
+ DevExpress item bar. The host must not clip it. */
192
+ .grafloria-node-host > .axdb-grip {
193
+ position: absolute; z-index: 4; box-sizing: border-box; width: 24px; height: 12px;
194
+ border: 1px solid var(--axdb-line, #e7eaf1); border-radius: 3px; background: var(--axdb-card, #fff);
195
+ color: var(--axdb-muted, #5a6478); cursor: grab; opacity: .8;
196
+ }
197
+ .grafloria-node-host > .axdb-grip::before {
198
+ content: ''; position: absolute; left: 5px; top: 2px; width: 12px; height: 6px;
199
+ background: radial-gradient(circle, currentColor 1px, transparent 1.4px) 0 0 / 4px 3px;
200
+ }
201
+ .grafloria-node-host:hover > .axdb-grip, .grafloria-node-host > .axdb-grip:hover { opacity: 1; border-color: #3b52d9; color: #3b52d9; }
202
+ .grafloria-node-host > .axdb-grip--inside { top: 5px; }
203
+ .grafloria-node-host > .axdb-grip--outside { top: -11px; height: 11px; border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: 0; box-shadow: 0 -1px 2px rgba(16, 24, 40, .08); }
204
+ .grafloria-node-host > .axdb-grip--left { left: 8px; }
205
+ .grafloria-node-host > .axdb-grip--right { right: 8px; }
206
+ .grafloria-node-host > .axdb-grip--center { left: 50%; transform: translateX(-50%); }
207
+ .grafloria-node-host.axdb-gp-inside.axdb-gp-left .axdb-widget > .axdb-widget-h { padding-left: 24px; }
208
+ .grafloria-node-host.axdb-gp-inside.axdb-gp-right .axdb-widget > .axdb-widget-h { padding-right: 24px; }
209
+ .grafloria-node-host.axdb-gp-inside.axdb-gp-center .axdb-widget > .axdb-widget-h { padding-top: 10px; }
188
210
  .axdb-widget-b { flex: 1; min-height: 0; position: relative; }
189
211
  .axdb-widget-b > svg { display: block; width: 100%; height: 100%; }
190
212
  .axdb-widget-b.axdb-scroll { overflow: auto; }