@toclocoinc/lattice-grid 1.35.0 → 1.37.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 (68) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +154 -1
  3. package/docs/api-detail.html +87 -2
  4. package/lattice-grid.d.ts +95 -2
  5. package/lattice-grid.esm.min.js +646 -116
  6. package/lattice-grid.min.cjs +645 -116
  7. package/lattice-grid.min.js +645 -116
  8. package/modules/angular.esm.min.js +12 -3
  9. package/modules/angular.min.cjs +12 -3
  10. package/modules/angular.min.js +12 -3
  11. package/modules/chart-alluvial.esm.min.js +1 -1
  12. package/modules/chart-arc.esm.min.js +1 -1
  13. package/modules/chart-bubblemap.esm.min.js +1 -1
  14. package/modules/chart-bump.esm.min.js +1 -1
  15. package/modules/chart-calendar.esm.min.js +1 -1
  16. package/modules/chart-decomposition.esm.min.js +1 -1
  17. package/modules/chart-diverging.esm.min.js +1 -1
  18. package/modules/chart-dumbbell.esm.min.js +1 -1
  19. package/modules/chart-fan.esm.min.js +1 -1
  20. package/modules/chart-hexbin.esm.min.js +1 -1
  21. package/modules/chart-hexmap.esm.min.js +1 -1
  22. package/modules/chart-icicle.esm.min.js +1 -1
  23. package/modules/chart-parallel.esm.min.js +1 -1
  24. package/modules/chart-ridgeline.esm.min.js +1 -1
  25. package/modules/chart-roc.esm.min.js +1 -1
  26. package/modules/chart-slope.esm.min.js +1 -1
  27. package/modules/chart-splom.esm.min.js +1 -1
  28. package/modules/chart-waffle.esm.min.js +1 -1
  29. package/modules/charts.esm.min.js +4 -4
  30. package/modules/charts.min.cjs +4 -4
  31. package/modules/charts.min.js +4 -4
  32. package/modules/data-router.esm.min.js +30 -11
  33. package/modules/data-router.min.cjs +30 -11
  34. package/modules/data-router.min.js +30 -11
  35. package/modules/devtools.esm.min.js +13 -2
  36. package/modules/devtools.min.cjs +13 -2
  37. package/modules/devtools.min.js +13 -2
  38. package/modules/dhtmlx-compat.esm.min.js +4 -4
  39. package/modules/dhtmlx-compat.min.cjs +4 -4
  40. package/modules/dhtmlx-compat.min.js +4 -4
  41. package/modules/gantt.esm.min.js +367 -16
  42. package/modules/gantt.min.cjs +367 -16
  43. package/modules/gantt.min.js +367 -16
  44. package/modules/htmx.esm.min.js +644 -116
  45. package/modules/htmx.min.cjs +644 -116
  46. package/modules/htmx.min.js +644 -116
  47. package/modules/kanban.esm.min.js +110 -10
  48. package/modules/kanban.min.cjs +110 -10
  49. package/modules/kanban.min.js +110 -10
  50. package/modules/kpi.esm.min.js +4 -4
  51. package/modules/kpi.min.cjs +4 -4
  52. package/modules/kpi.min.js +4 -4
  53. package/modules/mock-socket.esm.min.js +2 -2
  54. package/modules/mock-socket.min.cjs +2 -2
  55. package/modules/mock-socket.min.js +2 -2
  56. package/modules/react.esm.min.js +12 -3
  57. package/modules/react.min.cjs +12 -3
  58. package/modules/react.min.js +12 -3
  59. package/modules/svelte.esm.min.js +12 -3
  60. package/modules/svelte.min.cjs +12 -3
  61. package/modules/svelte.min.js +12 -3
  62. package/modules/vue.esm.min.js +12 -3
  63. package/modules/vue.min.cjs +12 -3
  64. package/modules/vue.min.js +12 -3
  65. package/modules/webcomponent.esm.min.js +645 -116
  66. package/modules/webcomponent.min.cjs +645 -116
  67. package/modules/webcomponent.min.js +645 -116
  68. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  dependencies, no build step required. Optional adapters for React, Vue, Svelte
5
5
  and Web Components ship alongside it.
6
6
 
7
- Version 1.35.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.37.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
package/docs/API.html CHANGED
@@ -2478,6 +2478,49 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'tria
2478
2478
  </table>
2479
2479
  </div>
2480
2480
 
2481
+ <h3 id="url-source">Load from a URL: a JSON or NDJSON file</h3>
2482
+ <p class="section-note">
2483
+ <code>createUrlSource(url, opts)</code> points the grid straight at a file. A <strong>JSON</strong>
2484
+ file (a top-level array, or a nested array picked out with <code>rowsPath</code> or <code>map</code>)
2485
+ is read whole and handed over as rows. An <strong>NDJSON / JSONL</strong> file &mdash; one JSON value
2486
+ per line &mdash; is <strong>streamed in incrementally</strong>: the first rows render while the rest is
2487
+ still arriving, and a large file never sits in memory as one string. It is built on the
2488
+ <code>stream</code> source, so it inherits the frame-coalesced render, the <code>stream:chunk</code> /
2489
+ <code>stream:end</code> progress events and promotion to memory once a small file has fully landed.
2490
+ </p>
2491
+ <p>Format is resolved in order: an explicit <code>format: 'json' | 'ndjson'</code> wins; else the URL
2492
+ extension (<code>.ndjson</code>/<code>.jsonl</code> vs <code>.json</code>); else the
2493
+ <code>Content-Type</code> (<code>application/x-ndjson</code> vs <code>application/json</code>); else a
2494
+ sniff of the first bytes, or a clear error asking for an explicit <code>format</code>. Pass
2495
+ <code>fetch</code> to supply auth or a proxy (default is the global <code>fetch</code>),
2496
+ <code>headers</code> to merge request headers, <code>batchSize</code> to tune NDJSON chunking
2497
+ (default&nbsp;500), <code>lenient: true</code> to skip a malformed NDJSON line with a warning rather
2498
+ than failing, and <code>poll</code> (ms) to re-fetch on an interval, replacing the rows each pass.
2499
+ Every failure &mdash; a non-2xx status, a network error, a bad body, a malformed line &mdash; surfaces
2500
+ as <code>source:error</code> and leaves the grid usable, never an uncaught throw. The fetch is aborted
2501
+ when the grid is destroyed. Zero new dependencies: <code>fetch</code>,
2502
+ <code>response.body.getReader()</code> and <code>TextDecoder</code>.</p>
2503
+ <p class="section-note">A JSON file loaded through a mock transport so the example runs headless with no
2504
+ network; in an app, omit <code>fetch</code> and the global one is used. Run on every build.</p>
2505
+ <pre data-run="js" data-expect="3" data-covers="export:createUrlSource"><code><span class="kw">const</span> { createHeadlessGrid, createUrlSource } = <span class="kw">await</span> import('../packages/core/src/index.js');
2506
+
2507
+ <span class="cmt">// A mock transport, so this runs with no network. In your app, drop `fetch`.</span>
2508
+ <span class="kw">const</span> file = JSON.stringify([{ id: 1, city: 'Oslo' }, { id: 2, city: 'Lima' }, { id: 3, city: 'Cairo' }]);
2509
+ <span class="kw">const</span> fetchImpl = <span class="kw">async</span> () =&gt; <span class="kw">new</span> Response(file, { headers: { 'content-type': 'application/json' } });
2510
+
2511
+ <span class="kw">const</span> grid = createHeadlessGrid({
2512
+ rowKey: 'id',
2513
+ columns: [{ field: 'id' }, { field: 'city' }],
2514
+ source: createUrlSource('https://example.test/cities.json', { fetch: fetchImpl }),
2515
+ });
2516
+
2517
+ <span class="kw">await</span> <span class="kw">new</span> Promise((r) =&gt; setTimeout(r, 50)); <span class="cmt">// let the file load</span>
2518
+ <span class="kw">const</span> count = grid.rows.count();
2519
+ grid.destroy();
2520
+ <span class="kw">return</span> count; <span class="cmt">// 3</span></code></pre>
2521
+ <p>For NDJSON, point it at a <code>.ndjson</code> or <code>.jsonl</code> URL &mdash;
2522
+ <code>createUrlSource('/events.ndjson')</code> &mdash; and the rows stream in as they parse.</p>
2523
+
2481
2524
  <h3 id="derived-source">Derived sources: a grid built from another grid</h3>
