@toclocoinc/lattice-grid 1.50.0 → 1.52.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 (74) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +477 -11
  3. package/docs/api-detail.html +348 -1
  4. package/lattice-grid.d.ts +399 -10
  5. package/lattice-grid.esm.min.js +619 -69
  6. package/lattice-grid.min.cjs +619 -69
  7. package/lattice-grid.min.js +619 -69
  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 +181 -87
  33. package/modules/charts.min.cjs +181 -87
  34. package/modules/charts.min.js +181 -87
  35. package/modules/data-router.esm.min.js +7 -5
  36. package/modules/data-router.min.cjs +7 -5
  37. package/modules/data-router.min.js +7 -5
  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 +300 -62
  45. package/modules/gantt.min.cjs +300 -62
  46. package/modules/gantt.min.js +300 -62
  47. package/modules/htmx.esm.min.js +619 -69
  48. package/modules/htmx.min.cjs +619 -69
  49. package/modules/htmx.min.js +619 -69
  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 +752 -35
  54. package/modules/kpi.min.cjs +752 -35
  55. package/modules/kpi.min.js +752 -35
  56. package/modules/mock-socket.esm.min.js +2 -2
  57. package/modules/mock-socket.min.cjs +2 -2
  58. package/modules/mock-socket.min.js +2 -2
  59. package/modules/react.esm.min.js +2 -2
  60. package/modules/react.min.cjs +2 -2
  61. package/modules/react.min.js +2 -2
  62. package/modules/svelte.esm.min.js +2 -2
  63. package/modules/svelte.min.cjs +2 -2
  64. package/modules/svelte.min.js +2 -2
  65. package/modules/tabs.esm.min.js +94 -12
  66. package/modules/tabs.min.cjs +94 -12
  67. package/modules/tabs.min.js +94 -12
  68. package/modules/vue.esm.min.js +2 -2
  69. package/modules/vue.min.cjs +2 -2
  70. package/modules/vue.min.js +2 -2
  71. package/modules/webcomponent.esm.min.js +619 -69
  72. package/modules/webcomponent.min.cjs +619 -69
  73. package/modules/webcomponent.min.js +619 -69
  74. package/package.json +1 -1
@@ -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.50.0</p>
440
+ <p class="rail__sub">Developer guide · v1.52.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -473,6 +473,7 @@
473
473
  <span class="rail__label">Connected grids</span>
474
474
  <a href="#pushdown">Querying an engine</a>
475
475
  <a href="#derived">Derived grids</a>
476
+ <a href="#derived-union">Combining several grids</a>
476
477
  <a href="#cross-filter">Cross-filtering</a>
477
478
  <a href="#joins">Joining two grids</a>
478
479
  <a href="#aggregate-safety">Aggregate safety</a>
