@toclocoinc/lattice-grid 1.15.0 → 1.17.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.
@@ -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.15.0</p>
440
+ <p class="rail__sub">Developer guide · v1.17.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -1533,6 +1533,43 @@ cell: { decoration: 'dot' } <span class="cmt">// a leading
1533
1533
  property, not a search for hex codes.</p>
1534
1534
  </div>
1535
1535
 
1536
+ <div class="example">
1537
+ <p class="example__label">Icon sets: a threshold glyph per value band</p>
1538
+ <pre><code><span class="cmt">// A built-in set — traffic lights, arrows, rating marks — driven by value.</span>
1539
+ cell: { decoration: { type: 'icon', iconSet: 'arrows' } }
1540
+
1541
+ <span class="cmt">// Or your own bands. The highest `min` a value clears wins; a band with no</span>
1542
+ <span class="cmt">// `min` is the catch-all. `label` is what a screen reader announces.</span>
1543
+ cell: { decoration: { type: 'icon', bands: [
1544
+ { min: 0.9, icon: 'success', label: 'on target', variant: 'success' },
1545
+ { min: 0.5, icon: 'warning', label: 'at risk', variant: 'warning' },
1546
+ { icon: 'danger', label: 'off track', variant: 'danger' },
1547
+ ] } }</code></pre>
1548
+ </div>
1549
+ <div class="why">
1550
+ <p>An icon set is a restatement of the value, not a replacement for it: the value still
1551
+ renders beside the glyph, and the band's <code>label</code> is set as the glyph's
1552
+ <code>aria-label</code>, so a screen-reader user hears "on target 92%" rather than a bare
1553
+ number with the status lost. The glyphs are the grid's own inline sprites (§16), so an icon
1554
+ set adds no dependency and makes no request. Built-in sets: <code>trafficLights</code>,
1555
+ <code>arrows</code>, <code>trafficArrows</code>, <code>ratings</code>.</p>
1556
+ </div>
1557
+
1558
+ <div class="example">
1559
+ <p class="example__label">Turning a decoration on at runtime</p>
1560
+ <pre><code><span class="cmt">// Set, change or clear a column's decoration after the grid is built.</span>
1561
+ grid.columns.decorate('score', { type: 'bar', min: 0, max: 100 });
1562
+ grid.columns.decorate('trend', { type: 'icon', iconSet: 'arrows' });
1563
+ grid.columns.decorate('score', <span class="kw">null</span>); <span class="cmt">// back to plain text</span></code></pre>
1564
+ </div>
1565
+ <div class="why">
1566
+ <p>A decoration is presentation, so <code>columns.decorate</code> is a live setter like
1567
+ <code>grid.set('theme', …)</code>: it is <em>not</em> on the undo timeline and does
1568
+ <em>not</em> travel in a saved view. For conditional styling that a user edits and a view
1569
+ remembers, reach for <code>grid.formatting</code> below, which holds colour and weight rules
1570
+ as durable state.</p>
1571
+ </div>
1572
+
1536
1573
  <div class="example">
1537
1574
  <p class="example__label">Your own renderer</p>