2482
2525
  <p class="section-note">
2483
2526
  Most dashboards put a summary panel beside a table, the top five sales people, the
@@ -3639,6 +3682,35 @@ off(); <span class="cmt">// on() returns i
3639
3682
  <tr><td class="name">presence:left</td><td class="desc">A peer disconnected.</td></tr>
3640
3683
  <tr><td class="name">presence:failed</td><td class="desc">A presence transport error. Presence is lossy by design; this is informational.</td></tr>
3641
3684
  <tr><td class="name">presence:lockRefused</td><td class="desc">An edit was refused because a peer holds the cell.</td></tr>
3685
+ <tr><td class="name">export:request</td><td class="type">{ request }</td><td class="desc">A remote export was requested. Past-tense notification.</td></tr>
3686
+ <tr><td class="name">export:done</td><td class="type">{ request, remote }</td><td class="desc">A remote export completed. Past-tense notification.</td></tr>
3687
+ <tr><td class="name">shortcuts:opened</td><td class="type">{}</td><td class="desc">The keyboard-shortcuts help overlay opened. Past-tense notification.</td></tr>
3688
+ <tr><td class="name">shortcuts:closed</td><td class="type">{}</td><td class="desc">The keyboard-shortcuts help overlay closed. Past-tense notification.</td></tr>
3689
+ <tr><td class="name">print:before</td><td class="type">{ rows }</td><td class="desc">Print mode is about to snapshot. Past-tense notification, not cancellable.</td></tr>
3690
+ <tr><td class="name">print:after</td><td class="type">{ rows }</td><td class="desc">Print mode restored the grid. Fires even if the print was cancelled by the browser.</td></tr>
3691
+ <tr><td colspan="3" class="desc"><strong>Cancellable before-events (BACKLOG-0000943).</strong> Each gates a user-initiated mutation. The handler receives a <code>BeforeEvent</code> carrying the action context plus <code>preventDefault(reason?)</code>, <code>defaultPrevented</code> and <code>reason</code>. Calling <code>preventDefault()</code> (or returning <code>false</code>) cancels the action; the handler may be <code>async</code>, and the mutation is held until every before-handler settles. On a veto the paired <code>&lt;action&gt;:cancelled</code> fires with the reason. Host/API writes and remote/router-applied deltas (origin&nbsp;!==&nbsp;<code>'user'</code>) do not fire these. The <code>origin</code> field distinguishes a genuine user gesture from a module-driven re-entry, which is how a host deduplicates.</td></tr>
3692
+ <tr><td class="name">beforeEdit</td><td class="type">{ row, key, mode, changes, origin }</td><td class="desc">A validated cell/row commit is about to apply. <code>changes</code> is <code>[{ colId, oldValue, newValue }]</code>. Validation (<code>edit.validate</code>) is separate and runs first.</td></tr>
3693
+ <tr><td class="name">beforeSort</td><td class="type">{ sort, origin }</td><td class="desc">A sort is about to be set.</td></tr>
3694
+ <tr><td class="name">beforeFilter</td><td class="type">{ filters?, quick?, kind, origin }</td><td class="desc">A structured (<code>kind: 'structured'</code>) or quick (<code>kind: 'quick'</code>) filter is about to be set.</td></tr>
3695
+ <tr><td class="name">beforeColumnMove</td><td class="type">{ column, to, origin }</td><td class="desc">A column reorder is about to apply.</td></tr>
3696
+ <tr><td class="name">beforeColumnResize</td><td class="type">{ column, width, origin }</td><td class="desc">A column width change is about to apply.</td></tr>
3697
+ <tr><td class="name">beforeColumnHide</td><td class="type">{ columns, origin }</td><td class="desc">One or more columns are about to be hidden.</td></tr>
3698
+ <tr><td class="name">beforeSelect</td><td class="type">{ keys, previous, origin }</td><td class="desc">A user selection change is about to apply. A veto snaps back to the last announced selection.</td></tr>
3699
+ <tr><td class="name">beforeRowAdd</td><td class="type">{ row, origin }</td><td class="desc">An optimistic row append is about to apply.</td></tr>
3700
+ <tr><td class="name">beforeDelete</td><td class="type">{ key, rows, origin }</td><td class="desc">An optimistic row delete is about to apply — the canonical confirm-before-delete hook.</td></tr>
3701
+ <tr><td class="name">beforeRowMove</td><td class="type">{ key, from, to, origin }</td><td class="desc">A row reorder is about to apply.</td></tr>
3702
+ <tr><td class="name">beforeGroup</td><td class="type">{ key, expanded, origin }</td><td class="desc">A group/tree expand or collapse is about to apply.</td></tr>
3703
+ <tr><td class="name">edit:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeEdit</code> was vetoed. <code>reason</code> is <code>'stale'</code> when a live delta moved the cell during an async gate.</td></tr>
3704
+ <tr><td class="name">sort:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeSort</code> was vetoed.</td></tr>
3705
+ <tr><td class="name">filter:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeFilter</code> was vetoed.</td></tr>
3706
+ <tr><td class="name">columnMove:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeColumnMove</code> was vetoed.</td></tr>
3707
+ <tr><td class="name">columnResize:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeColumnResize</code> was vetoed.</td></tr>
3708
+ <tr><td class="name">columnHide:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeColumnHide</code> was vetoed.</td></tr>
3709
+ <tr><td class="name">selection:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeSelect</code> was vetoed; the selection snapped back.</td></tr>
3710
+ <tr><td class="name">rowAdd:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeRowAdd</code> was vetoed.</td></tr>
3711
+ <tr><td class="name">delete:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeDelete</code> was vetoed. <code>reason</code> is <code>'stale'</code> when the row was already gone.</td></tr>
3712
+ <tr><td class="name">rowMove:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeRowMove</code> was vetoed. <code>reason</code> is <code>'stale'</code> when the row had moved.</td></tr>
3713
+ <tr><td class="name">group:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeGroup</code> was vetoed.</td></tr>
3642
3714
  </tbody>
3643
3715
  </table>
3644
3716
  </div>
@@ -3672,6 +3744,67 @@ grid.destroy();
3672
3744
  handlerName('annotation:changed'), <span class="cmt">// the adapter prop the frameworks expose</span>
3673
3745
  ].join(' | ');</code></pre>
3674
3746
 
