@grafloria/element 0.4.23 → 0.4.25

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.23",
3
+ "version": "0.4.25",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -91,6 +91,37 @@ export declare function captionOfGroup(grp: {
91
91
  name?: string;
92
92
  } | undefined): SectionCaptionOptions | null;
93
93
  export declare function pairOf(v: number | [number, number] | undefined, dflt: [number, number]): [number, number];
94
+ /** The shape of the model and groups this module needs — structural, so the kit's
95
+ * caption logic stays free of engine imports. */
96
+ interface CaptionGroup {
97
+ id: string;
98
+ name?: string;
99
+ members?: {
100
+ has(id: string): boolean;
101
+ } | null;
102
+ getMetadata(key: string): unknown;
103
+ }
104
+ interface CaptionDiagram {
105
+ getGroups?(): CaptionGroup[];
106
+ }
107
+ /**
108
+ * Does the board that HOLDS this section paint section chrome? Only the grid
109
+ * binder draws the slab overlay the band lives on, so a section inside a
110
+ * SPLIT board has no band — and must not reserve space for one.
111
+ *
112
+ * Read from the parent group's persisted layout rather than from the live
113
+ * binder registry: membership and metadata both exist before either binder is
114
+ * built, so the answer is the same on the first projection as on the hundredth
115
+ * (asking the registry made the reserve depend on which binder registered
116
+ * first, and a child binds BEFORE its parent).
117
+ */
118
+ export declare function parentPaintsSectionChrome(diagram: CaptionDiagram, group: CaptionGroup): boolean;
119
+ /** A section's own caption reserve: its band's pixels, or 0 when nothing paints one. */
120
+ export declare function sectionCaptionReserve(diagram: CaptionDiagram, group: CaptionGroup & {
121
+ size?: {
122
+ height: number;
123
+ } | null;
124
+ }, isStatic: boolean): number;
94
125
  /** Painted at all? `show: 'design'` disappears under static. */
95
126
  export declare function captionPainted(c: SectionCaptionOptions | null, isStatic: boolean): boolean;
96
127
  /**
@@ -117,8 +148,12 @@ export declare function captionKey(c: SectionCaptionOptions, ctx: {
117
148
  rtl: boolean;
118
149
  static: boolean;
119
150
  }): string;
120
- /** Is a section of `sectionH` px in the tight tier? */
121
- export declare const captionTight: (sectionH: number) => boolean;
151
+ /**
152
+ * Is the band too short to carry a subtitle, actions or full-size text? Asked
153
+ * of the band's ACTUAL height, so an authored `height` that survives on a
154
+ * short section keeps its content instead of being styled as squeezed.
155
+ */
156
+ export declare const captionTight: (c: SectionCaptionOptions, sectionH: number) => boolean;
122
157
  /** The per-sync geometry of a painted band: height and tier follow the section's live size. */
123
158
  export declare function sizeCaptionBand(band: HTMLElement, c: SectionCaptionOptions, sectionH: number): void;
124
159
  /**
@@ -142,3 +177,4 @@ export declare function paintCaptionBand(band: HTMLElement, c: SectionCaptionOpt
142
177
  render?: (host: HTMLElement) => void;
143
178
  onAction?: (actionId: string) => void;
144
179
  }): void;
180
+ export {};
@@ -48,6 +48,35 @@ export function pairOf(v, dflt) {
48
48
  return dflt;
49
49
  return typeof v === 'number' ? [v, v] : [v[0], v[1]];
50
50
  }
51
+ /**
52
+ * Does the board that HOLDS this section paint section chrome? Only the grid
53
+ * binder draws the slab overlay the band lives on, so a section inside a
54
+ * SPLIT board has no band — and must not reserve space for one.
55
+ *
56
+ * Read from the parent group's persisted layout rather than from the live
57
+ * binder registry: membership and metadata both exist before either binder is
58
+ * built, so the answer is the same on the first projection as on the hundredth
59
+ * (asking the registry made the reserve depend on which binder registered
60
+ * first, and a child binds BEFORE its parent).
61
+ */
62
+ export function parentPaintsSectionChrome(diagram, group) {
63
+ var _a, _b, _c, _d;
64
+ const groups = (_b = (_a = diagram.getGroups) === null || _a === void 0 ? void 0 : _a.call(diagram)) !== null && _b !== void 0 ? _b : [];
65
+ for (const g of groups) {
66
+ if (g === group || !((_c = g.members) === null || _c === void 0 ? void 0 : _c.has(group.id)))
67
+ continue;
68
+ const board = g.getMetadata('dashboardBoard');
69
+ return ((_d = board === null || board === void 0 ? void 0 : board.layout) !== null && _d !== void 0 ? _d : 'grid') !== 'split';
70
+ }
71
+ return false; // not a member of any board: a view, which carries no caption
72
+ }
73
+ /** A section's own caption reserve: its band's pixels, or 0 when nothing paints one. */
74
+ export function sectionCaptionReserve(diagram, group, isStatic) {
75
+ var _a, _b;
76
+ if (!parentPaintsSectionChrome(diagram, group))
77
+ return 0;
78
+ return captionReserve(captionOfGroup(group), { static: isStatic, sectionH: (_b = (_a = group.size) === null || _a === void 0 ? void 0 : _a.height) !== null && _b !== void 0 ? _b : 0 });
79
+ }
51
80
  /** Painted at all? `show: 'design'` disappears under static. */