@@ -3325,6 +3326,196 @@ createGrid(right, {
3325
3326
  </table>
3326
3327
  </div>
3327
3328
 
3329
+ <h2 id="derived-union">Combining several grids into one</h2>
3330
+ <p class="lead-in">
3331
+ A <code>join</code> matches two grids on a shared key. Some questions have no key to share at
3332
+ all &mdash; "the worst performers across two regional datasets" when the two regions use
3333
+ unrelated ids &mdash; and for those, <code>from</code> takes an array of sources instead of one
3334
+ grid: every source is read, concatenated in the order you declared them, and only then does the
3335
+ rest of the pipeline run, once, over the combined set.
3336
+ </p>
3337
+ <div class="example">
3338
+ <p class="example__label">Worst performers, two unrelated datasets</p>
3339
+ <pre><code>createGrid(right, {
3340
+ columns: [{ id: 'title' }, { id: 'severity', type: 'number' }, { id: '__source' }],
3341
+ source: {
3342
+ mode: 'derived',
3343
+ from: [
3344
+ { grid: eastIncidents, label: 'east' },
3345
+ { grid: westIncidents, label: 'west', map: (row) =&gt; ({ title: row.name, severity: row.rating }) },
3346
+ ],
3347
+ sort: [{ col: 'severity', dir: 'desc' }],
3348
+ limit: 10,
3349
+ },
3350
+ });</code></pre>
3351
+ </div>
3352
+ <p>
3353
+ <code>label</code> defaults to the source's position in the array (<code>'0'</code>,
3354
+ <code>'1'</code>, &hellip;), and <code>follow</code> is independent per source &mdash; filtering
3355
+ one narrows only its own contribution, exactly as a lone <code>from</code> follows its grid
3356
+ today. A source with differently-named fields uses <code>map</code> to project them into a
3357
+ common shape before it joins the rest.
3358
+ </p>
3359
+ <div class="why">
3360
+ <p><strong><code>__source</code> is not optional.</strong> Every combined row carries it &mdash;
3361
+ the entry's <code>label</code>, or its index when unlabelled &mdash; and it is an ordinary field
3362
+ to <code>where</code>, <code>groupBy</code> and <code>select</code>. Leaving it out would mean a
3363
+ combined list that cannot say where any row came from, which defeats most of the reason to
3364
+ combine several sources in the first place.</p>
3365
+ <p><strong>The union of fields, never a merge.</strong> A field only one source has is
3366
+ <code>undefined</code> on the others' rows, not fabricated and not type-coerced &mdash; two
3367
+ sources disagreeing about what a field means is yours to resolve with <code>map</code>, not
3368
+ something the union guesses at. And there is no dedup: two sources reporting the same fact both
3369
+ appear as separate rows. There is no UNION-vs-UNION-ALL distinction to draw; reach for
3370
+ <code>join</code> when rows should be matched on a key rather than stacked.</p>
3371
+ <p><strong>The key is namespaced, only where it needs to be.</strong> Two sources can easily
3372
+ share row identifiers, so the derived <code>__key</code> is qualified by the source tag when
3373
+ nothing is grouped. Grouped, <code>__key</code> is the group value exactly as it always has
3374
+ been, and rows from different sources landing in the same group is what grouping a union is
3375
+ <em>for</em>, not a collision.</p>
3376
+ <p><strong>An empty source is fine; a broken one is loud.</strong> A source with no rows
3377
+ contributes nothing. A source that throws while being read is named in a console warning and
3378
+ skipped for that pass &mdash; a silently missing source would make "worst across both" quietly
3379
+ wrong, so it is reported rather than swallowed.</p>
3380
+ <p><strong>A cycle is refused when the source is built.</strong> If a union's sources include
3381
+ the grid being derived, directly or through a chain of other derived grids, it is refused up
3382
+ front, naming the offending source, rather than being recursed into.</p>
3383
+ <p><strong>A union never patches &mdash; know the cost before you reach for one at scale.</strong>
3384
+ A lone <code>from</code> maintains its grouping incrementally: an edit that names the rows it
3385
+ touched re-reduces only the groups those rows belong to. A union does not do this for any of its
3386
+ sources &mdash; every change on <em>any</em> parent re-reads and re-derives the <em>whole</em>
3387
+ combined set from scratch. On a synthetic 200,000-row union across four sources, one row changed
3388
+ on one parent cost on the order of <strong>800&nbsp;ms&ndash;3&nbsp;s</strong> (machine-dependent;
3389
+ run <code>node bench/union-parents.mjs</code> against your own shape), against a few
3390
+ milliseconds for the equivalent patched change on a lone <code>from</code> over the same row
3391
+ count. And because a union is watched by as many independent change streams as it has sources,
3392
+ that full-rescan cost is paid once <em>per source that moves</em>, not once per union &mdash;
3393
+ four active parents each firing their own updates pay it four times over. Below a few tens of
3394
+ thousands of combined rows this is unlikely to matter; above that, or with several sources each
3395
+ updating on their own live feed, budget for it, keep <code>refresh</code> away from every tick
3396
+ (<code>idle</code>, or a debounce), and prefer fewer, larger sources over many small ones where
3397
+ the shape of your data allows it.</p>
3398
+ <p><strong>Not supported alongside a union.</strong> <code>crossFilter</code> has no single
3399
+ target once there is more than one parent; <code>profile</code> and <code>statistics</code>
3400
+ reduce one grid's own columns. All three are refused with a warning rather than guessed at, and
3401
+ the top-level <code>follow</code> is ignored in favour of each source's own.</p>
3402
+ </div>
3403
+
3404
+ <h2 id="derived-statistics">The relational statistics, as rows</h2>
3405
+ <p class="lead-in">
3406
+ One grid is the data; a second <em>is</em> the analysis of it. <code>statistics</code> projects
3407
+ the figures that need two or more columns &mdash; or a second grid &mdash; into rows you can
3408
+ sort, filter, chart and export like any others.
3409
+ </p>
3410
+ <p><strong>You probably do not need it for a single-column statistic.</strong> Those already have
3411
+ a route: a derived <code>select</code> reduces a group with any kernel the totals row uses, and
3412
+ that table is a superset of the statistics one. <code>select: { p95: { of: 'amount', fn: 'p95'
3413
+ } }</code> works today, and so do <code>median</code>, <code>stddev</code>, <code>gini</code>,
3414
+ <code>iqr</code>, <code>entropy</code>, <code>trimmedMean</code> and the rest.
3415
+ <code>statistics</code> is for what <code>select</code> structurally cannot reach.</p>
3416
+ <div class="example">
3417
+ <p class="example__label">Which columns move together</p>
3418
+ <pre><code>source: {
3419
+ mode: <span class="str">'derived'</span>,
3420
+ from: trades,
3421
+ statistics: { fn: <span class="str">'correlation'</span>, columns: [<span class="str">'price'</span>, <span class="str">'volume'</span>, <span class="str">'spread'</span>] },
3422
+ <span class="cmt">// -&gt; one row per PAIR: { a, b, coefficient, n }</span>
3423
+ }</code></pre>
3424
+ </div>
3425
+ <p><strong>Three statistics in this release.</strong> Each has one row shape, and the shape is the
3426
+ contract:</p>
3427
+ <ul>
3428
+ <li><code>{ fn: 'correlation', columns, orient? }</code> &mdash; Pearson's r across N columns.
3429
+ <code>orient: 'pairs'</code> (the default) gives <strong>one row per unordered pair</strong>,
3430
+ <code>{ a, b, coefficient, n }</code>. Long form by default because that is what
3431
+ a grid sorts, filters and charts well &mdash; "the three most correlated pairs" is then a
3432
+ sort and a <code>limit</code> on the derived grid. Only the upper triangle is emitted: r is
3433
+ symmetric, so <code>(a,b)</code> and <code>(b,a)</code> are one finding, and a column against
3434
+ itself is 1 by definition. <code>orient: 'matrix'</code> gives the classic square instead,
3435
+ one row per column with a field per other column, for a heat map.</li>
3436
+ <li><code>{ fn: 'series', of, by, periodsPerYear? }</code> &mdash; <strong>one row per
3437
+ metric</strong>, <code>{ metric, value, n }</code>. Note <em>per metric</em>, not
3438
+ per point: <code>grid.statistics.series</code> returns a summary &mdash; <code>n</code>,
3439
+ <code>first</code>, <code>last</code>, <code>change</code>, <code>changePercent</code>,
3440
+ <code>volatility</code>, <code>annualisedVolatility</code>, <code>growth</code>,
3441
+ <code>maxDrawdown</code>, <code>maxDrawdownFrom</code>, <code>maxDrawdownTo</code>,
3442
+ <code>autocorrelation</code>, <code>upDays</code>, <code>downDays</code> &mdash; and not a
3443
+ value per row. The shape is the one <code>profile</code>'s <code>orient: 'metrics'</code>
3444
+ already emits, deliberately, rather than a third convention for the same idea.
3445
+ <code>by</code> is required and never guessed, because kernels see rows in arrival order and
3446
+ that is not the grid's sort.</li>
3447
+ <li><code>{ fn: 'datasetVsDataset', with, columns? }</code> &mdash; <strong>one row per compared
3448
+ column</strong>, largest difference first: <code>{ column, measure, magnitude, distance,
3449
+ direction, nA, nB, reliable, unmatched }</code>. Both sides are read over their
3450
+ <em>filtered</em> rows, and <strong>the peer is watched</strong> &mdash; an edit or a filter
3451
+ on it re-derives the comparison, because a comparison whose other side has moved is wrong
3452
+ rather than merely late. A column present on only one side cannot be compared and is still
3453
+ reported, with a null <code>magnitude</code> and <code>unmatched</code> set to
3454
+ <code>'A'</code> or <code>'B'</code>, so you see that it was skipped and why.</li>
3455
+ </ul>
3456
+ <p><strong>Every row says how much it saw.</strong> <code>n</code> is the rows the figure
3457
+ covered, and it is on the row because a derived statistic travels: a coefficient exported to
3458
+ CSV or bound to a chart has left every bit of its context behind, and &ldquo;r = 0.98 over
3459
+ eleven rows&rdquo; is a different claim from the same number over eleven thousand.</p>
3460
+ <p><strong>What the row does <em>not</em> tell you: whether the source was windowed.</strong> A
3461
+ statistic over a source holding fewer rows than match its filters is computed on the loaded
3462
+ window rather than the whole set. The grid detects that from the <em>source's</em> own
3463
+ counters and says so in a console warning
3464
+ (<code>[lattice] correlation on &hellip; computed over N of M matching rows</code>) &mdash; and
3465
+ that remains the signal to watch. A derived source cannot reach those counters: a grid's public
3466
+ <code>rows.matchCount()</code> reports the <em>loaded</em> matches, so on a bounded stream
3467
+ evicted to 200 of 2,000 rows it returns 200 and agrees exactly with <code>rows.count()</code>.
3468
+ Rather than ship a flag that could never be true, no such flag is emitted; <code>n</code> says
3469
+ what the figure actually saw and nothing more is claimed.</p>
3470
+ <p><strong>A terminal producer, not a pipeline stage.</strong> A correlation is one row per pair,
3471
+ a series summary one row per metric, a comparison one row per column &mdash; none of which is
3472
+ one row per group, so there is no position in
3473
+ <code>unnest &rarr; where &rarr; bucket &rarr; groupBy &rarr; select &rarr; sort &rarr;
3474
+ limit</code> for <code>statistics</code> to occupy. It replaces the pipeline, exactly as
3475
+ <code>profile</code> does. Those keys are now <strong>ignored with a warning that names
3476
+ them</strong> rather than discarded in silence, for both producers. Sort, filter or limit the
3477
+ derived grid itself, or chain a second derived grid whose <code>from</code> is this one.
3478
+ <code>profile</code> and <code>statistics</code> are mutually exclusive and declaring both is
3479
+ refused, by name, when the source is built.</p>
3480
+
3481
+ <h3 id="derived-producer-cost">What a terminal producer costs</h3>
3482
+ <p><strong>No terminal producer patches incrementally &mdash; know this before you point one at a
3483
+ live feed.</strong> The grouped pipeline maintains its grouping across changes: an edit that
3484
+ names the rows it touched re-reduces only the groups those rows belong to. A producer has no
3485
+ grouping to maintain, so <em>every</em> change on the parent re-derives its whole output. This
3486
+ has always been true of <code>profile</code> and was not previously written down; it is written
3487
+ down here now, and it applies to <code>statistics</code> in the same way.</p>
3488
+ <p>Measured on a synthetic 200,000-row grid, five changes after a warm-up, one derived grid
3489
+ attached (machine-dependent &mdash; run <code>node bench/derived-producers.mjs</code> against
3490
+ your own shape):</p>
3491
+ <table>
3492
+ <thead><tr><th>Derived shape</th><th>Per change at 200k rows</th></tr></thead>
3493
+ <tbody>
3494
+ <tr><td>grouped, no <code>where</code> &mdash; <em>the patching pipeline</em></td><td>~1&nbsp;ms</td></tr>
3495
+ <tr><td><code>profile</code>, one column</td><td>~20&nbsp;ms</td></tr>
3496
+ <tr><td><code>statistics</code> <code>correlation</code>, 2 columns (1 pair)</td><td>~5&nbsp;ms</td></tr>
3497
+ <tr><td><code>statistics</code> <code>correlation</code>, 6 columns (15 pairs)</td><td>~32&nbsp;ms</td></tr>
3498
+ <tr><td><code>statistics</code> <code>series</code></td><td>~23&nbsp;ms</td></tr>
3499
+ <tr><td><code>statistics</code> <code>datasetVsDataset</code>, two grids</td><td>~36&nbsp;ms</td></tr>
3500
+ <tr><td><code>where</code>, no <code>groupBy</code> &mdash; <em>a pipeline shape that also never patches</em></td><td>~700&nbsp;ms</td></tr>
3501
+ </tbody>
3502
+ </table>
3503
+ <p><strong>Correlation is quadratic in its column count.</strong> It scans the rows once per pair,
3504
+ so N columns cost N&middot;(N&minus;1)/2 passes: six columns is fifteen passes, twenty columns is
3505
+ a hundred and ninety. Correlate the columns you mean rather than every numeric column you
3506
+ have.</p>
3507
+ <p><strong>The costs of several panels add up.</strong> Every derived grid attached to a parent
3508
+ re-derives on the same change, so three analysis panels over one grid cost the sum of the
3509
+ three, not the largest. This is BACKLOG-0001044's known gap &mdash; a hidden derived grid still
3510
+ does full read and compute work &mdash; with a larger constant behind it; a hidden analysis tab
3511
+ recomputing a correlation matrix on every tick is exactly that cost.</p>
3512
+ <p><strong>The escape hatch is <code>refresh</code>, and it already exists.</strong>
3513
+ <code>'idle'</code> is the default and coalesces a burst of changes into one derivation on the
3514
+ next frame; a <strong>number</strong> is a debounce in milliseconds; <code>'live'</code>
3515
+ derives on every change and is the one to avoid for an expensive analysis over a ticking feed;
3516
+ <code>'manual'</code> stops automatic derivation entirely, leaving the host to drive the
3517
+ source. Below a few tens of thousands of rows none of this matters.</p>
3518
+
3328
3519
  <h2 id="cross-filter">Cross-filtering</h2>
3329
3520
  <p class="lead-in">
3330
3521
  A derived panel can filter the grid it summarises. Click a region in the summary and the
@@ -5982,6 +6173,162 @@ r.ok ? r.value : r.error; <span class="cmt">// 42</span></code></pre>
5982
6173
  a missing <code>return</code> is a typo and deleting the whole menu is a harsh reading of
5983
6174
  one.</p>
5984
6175
  </div>
6176
+ <h3 id="per-column-menu">Declaring a menu on the column itself</h3>
6177
+ <p class="lead-in">
6178
+ A cell menu can also be declared <strong>on the column</strong>, with
6179
+ <code>contextMenu</code> on the column definition. It takes the same shapes the grid-level
6180
+ option takes, plus a bare array for the common &ldquo;just these items here&rdquo; case:
6181
+ <code>boolean | MenuItem[] | (params, defaults) =&gt; items</code>.
6182
+ </p>
6183
+ <div class="example">
6184
+ <p class="example__label">Each column's menu logic beside the column it is about</p>
6185
+ <pre><code>createGrid(el, {
6186
+ columns: [
6187
+ { field: 'account' },
6188
+ <span class="cmt">// Just these items, here.</span>
6189
+ { field: 'owner', contextMenu: [{ name: 'Reassign', action: reassign }] },
6190
+ <span class="cmt">// Or the built-ins plus one, the same form the grid-level option takes.</span>
6191
+ {
6192
+ field: 'amount',
6193
+ contextMenu: (params, defaults) =&gt; [
6194
+ ...defaults,
6195
+ { separator: <span class="kw">true</span> },
6196
+ { name: `Reprice ${params.value}`, action: (ctx) =&gt; reprice(ctx.data) },
6197
+ ],
6198
+ },
6199
+ <span class="cmt">// And nothing at all on a column nobody should act on from here.</span>
6200
+ { field: 'nationalId', contextMenu: <span class="kw">false</span> },
6201
+ ],
6202
+ });</code></pre>
6203
+ </div>
6204
+ <p class="lead-in">
6205
+ This adds no power the grid-level option did not have &mdash; <code>params.colId</code> and
6206
+ <code>params.column</code> always let one callback branch by column. What it adds is
6207
+ <strong>locality</strong>: the menu for a column is declared where the column is, instead of
6208
+ collecting into one growing <code>switch</code> a long way from the thing it is about.
6209
+ </p>
6210
+
6211
+ <h4 id="per-column-menu-chain">The three levels compose as a chain</h4>
6212
+ <p class="lead-in">
6213
+ The built-in items go in first, then the grid-level <code>contextMenu</code>, then the
6214
+ column's own &mdash; <strong>each handed the previous level's result as its
6215
+ <code>defaults</code></strong>. A column that wants one extra item writes one extra item; it
6216
+ never has to restate Paste, Clear and Fill&nbsp;down, nor whatever the grid-level builder just
6217
+ added.
6218
+ </p>
6219
+ <div class="example">
6220
+ <p class="example__label">Built-ins &rarr; grid &rarr; column, executed</p>
6221
+ <pre data-run="js" data-expect="grid|column" data-covers="config:contextMenu"><code><span class="kw">const</span> { createTestDom, flushFrames } = <span class="kw">await</span> import('../packages/dom/src/renderer/testdom.js');
6222
+ <span class="kw">const</span> { createGrid } = <span class="kw">await</span> import('../packages/dom/src/index.js');
6223
+ <span class="kw">const</span> { root } = createTestDom({ width: 600, height: 300 });
6224
+
6225
+ <span class="kw">let</span> handedToTheColumn = [];
6226
+ <span class="kw">const</span> grid = createGrid(root, {
6227
+ rowKey: 'id',
6228
+ rows: [{ id: 1, account: 'Acme', amount: 120 }],
6229
+ columns: [
6230
+ { field: 'account' },
6231
+ {
6232
+ field: 'amount',
6233
+ contextMenu: (params, defaults) =&gt; {
6234
+ handedToTheColumn = defaults.map((d) =&gt; d.name);
6235
+ <span class="kw">return</span> [...defaults, { name: 'from the column', action() {} }];
6236
+ },
6237
+ },
6238
+ ],
6239
+ contextMenu: (params, defaults) =&gt; [...defaults, { name: 'from the grid', action() {} }],
6240
+ });
6241
+ flushFrames();
6242
+
6243
+ <span class="kw">const</span> row = grid.rows.get(0);
6244
+ grid.emit('cell:contextmenu', {
6245
+ row, key: row.key, index: 0, colId: 'amount',
6246
+ column: grid.columns.get('amount'), value: 120,
6247
+ event: { clientX: 10, clientY: 10, preventDefault() {} },
6248
+ });
6249
+ flushFrames();
6250
+
6251
+ <span class="cmt">// The column builder was handed the grid builder's output, not the raw</span>
6252
+ <span class="cmt">// built-ins: 'from the grid' is already in its `defaults`.</span>
6253
+ <span class="kw">const</span> chained = handedToTheColumn.includes('from the grid');
6254
+ <span class="kw">const</span> shown = [...root.querySelectorAll('.lat-menu__item')]
6255
+ .map((i) =&gt; String(i.textContent).trim());
6256
+ grid.destroy();
6257
+ <span class="kw">return</span> chained &amp;&amp; shown.includes('from the column') ? 'grid|column' : 'broken';</code></pre>
6258
+ </div>
6259
+ <p class="lead-in">
6260
+ <strong>Suppression follows the same order, and the more specific level wins.</strong>
6261
+ <code>contextMenu: false</code> on a column is a statement about <em>that column</em> and no
6262
+ other. Equally, a column may declare a menu on a grid whose <code>contextMenu</code> is
6263
+ <code>false</code> &mdash; which is how you say &ldquo;no menu anywhere except here&rdquo;.
6264
+ <code>contextMenu: true</code> on a column means &ldquo;whatever came before&rdquo;, so it
6265
+ restores the built-in menu on a grid that turned it off.
6266
+ </p>
6267
+ <table class="ref">
6268
+ <thead><tr><th>Grid level</th><th>Column level</th><th>What opens on that column</th></tr></thead>
6269
+ <tbody>
6270
+ <tr><td><em>not set</em></td><td><em>not set</em></td><td>the built-in menu</td></tr>
6271
+ <tr><td>a builder</td><td><em>not set</em></td><td>the builder's result</td></tr>
6272
+ <tr><td>a builder</td><td>a builder</td><td>the column's builder, handed the grid builder's result</td></tr>
6273
+ <tr><td>a builder</td><td>an array</td><td>the array &mdash; the grid level still ran, and was replaced</td></tr>
6274
+ <tr><td>a builder, or not set</td><td><code>false</code></td><td><strong>nothing</strong> &mdash; the column suppresses, and no other column is affected</td></tr>
6275
+ <tr><td><code>false</code></td><td><em>not set</em></td><td><strong>nothing</strong> &mdash; the grid-level off stands, as it always has</td></tr>
6276
+ <tr><td><code>false</code></td><td>an array</td><td><strong>the column's array.</strong> The column opts back in: grid-level <code>false</code> is a default, not a lock</td></tr>
6277
+ <tr><td><code>false</code></td><td>a builder</td><td><strong>the builder's result</strong>, handed the built-in items as its <code>defaults</code>. The column opts back in</td></tr>
6278
+ <tr><td><code>false</code></td><td><code>true</code></td><td><strong>the built-in menu.</strong> The column opts back in and asks for the defaults</td></tr>
6279
+ </tbody>
6280
+ </table>
6281
+ <div class="note">
6282
+ <p><strong><code>contextMenu: false</code> on the grid is a default, not a lock.</strong> If
6283
+ you set it as a safety property &mdash; a read-only grid, a screen where nobody should be
6284
+ able to copy or clear from a right-click &mdash; be aware that a column declaring its own
6285
+ <code>contextMenu</code> will still open one, because the more specific level wins in
6286
+ <em>both</em> directions. That is deliberate: a read-only grid with one actionable column is a
6287
+ real shape, and it is the only way to say &ldquo;no menu anywhere except here&rdquo;. But it
6288
+ does mean grid-level <code>false</code> does not guarantee that no cell menu can open
6289
+ anywhere &mdash; only that none opens unless a column asks for one. If you need the absolute
6290
+ guarantee, do not declare <code>contextMenu</code> on any column.</p>
6291
+ </div>
6292
+ <div class="why">
6293
+ <p><strong>A chain, not a replacement.</strong> If the column level replaced the grid level,
6294
+ every column that wanted one extra item would have to restate everything the grid-level
6295
+ builder does &mdash; and would then stop tracking it the first time it changed. This is the
6296
+ same rule <code>columnMenu</code> already follows for the header: you are handed what came
6297
+ before so you can add to it rather than reproduce it.</p>
6298
+ </div>
6299
+
6300
+ <h4 id="per-column-menu-edges">A range, and rows that belong to no column</h4>
6301
+ <p class="lead-in">
6302
+ <strong>On a multi-column selection, the column you right-clicked decides.</strong> Not the
6303
+ intersection of the selected columns' menus, which silently drops items; not their union,
6304
+ which offers actions that are wrong for most of the selection. The clicked column is the one
6305
+ the user pointed at, and it is the one that answers &mdash; the built-in range actions
6306
+ (Copy, Clear, Fill&nbsp;down) still act on the whole range as they always did.
6307
+ </p>
6308
+ <p class="lead-in">
6309
+ <strong>A row with no owning column falls back to the grid-level menu.</strong> Group rows,
6310
+ pivot group rows and full-width rows do not belong to one column, so there is no column-level
6311
+ declaration to consult; the chain simply has one fewer link and the grid-level menu stands.
6312
+ Nothing errors and nothing silently shows an empty menu.
6313
+ </p>
6314
+ <p class="lead-in">
6315
+ Every route honours the column: a right-click, and the keyboard's
6316
+ <kbd>Shift</kbd>+<kbd>F10</kbd> or <kbd>Context&nbsp;Menu</kbd> key on the focused cell.
6317
+ The menu is a <code>role="menu"</code> of <code>role="menuitem"</code>s that takes focus and
6318
+ closes on <kbd>Escape</kbd> wherever it was opened from.
6319
+ </p>
6320
+ <div class="why">
6321
+ <p><strong>Trust is unchanged.</strong> A <code>MenuItem</code> is the same object it always
6322
+ was, including <code>icon</code> markup being trusted at the same level as
6323
+ <code>action</code>. Declaring one on a column changes <em>where</em> it is written, not who
6324
+ is trusted to write it: a column definition is your code, exactly as a grid config is.</p>
6325
+ </div>
6326
+ <p class="lead-in">
6327
+ A column preset or <code>columnDefaults</code> may supply <code>contextMenu</code> too, and
6328
+ the column's own declaration outranks both &mdash; so a house rule like &ldquo;no cell menu on
6329
+ anything tagged sensitive&rdquo; is written once.
6330
+ </p>
6331
+
5985
6332
  <p class="lead-in">
5986
6333
  <code>columnMenu</code> takes the same function form, for both routes into a column's menu:
5987
6334
  the header's 3-dot button and a right-click on the heading. Its <code>params</code> is