3747
+ <h3 id="before-events-example">Cancellable before-events: guarded editing and confirm-before-delete, executed</h3>
3748
+ <p class="section-note">Every user-initiated mutation has a cancellable <code>before</code> event
3749
+ (BACKLOG-0000943). A handler cancels the pending action with
3750
+ <code>preventDefault(reason?)</code> and may be <code>async</code> — the mutation is held until it
3751
+ settles, which is what makes a confirm dialog or a server check a genuine gate. On a veto the paired
3752
+ <code>&lt;action&gt;:cancelled</code> fires with the reason. Host/API writes and remote deltas do not
3753
+ fire them. Run headless on every build.</p>
3754
+ <pre data-run="js" data-expect="0|Ann|sort,edit locked" data-covers="event:beforeEdit event:beforeSort event:beforeFilter event:beforeColumnMove event:beforeColumnResize event:beforeColumnHide event:beforeSelect event:beforeRowAdd event:beforeDelete event:beforeRowMove event:beforeGroup event:edit:cancelled event:sort:cancelled event:filter:cancelled event:columnMove:cancelled event:columnResize:cancelled event:columnHide:cancelled event:selection:cancelled event:rowAdd:cancelled event:delete:cancelled event:rowMove:cancelled event:group:cancelled event:print:before event:print:after event:export:request event:export:done event:shortcuts:opened event:shortcuts:closed"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
3755
+ <span class="kw">const</span> grid = createHeadlessGrid({
3756
+ rowKey: 'id',
3757
+ columns: [{ field: 'id' }, { field: 'name', edit: { enabled: <span class="kw">true</span> } }, { field: 'v', type: 'number', edit: { enabled: <span class="kw">true</span> } }],
3758
+ rows: [{ id: 'a', name: 'Ann', v: 1 }, { id: 'b', name: 'Bo', v: 2 }],
3759
+ selection: 'multiple',
3760
+ });
3761
+
3762
+ <span class="kw">const</span> log = [];
3763
+
3764
+ <span class="cmt">// Guarded editing: veto a commit on a locked row, and hear the cancellation.</span>
3765
+ grid.on('beforeEdit', (e) =&gt; { <span class="kw">if</span> (e.key === 'a') e.preventDefault('locked'); });
3766
+ grid.on('edit:cancelled', (e) =&gt; log.push('edit ' + e.reason));
3767
+
3768
+ <span class="cmt">// Guard the query and layout surfaces.</span>
3769
+ grid.on('beforeSort', (e) =&gt; e.preventDefault('view-locked'));
3770
+ grid.on('sort:cancelled', () =&gt; log.push('sort'));
3771
+ grid.on('beforeFilter', (e) =&gt; e.preventDefault('no-filter'));
3772
+ grid.on('filter:cancelled', () =&gt; log.push('filter'));
3773
+ grid.on('beforeColumnMove', (e) =&gt; e.preventDefault('fixed'));
3774
+ grid.on('columnMove:cancelled', () =&gt; log.push('colmove'));
3775
+ grid.on('beforeColumnResize', (e) =&gt; e.preventDefault('fixed'));
3776
+ grid.on('columnResize:cancelled', () =&gt; log.push('colresize'));
3777
+ grid.on('beforeColumnHide', (e) =&gt; e.preventDefault('mandatory'));
3778
+ grid.on('columnHide:cancelled', () =&gt; log.push('colhide'));
3779
+ grid.on('beforeGroup', (e) =&gt; e.preventDefault('frozen'));
3780
+ grid.on('group:cancelled', () =&gt; log.push('group'));
3781
+ grid.on('beforeRowMove', (e) =&gt; e.preventDefault('ordered'));
3782
+ grid.on('rowMove:cancelled', () =&gt; log.push('rowmove'));
3783
+ grid.on('beforeRowAdd', (e) =&gt; e.preventDefault('quota'));
3784
+ grid.on('rowAdd:cancelled', () =&gt; log.push('rowadd'));
3785
+ grid.on('beforeSelect', () =&gt; {});
3786
+ grid.on('selection:cancelled', () =&gt; log.push('sel'));
3787
+
3788
+ <span class="cmt">// Confirm before delete: an async handler holds the delete until it settles.</span>
3789
+ grid.on('beforeDelete', <span class="kw">async</span> (e) =&gt; { <span class="kw">await</span> Promise.resolve(); e.preventDefault('user cancelled'); });
3790
+ grid.on('delete:cancelled', (e) =&gt; log.push('delete ' + e.reason));
3791
+
3792
+ <span class="cmt">// Past-tense notifications a host can also observe (not gates): print, remote</span>
3793
+ <span class="cmt">// export, and the keyboard-shortcuts overlay.</span>
3794
+ grid.on('print:before', () =&gt; log.push('print-before'));
3795
+ grid.on('print:after', () =&gt; log.push('print-after'));
3796
+ grid.on('export:request', () =&gt; {});
3797
+ grid.on('export:done', () =&gt; {});
3798
+ grid.on('shortcuts:opened', () =&gt; {});
3799
+ grid.on('shortcuts:closed', () =&gt; {});
3800
+
3801
+ <span class="cmt">// A vetoed sort (sync) and an edit blocked on the locked row.</span>
3802
+ grid.sort.set([{ col: 'v', dir: 'desc' }]);
3803
+ grid.edit.start('a', 'name');
3804
+ grid.edit.stop(<span class="kw">false</span>, { value: 'Nope' });
3805
+
3806
+ <span class="kw">return</span> grid.sort.get().length + '|' + grid.rows.byKey('a').data.name + '|' + log.join(',');</code></pre>
3807
+
3675
3808
  <h2 id="rules">Conditional formatting</h2>
3676
3809
  <p>Rules compile into the function <code>cell.style</code> already takes, so a compiled rule set
3677
3810
  installs exactly like a hand-written style function.</p>
@@ -4200,6 +4333,13 @@ router.attachDefault(restGrid); <span class="cmt">// the "rest" s
4200
4333
  const counts = router.load(snapshot); <span class="cmt">// keyed diff per grid: [{added,updated,removed}, ...]</span>
4201
4334
  router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); <span class="cmt">// in-place deltas by rowKey</span></code></pre>
4202
4335
  <p><strong>A snapshot is a keyed diff, not a replace.</strong> <code>load</code> re-partitions the whole dataset and, per grid, adds the new rows, updates only the changed ones and removes the gone ones &mdash; so an unchanged row never repaints and selection, scroll and edit state survive. <strong>Deltas are applied in place</strong> by <code>rowKey</code>, last-writer-wins within a batch; an <code>upsert</code> whose partition property has changed <em>moves</em> the row (it leaves the route it no longer matches and joins the one it now does, never duplicated). A record matching no route is counted in <code>router.unrouted</code>, handed to <code>onUnrouted</code>, and &mdash; if an <code>attachDefault</code> grid exists &mdash; routed there, so nothing is ever silently lost. By default a record goes to the first route it matches; <code>overlap: true</code> fans it to every matching route.</p>