52
81
  export function captionPainted(c, isStatic) {
53
82
  if (!c)
@@ -62,7 +91,10 @@ export function captionPainted(c, isStatic) {
62
91
  */
63
92
  export function captionBandHeight(c, sectionH) {
64
93
  var _a;
65
- const want = captionTight(sectionH) ? CAPTION_HEIGHT_TIGHT : (_a = c.height) !== null && _a !== void 0 ? _a : (c.subtitle ? CAPTION_HEIGHT_SUBTITLE : CAPTION_HEIGHT);
94
+ // An authored `height` WINS over the squeezed tier the tier is a default
95
+ // for a short section, not an override of what the app asked for. The clamp
96
+ // still protects the children either way.
97
+ const want = (_a = c.height) !== null && _a !== void 0 ? _a : (sectionH > 0 && sectionH < CAPTION_TIGHT_BELOW ? CAPTION_HEIGHT_TIGHT : c.subtitle ? CAPTION_HEIGHT_SUBTITLE : CAPTION_HEIGHT);
66
98
  if (sectionH <= 0)
67
99
  return want;
68
100
  return Math.max(CAPTION_MIN_HEIGHT, Math.min(want, sectionH - CAPTION_MIN_CONTENT));
@@ -87,8 +119,12 @@ export function captionReserve(c, ctx) {
87
119
  export function captionKey(c, ctx) {
88
120
  return JSON.stringify([c, ctx.rtl, ctx.static]);
89
121
  }
90
- /** Is a section of `sectionH` px in the tight tier? */
91
- export const captionTight = (sectionH) => sectionH > 0 && sectionH < CAPTION_TIGHT_BELOW;
122
+ /**
123
+ * Is the band too short to carry a subtitle, actions or full-size text? Asked
124
+ * of the band's ACTUAL height, so an authored `height` that survives on a
125
+ * short section keeps its content instead of being styled as squeezed.
126
+ */
127
+ export const captionTight = (c, sectionH) => captionBandHeight(c, sectionH) <= CAPTION_HEIGHT_TIGHT;
92
128
  /** Where the band's box sits horizontally: a tab hugs the LEADING edge, mirrored on RTL. */
93
129
  function bandSides(c, mh, rtl) {
94
130
  if (c.position !== 'tab')
@@ -97,7 +133,7 @@ function bandSides(c, mh, rtl) {
97
133
  }
98
134
  /** The per-sync geometry of a painted band: height and tier follow the section's live size. */
99
135
  export function sizeCaptionBand(band, c, sectionH) {
100
- band.classList.toggle('axdb-slab-h--tight', captionTight(sectionH));
136
+ band.classList.toggle('axdb-slab-h--tight', captionTight(c, sectionH));
101
137
  band.style.height = `${captionBandHeight(c, sectionH)}px`;
102
138
  }
103
139
  /**
@@ -139,7 +175,7 @@ export function paintCaptionBand(band, c, ctx) {
139
175
  const cls = (name, on) => band.classList.toggle(name, on);
140
176
  cls('axdb-slab-h--tab', c.position === 'tab');
141
177
  cls('axdb-slab-h--hover', c.show === 'hover');
142
- cls('axdb-slab-h--tight', captionTight(ctx.sectionH));
178
+ cls('axdb-slab-h--tight', captionTight(c, ctx.sectionH));
143
179
  cls('axdb-slab-h--center', c.align === 'center');
144
180
  cls('axdb-slab-h--end', c.align === 'end');
145
181
  cls('axdb-slab-h--vtop', c.valign === 'top');
@@ -436,14 +436,6 @@ export interface DashboardGridHandle {
436
436
  * the feature).
437
437
  */
438
438
  interface BinderPeer {
439
- /**
440
- * Does this board paint SECTION CHROME (the slab overlay that carries the
441
- * ring, the corner handle and the caption band) for its member groups? Only
442
- * the grid binder does. A section reserves its caption's pixels ONLY when
443
- * its parent paints one — otherwise a captioned section inside a SPLIT
444
- * board pushed its children down by 44 px under a band nobody drew.
445
- */
446
- paintsCaptions?: boolean;
447
439
  group: GroupModel;
448
440
  /** True when this board's engine holds `id` as an item (member lookup). */
449
441
  hasItem(id: string): boolean;
@@ -54,7 +54,7 @@ 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
- import { captionOfGroup, captionPainted, captionPassThrough, captionKey, captionReserve, paintCaptionBand, sizeCaptionBand } from './caption.js';
57
+ import { captionOfGroup, captionPainted, captionPassThrough, captionKey, paintCaptionBand, sectionCaptionReserve, sizeCaptionBand } from './caption.js';
58
58
  /** The class of the painted grip element (a child of the node host). */
59
59
  export const GRIP_CLASS = 'axdb-grip';
60
60
  /** The container class that turns the caption strip's grip dots on. */
@@ -453,12 +453,7 @@ export function bindDashboardGrid(api, group, options = {}) {
453
453
  * band is outside `containsWorld`, so a press on it is the PARENT's (it
454
454
  * selects the section) rather than an empty press of this board.
455
455
  */
456
- const ownReserve = () => {
457
- var _a, _b, _c;
458
- return ((_a = parentPeer()) === null || _a === void 0 ? void 0 : _a.paintsCaptions) === true
459
- ? captionReserve(captionOfGroup(group), { static: isStatic, sectionH: (_c = (_b = group.size) === null || _b === void 0 ? void 0 : _b.height) !== null && _c !== void 0 ? _c : 0 })
460
- : 0;
461
- };
456
+ const ownReserve = () => sectionCaptionReserve(diagram, group, isStatic);
462
457
  const frame = () => {
463
458
  var _a, _b, _c, _d;
464
459
  const r = ownReserve();
@@ -1182,6 +1177,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1182
1177
  for (const [id, el] of slabEls) {
1183
1178
  if (!seen.has(id)) {
1184
1179
  el.remove();
1180
+ hoverSlabs.delete(el);
1185
1181
  slabEls.delete(id);
1186
1182
  }
1187
1183
  }
@@ -1198,9 +1194,18 @@ export function bindDashboardGrid(api, group, options = {}) {
1198
1194
  let band = el.querySelector(':scope > .axdb-slab-h');
1199
1195
  if (!cap || !captionPainted(cap, isStatic)) {
1200
1196
  band === null || band === void 0 ? void 0 : band.remove();
1197
+ hoverSlabs.delete(el);
1198
+ el.classList.remove('axdb-slab--hot');
1201
1199
  el.removeAttribute('aria-label');
1200
+ el.removeAttribute('role');
1202
1201
  return;
1203
1202
  }
1203
+ if (cap.show === 'hover')
1204
+ hoverSlabs.add(el);
1205
+ else {
1206
+ hoverSlabs.delete(el);
1207
+ el.classList.remove('axdb-slab--hot');
1208
+ }
1204
1209
  const ctx = { rtl, static: isStatic, sectionH };
1205
1210
  const key = captionKey(cap, ctx);
1206
1211
  if (band && band.getAttribute('data-key') === key) {
@@ -1213,10 +1218,18 @@ export function bindDashboardGrid(api, group, options = {}) {
1213
1218
  const render = options.renderCaption;
1214
1219
  paintCaptionBand(band, cap, Object.assign(Object.assign(Object.assign({}, ctx), (render ? { render: (host) => render(id, host) } : {})), { onAction: (actionId) => { var _a; return (_a = options.onCaptionAction) === null || _a === void 0 ? void 0 : _a.call(options, id, actionId); } }));
1215
1220
  band.setAttribute('data-key', key);
1216
- if (cap.text)
1221
+ // A named group, so the caption text is the section's name in the
1222
+ // accessibility tree rather than a stray label on an unnamed div. (The
1223
+ // band is not a tab stop yet — the actions are deliberately out of the
1224
+ // tab order so a board keeps exactly ONE stop; see the plan.)
1225
+ if (cap.text) {
1226
+ el.setAttribute('role', 'group');
1217
1227
  el.setAttribute('aria-label', cap.text);
1218
- else
1228
+ }
1229
+ else {
1230
+ el.removeAttribute('role');
1219
1231
  el.removeAttribute('aria-label');
1232
+ }
1220
1233
  };
1221
1234
  const insideMemberGroupFrame = (x, y) => {
1222
1235
  var _a;
@@ -1693,6 +1706,19 @@ export function bindDashboardGrid(api, group, options = {}) {
1693
1706
  // ratchet's second disguise (grow committed; a fresh shrink gesture could
1694
1707
  // never pull low enough to ask the parent for a row back).
1695
1708
  let h = bottom - top;
1709
+ /**
1710
+ * Did the escalation below move this tile AS PART OF THE STRIP? A
1711
+ * full-height tile shares its row count with every other full-height tile
1712
+ * in the section, so once the strip grew, this tile's height is the
1713
+ * strip's — not whatever its own pixels re-quantise to. Without this the
1714
+ * gesture undid itself for its own tile and left the NEIGHBOUR grown:
1715
+ * drag Churn's bottom 70 px and ORDERS became two rows while Churn stayed
1716
+ * one (reported from the fluid demo). The cause is the row height
1717
+ * changing mid-gesture: 86 px rows make +70 read as 2 rows, and the
1718
+ * moment the section grows, rows become 108 px and the same pixels read
1719
+ * as 1 again. See the sibling rule for the un-pulled axis below.
1720
+ */
1721
+ let stripFollow = false;
1696
1722
  // NESTED HEIGHT ESCALATION (live report: "i cant increase height"). A
1697
1723
  // bounded strip cannot grow a tile taller than itself — so pulling
1698
1724
  // clearly past its bottom GROWS THE STRIP: the slab gains a row in the
@@ -1751,9 +1777,30 @@ export function bindDashboardGrid(api, group, options = {}) {
1751
1777
  // The SAME rounding the resize path quantises with, or the tile shrinks
1752
1778
  // through that path first and the section never gets its row back.
1753
1779
  const wantRows = Math.max(1, Math.round((h + gap) / rowPx));
1754
- const wantsMore = wantRows > pulled.h;
1755
- const wantsLess = wantRows < pulled.h;
1756
1780
  const fullHeight = pulled.y === 0 && pulled.h >= inner;
1781
+ // For the WHOLE gesture, not just the move that escalated: a strip
1782
+ // tile's height is its cells. Set on every move so the tile cannot
1783
+ // re-quantise back a row on the moves in between.
1784
+ stripFollow = fullHeight;
1785
+ /**
1786
+ * A STRIP IS MEASURED FROM THE PRESS, NOT FROM ITSELF. Growing the
1787
+ * strip changes the row height, so quantising the live pixels against
1788
+ * the LIVE row height feeds the decision back into its own input: at
1789
+ * 86 px rows a +80 px pull reads as 2 rows, the section grows, rows
1790
+ * become 108 px, the same pointer now reads as 1 row, the section
1791
+ * shrinks — and the gesture oscillates and lands wherever the last
1792
+ * move left it (measured: +60/+70/+90 grew, +80 did nothing).
1793
+ * Counting rows from the row height AT PRESS is monotonic in the
1794
+ * pointer, so the strip cannot fight itself.
1795
+ */
1796
+ const startCell = g.startCells.get(g.id);
1797
+ const startRowPx = startCell && startCell.h > 0 ? (g.startSize.height + gap) / startCell.h : rowPx;
1798
+ const stripRows = startCell
1799
+ ? Math.max(1, startCell.h + Math.round((h - g.startSize.height) / startRowPx))
1800
+ : wantRows;
1801
+ const effWant = fullHeight ? stripRows : wantRows;
1802
+ const wantsMore = effWant > pulled.h;
1803
+ const wantsLess = effWant < pulled.h;
1757
1804
  const fullOnes = engine.getItems().filter((i) => i.y === 0 && i.h >= inner).map((i) => i.id);
1758
1805
  const resizeAll = (ids, rowsTo) => {
1759
1806
  for (const id of ids) {
@@ -1858,7 +1905,11 @@ export function bindDashboardGrid(api, group, options = {}) {
1858
1905
  // projection of its cell span instead of the start-of-gesture pixels.
1859
1906
  if (!pullsX)
1860
1907
  w = itemNow.w * (cuNow + gap) - gap;
1861
- if (!pullsY)
1908
+ // …and a tile the STRIP just moved follows its cells on the pulled axis
1909
+ // too: the strip is one row of tiles, so the dragged tile keeps the row
1910
+ // count its peers were given rather than re-quantising against the row
1911
+ // height the escalation itself just changed.
1912
+ if (!pullsY || stripFollow)
1862
1913
  h = itemNow.h * (rhNow + gap) - gap;
1863
1914
  }
1864
1915
  // Anchor: the pulled edges follow w/h, the opposite ones stay put — on
@@ -2191,7 +2242,6 @@ export function bindDashboardGrid(api, group, options = {}) {
2191
2242
  containsWorldExtended: worldInsideBoardExtended,
2192
2243
  frameArea: boardArea,
2193
2244
  adopt,
2194
- paintsCaptions: true, // the grid binder owns the slab overlays (syncSlabs)
2195
2245
  };
2196
2246
  peersOnCanvas().add(selfPeer);
2197
2247
  selfPeerRef = selfPeer;
@@ -2425,20 +2475,19 @@ export function bindDashboardGrid(api, group, options = {}) {
2425
2475
  * in CSS terms. The binder already tracks the pointer; it marks the section
2426
2476
  * under it instead.
2427
2477
  */
2478
+ /** Only the sections carrying a `show: 'hover'` band — usually none, so the
2479
+ * pointer handler costs nothing on a board that has no hover caption. */
2480
+ const hoverSlabs = new Set();
2428
2481
  const markHotSection = (clientX, clientY) => {
2429
- if (!slabEls.size)
2482
+ if (!hoverSlabs.size)
2430
2483
  return;
2431
- for (const [id, el] of slabEls) {
2432
- if (!el.querySelector(':scope > .axdb-slab-h--hover'))
2433
- continue;
2484
+ for (const el of hoverSlabs) {
2434
2485
  const r = el.getBoundingClientRect();
2435
- const hot = clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom;
2436
- el.classList.toggle('axdb-slab--hot', hot);
2437
- void id;
2486
+ el.classList.toggle('axdb-slab--hot', clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom);
2438
2487
  }
2439
2488
  };
2440
2489
  const onHoverLeave = () => {
2441
- for (const el of slabEls.values())
2490
+ for (const el of hoverSlabs)
2442
2491
  el.classList.remove('axdb-slab--hot');
2443
2492
  };
2444
2493
  const onHover = (e) => {
@@ -3091,6 +3140,7 @@ export function bindDashboardGrid(api, group, options = {}) {
3091
3140
  for (const el of slabEls.values())
3092
3141
  el.remove();
3093
3142
  slabEls.clear();
3143
+ hoverSlabs.clear();
3094
3144
  // A rebind inside the 60 ms window (a layout switch right after an undo)
3095
3145
  // disposed this binder with the timer pending — the host kept its
3096
3146
  // lifted ghost for good (visual gate, nested-containers ⑤).
@@ -29,7 +29,7 @@ import { anyEdge, clearOtherSelections, dragHandleSelector, gripHostOf, gripOf,
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';
32
- import { captionOfGroup, captionReserve } from './caption.js';
32
+ import { sectionCaptionReserve } from './caption.js';
33
33
  /** Group metadata key the tree persists under. */
34
34
  export const SPLIT_TREE_KEY = 'dashboardTree';
35
35
  /**
@@ -112,12 +112,7 @@ export function bindDashboardSplit(api, group, options = {}) {
112
112
  * reserves nothing until the tab-container round gives the split binder its
113
113
  * own overlays.
114
114
  */
115
- const ownReserve = () => {
116
- var _a, _b, _c;
117
- return ((_a = parentPeerOf(api.container, group.id)) === null || _a === void 0 ? void 0 : _a.paintsCaptions) === true
118
- ? captionReserve(captionOfGroup(group), { static: isStatic, sectionH: (_c = (_b = group.size) === null || _b === void 0 ? void 0 : _b.height) !== null && _c !== void 0 ? _c : 0 })
119
- : 0;
120
- };
115
+ const ownReserve = () => sectionCaptionReserve(diagram, group, isStatic);
121
116
  const frame = () => {
122
117
  var _a, _b, _c, _d;
123
118
  const r = ownReserve();