@toclocoinc/lattice-grid 1.55.0 → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +280 -34
  3. package/docs/api-detail.html +276 -6
  4. package/lattice-grid.d.ts +247 -15
  5. package/lattice-grid.esm.min.js +1358 -573
  6. package/lattice-grid.min.cjs +1358 -573
  7. package/lattice-grid.min.js +1358 -573
  8. package/modules/ai.esm.min.js +4 -4
  9. package/modules/ai.min.cjs +4 -4
  10. package/modules/ai.min.js +4 -4
  11. package/modules/angular.esm.min.js +2 -2
  12. package/modules/angular.min.cjs +2 -2
  13. package/modules/angular.min.js +2 -2
  14. package/modules/chart-alluvial.esm.min.js +1 -1
  15. package/modules/chart-arc.esm.min.js +1 -1
  16. package/modules/chart-bubblemap.esm.min.js +1 -1
  17. package/modules/chart-bump.esm.min.js +1 -1
  18. package/modules/chart-calendar.esm.min.js +1 -1
  19. package/modules/chart-decomposition.esm.min.js +1 -1
  20. package/modules/chart-diverging.esm.min.js +1 -1
  21. package/modules/chart-dumbbell.esm.min.js +1 -1
  22. package/modules/chart-fan.esm.min.js +1 -1
  23. package/modules/chart-hexbin.esm.min.js +1 -1
  24. package/modules/chart-hexmap.esm.min.js +1 -1
  25. package/modules/chart-icicle.esm.min.js +1 -1
  26. package/modules/chart-parallel.esm.min.js +1 -1
  27. package/modules/chart-ridgeline.esm.min.js +1 -1
  28. package/modules/chart-roc.esm.min.js +1 -1
  29. package/modules/chart-slope.esm.min.js +1 -1
  30. package/modules/chart-splom.esm.min.js +1 -1
  31. package/modules/chart-waffle.esm.min.js +1 -1
  32. package/modules/charts.esm.min.js +15 -10
  33. package/modules/charts.min.cjs +15 -10
  34. package/modules/charts.min.js +15 -10
  35. package/modules/data-router.esm.min.js +37 -4
  36. package/modules/data-router.min.cjs +37 -4
  37. package/modules/data-router.min.js +37 -4
  38. package/modules/devtools.esm.min.js +2 -2
  39. package/modules/devtools.min.cjs +2 -2
  40. package/modules/devtools.min.js +2 -2
  41. package/modules/dhtmlx-compat.esm.min.js +4 -4
  42. package/modules/dhtmlx-compat.min.cjs +4 -4
  43. package/modules/dhtmlx-compat.min.js +4 -4
  44. package/modules/gantt.esm.min.js +4 -4
  45. package/modules/gantt.min.cjs +4 -4
  46. package/modules/gantt.min.js +4 -4
  47. package/modules/htmx.esm.min.js +1358 -573
  48. package/modules/htmx.min.cjs +1358 -573
  49. package/modules/htmx.min.js +1358 -573
  50. package/modules/kanban.esm.min.js +4 -4
  51. package/modules/kanban.min.cjs +4 -4
  52. package/modules/kanban.min.js +4 -4
  53. package/modules/kpi.esm.min.js +44 -7
  54. package/modules/kpi.min.cjs +44 -7
  55. package/modules/kpi.min.js +44 -7
  56. package/modules/layout.esm.min.js +4 -4
  57. package/modules/layout.min.cjs +4 -4
  58. package/modules/layout.min.js +4 -4
  59. package/modules/mock-socket.esm.min.js +2 -2
  60. package/modules/mock-socket.min.cjs +2 -2
  61. package/modules/mock-socket.min.js +2 -2
  62. package/modules/react.esm.min.js +2 -2
  63. package/modules/react.min.cjs +2 -2
  64. package/modules/react.min.js +2 -2
  65. package/modules/svelte.esm.min.js +2 -2
  66. package/modules/svelte.min.cjs +2 -2
  67. package/modules/svelte.min.js +2 -2
  68. package/modules/tabs.esm.min.js +4 -4
  69. package/modules/tabs.min.cjs +4 -4
  70. package/modules/tabs.min.js +4 -4
  71. package/modules/vue.esm.min.js +2 -2
  72. package/modules/vue.min.cjs +2 -2
  73. package/modules/vue.min.js +2 -2
  74. package/modules/webcomponent.esm.min.js +1358 -573
  75. package/modules/webcomponent.min.cjs +1358 -573
  76. package/modules/webcomponent.min.js +1358 -573
  77. package/package.json +3 -2