4336
+ <div class="note"><p><strong>Fanning one partition value to several viewers? Set <code>overlap: true</code>.</strong> The headline "route one feed to many viewers" &mdash; a grid <em>and</em> a KPI panel <em>and</em> a chart, all off one feed &mdash; needs <code>overlap: true</code>. With the default <code>overlap: false</code> a record stops at the <strong>first</strong> route it matches, so a second viewer attached to the <em>same</em> value silently receives nothing. Distinct values (one viewer per value) do not need it. When two routes claim the same value while <code>overlap</code> is false, the router emits a one-time dev warning naming the value.</p>
4337
+ <pre><code><span class="cmt">// One value, several viewers: a grid, a KPI panel and a chart all see 'deal'.</span>
4338
+ <span class="kw">const</span> router = createDataRouter({ key: 'kind', rowKey: 'id', overlap: <span class="kw">true</span> });
4339
+ router.attach(dealsGrid, 'deal'); <span class="cmt">// the grid</span>
4340
+ router.subscribe('deal', kpiPanel); <span class="cmt">// a KPI tile off the same value</span>
4341
+ router.attach(dealsChart, 'deal'); <span class="cmt">// a chart off the same value</span>
4342
+ router.load(snapshot); <span class="cmt">// every viewer fills; without overlap:true only the grid would</span></code></pre></div>
4203
4343
  <div class="table-wrap">
4204
4344
  <table>
4205
4345
  <thead><tr><th>Member</th><th>Description</th></tr></thead>
@@ -4218,7 +4358,7 @@ router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); <span class="cmt">
4218
4358
  <tr><td class="sig">flushStream()</td><td class="desc"><strong>v3:</strong> apply the buffered deltas now, coalesced into a single apply (a deterministic point, and for tests).</td></tr>
4219
4359
  <tr><td class="sig">dropped</td><td class="desc"><strong>v3:</strong> how many stale/duplicate deltas the <code>seq</code> dedupe gate has dropped.</td></tr>
4220
4360
  <tr><td class="sig">lastSeq() / checkpoint() / seenThrough(mark)</td><td class="desc"><strong>v3:</strong> the resume point &mdash; the highest applied <code>seq</code>, a per-record checkpoint to persist, and a way to prime it after a reconnect so an early replay is dropped.</td></tr>
4221
- <tr><td class="sig">subscribe(predicate, handler, opts?)</td><td class="desc"><strong>v5:</strong> route a slice to any non-grid view. <code>handler(change)</code> receives the same keyed diff <code>{ add, update, remove }</code> a grid does &mdash; drive a KPI tile, detail pane, map or form. A peer to <code>attach</code>: same partitioning and the same <code>transform</code>/<code>filter</code>/<code>sort</code>/<code>rollup</code> options; grids and charts are unchanged.</td></tr>
4361
+ <tr><td class="sig">subscribe(predicate, handler, opts?)</td><td class="desc"><strong>v5:</strong> route a slice to any non-grid view. <code>handler(change)</code> receives the same keyed diff <code>{ add, update, remove }</code> a grid does &mdash; drive a KPI tile, detail pane, map or form. A peer to <code>attach</code>: same partitioning and the same <code>transform</code>/<code>filter</code>/<code>sort</code>/<code>rollup</code> options; grids and charts are unchanged. To drive a KPI/pane <em>and</em> a grid off the <strong>same</strong> partition value, create the router with <code>overlap: true</code> &mdash; with the default <code>overlap: false</code> only the first route on that value receives rows (the router warns once when it detects the clash).</td></tr>
4222
4362
  <tr><td class="sig">alert(predicate, condition, handler, { filter?, debounce?, rowKey? })</td><td class="desc"><strong>v5:</strong> watch a slice and emit rather than render. <code>condition(rows)</code> is evaluated over the slice on every load and delta; when it first becomes truthy, <code>handler(signal, rows)</code> fires. Edge-triggered (once per crossing, re-arms on release), <code>debounce</code> coalesces a burst, and it never competes for a partition or touches a grid.</td></tr>
4223
4363
  <tr><td class="sig">configure(spec)</td><td class="desc"><strong>v5:</strong> the whole routing graph as one data spec &mdash; <code>routes</code> (grid/<code>default</code>/<code>subscribe</code>/<code>alert</code> entries), <code>links</code>, <code>relate</code>, <code>buffer</code> &mdash; desugared to the imperative API. Composes with imperative calls and round-trips to identical behaviour. Also accepted as <code>createDataRouter({ config })</code>.</td></tr>
4224
4364
  <tr><td class="sig">attach(grid, predicate, { writable, onWrite?, onConflict? })</td><td class="desc"><strong>v8 (BACKLOG-0000912):</strong> make a route <strong>writable</strong> &mdash; the router captures the grid's committed edits off its public edit surface (<code>grid.on('cell:changed')</code> &rarr; <code>grid.edit.setCells</code>) and routes them to <code>onWrite(change, { route, source })</code> (per-route here, or the router-global <code>onWrite</code>), reverting the cell on reject and re-entering an accepted write as a normal delta. <code>onConflict(change, { serverRow })</code> surfaces a last-write-wins conflict. A derived (<code>rollup</code>/<code>transform</code>) route cannot be writable &mdash; its edits are reverted and warned.</td></tr>
@@ -6097,6 +6237,18 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
6097
6237
  </tbody>
6098
6238
  </table>
6099
6239
  </div>
6240
+ <h3 id="type-BeforeEvent">BeforeEvent</h3>
6241
+ <p class="section-note">A cancellable *before*-event (BACKLOG-0000943), delivered to `on('beforeX')` handlers before a user-initiated mutation is applied. A handler cancels the pending action by calling `preventDefault(reason?)`; the mutation is then abandoned and a past-tense `&lt;action&gt;:cancelled` event carries the reason. A handler may be `async` (or return a Promise): the grid awaits every before-handler before deciding, so a confirm dialog or a server check can gate the write. Any one handler preventing cancels the action. The action-specific fields (the edited cells, the target index, the affected rows) are spread alongside these, so a handler decides without reaching into grid internals. `origin` distinguishes a genuine user gesture from a host/module-driven or remote write, which is how a module whose move re-enters core is deduplicated by the host.</p>
6242
+ <div class="table-wrap">
6243
+ <table>
6244
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6245
+ <tbody>
6246
+ <tr><td class="name">preventDefault</td><td class="type">(reason?: string): void</td><td class="desc">Cancel the pending action; the optional reason is surfaced on the cancellation event.</td></tr>
6247
+ <tr><td class="name">defaultPrevented</td><td class="type">boolean</td><td class="desc">True once any handler has called `preventDefault` or returned false.</td></tr>
6248
+ <tr><td class="name">reason</td><td class="type">string | null</td><td class="desc">The reason given to `preventDefault`, or null; `'stale'` when re-validation failed.</td></tr>
6249
+ </tbody>
6250
+ </table>
6251
+ </div>
6100
6252
  <h3 id="type-BooleanFormat">BooleanFormat</h3>
6101
6253
  <div class="table-wrap">
6102
6254
  <table>
@@ -6165,6 +6317,7 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
6165
6317
  <tr><td class="name">text</td><td class="type">string</td><td class="desc"></td></tr>
6166
6318
  <tr><td class="name">index</td><td class="type">number</td><td class="desc"></td></tr>
6167
6319
  <tr><td class="name">props</td><td class="type">Record&lt;string, unknown&gt;</td><td class="desc"><small>(optional)</small></td></tr>
6320
+ <tr><td class="name">t</td><td class="type">(key: string, vars?: Record&lt;string, unknown&gt;) =&gt; string</td><td class="desc">Format a message from the grid's catalogue, for a renderer that wants its own accessible names and labels localised rather than hard-coded (§17, WCAG 4.1.2). The built-in renderers use this; a custom renderer may too. Optional: absent when a renderer is exercised without a grid to ask. <small>(optional)</small></td></tr>
6168
6321
  </tbody>
6169
6322
  </table>
6170
6323
  </div>
@@ -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.35.0</p>
440
+ <p class="rail__sub">Developer guide · v1.37.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -1869,6 +1869,38 @@ grid.rows.load(data);
1869
1869
  grid.overlay.hide();</code></pre>