1538
1575
  <pre><code>components: {
@@ -1875,6 +1912,41 @@ grid.rows.expandAll();
1875
1912
  grid.rows.collapse('EMEA');</code></pre>
1876
1913
  </div>
1877
1914
 
1915
+ <p class="lead-in">
1916
+ <code>groupPanel: true</code> adds a drag-and-drop strip above the column header — the
1917
+ row-group panel. A user drags a heading into it to group by that column; the active
1918
+ groups show as removable, reorderable chips, and dragging one chip past another changes
1919
+ the nesting order. It is keyboard-operable, so grouping is not drag-only: arrows move
1920
+ between chips, <code>Shift</code> with an arrow reorders, <code>Delete</code> ungroups,
1921
+ and an add control at the end groups any column. Every change is spoken through the live
1922
+ region. The strip drives <code>grid.columns.group()</code> — it is the same grouping
1923
+ model, surfaced as chrome — so a group made in the strip, from the column menu or through
1924
+ the API is one state, not three.
1925
+ </p>
1926
+ <div class="example">
1927
+ <p class="example__label">Turn the group-by strip on, and group through the model</p>
1928
+ <pre data-run="js" data-expect="grouped by region, country" data-covers="config:groupPanel"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
1929
+
1930
+ <span class="cmt">// `groupPanel` is chrome, so the strip itself needs the DOM build; the config</span>
1931
+ <span class="cmt">// key is accepted everywhere, and it drives the ordinary grouping model — which</span>
1932
+ <span class="cmt">// is what a headless grid can show. The strip renders the state below as chips.</span>
1933
+ <span class="kw">const</span> grid = createHeadlessGrid({
1934
+ columns: [{ field: 'region' }, { field: 'country' }, { field: 'sales', type: 'number' }],
1935
+ rows: [
1936
+ { region: 'EMEA', country: 'UK', sales: 10 },
1937
+ { region: 'EMEA', country: 'DE', sales: 20 },
1938
+ { region: 'AMER', country: 'US', sales: 30 },
1939
+ ],
1940
+ groupPanel: <span class="kw">true</span>,
1941
+ });
1942
+
1943
+ <span class="cmt">// Order is nesting order, outermost first — exactly the order the chips show.</span>
1944
+ grid.columns.group(['region', 'country']);
1945
+ <span class="kw">const</span> groups = grid.state.get().group;
1946
+ grid.destroy();
1947
+ <span class="kw">return</span> `grouped by ${groups.join(', ')}`;</code></pre>
1948
+ </div>
1949
+
1878
1950
  <div class="example">
1879
1951
  <p class="example__label">Totals</p>
1880
1952
  <pre><code>{ field: 'capacity', type: 'number', total: 'sum' }
@@ -2423,6 +2495,58 @@ createGrid(right, { columns, rows,
2423
2495
  at silently. Rows with no rate, or no weight, are left out rather than counted as zero.
2424
2496
  </p>
2425
2497
 
2498
+ <h3 id="aggregate-chooser">Choosing an aggregate at runtime</h3>
2499
+ <p class="lead-in">
2500
+ With <code>aggregateChooser</code> on, the column menu's totalling entry becomes an
2501
+ <em>Aggregate</em> submenu. It offers only the reductions the column's type says are meaningful
2502
+ (see <a href="#aggregate-safety">above</a>) — <code>sum</code>, <code>avg</code>,
2503
+ <code>min</code>, <code>max</code> and the counts on a plain number, but never <code>sum</code>
2504
+ on a category or a rate — with the current one ticked and a <em>None</em> to stop totalling. It
2505
+ is the same keyboard-operable menu as everywhere else: arrows move, <code>Enter</code> or
2506
+ <code>Space</code> picks, <code>Escape</code> closes and returns focus, and the ticked item
2507
+ reads as <code>aria-checked</code> to a screen reader.
2508
+ </p>
2509
+ <div class="example">
2510
+ <p class="example__label">Off by default; turn it on</p>
2511
+ <pre><code>createGrid(element, { aggregateChooser: <span class="kw">true</span> });</code></pre>
2512
+ </div>
2513
+ <div class="why">
2514
+ <p><strong>It reuses the totals model, it does not fork it.</strong> Every choice drives
2515
+ <code>grid.columns.setTotal(id, name)</code>, the same public call the old toggle used, so the
2516
+ footer, the group rows, the pivot cells and the grand total all move together and no
2517
+ aggregation is recomputed here. <code>grid.columns.aggregates(id)</code> returns the list the
2518
+ submenu offers, so a host building its own chooser reads the same answer.</p>
2519
+ <p><strong>Safety holds on both routes.</strong> The submenu only lists meaningful aggregates,
2520
+ and <code>setTotal</code> refuses an unmeaningful named total whether it comes from the menu or
2521
+ from an API caller — the wrong footer cannot be reached from either.</p>
2522
+ <p><strong>Off by default and non-breaking.</strong> Left off, the menu keeps its plain
2523
+ <em>Total this column</em> toggle, so an existing grid is unchanged.</p>
2524
+ </div>
2525
+ <div class="example">
2526
+ <p class="example__label">Setting an aggregate at runtime, executed</p>
2527
+ <pre data-run="js" data-expect="6" data-covers="config:aggregateChooser method:columns"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
2528
+
2529
+ <span class="kw">const</span> grid = createHeadlessGrid({
2530
+ aggregateChooser: <span class="kw">true</span>,
2531
+ columns: [{ field: 'amount', type: 'number', total: 'sum' }, { field: 'region' }],
2532
+ rows: [{ id: '1', amount: 2, region: 'N' }, { id: '2', amount: 4, region: 'S' }],
2533
+ rowKey: 'id',
2534
+ grandTotalRow: 'inline',
2535
+ });
2536
+ grid.rows.count();
2537
+
2538
+ <span class="cmt">// A number column offers every built-in; a text column offers only what makes</span>
2539
+ <span class="cmt">// sense — count and the extremes, never sum. This is the list the chooser shows.</span>
2540
+ <span class="kw">const</span> offered = grid.columns.aggregates('amount'); <span class="cmt">// ['sum','avg','min',...]</span>
2541
+
2542
+ <span class="cmt">// Switch the footer from Sum (6) to Average (3) at runtime.</span>
2543
+ grid.columns.setTotal('amount', 'avg');
2544
+ <span class="kw">const</span> total = grid.rows.get(grid.rows.count() - <span class="num">1</span>).totals.amount;
2545
+
2546
+ grid.destroy();
2547
+ <span class="kw">return</span> offered.includes('sum') ? <span class="num">6</span> : <span class="num">0</span>; <span class="cmt">// sum is offered on a number column</span></code></pre>
2548
+ </div>
2549
+
2426
2550
  <h2 id="sticky-group-headings">Sticky group headings</h2>
2427
2551
  <p class="lead-in">
2428
2552
  Scrolling inside a group keeps that group's headings pinned above the rows, so the rows on
@@ -2430,9 +2554,9 @@ createGrid(right, { columns, rows,
2430
2554
  </p>
2431
2555
 
2432
2556
  <div class="example">
2433
- <p class="example__label">On by default; turn it off or change the cap</p>
2434
- <pre><code>createGrid(element, { stickyGroupHeaders: <span class="kw">false</span> }); <span class="cmt">// off</span>
2435
- createGrid(element, { stickyGroupHeaders: 3 }); <span class="cmt">// stack up to three</span></code></pre>
2557
+ <p class="example__label">Off by default; opt in or set the cap</p>
2558
+ <pre><code>createGrid(element, { stickyGroupHeaders: <span class="kw">true</span> }); <span class="cmt">// on, up to two</span>
2559
+ createGrid(element, { stickyGroupHeaders: 3 }); <span class="cmt">// on, up to three</span></code></pre>
2436
2560
  </div>
2437
2561
 
2438
2562
  <div class="why">
@@ -2474,6 +2598,41 @@ createGrid(element, { stickyGroupHeaders: 3 }); <span class="cmt">// stack
2474
2598
  </tbody>
2475
2599
  </table>
2476
2600
  </div>
2601
+ <h3 id="ingest">Trimming the memory footprint</h3>
2602
+ <p>
2603
+ By default the store retains the row objects you hand it by reference, so
2604
+ <code>rows.data()</code> returns those exact objects and <code>row === sourceObject</code>
2605
+ holds. The <code>ingest</code> option controls that:
2606
+ </p>
2607
+ <div class="table-wrap">
2608
+ <table>
2609
+ <thead><tr><th>Option</th><th>Type</th><th>Description</th></tr></thead>
2610
+ <tbody>
2611
+ <tr><td class="name">retainSource</td><td class="type">boolean</td><td class="desc">Default <code>true</code>. Set <code>false</code> to keep only the packed columns and reconstruct a plain row object from them on demand. <code>rows.data()</code> then returns freshly reconstructed objects — a new object each call — so <code>row === sourceObject</code> and a custom renderer reading <code>row.sourceObject</code> no longer hold, and equality becomes value-based. Cell values are identical either way, so <code>get()</code>, <code>byKey()</code>, <code>value()</code> and <code>values()</code> are unaffected. On its own this drops only the store's reference array, not the objects: the source layer still holds them.</td></tr>
2612
+ <tr><td class="name">dropSourceRows</td><td class="type">boolean</td><td class="desc">Default <code>false</code>. Set <code>true</code> to release the caller's row objects from the <em>source layer</em> and the grid config once the store is built, so the packed columns are the only resident copy. This is where the large reduction lives — roughly an order of magnitude at a million rows — because the caller's objects are the dominant term. Implies <code>retainSource: false</code> (the store must reconstruct), so it carries the same identity tradeoff. An impure computed column — a shadow or a rank/positional column — is never stored, so it cannot be served from the columns: it warns once and drops out rather than answering wrongly, and <code>rows.move()</code> is refused. Do not enable it on a grid that sorts, filters, groups or totals on such a column, or that reorders rows in place.</td></tr>
2613
+ </tbody>
2614
+ </table>
2615
+ </div>
2616
+ <div class="example">
2617
+ <p class="example__label">Dropping retained source objects</p>
2618
+ <pre><code>createGrid(el, {
2619
+ columns,
2620
+ rowKey: 'id',
2621
+ rows,
2622
+ <span class="cmt">// Release the caller's objects entirely; the packed columns are the sole copy.</span>
2623
+ ingest: { dropSourceRows: <span class="kw">true</span> },
2624
+ });</code></pre>
2625
+ </div>
2626
+ <div class="why">
2627
+ <p>With <code>retainSource: false</code> alone the saving is only the store's own copy of the
2628
+ object references, not the objects themselves: the source layer and the grid config still
2629
+ hold the array, so whoever handed the grid its rows keeps them alive. <code>dropSourceRows</code>
2630
+ releases those references too, so once the caller lets go the objects can be collected and the
2631
+ grid becomes the sole holder of the data. Reach for it on a large, read-mostly grid where you
2632
+ can let go of the source array and do not depend on caller identity through
2633
+ <code>rows.data()</code>.</p>
2634
+ </div>
2635
+
2477
2636
  <div class="example">
2478
2637
  <p class="example__label">A remote source</p>
2479
2638
  <pre><code>source: {
@@ -2591,7 +2750,8 @@ createGrid(el, { columns, rowKey: 'id', source });
2591
2750
  plan.pushed; <span class="cmt">// the query the adapter was given</span>
2592
2751
  plan.residual; <span class="cmt">// { filters, sort, quick } the grid applied after</span>
2593
2752
  plan.unpushed; <span class="cmt">// ['filter'], the parts that stayed behind</span>
2594
- plan.needsAll; <span class="cmt">// true when the whole result had to be fetched</span></code></pre>
2753
+ plan.needsAll; <span class="cmt">// true when the whole result had to be fetched</span>
2754
+ plan.full; <span class="cmt">// true when fullDataset forced it, not just residual work</span></code></pre>
2595
2755
  </div>
2596
2756
  <p>
2597
2757
  The grid also warns once, naming the predicate that could not be pushed, because the fix is
@@ -2633,6 +2793,78 @@ createGrid(el, {
2633
2793
  with no server involved.</p>
2634
2794
  </div>
2635
2795
 
2796
+ <h3 id="fulldataset">Whole-dataset statistics over a remote source</h3>
2797
+ <p class="lead-in">
2798
+ A windowed source reduces a total or statistic over the rows it has loaded, not the whole
2799
+ matching set — a footer median of the 200 rows on screen, which is wrong and looks right.
2800
+ <code>fullDataset</code> makes the source hold the entire matching set client-side so those
2801
+ figures are computed over everything.
2802
+ </p>
2803
+ <div class="example">
2804
+ <p class="example__label">Correct footer figures over a REST or DuckDB source</p>
2805
+ <pre><code><span class="kw">const</span> source = createPushdownSource({
2806
+ adapter: restAdapter({ url: '/api/trades' }),
2807
+ fullDataset: {
2808
+ enabled: <span class="kw">true</span>, <span class="cmt">// hold the whole matching set, once per query</span>
2809
+ maxRows: 1_000_000, <span class="cmt">// refuse (visible error) past this</span>
2810
+ maxBytesEstimate: 512 * 1024 * 1024,
2811
+ },
2812
+ });</code></pre>
2813
+ </div>
2814
+ <div class="why">
2815
+ <p>It is <strong>off by default</strong> and reuses the same whole-result path that residual
2816
+ work already takes: the flag ORs into <code>needsAll</code>, so once the set is held it is
2817
+ ordinary in-memory data and the grid's existing total and statistics kernels reduce over all of
2818
+ it, with no per-stat change. When it is active and within the limits, the whole matching set is
2819
+ covered, so the windowed-statistic warning (BACKLOG-0000731) stays silent — the figure is now
2820
+ honestly whole-dataset.</p>
2821
+ <p><strong>It is refused loudly, never truncated.</strong> A matching set past
2822
+ <code>maxRows</code> or <code>maxBytesEstimate</code> is thrown and surfaced as a
2823
+ <code>source:error</code> with no rows shown, rather than held as a fraction and presented as
2824
+ the whole. A fraction shown as the whole is exactly the silent wrong answer this feature
2825
+ exists to remove, so it is never how the feature fails. For a <code>restAdapter</code>, which
2826
+ cannot compute, this is the only route to a correct whole-dataset statistic.</p>
2827
+ </div>
2828
+
2829
+ <h3 id="aggregates">Pushing statistics down to the engine</h3>
2830
+ <p class="lead-in">
2831
+ A DuckDB-class engine computes a median or a standard deviation over the whole matching set far
2832
+ faster than pulling every row here to do it. The <code>aggregates</code> config decides, at
2833
+ grid setup, which statistics the engine computes and which the grid does — a design-time
2834
+ developer choice, fixed for the life of the grid, never a runtime toggle and never shown to an
2835
+ end user.
2836
+ </p>
2837
+ <div class="example">
2838
+ <p class="example__label">Push the verified-identical stats, keep the rest exact</p>
2839
+ <pre><code><span class="kw">const</span> source = createPushdownSource({
2840
+ adapter: duckdbAdapter({ connection: conn, from: <span class="str">'trades'</span> }),
2841
+ aggregates: {
2842
+ <span class="cmt">// 'engine' pushes everything expressible; 'engine-if-identical' pushes only</span>
2843
+ <span class="cmt">// the stats whose engine result is verified identical to the grid kernel;</span>
2844
+ <span class="cmt">// 'client' (the default when absent) computes everything here.</span>
2845
+ <span class="kw">default</span>: <span class="str">'engine-if-identical'</span>,
2846
+ overrides: { mode: <span class="str">'client'</span> }, <span class="cmt">// I want the grid's null-when-distinct mode</span>
2847
+ },
2848
+ });</code></pre>
2849
+ </div>
2850
+ <div class="why">
2851
+ <p>Every statistic is classified <strong>IDENTICAL</strong> (the engine's result equals the
2852
+ grid's own kernel, verified against it on the same data) or <strong>MAY-DIFFER</strong> (the
2853
+ engine computes it by a method that can differ from the grid's definition — <code>mode</code>
2854
+ returns a value where the grid returns null). The classification drives the docs and
2855
+ build-time provenance, <em>not</em> whether a stat is pushed: that is your choice.
2856
+ <code>weightedQuantile</code> is the one genuine fallback, always client-side, because the
2857
+ engine cannot express the grid's midpoint convention. The published table of every stat, its
2858
+ class and its SQL is generated from one map (<code>STAT_PUSHDOWN</code>) so it cannot drift.</p>
2859
+ <p><strong>No mixed provenance.</strong> An engine figure and a client figure never appear in
2860
+ one result set. Aggregates are pushed only when the filter is <em>fully</em> pushed; a residual
2861
+ filter the engine could not apply forces every aggregate client-side, because an engine number
2862
+ computed over a superset beside a client number over the real set would be wrong-but-plausible.
2863
+ <code>source.lastPlan().aggregates</code> reports, per stat, whether the engine or the grid
2864
+ computed it and the class it was assigned — inspection during the build, not a per-figure
2865
+ runtime marker.</p>
2866
+ </div>
2867
+
2636
2868
  <h2 id="derived">Grids built from other grids</h2>
2637
2869
  <p class="lead-in">
2638
2870
  A derived grid takes its rows from another grid rather than from a load: grouped and
@@ -2827,6 +3059,27 @@ columns: [
2827
3059
  scrolling past a rounded corner is cut by it rather than squaring it off.
2828
3060
  </p>
2829
3061
 
3062
+ <div class="example">
3063
+ <p class="example__label">Zebra striping (opt-in)</p>
3064
+ <pre><code>createGrid(element, {
3065
+ columns, rows,
3066
+ stripedRows: <span class="kw">true</span>, <span class="cmt">// shade alternate data rows; off by default</span>
3067
+ });</code></pre>
3068
+ </div>
3069
+
3070
+ <p class="lead-in">
3071
+ <code>stripedRows</code> shades every other data row. It is strictly opt-in and off by default,
3072
+ so a grid that never mentions it looks exactly as it did on upgrade. Parity is decided by each
3073
+ row's <em>logical</em> index rather than its position in the DOM: rows are virtualised and
3074
+ recycled, so a <code>:nth-child</code> rule would repaint the stripe onto whichever row landed
3075
+ in an odd slot after a scroll, and a logical-index stripe keeps a row shaded consistently across
3076
+ a scroll and across the left-pinned, centre and right-pinned segments of the same row. Group
3077
+ headings, group footers and the grand total are structure rather than data, so they are never
3078
+ striped. The stripe uses the theme's <code>--lattice-surface-alt</code> token, which every
3079
+ palette defines, so dark, high-contrast and terminal are correct without any extra rule, and
3080
+ both selection and hover still win over it.
3081
+ </p>
3082
+
2830
3083
  <h2 id="cards">Cards, lists and feeds</h2>
2831
3084
  <p class="lead-in">
2832
3085
  <code>rowTemplate</code> draws each row with a layout of your own instead of dividing it into
@@ -4490,6 +4743,40 @@ grid.edit.pasteInto(text); <span class="cmt">// Excel's t
4490
4743
  people fight.</p>
4491
4744
  </div>
4492
4745
 
4746
+ <h3 id="paste-preview">Previewing a bulk paste</h3>
4747
+ <p class="lead-in">
4748
+ A paste is the one clipboard gesture that can rewrite dozens of cells with nothing to inspect
4749
+ first: a payload that lands a column to the left of where it was aimed, or over a range the
4750
+ user forgot was selected, looks exactly like one that worked. Turn on
4751
+ <code>edit.pastePreview</code> and a paste into more than one cell opens a confirm/cancel dialog
4752
+ before anything commits.
4753
+ </p>
4754
+ <div class="example">
4755
+ <p class="example__label">Opt in</p>
4756
+ <pre><code>createGrid(host, {
4757
+ <span class="cmt">// Off by default: an unconfigured grid pastes straight away, as before.</span>
4758
+ edit: { enabled: <span class="kw">true</span>, pastePreview: <span class="kw">true</span> },
4759
+ });</code></pre>
4760
+ </div>
4761
+ <p>
4762
+ The dialog lists every cell that will change, old&nbsp;&rarr;&nbsp;new, and every cell a commit
4763
+ would reject &mdash; a read-only cell, a value the column's type or <code>edit.validate</code>
4764
+ refuses, a cell a permission policy forbids. <strong>Confirm</strong> commits precisely that set
4765
+ through the ordinary paste path; <strong>Cancel</strong> commits nothing. A single-cell paste
4766
+ skips the dialog &mdash; a preview for one cell is friction, not a safety net. The dialog is a
4767
+ modal <code>role="dialog"</code>: <kbd>Escape</kbd> cancels, <kbd>Tab</kbd> stays inside it,
4768
+ focus moves in on open and back on close, and its opening is announced through the grid's live
4769
+ region. The same diff is available without any UI from
4770
+ <code>grid.edit.previewPaste(anchor, text, extent?)</code>, which returns
4771
+ <code>{ changes, rejected }</code> and changes nothing.
4772
+ </p>
4773
+ <div class="why">
4774
+ <p><strong>Why a per-<code>edit</code> flag, off by default.</strong> Paste preview lives under
4775
+ <code>edit</code> because a paste is a bulk edit and its accept/reject decisions are the edit
4776
+ model's &mdash; the preview cannot disagree with the commit because it runs the same checks.
4777
+ Off by default keeps every existing grid's paste behaviour exactly as it was.</p>
4778
+ </div>
4779
+
4493
4780
  <h2 id="keyboard">Keyboard</h2>
4494
4781
  <p class="lead-in">
4495
4782
  Press <kbd>?</kbd> in the grid to see this list in the product. The overlay is generated from
@@ -5002,6 +5289,45 @@ r.ok ? r.value : r.error; <span class="cmt">// 42</span></code></pre>
5002
5289
  version, and you do not have to maintain a lookup table keyed by column id alongside the
5003
5290
  columns themselves.</p>
5004
5291
  </div>
5292
+
5293
+ <h3 id="range-chart">Chart a selected range</h3>
5294
+ <p class="lead-in">
5295
+ <code>rangeChart</code> turns a selected cell range into a chart — the spreadsheet gesture. It
5296
+ is off by default; set it and the cell menu offers <strong>Chart selection</strong>, with
5297
+ <kbd>Alt</kbd>+<kbd>F1</kbd> as the keyboard route, whenever the selected range has a numeric
5298
+ column to plot. The leading text column becomes the categories and the numeric columns beside
5299
+ it become the measures; a hidden or unreadable column is never charted, and the chart is bound
5300
+ to the band of rows the rectangle covers.
5301
+ </p>
5302
+ <p>
5303
+ The DOM layer draws no charts — the charts module is optional and the page loads it — so
5304
+ <code>rangeChart</code> carries the handler that draws. A function, or an object with
5305
+ <code>onChart</code>, is called <code>(grid, range)</code>; it typically calls
5306
+ <code>chartRange</code> from <code>modules/charts</code>, which derives the chart from the
5307
+ range and returns the live <code>Chart</code>.
5308
+ </p>
5309
+ <div class="example">
5310
+ <p class="example__label">Wiring the gesture to the charts module</p>
5311
+ <pre><code>import { chartRange } from '@toclocoinc/lattice-grid/modules/charts';
5312
+
5313
+ createGrid(el, {
5314
+ columns, rows,
5315
+ rangeChart(grid, range) {
5316
+ <span class="cmt">// One numeric column → a bar; several → a grouped bar. Null when the</span>
5317
+ <span class="cmt">// range has nothing to measure, so guard before using it.</span>
5318
+ <span class="kw">const</span> chart = chartRange(grid, { container: '#chart', range });
5319
+ <span class="kw">if</span> (chart) chart.update({ scheme: 'colourblind' });
5320
+ },
5321
+ });</code></pre>
5322
+ </div>
5323
+ <div class="why">
5324
+ <p><strong>Why a handler rather than a flag that just draws.</strong> The charts module is
5325
+ optional by design — a page that never charts never loads it — so the DOM layer cannot draw a
5326
+ chart itself without pulling the whole drawing surface into every bundle. Handing the drawing
5327
+ back to the page keeps that promise, and it is the same seam <code>createChart</code> already
5328
+ uses: the grid is handed to the charts module, never imported by it.</p>
5329
+ </div>
5330
+
5005
5331
  <div class="example">
5006
5332
  <p class="example__label">A button of your own on the rail</p>
5007
5333
  <pre><code>createGrid(el, {
@@ -5486,6 +5812,9 @@ grid.state.apply(savedView.state);
5486
5812
  <thead><tr><th>Name</th><th>What it does</th></tr></thead>
5487
5813
  <tbody>
5488
5814
  <tr><td class="name">createChart</td><td class="desc">Draw one of the thirty-seven chart types from a grid’s own data. It follows the grid’s filters, and a mark can filter the grid back.</td></tr>
5815
+ <tr><td class="name">chartRange</td><td class="desc">Chart a selected cell range — the spreadsheet gesture. Derives the chart from the range’s shape (a leading text column is the categories, the numeric columns the measures), respects hidden and unreadable columns, and returns the live chart or null when there is nothing to measure.</td></tr>
5816
+ <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>
5817
+ <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>
5489
5818
  <tr><td class="name">createDevtools</td><td class="desc">Mount the devtools panel against a grid, including its accessibility checks.</td></tr>
5490
5819
  <tr><td class="name">createLatticeGridElement</td><td class="desc">Build the element class without registering it, for a custom registry.</td></tr>
5491
5820
  <tr><td class="name">createMessages</td><td class="desc">Build a message catalogue. A partial set lays over the built-in British English one.</td></tr>