@@ -437,7 +437,7 @@
437
437
  <div class="shell">
438
438
  <aside class="rail">
439
439
  <p class="rail__brand">Lattice Grid</p>
440
- <p class="rail__sub">Developer guide · v1.55.0</p>
440
+ <p class="rail__sub">Developer guide · v1.56.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -553,7 +553,7 @@
553
553
  <a href="API.html">reference tables</a> are the shorter version for when you already know.
554
554
  </p>
555
555
  <p class="chips">
556
- <span class="chip">Version 1.55.0</span>
556
+ <span class="chip">Version 1.56.0</span>
557
557
  <span class="chip">Zero dependencies</span>
558
558
  <span class="chip">No build step</span>
559
559
  </p>
@@ -1278,7 +1278,7 @@ off(); <span class="cmt">// every subscrip
1278
1278
  </p>
1279
1279
  <div class="example">
1280
1280
  <p class="example__label">Which version am I running?</p>
1281
- <pre><code>grid.getVersion(); <span class="cmt">// '1.55.0'</span>
1281
+ <pre><code>grid.getVersion(); <span class="cmt">// '1.56.0'</span>
1282
1282
  LatticeGrid.getVersion(); <span class="cmt">// the same, when you have no grid to hand</span></code></pre>
1283
1283
  </div>
1284
1284
  <p class="lead-in">
@@ -1286,6 +1286,46 @@ LatticeGrid.getVersion(); <span class="cmt">// the same, when you have no grid
1286
1286
  "the grid on this page", and whoever reads it has a grid rather than the module it was built
1287
1287
  from.
1288
1288
  </p>