1870
1870
  </div>
1871
1871
 
1872
+ <h3 id="url-source-guide">Straight from a URL, with <code>createUrlSource</code></h3>
1873
+ <p class="lead-in">
1874
+ When the data is a file at a URL you do not have to fetch it yourself.
1875
+ <code>createUrlSource(url, opts)</code> loads a JSON file or streams an NDJSON/JSONL file directly,
1876
+ and you pass it as the <code>source</code>. See the
1877
+ <a href="API.html#url-source">Sources reference</a> for every option.
1878
+ </p>
1879
+ <div class="example">
1880
+ <p class="example__label">A JSON file, and a streamed NDJSON file</p>
1881
+ <pre><code><span class="cmt">// A JSON file: a top-level array, or nested via rowsPath / map.</span>
1882
+ createGrid(el, { columns, rowKey: 'id', source: createUrlSource('/data/circuits.json') });
1883
+
1884
+ <span class="cmt">// An NDJSON file: rows stream in as they parse, first rows first.</span>
1885
+ createGrid(el, { columns, rowKey: 'id', source: createUrlSource('/data/events.ndjson', { batchSize: 500 }) });
1886
+
1887
+ <span class="cmt">// Auth, a nested array, and a 30s refresh:</span>
1888
+ createGrid(el, {
1889
+ columns, rowKey: 'id',
1890
+ source: createUrlSource('/api/rows', {
1891
+ headers: { Authorization: 'Bearer …' },
1892
+ rowsPath: 'result.items',
1893
+ poll: 30000,
1894
+ }),
1895
+ });</code></pre>
1896
+ </div>
1897
+ <p>
1898
+ The format is inferred from the extension, then the <code>Content-Type</code>, then a sniff &mdash;
1899
+ override it with <code>format: 'json' | 'ndjson'</code>. A non-2xx response, a network error or a
1900
+ malformed NDJSON line becomes a <code>source:error</code> event rather than an exception, and
1901
+ <code>lenient: true</code> skips a bad NDJSON line instead of failing the whole stream.
1902
+ </p>
1903
+
1872
1904
  <h3>Incremental changes</h3>
1873
1905
  <p class="lead-in">
1874
1906
  <code>rows.load</code> replaces everything. When you have a delta, a websocket message, a
@@ -6238,7 +6270,7 @@ grid.state.apply(savedView.state);
6238
6270
  <tr><td class="name">canChartRange</td><td class="desc">Whether <code>chartRange</code> would draw something for the grid’s current selection — the question a menu asks before offering the item.</td></tr>
6239
6271
  <tr><td class="name">deriveRangeSpec</td><td class="desc">Decide what a chart of a range should be without drawing it: the type, the category column, the measures, and a spec ready for <code>createChart</code>.</td></tr>
6240
6272
  <tr><td class="name">regressionPlots</td><td class="desc">Turn a fitted regression model into diagnostic chart specs ready for <code>createChart</code>: the fit line with its confidence band, residuals-vs-fitted, a QQ plot of the residuals, and a multicollinearity correlogram with the model’s VIF. The plots that need a per-row or per-coefficient quantity the grid has no column for (scale-location, residuals-vs-leverage, the coefficient forest) are returned as a null spec carrying the reason rather than dropped.</td></tr>