1289
+
1290
+ <h3 id="headless-vs-dom">A headless grid is not a smaller grid, it is a grid without a renderer</h3>
1291
+ <p class="lead-in">
1292
+ <code>createGrid</code> builds this same core and attaches the DOM renderer to it;
1293
+ <code>createHeadlessGrid</code> stops one step earlier. Everything the core owns — data, state,
1294
+ sort, filter, group, total, pivot, formulas and computed columns, editing and optimistic
1295
+ write-back, export, and every event — runs unchanged with no renderer attached, which is why
1296
+ most of the examples in this guide that call <code>createHeadlessGrid</code> are settling real
1297
+ API questions, not toy snippets. What it does not have is the renderer: no layout, no
1298
+ measurement, no scrolling geometry, no focus, and <code>grid.element</code> is <code>null</code>.
1299
+ </p>
1300
+ <div class="why">
1301
+ <p><strong>A grid mounted where it has no rendered box paints only a handful of rows, not the
1302
+ whole dataset — by design, not as a bug.</strong> The visible row window is computed from the
1303
+ container's own height; a container that is detached from the document, or sits under a
1304
+ <code>display: none</code> ancestor, measures zero, and the virtualiser falls back to a small
1305
+ band around the top of the data (the overscan margin, five rows with the default configuration)
1306
+ rather than nothing at all. A harness that builds a grid off in a hidden or unattached container
1307
+ and then asserts on what is visible reads far fewer rows than it loaded and looks broken; giving
1308
+ the container a real, measured box before asserting is what fixes it. Note that this is about
1309
+ the container having <em>no computed size</em>, not about being visually off-screen: a
1310
+ container positioned outside the browser's visible area with real, explicit dimensions (for
1311
+ example <code>position: fixed; left: -9999px</code> with a width and height) still gets a real
1312
+ box and renders fully.</p>
1313
+ <p><strong>The in-repo test DOM (<code>testdom.js</code>) is a stub, not a browser, and says so
1314
+ in its own header comment.</strong> It implements exactly the surface the renderer touches —
1315
+ element creation, attributes, <code>classList</code>, <code>style</code>, children,
1316
+ <code>textContent</code>, event dispatch, injected geometry, and shims for
1317
+ <code>ResizeObserver</code> and <code>requestAnimationFrame</code> — which is enough to drive
1318
+ the renderer's logic in Node for most of the suite. It does not compute a cascade or a layout,
1319
+ so a class or style change that a stylesheet then overrides, or a box that depends on CSS rather
1320
+ than on the geometry a test injected, is invisible to it; those need the real-browser tests
1321
+ (<code>test/*-browser.test.js</code>) that drive an actual headless Chrome instead. It also does
1322
+ not model real focus semantics — its <code>focus()</code> simply records which element is
1323
+ "focused" with no check for visibility, tab order or focusability. Since BACKLOG-0001181,
1324
+ <code>classList.add</code> does throw on a token containing a space, the same
1325
+ <code>InvalidCharacterError</code> a real <code>DOMTokenList</code> throws, closing the specific
1326
+ gap where a two-word class name passed every headless test and then blanked a real page.</p>
1327
+ </div>
1328
+
1289
1329
  <h2 id="columns-guide">Defining columns</h2>
1290
1330
  <p class="lead-in">
1291
1331
  A column is an object with a <code>field</code> (the path to read from your data) and
@@ -1346,6 +1386,55 @@ LatticeGrid.getVersion(); <span class="cmt">// the same, when you have no grid
1346
1386
  A cycle is caught at compile time with the full path named, rather than becoming a stack
1347
1387
  overflow at render time.</p>
1348
1388
  </div>
1389
+ <p>When a computed value is re-run, stated plainly:</p>
1390
+ <ul>
1391
+ <li>A pure compute (the default) runs at ingest and is cached. It runs again when its row is
1392
+ replaced through <code>rows.apply({ update })</code> or the data through
1393
+ <code>rows.load()</code> &mdash; unconditionally, since a value derived from data that is
1394
+ gone is stale by definition; when the grid a derived grid follows changes; and when you ask
1395
+ with <code>rows.refresh({ rows, columns, force: true })</code>. A sort, a filter, a state
1396
+ restore or an edit to a column outside its <code>deps</code> does not re-run it. An in-place
1397
+ cell edit to one of its <code>deps</code> does not currently re-run it either.</li>
1398
+ <li>Naming a column re-runs what depends on it: <code>refresh({ columns: ['p'], force: true })</code>
1399
+ recomputes a <code>q</code> whose <code>deps</code> include <code>p</code>;
1400
+ <code>refresh({ columns: ['q'] })</code> does not recompute <code>p</code>.</li>
1401
+ <li><code>pure: false</code> guarantees the compute is re-evaluated on every read and every
1402
+ paint. It is never served from a cache.</li>
1403
+ <li>Whenever a compute re-runs, <code>rows.text()</code> and the painted cell show the new
1404
+ result, and so do a sort or filter on the column: every cache the grid keeps for that cell
1405
+ &mdash; the one behind the text and the paint, and the one sort and filter handles read
1406
+ &mdash; is invalidated together.</li>
1407
+ <li>Without <code>force</code>, a targeted <code>refresh({ rows, columns })</code> behaves
1408
+ differently by store mode, and which one you get flips at <code>columnarBelow</code>. On a
1409
+ grid below that row count the named cell is re-run on its next read; on a columnar one the
1410
+ stored value stands until you pass <code>force: true</code>. This is behaviour to plan for,
1411
+ not a tuning detail: the same call on the same data recomputes or does not purely according
1412
+ to how many rows arrived. Pass <code>force: true</code> when you want the same answer
1413
+ whatever the row count.</li>
1414
+ </ul>
1415
+ <div class="example">
1416
+ <p class="example__label">An answer that arrives later</p>
1417
+ <pre><code><span class="cmt">// A lookup the grid cannot see: show a placeholder, fill the table, then</span>
1418
+ <span class="cmt">// tell the grid which cells to recompute. The compute stays pure, so it is</span>
1419
+ <span class="cmt">// not re-run on every paint &mdash; only when you say the answer changed.</span>
1420
+ const names = new Map();
1421
+ const column = {
1422
+ id: 'owner', title: 'Owner',
1423
+ value: {
1424
+ deps: ['ownerId'],
1425
+ compute: (deps) =&gt; names.get(deps.ownerId) ?? 'Loading…',
1426
+ },
1427
+ };
1428
+
1429
+ const missing = [...new Set(grid.rows.data().map((r) =&gt; r.ownerId))].filter((id) =&gt; !names.has(id));
1430
+ const resolved = await fetchNames(missing); <span class="cmt">// { id: name }</span>
1431
+ for (const id of missing) names.set(id, resolved[id]);
1432
+ const rows = grid.rows.data().filter((r) =&gt; missing.includes(r.ownerId)).map((r) =&gt; r.id);
1433
+ grid.rows.refresh({ rows, columns: ['owner'], force: true });
1434
+
1435
+ <span class="cmt">// Or declare the column `pure: false` and it re-reads `names` on every</span>
1436
+ <span class="cmt">// paint; then a plain grid.rows.refresh() after the fetch is enough.</span></code></pre>
1437
+ </div>
1349
1438
 
1350
1439
  <h3>Sizing and pinning</h3>
1351
1440
  <div class="example">
@@ -2105,6 +2194,59 @@ grid.filters.quick(''); <span class="cmt">// clear</span></code></pre>
2105
2194
  <code>notBlank</code> work everywhere.
2106
2195
  </p>
2107
2196
 
2197
+ <h3 id="where-guide">Filters your application owns: <code>where</code></h3>
2198
+ <p class="lead-in">
2199
+ A condition tree can only test what is in a column. Plenty of real filters cannot be written
2200
+ that way &mdash; whether this user may see the row, whether you hold an exchange rate for its
2201
+ currency, whether it came back from your last search call. Those go in as <strong>named
2202
+ predicates</strong>, and they compose with everything above.
2203
+ </p>
2204
+
2205
+ <div class="example">
2206
+ <p class="example__label">A permission filter and a toggle, side by side</p>
2207
+ <pre><code>grid.filters.where('visibleToMe', row =&gt; row.owner === me, { pinned: true });
2208
+ grid.filters.where('rateKnown', row =&gt; rates.has(row.ccy), { deps: ['ccy'] });
2209
+
2210
+ grid.filters.where(); <span class="cmt">// ['visibleToMe', 'rateKnown']</span>
2211
+ grid.filters.where('rateKnown', null); <span class="cmt">// remove just that one</span>
2212
+ grid.filters.reapply('rateKnown'); <span class="cmt">// the rate table arrived late</span></code></pre>
2213
+ </div>
2214
+
2215
+ <div class="why">
2216
+ <p>There is deliberately no "a filter is present" flag. That flag is a second piece of state
2217
+ describing the first, and the two drift: the classic symptom is a grid that filters while the
2218
+ UI insists it is not, or insists it is filtering while every row passes. Here, registering a
2219
+ predicate is what puts it in force, and removing it is what takes it out.</p>
2220
+ </div>
2221
+
2222
+ <p class="lead-in">
2223
+ Three options shape one. <code>deps</code> names the columns the predicate reads, exactly as
2224
+ <code>value.deps</code> does for a computed column: the verdict is then cached per row and
2225
+ re-run when one of <em>those</em> columns changes on that row, not when an unrelated one does.
2226
+ Leave it off and the predicate is assumed to read the whole row, so it runs every pass and can
2227
+ never be stale. <code>pinned</code> makes a predicate survive
2228
+ <code>filters.clear()</code>, which is what you want for permissions and tenant scoping and
2229
+ not much else. <code>condition</code> gives the predicate a declarative twin that is pushed to
2230
+ the source while the function stays as the residual, so a pushdown engine narrows the fetch
2231
+ instead of your code filtering a page.
2232
+ </p>
2233
+
2234
+ <div class="why">
2235
+ <p><strong>Migrating from AG Grid's external filter:</strong> its three pieces become two.
2236
+ <code>isExternalFilterPresent()</code> goes away, because registration is presence.
2237
+ <code>doesExternalFilterPass(node)</code> becomes the predicate itself.
2238
+ <code>onFilterChanged()</code> becomes <code>deps</code> where the grid can watch the change
2239
+ for you, and <code>reapply(name?)</code> where it cannot.</p>
2240
+ </div>
2241
+
2242
+ <div class="why">
2243
+ <p><strong>What travels in a saved view is the name, not the function.</strong>
2244
+ <code>state.get()</code> carries <code>where: string[]</code>; your predicates are your code
2245
+ and the grid will not pretend it can serialise them. Applying a view that names a predicate
2246
+ you have not registered <em>reports</em> the skip instead of quietly showing a wider row set,
2247
+ and never removes a predicate the view did not mention.</p>
2248
+ </div>
2249
+
2108
2250
  <h2 id="grouping">Grouping, totals and pivot</h2>
2109
2251
  <div class="example">
2110
2252
  <p class="example__label">Group by one or more columns</p>
@@ -3342,6 +3484,15 @@ createGrid(right, {
3342
3484
  hundred updates against a two hundred thousand row source cost under 300 ms in total. Set
3343
3485
  <code>refresh</code> to <code>live</code>, <code>manual</code> or a number of milliseconds to
3344
3486
  change the coalescing; <code>idle</code> is the default and settles to a frame.</p>
3487
+ <p><strong><code>manual</code> means the host says when, and <code>rows.load()</code> is how it
3488
+ says it.</strong> A <code>manual</code> derived grid never re-derives on its own: the source can
3489
+ filter, edit and tick underneath it and the panel keeps showing what it last derived. Call
3490
+ <code>rows.load()</code> <em>on the derived grid</em>, with no argument, and it re-reads its
3491
+ <code>from</code> there and then and replaces its rows; call it again whenever you want the next
3492
+ reading. A derived grid takes its rows from <code>from</code>, so anything passed to
3493
+ <code>load</code> is not used. The derived grid's own sort and filters stay as they were. Press
3494
+ it as often as you like &mdash; the source is left exactly as one press leaves it, and
3495
+ destroying the panel leaves nothing of it attached there.</p>
3345
3496
  <p><strong>Derived grids are read-only.</strong> There is one copy of the data and it lives in
3346
3497
  the source. Write there and the derived grid follows: an edit through the source's editing
3347
3498
  API (<code>edit.setCells</code>, an inline commit, a fill, a paste, an undo, a redo or an
@@ -3358,6 +3509,59 @@ createGrid(right, {
3358
3509
  the source writes onto every row it produces: the group value, the profiled column, or the
3359
3510
  source row's own key when nothing is grouped. Set <code>rowKey</code> only to override it.</p>
3360
3511
  </div>
3512
+ <div class="example" id="derived-manual-refresh">
3513
+ <p class="example__label">A frozen panel, refreshed on a button press, executed</p>
3514
+ <pre data-run="js" data-expect="20 20 10 10 5; steady; released" data-covers="config:refresh method:rows method:diagnostics"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
3515
+
3516
+ <span class="kw">const</span> detail = createHeadlessGrid({
3517
+ rowKey: 'id',
3518
+ columns: [{ field: 'region' }, { field: 'capacity', type: 'number' }],
3519
+ rows: Array.from({ length: <span class="num">20</span> }, (_, i) =&gt; ({ id: i, region: i % <span class="num">2</span> ? 'North' : 'South', capacity: i })),
3520
+ });
3521
+ detail.rows.count();
3522
+
3523
+ <span class="cmt">// What the detail grid is listening with before any panel is attached.</span>
3524
+ <span class="kw">const</span> listening = () =&gt; Object.values(detail.diagnostics.events()).reduce((a, b) =&gt; a + b, <span class="num">0</span>);
3525
+ <span class="kw">const</span> alone = listening();
3526
+
3527
+ <span class="cmt">// A summary that derives once, then waits to be told.</span>
3528
+ <span class="kw">const</span> panel = createHeadlessGrid({
3529
+ columns: [{ field: 'region' }, { field: 'sites', type: 'number' }],
3530
+ source: { mode: 'derived', from: detail, groupBy: 'region',
3531
+ select: { sites: { fn: 'count' } }, refresh: 'manual' },
3532
+ });
3533
+
3534
+ <span class="cmt">// The Refresh button. In a page this is your &lt;button&gt;; here any EventTarget will do.</span>
3535
+ <span class="kw">const</span> button = <span class="kw">new</span> EventTarget();
3536
+ button.addEventListener('click', () =&gt; panel.rows.load()); <span class="cmt">// no argument</span>
3537
+
3538
+ <span class="kw">const</span> sites = () =&gt; {
3539
+ <span class="kw">let</span> n = <span class="num">0</span>;
3540
+ panel.rows.forEach((row) =&gt; { n += panel.rows.value(row.key, 'sites'); });
3541
+ <span class="kw">return</span> n;
3542
+ };
3543
+ <span class="cmt">// Longer than any automatic refresh would take to land, so "frozen" is a finding.</span>
3544
+ <span class="kw">const</span> settle = () =&gt; <span class="kw">new</span> Promise((done) =&gt; setTimeout(done, <span class="num">50</span>));
3545
+
3546
+ <span class="kw">const</span> attached = listening();
3547
+ <span class="kw">const</span> seen = [sites()]; <span class="cmt">// 20</span>
3548
+ detail.filters.set({ col: 'capacity', op: 'lt', value: <span class="num">10</span> });
3549
+ <span class="kw">await</span> settle(); seen.push(sites()); <span class="cmt">// 20: the source moved, the panel did not</span>
3550
+ button.dispatchEvent(<span class="kw">new</span> Event('click'));
3551
+ seen.push(sites()); <span class="cmt">// 10: re-derived on the press</span>
3552
+ detail.filters.set({ col: 'capacity', op: 'lt', value: <span class="num">5</span> });
3553
+ <span class="kw">await</span> settle(); seen.push(sites()); <span class="cmt">// 10: frozen again</span>
3554
+ button.dispatchEvent(<span class="kw">new</span> Event('click'));
3555
+ seen.push(sites()); <span class="cmt">// 5</span>
3556
+
3557
+ <span class="cmt">// Pressing it any number of times leaves the detail grid as one press does,</span>
3558
+ <span class="cmt">// and destroying the panel leaves nothing of it behind there.</span>
3559
+ <span class="kw">const</span> steady = listening() === attached ? 'steady' : 'grew';
3560
+ panel.destroy();
3561
+ <span class="kw">const</span> released = listening() === alone ? 'released' : 'left behind';
3562
+ detail.destroy();
3563
+ <span class="kw">return</span> `${seen.join(' ')}; ${steady}; ${released}`;</code></pre>
3564
+ </div>
3361
3565
 
3362
3566
  <h3>Other shapes</h3>
3363
3567
  <div class="table-wrap">
@@ -3561,7 +3765,11 @@ createGrid(right, {
3561
3765
  next frame; a <strong>number</strong> is a debounce in milliseconds; <code>'live'</code>
3562
3766
  derives on every change and is the one to avoid for an expensive analysis over a ticking feed;
3563
3767
  <code>'manual'</code> stops automatic derivation entirely, leaving the host to drive the
3564
- source. Below a few tens of thousands of rows none of this matters.</p>
3768
+ source: call <code>rows.load()</code> on the derived grid, with no argument, whenever the
3769
+ analysis should be brought up to date &mdash; from a Refresh button, when its tab is shown, or
3770
+ on a timer of your own. Each call re-derives once, at the cost in the table above;
3771
+ <a href="#derived-manual-refresh">a frozen panel refreshed on a button press</a> is executed
3772
+ above. Below a few tens of thousands of rows none of this matters.</p>
3565
3773
 
3566
3774
  <h2 id="cross-filter">Cross-filtering</h2>
3567
3775
  <p class="lead-in">
@@ -4610,6 +4818,27 @@ grid.selection.extendRange(34, 'cap'); <span class="cmt">// grows the block jus
4610
4818
  Selected rows carry <code>aria-selected</code> and the class
4611
4819
  <code>lat-row--selected</code>, which the theme styles.
4612
4820
  </p>
4821
+ <div class="example">
4822
+ <p class="example__label">A row with its own click action</p>
4823
+ <pre><code>selection: { mode: 'multiple', checkbox: <span class="kw">true</span>, checkboxOnly: <span class="kw">true</span> }</code></pre>
4824
+ </div>
4825
+ <p class="lead-in">
4826
+ <code>checkboxOnly: true</code> restricts row selection to the checkbox column:
4827
+ clicking the checkbox selects or deselects the row, and clicking anywhere else in the
4828
+ row does neither. This is for a host that binds its own action — typically opening a
4829
+ record's detail view — to a plain click on the row: without it, that click also
4830
+ selects the row, and a bulk action run afterwards operates on rows the user never
4831
+ chose to select. The same restriction applies to the keyboard: Space still toggles
4832
+ selection while focus is on the checkbox cell, and does nothing elsewhere. Cell ranges
4833
+ and the fill handle are unaffected either way. Off by default, so a plain click still
4834
+ selects a row exactly as it always has.
4835
+ </p>
4836
+ <p class="lead-in">
4837
+ <code>checkboxOnly</code> only narrows which gesture may change selection; it does not
4838
+ grant selection where <code>mode: 'none'</code> has already refused it, and it composes
4839
+ normally with <code>mode: 'single'</code> — the checkbox remains the only way to change
4840
+ which one row is selected.
4841
+ </p>
4613
4842
 
4614
4843
  <div class="why">
4615
4844
  <p><strong>Ctrl+Shift+Arrow is the keyboard form of ctrl-dragging.</strong> The first press
@@ -4743,6 +4972,15 @@ createChart({
4743
4972
  chart stops moving, even though time is still passing &mdash; and the silence is usually the
4744
4973
  thing worth seeing. <code>maxAge</code> is a span of wall clock, so it means the same thing
4745
4974
  whatever the feed is doing.</p>
4975
+ <p><strong>An empty reduction is a gap, not a zero.</strong> <code>fn: 'avg'</code> above
4976
+ &mdash; and <code>sum</code>, <code>mean</code>, <code>min</code>, <code>max</code>,
4977
+ <code>first</code> and <code>last</code> beside it &mdash; read as <code>null</code> when a
4978
+ bucket carries no rows to reduce (BACKLOG-0001088), so a producer that has stopped sending is
4979
+ drawn as a break in the line rather than a value dropping to zero, which would read as a real
4980
+ observation nobody made. <code>count</code> and <code>countValues</code> are the deliberate
4981
+ exception: they are already honest at zero, a tally of rows or of values actually present, so
4982
+ reach for <code>countValues</code> when the reading you actually want is &ldquo;how many
4983
+ arrived&rdquo; and zero has to be drawn as zero rather than as a gap.</p>
4746
4984
  <p><strong>Two bounds, one eviction path.</strong> <code>maxAge</code> and
4747
4985
  <code>maxRows</code> are independent and compose: both are applied on the same pass and
4748
4986
  whichever bites first is simply the one that drops rows. Neither is silently ignored.
@@ -4801,6 +5039,16 @@ createChart({
4801
5039
  that far ahead the chart shows its empty state, with the same warning.
4802
5040
  <code>chart.data().windowed</code> counts the readings dropped at either edge of the
4803
5041
  window. The fix for the warning is the producer&rsquo;s clock, not a wider window.</p>
5042
+ <p><strong>The other edge: a producer that has simply stopped.</strong> The case above is a
5043
+ clock running fast; the opposite is a feed that has gone quiet for longer than the window
5044
+ (BACKLOG-0001088) &mdash; every reading is older than the span, so every mark would fall to
5045
+ the left of the domain, off the plot, while the axes and legend keep drawing as if the chart
5046
+ were healthy. Rather than draw that, the chart shows its empty state and warns once <strong>per
5047
+ chart instance</strong>, naming the span and how old the newest reading actually is, so a dead
5048
+ feed reads as &ldquo;no data&rdquo; rather than as a chart that quietly stopped moving. Two
5049
+ charts bound to the same stale column each get their own warning &mdash; the key is scoped to
5050
+ the chart, not just the column, so a dashboard of tiled charts sharing one timestamp column
5051
+ does not lose the second warning to the first.</p>
4804
5052
  <p><strong>Idle costs nothing.</strong> Both halves advance on a plain interval &mdash; a
4805
5053
  quarter of the window, clamped to between 50&nbsp;ms and one second &mdash; and never on an
4806
5054
  animation frame. The source&rsquo;s wake returns after a single number comparison unless a
@@ -7287,7 +7535,7 @@ el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);</code></pre>
7287
7535
  <tr><td class="name">filter:changed</td><td class="desc">The condition tree or the quick filter changed.</td></tr>
7288
7536
  <tr><td class="name">page:changed</td><td class="desc">Fired after the rows have moved, whether the page changed by API or by the pager control.</td></tr>
7289
7537
  <tr><td class="name">sort:changed</td><td class="desc">The full sort entry list.</td></tr>
7290
- <tr><td class="name">state:changed</td><td class="desc">report lists anything a restore could not apply.</td></tr>
7538
+ <tr><td class="name">state:changed</td><td class="desc">Every state change, whether a user gesture or a programmatic call, announced exactly once — with one known gap, filters.where (BACKLOG-0001235), which changes the where section and the rows on screen without raising it. cause is 'user', 'apply' or 'reset'; sections names the GridState keys that moved; report lists anything a restore could not apply. A save layer subscribes to this one event and ignores cause 'reset'.</td></tr>
7291
7539
  <tr><td class="name">state:reset</td><td class="desc">The grid was returned to its baseline.</td></tr>
7292
7540
  <tr><td class="name">timeline:attached</td><td class="desc">A time brush was connected to the grid.</td></tr>
7293
7541
  <tr><td class="name">timeline:detached</td><td class="desc">The brush was removed.</td></tr>
@@ -7534,9 +7782,31 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'trial'<
7534
7782
  });</code></pre>
7535
7783
  </div>
7536
7784
 
7785
+ <h3 id="wrapping-creategrid-recipe">House-wide defaults, without patching <code>createGrid</code></h3>
7786
+ <p class="lead-in">
7787
+ <code>createGrid</code> is exported through a getter with no setter, so
7788
+ <code>LatticeGrid.createGrid = myWrapper</code> does not replace it — silently in a plain
7789
+ script, with a <code>TypeError</code> in a module (see the <a
7790
+ href="API.html#wrapping-creategrid">reference</a> for the exact descriptor). Own the seam
7791
+ yourself instead: one module that every call site imports from.
7792
+ </p>
7793
+ <div class="example">
7794
+ <pre><code><span class="cmt">// lattice.js</span>
7795
+ <span class="kw">import</span> { createGrid <span class="kw">as</span> baseCreateGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid';
7796
+
7797
+ <span class="kw">export function</span> createGrid(element, config) {
7798
+ <span class="kw">return</span> baseCreateGrid(element, { theme: 'house', locale: 'en-GB', ...config });
7799
+ }</code></pre>
7800
+ </div>
7801
+ <p class="lead-in">
7802
+ There is no shipped <code>defaults()</code> call that does this for you today (a separate card,
7803
+ BACKLOG-0001187, is considering one) — a wrapping module you own and every call site imports is
7804
+ the supported pattern until then.
7805
+ </p>
7806
+
7537
7807
  <footer>
7538
7808
  <p>
7539
- Lattice Grid 1.55.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
7809
+ Lattice Grid 1.56.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
7540
7810
  Written against the shipped source. Where this guide and the code disagree, the code wins,
7541
7811
  please <a href="https://www.latticegrid.dev">tell us</a>.
7542
7812
  </p>