6241
- <tr><td class="name">createDataRouter</td><td class="desc">Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed <code>rows.apply</code> path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. v2 adds cross-grid selection filtering: <code>link(source, target, relation)</code> makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: <code>subscribe(value, handler)</code> routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; <code>alert(value, condition, handler)</code> evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and <code>configure(spec)</code> (or <code>createDataRouter({ config })</code>) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. v3 also adds per-route reshaping — <code>transform</code>/<code>filter</code>/<code>sort</code> and <code>rollup</code> ({ groupBy, aggregate }) summaries — a relationship graph (<code>relate(edges)</code>: multi-hop, several-into-one AND, and mutual edges) that scales v2's pairwise <code>link</code>, and stream hygiene: a <code>seq</code>/version orders and de-duplicates a feed (stale/duplicate deltas dropped, counted in <code>dropped</code>), <code>push</code> with a <code>batch</code>/<code>coalesce</code> buffers a high-frequency feed (<code>flushStream</code> for a deterministic point), and <code>lastSeq</code>/<code>checkpoint</code>/<code>seenThrough</code> resume precisely after a dropped socket. v4 adds time-travel: <code>buffer({ window, max })</code> records the ordered stream into a bounded ring over a moving base, so <code>scrubTo</code> reconstructs a past point, <code>replay</code> (with <code>pause</code>/<code>resume</code>) walks a range, and <code>live</code> returns to the head — every state pushed by the same keyed diff, <code>traveling</code>/<code>buffered</code> reporting the state. v6 adds cross-tab sync: <code>broadcast({ channel })</code> mirrors the ordered, de-duplicated deltas to other tabs/windows over a BroadcastChannel with no echo loop, a popped-out grid joining the same feed with no second socket and resyncing mid-stream via the reconnect path. v7 adds query-slice routing: <code>query(adapter, request)</code> sources the router from a DFQL/DuckDB (or any pushdown) adapter, partitioning one result across the routes; a route-level <code>where</code> is pushed down where the adapter's capabilities allow and the residual finished client-side, with <code>lastQueryPlan</code> reporting the split. v8 adds write-back: a <code>writable</code> route captures the grid's committed edits off its public edit surface and routes them to <code>onWrite(change, ctx)</code> (per-route or router-global), reverting on reject, re-entering an accepted write as a delta, and surfacing a last-write-wins <code>onConflict</code>; a derived route cannot be writable. v9 adds fan-in: <code>addSource(feed, { map, key })</code> returns a per-feed handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>) whose rows are normalized and namespaced so many feeds merge into one keyed store without id collisions, <code>removeSource</code> dropping exactly a feed's rows and <code>sources</code> listing them. v10 adds observability: <code>metrics()</code> is a cheap snapshot of per-route/per-source counts and throughput plus the global unrouted/dropped/buffered/lag figures, <code>on('metrics')</code> drives a periodic emit (off unless a listener is registered), and <code>mountDevtools(el)</code> renders a live panel from the module's own DOM file. Detaches its grids on <code>destroy</code>; the host owns them.</td></tr>
6273
+ <tr><td class="name">createDataRouter</td><td class="desc">Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed <code>rows.apply</code> path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. By default routing is first-match-wins (<code>overlap: false</code>); to fan one partition value to several viewers at once (a grid <em>and</em> a KPI panel <em>and</em> a chart off one feed) create the router with <code>overlap: true</code> — with the default, a second viewer on the same value receives nothing and the router emits a one-time dev warning naming the clash. v2 adds cross-grid selection filtering: <code>link(source, target, relation)</code> makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: <code>subscribe(value, handler)</code> routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; <code>alert(value, condition, handler)</code> evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and <code>configure(spec)</code> (or <code>createDataRouter({ config })</code>) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. v3 also adds per-route reshaping — <code>transform</code>/<code>filter</code>/<code>sort</code> and <code>rollup</code> ({ groupBy, aggregate }) summaries — a relationship graph (<code>relate(edges)</code>: multi-hop, several-into-one AND, and mutual edges) that scales v2's pairwise <code>link</code>, and stream hygiene: a <code>seq</code>/version orders and de-duplicates a feed (stale/duplicate deltas dropped, counted in <code>dropped</code>), <code>push</code> with a <code>batch</code>/<code>coalesce</code> buffers a high-frequency feed (<code>flushStream</code> for a deterministic point), and <code>lastSeq</code>/<code>checkpoint</code>/<code>seenThrough</code> resume precisely after a dropped socket. v4 adds time-travel: <code>buffer({ window, max })</code> records the ordered stream into a bounded ring over a moving base, so <code>scrubTo</code> reconstructs a past point, <code>replay</code> (with <code>pause</code>/<code>resume</code>) walks a range, and <code>live</code> returns to the head — every state pushed by the same keyed diff, <code>traveling</code>/<code>buffered</code> reporting the state. v6 adds cross-tab sync: <code>broadcast({ channel })</code> mirrors the ordered, de-duplicated deltas to other tabs/windows over a BroadcastChannel with no echo loop, a popped-out grid joining the same feed with no second socket and resyncing mid-stream via the reconnect path. v7 adds query-slice routing: <code>query(adapter, request)</code> sources the router from a DFQL/DuckDB (or any pushdown) adapter, partitioning one result across the routes; a route-level <code>where</code> is pushed down where the adapter's capabilities allow and the residual finished client-side, with <code>lastQueryPlan</code> reporting the split. v8 adds write-back: a <code>writable</code> route captures the grid's committed edits off its public edit surface and routes them to <code>onWrite(change, ctx)</code> (per-route or router-global), reverting on reject, re-entering an accepted write as a delta, and surfacing a last-write-wins <code>onConflict</code>; a derived route cannot be writable. v9 adds fan-in: <code>addSource(feed, { map, key })</code> returns a per-feed handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>) whose rows are normalized and namespaced so many feeds merge into one keyed store without id collisions, <code>removeSource</code> dropping exactly a feed's rows and <code>sources</code> listing them. v10 adds observability: <code>metrics()</code> is a cheap snapshot of per-route/per-source counts and throughput plus the global unrouted/dropped/buffered/lag figures, <code>on('metrics')</code> drives a periodic emit (off unless a listener is registered), and <code>mountDevtools(el)</code> renders a live panel from the module's own DOM file. Detaches its grids on <code>destroy</code>; the host owns them.</td></tr>
6242
6274
  <tr><td class="name">createKanban</td><td class="desc">A board (kanban) view of rows as cards, grouped into columns by a configurable property (a status, a stage, a state field), with per-column card count and an optional points sum, a WIP over-limit flag, configured columns shown even when empty, granular readonly (whole board / per column / per card), field-mapped card templates that reuse the grid’s own column formatters when a grid is bound, and the core pointer events (<code>card:click</code>, <code>card:dblclick</code>, <code>card:contextmenu</code>). It is a dataset viewer like any other: it consumes data through the same keyed-diff <code>rows.apply({ add, update, remove })</code> contract a grid exposes, so a Data Router can <code>attach(value, board)</code> and drive a kanban beside a grid and a chart off one feed. Accessibility is built in from the start — a labelled group of labelled column lists, cards in a roving-tabindex focus ring with arrow-key navigation, and a polite live region. Every structural property is named in config (<code>columnProperty</code>, <code>pointsProperty</code>, <code>orderProperty</code>, <code>swimlaneProperty</code>, <code>sprintProperty</code>, <code>epicProperty</code>) so it maps DemandFlow and any customer schema without code change. Pass <code>null</code> as the element for a headless board that computes the same model without a DOM. Cards drag between columns (writing the column property) and within a column into a position (writing the order property with fractional ranking), and the same move is keyboard-accessible — <kbd>Space</kbd> to grab, arrows to choose a target, <kbd>Space</kbd>/<kbd>Enter</kbd> to drop, <kbd>Escape</kbd> to cancel — announced on the live region; multi-select drags every selected card. A move calls <code>onBeforeMove(card, from, to, index)</code> first (return <code>false</code> to veto) and then persists through the grid’s shipped write-back path — grid-bound via <code>grid.edit.setCells</code> (the same public edit-commit path inline editing uses, so the grid’s pipeline owns optimistic apply / confirm / revert), standalone with a revert when <code>onCardMove</code> rejects — emitting <code>card:move</code>. A configurable per-card <code>contextMenu</code> replaces the <code>card:contextmenu</code> event when present. With <code>swimlanes: true</code> it renders a 2D lane&times;column grid grouped by <code>swimlaneProperty</code> (per-lane count/points, columns aligned across lanes, a cross-lane drag writing the swimlane property); columns and lanes collapse (state surviving a keyed-diff update), columns reorder by header drag, and <code>setQuickFilter</code>/<code>setFilter</code>/<code>facets</code> drive search and faceting. <code>setSprint</code>/<code>showBacklog</code>/<code>sprints</code> give a sprint view, switcher and backlog; <code>setEpic</code>/<code>epicRollup</code>/<code>rollup</code> give an epic view and rollups (count, points, progress toward the <code>done</code> columns). A card can pop out a nested grid of its children (an epic's stories, a story's tasks, recursively) via a <code>childrenProperty</code> and/or <code>loadChildren(card)</code>: the child is a full composed <code>createGrid</code> (or, with <code>asBoard</code>, a nested board) opened in a drawer/modal/inline container — reuse by composition, no grid-core coupling — emitting <code>card:expand</code>/<code>card:drill</code>. Live updates arrive through the same keyed-diff contract a grid uses, so a Data Router drives the board directly (<code>attach(board, predicate)</code>); a live <code>rows.apply</code> re-renders preserving scroll, focus, selection, collapse and any open pop-out. <code>virtualize</code> renders only a scroll window of a tall column; <code>getState</code>/<code>setState</code> (and <code>config.state</code>) save and restore collapse, order, filter and sprint/epic selection; <code>setLoading</code>/<code>setError</code> give loading and error states. The move is fully keyboard-driven — Space to grab, arrows for column/position, Alt+Up/Down across swimlanes, Space/Enter to drop, Escape to cancel — announced on a live region. A field opted in with <code>card: { title: { field, edit: true } }</code> edits inline (double-click or <code>editCard</code>): grid-bound through the grid's own field editor via its public edit path, standalone through a host editor factory or a default input with an <code>onCardEdit</code> revert; a per-column add-card (<code>config.addCard</code>/<code>onAddCard</code>, or <code>grid.edit.addRow</code>) creates a card and opens it in edit. The module imports nothing from the grid's DOM package.</td></tr>
6243
6275
  <tr><td class="name">createKPI</td><td class="desc">A KPI / stat-tile view (module <code>kpi</code>) of a dataset as a panel of aggregate tiles — each tile a <code>sum</code>, <code>avg</code>, <code>min</code>, <code>max</code>, <code>count</code>, <code>countDistinct</code> or a <code>custom</code> reducer over the routed rows, with an optional <code>filter</code> predicate, number <code>format</code> (<code>number</code>/<code>currency</code>/<code>percent</code>/<code>compact</code>), a <code>baseline</code> for a delta, semantic threshold bands (<code>thresholds</code> with two cut points and a direction, or an explicit <code>bands</code> list, giving a <code>good</code>/<code>warn</code>/<code>critical</code> status kept separate from any accent), and an optional <code>sparkline</code> series. It is a dataset viewer like any other: it consumes data through the same keyed-diff <code>rows.apply({ add, update, remove })</code> contract a grid exposes, so a Data Router can <code>attach(value, kpi)</code> and drive a KPI panel beside a grid, a kanban and a chart off one feed. Updates are incremental — a delta adjusts each tile's running accumulator by only the rows it carries (an add contributes, a remove reverses, an update reverses-then-contributes), the two bounded exceptions being a <code>min</code>/<code>max</code> whose current extreme is removed (a rescan of that tile's own value multiset) and a <code>custom</code> reducer (recomputed over the filtered store, an arbitrary function having no inverse). Each tile is a labelled <code>&lt;figure&gt;</code>, focusable and keyboard-activatable, its value announced, the sparkline respecting <code>prefers-reduced-motion</code>; a <code>tile:click</code> event (also from the keyboard) lets a host drill down or filter a routed grid. Pass <code>null</code> as the element for a headless panel that computes the same tile model without a DOM. Not a dashboard layout engine (that is the parked dashboard generator) and no charting beyond the minimal sparkline (that is the charts module).</td></tr>
6244
6276
  <tr><td class="name">createDevtools</td><td class="desc">Mount the devtools panel against a grid, including its accessibility checks.</td></tr>
@@ -6493,6 +6525,59 @@ el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);</code></pre>
6493
6525
  </tbody>
6494
6526
  </table>
6495
6527
  </div>
6528
+ <h4>Cancellable before-events (BACKLOG-0000943)</h4>
6529
+ <p>Every user-initiated mutation has a paired cancellable <code>before</code> event. The handler
6530
+ receives a <code>BeforeEvent</code> carrying the action context plus <code>preventDefault(reason?)</code>,
6531
+ <code>defaultPrevented</code> and <code>reason</code>. Calling <code>preventDefault()</code> — or
6532
+ returning <code>false</code>, the legacy kanban <code>onBeforeMove</code> idiom — cancels the action.
6533
+ A handler may be <code>async</code>; the mutation is held until every registered before-handler
6534
+ settles, so a confirm dialog or a server check genuinely gates the write. Any one handler
6535
+ preventing cancels it (veto wins), and a handler that throws is treated as a cancel and surfaced.
6536
+ On a veto the paired <code>&lt;action&gt;:cancelled</code> event fires carrying the reason.</p>
6537
+ <p>These fire for <em>user</em> actions only. Host/API writes (for example
6538
+ <code>grid.edit.setCells</code>) and remote/router-applied deltas (<code>rows.apply</code>,
6539
+ origin&nbsp;!==&nbsp;<code>'user'</code>) do not fire them — remote truth is not a user gesture and
6540
+ does not self-veto. The <code>origin</code> field carried on each before-event lets a host
6541
+ deduplicate a module-initiated write (a kanban or Gantt move that re-enters core) from a genuine
6542
+ user gesture. If a handler was <code>async</code> and the underlying state moved during the await
6543
+ (a row removed, a value changed by a live delta), the gate re-validates and cancels with reason
6544
+ <code>'stale'</code> rather than applying against state that has moved. With no before-handler
6545
+ registered every mutation stays synchronous and behaves exactly as before.</p>
6546
+ <div class="table-wrap">
6547
+ <table>
6548
+ <thead><tr><th>Event</th><th>Fires when</th></tr></thead>
6549
+ <tbody>
6550
+ <tr><td class="name">beforeEdit</td><td class="desc">Before a validated cell/row commit applies. Carries row, key, mode, changes, origin. Validation (edit.validate) is separate and runs first. Paired with edit:cancelled.</td></tr>
6551
+ <tr><td class="name">beforeSort</td><td class="desc">Before a sort is set. Paired with sort:cancelled.</td></tr>
6552
+ <tr><td class="name">beforeFilter</td><td class="desc">Before a structured or quick filter is set (kind tells them apart). Paired with filter:cancelled.</td></tr>
6553
+ <tr><td class="name">beforeColumnMove</td><td class="desc">Before a column reorder applies, earlier than the post-mutation column change. Paired with columnMove:cancelled.</td></tr>
6554
+ <tr><td class="name">beforeColumnResize</td><td class="desc">Before a column width change applies. Paired with columnResize:cancelled.</td></tr>
6555
+ <tr><td class="name">beforeColumnHide</td><td class="desc">Before one or more columns are hidden. Paired with columnHide:cancelled.</td></tr>
6556
+ <tr><td class="name">beforeSelect</td><td class="desc">Before a user selection change applies; a veto snaps back to the last announced selection. Paired with selection:cancelled.</td></tr>
6557
+ <tr><td class="name">beforeRowAdd</td><td class="desc">Before an optimistic row append applies. Paired with rowAdd:cancelled.</td></tr>
6558
+ <tr><td class="name">beforeDelete</td><td class="desc">Before an optimistic row delete applies — the canonical confirm-before-delete hook. Paired with delete:cancelled.</td></tr>
6559
+ <tr><td class="name">beforeRowMove</td><td class="desc">Before a row reorder applies. Paired with rowMove:cancelled.</td></tr>
6560
+ <tr><td class="name">beforeGroup</td><td class="desc">Before a group/tree expand or collapse applies. Paired with group:cancelled.</td></tr>
6561
+ <tr><td class="name">edit:cancelled</td><td class="desc">A beforeEdit was vetoed; reason is 'stale' when a live delta moved the cell during an async gate.</td></tr>
6562
+ <tr><td class="name">sort:cancelled</td><td class="desc">A beforeSort was vetoed.</td></tr>
6563
+ <tr><td class="name">filter:cancelled</td><td class="desc">A beforeFilter was vetoed.</td></tr>
6564
+ <tr><td class="name">columnMove:cancelled</td><td class="desc">A beforeColumnMove was vetoed.</td></tr>
6565
+ <tr><td class="name">columnResize:cancelled</td><td class="desc">A beforeColumnResize was vetoed.</td></tr>
6566
+ <tr><td class="name">columnHide:cancelled</td><td class="desc">A beforeColumnHide was vetoed.</td></tr>
6567
+ <tr><td class="name">selection:cancelled</td><td class="desc">A beforeSelect was vetoed; the selection snapped back.</td></tr>
6568
+ <tr><td class="name">rowAdd:cancelled</td><td class="desc">A beforeRowAdd was vetoed.</td></tr>
6569
+ <tr><td class="name">delete:cancelled</td><td class="desc">A beforeDelete was vetoed; reason is 'stale' when the row was already gone.</td></tr>
6570
+ <tr><td class="name">rowMove:cancelled</td><td class="desc">A beforeRowMove was vetoed; reason is 'stale' when the row had moved.</td></tr>
6571
+ <tr><td class="name">group:cancelled</td><td class="desc">A beforeGroup was vetoed.</td></tr>
6572
+ <tr><td class="name">export:request</td><td class="desc">A remote export was requested. Past-tense notification.</td></tr>
6573
+ <tr><td class="name">export:done</td><td class="desc">A remote export completed. Past-tense notification.</td></tr>
6574
+ <tr><td class="name">shortcuts:opened</td><td class="desc">The keyboard-shortcuts help overlay opened. Past-tense notification.</td></tr>
6575
+ <tr><td class="name">shortcuts:closed</td><td class="desc">The keyboard-shortcuts help overlay closed. Past-tense notification.</td></tr>
6576
+ <tr><td class="name">print:before</td><td class="desc">Print mode is about to snapshot. Past-tense notification, not cancellable (BACKLOG-0000941).</td></tr>
6577
+ <tr><td class="name">print:after</td><td class="desc">Print mode restored the grid, even if the browser cancelled the print (BACKLOG-0000941).</td></tr>
6578
+ </tbody>
6579
+ </table>
6580
+ </div>
6496
6581
  <h4>Presentation and formatting</h4>
6497
6582
  <div class="table-wrap">
6498
6583
  <table>
package/lattice-grid.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Lattice Grid 1.35.0, type declarations
2
+ * Lattice Grid 1.37.0, type declarations
3
3
  * Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
4
4
  * https://latticegrid.dev
5
5
  */
@@ -182,6 +182,13 @@ export interface CellParams extends ValueParams {
182
182
  text: string;
183
183
  index: number;
184
184
  props?: Record<string, unknown>;
185
+ /**
186
+ * Format a message from the grid's catalogue, for a renderer that wants its
187
+ * own accessible names and labels localised rather than hard-coded (§17,
188
+ * WCAG 4.1.2). The built-in renderers use this; a custom renderer may too.
189
+ * Optional: absent when a renderer is exercised without a grid to ask.
190
+ */
191
+ t?: (key: string, vars?: Record<string, unknown>) => string;
185
192
  }
186
193
 
187
194
  export interface FormatParams extends ValueParams { locale: string }
@@ -3442,7 +3449,21 @@ export type EventName =
3442
3449
  /* Annotations */
3443
3450
  | 'annotation:changed'
3444
3451
  /* Export */
3445
- | 'export:progress'
3452
+ | 'export:progress' | 'export:request' | 'export:done'
3453
+ /* Keyboard help overlay (past-tense notifications) */
3454
+ | 'shortcuts:opened' | 'shortcuts:closed'
3455
+ /* Print (past-tense notifications, BACKLOG-0000941) */
3456
+ | 'print:before' | 'print:after'
3457
+ /* Cancellable before-events (BACKLOG-0000943). Delivered through the async
3458
+ * before-dispatch path with a {@link BeforeEvent} carrying preventDefault. */
3459
+ | 'beforeEdit' | 'beforeSort' | 'beforeFilter'
3460
+ | 'beforeColumnMove' | 'beforeColumnResize' | 'beforeColumnHide'
3461
+ | 'beforeSelect' | 'beforeRowAdd' | 'beforeDelete' | 'beforeRowMove' | 'beforeGroup'
3462
+ /* Their cancellation notifications (past-tense, non-cancellable). */
3463
+ | 'edit:cancelled' | 'sort:cancelled' | 'filter:cancelled'
3464
+ | 'columnMove:cancelled' | 'columnResize:cancelled' | 'columnHide:cancelled'
3465
+ | 'selection:cancelled' | 'rowAdd:cancelled' | 'delete:cancelled'
3466
+ | 'rowMove:cancelled' | 'group:cancelled'
3446
3467
  /* Every event at once, for logging and debugging. */
3447
3468
  | '*';
3448
3469
 
@@ -3453,6 +3474,31 @@ export interface GridEvent {
3453
3474
  [key: string]: unknown;
3454
3475
  }
3455
3476
 
3477
+ /**
3478
+ * A cancellable *before*-event (BACKLOG-0000943), delivered to `on('beforeX')`
3479
+ * handlers before a user-initiated mutation is applied.
3480
+ *
3481
+ * A handler cancels the pending action by calling `preventDefault(reason?)`; the
3482
+ * mutation is then abandoned and a past-tense `<action>:cancelled` event carries
3483
+ * the reason. A handler may be `async` (or return a Promise): the grid awaits
3484
+ * every before-handler before deciding, so a confirm dialog or a server check
3485
+ * can gate the write. Any one handler preventing cancels the action.
3486
+ *
3487
+ * The action-specific fields (the edited cells, the target index, the affected
3488
+ * rows) are spread alongside these, so a handler decides without reaching into
3489
+ * grid internals. `origin` distinguishes a genuine user gesture from a
3490
+ * host/module-driven or remote write, which is how a module whose move re-enters
3491
+ * core is deduplicated by the host.
3492
+ */
3493
+ export interface BeforeEvent extends GridEvent {
3494
+ /** Cancel the pending action; the optional reason is surfaced on the cancellation event. */
3495
+ preventDefault(reason?: string): void;
3496
+ /** True once any handler has called `preventDefault` or returned false. */
3497
+ defaultPrevented: boolean;
3498
+ /** The reason given to `preventDefault`, or null; `'stale'` when re-validation failed. */
3499
+ reason: string | null;
3500
+ }
3501
+
3456
3502
  export type EventHandler = (e: GridEvent) => void;
3457
3503
  export type Unsubscribe = () => void;
3458
3504
 
@@ -4997,6 +5043,53 @@ export function createPushdownSource(
4997
5043
  }>;
4998
5044
  };
4999
5045
 
5046
+ /**
5047
+ * Load a JSON or NDJSON file from a URL (BACKLOG-0000944).
5048
+ *
5049
+ * Returns a `StreamSourceConfig` for `createGrid(el, { source: createUrlSource(url, opts) })`.
5050
+ * A JSON file (a top-level array, or a nested array selected by `rowsPath`/`map`)
5051
+ * is read whole and handed over as rows; an NDJSON/JSONL file (one JSON value per
5052
+ * line) is streamed in incrementally in batches. Format is resolved from an
5053
+ * explicit `format`, else the URL extension, else the `Content-Type`, else a
5054
+ * sniff of the first bytes or a clear error. Errors — a non-2xx status, a network
5055
+ * failure, a bad body, a malformed line — surface as `source:error`, never as an
5056
+ * uncaught throw. Zero new dependencies: `fetch`, `response.body.getReader()` and
5057
+ * `TextDecoder`.
5058
+ *
5059
+ * @param url the file URL
5060
+ * @param opts loading options
5061
+ * @returns a stream source config
5062
+ */
5063
+ export function createUrlSource(
5064
+ url: string,
5065
+ opts?: {
5066
+ /** Explicit format; wins over inference. */
5067
+ format?: 'json' | 'ndjson';
5068
+ /** Dot path to the array inside a wrapped JSON body (JSON only). */
5069
+ rowsPath?: string;
5070
+ /** Extract the array from the parsed JSON (JSON only); runs after `rowsPath`. */
5071
+ map?: (parsed: unknown) => unknown[];
5072
+ /** Transport override for auth, headers or a proxy; default `globalThis.fetch`. */
5073
+ fetch?: typeof fetch;
5074
+ /** Headers merged into the request. */
5075
+ headers?: Record<string, string>;
5076
+ /** Re-fetch on this interval in milliseconds; each pass replaces the rows. */
5077
+ poll?: number;
5078
+ /** NDJSON rows per emitted chunk, to avoid render thrash; default 500. */
5079
+ batchSize?: number;
5080
+ /** NDJSON: skip a malformed line with a warning rather than failing the stream. */
5081
+ lenient?: boolean;
5082
+ /** Start fetching on construction; default true. */
5083
+ autoStart?: boolean;
5084
+ /** Sliding-window bound passed through to the stream. */
5085
+ maxRows?: number;
5086
+ /** Render-coalescing window in ms, passed through to the stream. */
5087
+ coalesceMs?: number;
5088
+ /** Promote to a memory source below this row count, passed through to the stream. */
5089
+ promoteToMemoryBelow?: number;
5090
+ },
5091
+ ): StreamSourceConfig;
5092
+
5000
5093
  /**
5001
5094
  * The pushdown map (BACKLOG-0000730 Part B): one published record per statistic
5002
5095
  * giving whether the engine can express it, the DuckDB aggregate SQL it emits,