@toclocoinc/lattice-grid 1.47.0 → 1.49.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 (72) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +137 -5
  3. package/docs/api-detail.html +186 -2
  4. package/lattice-grid.d.ts +165 -3
  5. package/lattice-grid.esm.min.js +1665 -154
  6. package/lattice-grid.min.cjs +1665 -154
  7. package/lattice-grid.min.css +1 -1
  8. package/lattice-grid.min.js +1665 -154
  9. package/modules/ai.esm.min.js +29 -4
  10. package/modules/ai.min.cjs +29 -4
  11. package/modules/ai.min.js +29 -4
  12. package/modules/angular.esm.min.js +3 -3
  13. package/modules/angular.min.cjs +3 -3
  14. package/modules/angular.min.js +3 -3
  15. package/modules/chart-alluvial.esm.min.js +1 -1
  16. package/modules/chart-arc.esm.min.js +1 -1
  17. package/modules/chart-bubblemap.esm.min.js +1 -1
  18. package/modules/chart-bump.esm.min.js +1 -1
  19. package/modules/chart-calendar.esm.min.js +1 -1
  20. package/modules/chart-decomposition.esm.min.js +1 -1
  21. package/modules/chart-diverging.esm.min.js +1 -1
  22. package/modules/chart-dumbbell.esm.min.js +1 -1
  23. package/modules/chart-fan.esm.min.js +1 -1
  24. package/modules/chart-hexbin.esm.min.js +1 -1
  25. package/modules/chart-hexmap.esm.min.js +1 -1
  26. package/modules/chart-icicle.esm.min.js +1 -1
  27. package/modules/chart-parallel.esm.min.js +1 -1
  28. package/modules/chart-ridgeline.esm.min.js +1 -1
  29. package/modules/chart-roc.esm.min.js +1 -1
  30. package/modules/chart-slope.esm.min.js +1 -1
  31. package/modules/chart-splom.esm.min.js +1 -1
  32. package/modules/chart-waffle.esm.min.js +1 -1
  33. package/modules/charts.esm.min.js +1368 -1237
  34. package/modules/charts.min.cjs +1368 -1237
  35. package/modules/charts.min.js +1368 -1237
  36. package/modules/data-router.esm.min.js +4 -4
  37. package/modules/data-router.min.cjs +4 -4
  38. package/modules/data-router.min.js +4 -4
  39. package/modules/devtools.esm.min.js +2 -2
  40. package/modules/devtools.min.cjs +2 -2
  41. package/modules/devtools.min.js +2 -2
  42. package/modules/dhtmlx-compat.esm.min.js +4 -4
  43. package/modules/dhtmlx-compat.min.cjs +4 -4
  44. package/modules/dhtmlx-compat.min.js +4 -4
  45. package/modules/gantt.esm.min.js +4 -4
  46. package/modules/gantt.min.cjs +4 -4
  47. package/modules/gantt.min.js +4 -4
  48. package/modules/htmx.esm.min.js +1665 -154
  49. package/modules/htmx.min.cjs +1665 -154
  50. package/modules/htmx.min.js +1665 -154
  51. package/modules/kanban.esm.min.js +4 -4
  52. package/modules/kanban.min.cjs +4 -4
  53. package/modules/kanban.min.js +4 -4
  54. package/modules/kpi.esm.min.js +4 -4
  55. package/modules/kpi.min.cjs +4 -4
  56. package/modules/kpi.min.js +4 -4
  57. package/modules/mock-socket.esm.min.js +2 -2
  58. package/modules/mock-socket.min.cjs +2 -2
  59. package/modules/mock-socket.min.js +2 -2
  60. package/modules/react.esm.min.js +3 -3
  61. package/modules/react.min.cjs +3 -3
  62. package/modules/react.min.js +3 -3
  63. package/modules/svelte.esm.min.js +3 -3
  64. package/modules/svelte.min.cjs +3 -3
  65. package/modules/svelte.min.js +3 -3
  66. package/modules/vue.esm.min.js +3 -3
  67. package/modules/vue.min.cjs +3 -3
  68. package/modules/vue.min.js +3 -3
  69. package/modules/webcomponent.esm.min.js +1665 -154
  70. package/modules/webcomponent.min.cjs +1665 -154
  71. package/modules/webcomponent.min.js +1665 -154
  72. 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.47.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.49.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
package/docs/API.html CHANGED
@@ -865,6 +865,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
865
865
  <tr><td class="name">columnMenu</td><td class="type">boolean | (p) =&gt; MenuItem[]</td><td class="desc">The header's 3-dot menu, and a right-click on a column heading. The function form is <code>(params, defaults) =&gt; items</code>, with <code>params</code> carrying <code>colId</code>, <code>column</code> and <code>grid</code>: see <a href="#custom-menu">custom items</a>. <code>false</code> suppresses it.</td></tr>
866
866
  <tr><td class="name">rangeChart</td><td class="type">fn | { onChart } | boolean</td><td class="desc">Off by default. Offers <strong>Chart selection</strong> in the cell menu and binds <kbd>Alt</kbd>+<kbd>F1</kbd> when a selected range has a number to plot. The DOM layer draws no charts, so the handler you give — a function, or <code>{ onChart }</code>, called <code>(grid, range)</code> — is where the page wires in <code>chartRange</code> from <a href="#chart-a-range">the charts module</a>.</td></tr>
867
867
  <tr><td class="name">shortcuts</td><td class="type">boolean</td><td class="dflt">true</td><td class="desc">The <kbd>?</kbd> keyboard shortcut overlay. <code>false</code> suppresses it, for a host that wants <kbd>?</kbd> for itself. See <a href="api-detail.html#keyboard">Keyboard</a>.</td></tr>
868
+ <tr><td class="name">find</td><td class="type">boolean | FindConfig</td><td class="dflt">true</td><td class="desc">The in-grid find bar: <kbd>Ctrl</kbd>+<kbd>F</kbd> (<kbd>Cmd</kbd>+<kbd>F</kbd>) with focus in the grid opens it; typing highlights every matching cell in place without filtering a row away; <kbd>Enter</kbd> / <kbd>Shift</kbd>+<kbd>Enter</kbd> step through the matches. <code>{ shortcut, debounce }</code>: <code>shortcut: false</code> keeps the bar reachable through <code>grid.find.open()</code> only; <code>debounce</code> is the typing quiet period in ms (120). <code>false</code> removes the bar and the binding; <code>grid.find(text)</code> still searches. See <a href="api-detail.html#find">Find</a>.</td></tr>
868
869
  <tr><td class="name">rowReorder</td><td class="type">boolean | { column }</td><td class="dflt">, </td><td class="desc">Let a user reorder rows by dragging a handle or with <kbd>Alt</kbd>+<kbd>Shift</kbd>+arrows. The handle goes in the first visible column unless <code>column</code> names another. Refused, with a reason announced, while a sort, filter or grouping is active. See <a href="api-detail.html#row-reorder">Row reorder</a>.</td></tr>
869
870
  <tr><td class="name">rowTransfer</td><td class="type">boolean | { send, receive, mode, group }</td><td class="dflt">, </td><td class="desc">Let rows be dragged between grids. Off by default. <code>send</code> and <code>receive</code> are both on when present, so one-way is <code>{ receive: false }</code> or <code>{ send: false }</code>. <code>mode: 'copy'</code> leaves the row behind; <code>group</code> restricts which grids may exchange. See <a href="api-detail.html#row-transfer">Moving rows between grids</a>.</td></tr>
870
871
  <tr><td class="name">alignedGrids</td><td class="type">Grid[]</td><td class="dflt">, </td><td class="desc">Other grids to stay column-aligned with. Widths, order, visibility, pinning and horizontal scroll are shared; sort, filters, selection and rows stay independent. Declare it on the grid created last. See <a href="api-detail.html#aligned-grids">Aligned grids</a>.</td></tr>
@@ -3218,6 +3219,42 @@ createGrid(host, { source, columns: [...] });</code></pre>
3218
3219
  </table>
3219
3220
  </div>
3220
3221
 
3222
+ <p class="section-note">
3223
+ <strong>Typed binding for timestamp and date columns.</strong> A prepared statement binds a
3224
+ filter value with the value's own type, not the column's: the grid sends an instant as an
3225
+ ISO-8601 string, the client binds it as <code>VARCHAR</code>, and DuckDB refuses
3226
+ <code>"ts" &gt;= ?</code> against a <code>TIMESTAMP</code> column (<em>Binder Error: Cannot
3227
+ compare values of type TIMESTAMP and type VARCHAR</em>). The adapter therefore types the
3228
+ <em>placeholder</em>: a comparison or <code>IN</code> member against a <code>TIMESTAMP</code>,
3229
+ <code>TIMESTAMP WITH TIME ZONE</code>, <code>DATE</code>, <code>TIME</code> or
3230
+ <code>TIMESTAMP_S/_MS/_NS</code> column is written <code>CAST(? AS &lt;that type&gt;)</code>,
3231
+ and the value is still bound, never interpolated. The column's type comes from the engine — one
3232
+ <code>DESCRIBE SELECT * FROM &lt;from&gt;</code> on the first query, cached for the adapter's
3233
+ life and exposed as <code>adapter.describe()</code> — so an untyped grid column over a
3234
+ timestamp is covered. When the schema does not name the column (a <code>DESCRIBE</code> that
3235
+ failed, said once), the grid column's declared type on the condition is the fallback:
3236
+ <code>timestamp</code>/<code>datetime</code> cast to <code>TIMESTAMP</code>,
3237
+ <code>date</code>/<code>dateString</code> to <code>DATE</code>, <code>time</code> to
3238
+ <code>TIME</code>. The engine's type wins when both are known. A <code>Date</code> or an
3239
+ epoch-milliseconds number is bound as its ISO instant, because DuckDB has no cast from a number
3240
+ to a timestamp. The same schema fixes <code>blank</code>: <code>= ''</code> is a conversion
3241
+ error on any non-text column, so a typed column's blank test is <code>IS NULL</code> alone.
3242
+ Text and numeric comparisons (<code>VARCHAR</code>, <code>BIGINT</code>, <code>DOUBLE</code>,
3243
+ <code>DECIMAL</code>, <code>HUGEINT</code>) are written exactly as before, with no cast.
3244
+ </p>
3245
+ <p class="section-note">
3246
+ <strong>Time zones, honestly.</strong> The cast is the engine's, so its zone rules apply. Against
3247
+ a naive <code>TIMESTAMP</code> column the wall-clock digits of the bound string are compared;
3248
+ an instant ending in <code>Z</code> — which is what the grid's own date filter sends — therefore
3249
+ matches a column that stores UTC wall time, the usual convention for log and event data. A
3250
+ non-zero offset in the string is engine-version dependent (DuckDB 1.1 converts it to UTC, 1.5
3251
+ keeps the digits as written), so send <code>Z</code> instants, not local offsets. Against a
3252
+ <code>TIMESTAMP WITH TIME ZONE</code> column an offset or <code>Z</code> is honoured exactly,
3253
+ and a string with <em>no</em> zone is interpreted in the engine's session
3254
+ <code>TimeZone</code> (UTC in DuckDB-Wasm unless the ICU extension is loaded and the setting
3255
+ changed). Against a <code>DATE</code> column an instant is truncated to its UTC day.
3256
+ </p>
3257
+
3221
3258
  <h5 id="dfql-options"><code>dfqlAdapter</code></h5>
3222
3259
  <div class="table-wrap">
3223
3260
  <table>
@@ -3857,6 +3894,7 @@ off(); <span class="cmt">// on() returns i
3857
3894
  <tr><td class="name">history:applied</td><td class="type">{ direction, step }</td><td class="desc">An action was undone or redone. Distinct from <code>history:changed</code>, which also fires when a new action is pushed onto the stacks and so cannot tell you anything was reversed.</td></tr>
3858
3895
  <tr><td class="name">state:reset</td><td class="type">{ state }</td><td class="desc">The grid was returned to its baseline.</td></tr>
3859
3896
  <tr><td class="name">highlight:changed</td><td class="type">{ highlights }</td><td class="desc">A highlight was added or cleared.</td></tr>
3897
+ <tr><td class="name">find:changed</td><td class="type">{ text, caseSensitive, wholeCell, columns, open, count }</td><td class="desc">The find query, its matches, the current match or the bar's open state changed. <code>count</code> is a <code>FindCount</code>; while the bar's sliced scan is still running <code>count.complete</code> is false and the figure is partial.</td></tr>
3860
3898
  <tr><td class="name">redaction:changed</td><td class="type">{ columns }</td><td class="desc">A column was redacted or restored.</td></tr>
3861
3899
  <tr><td class="name">header:contextmenu</td><td class="type">{ colId, column, element, x, y }</td><td class="desc">A column heading was right-clicked.</td></tr>
3862
3900
  <tr><td class="name">render:done</td><td class="type">{ first, last }</td><td class="desc">The cells are written and stable. Anything decorating them from outside must run after this, the cell layer rewrites each cell's <code>className</code> wholesale and would otherwise erase it.</td></tr>
@@ -6130,7 +6168,7 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
6130
6168
  <h3 id="nested-config-example">Nested configuration, executed</h3>
6131
6169
  <p class="section-note">Thirteen option blocks, each key written where it belongs. Parsed and evaluated on
6132
6170
  every build, so a key that was renamed or moved shows up here.</p>
6133
- <pre data-run="js" data-expect="14" data-covers="config:aboveLimit config:adapter config:binary config:bucket config:bucketFn config:buckets config:cacheLimit config:cardinalityLimit config:checkbox config:coalesceMs config:commit config:confirm config:cumulative config:debounce config:decimals config:delimiter config:display config:download config:enabled config:enterMovesDown config:fetch config:fileName config:fill config:fillHandle config:follow config:format config:from config:granularity config:groupBy config:hasChildren config:headerCheckbox config:headers config:hint config:idleMs config:indexLimit config:isMaster config:join config:label config:limit config:limitPer config:lineEnding config:loadChildren config:lock config:lockMs config:markdown config:maxCachedPages config:maxDecimals config:maxRows config:me config:minDecimals config:mode config:onCreate config:open config:orient config:orphans config:pageSize config:palette config:parentKey config:path config:pendingTimeout config:placement config:processCell config:profile config:promoteToMemoryBelow config:provider config:quote config:ranges config:removeMs config:retainSource config:roster config:scale config:select config:space config:start config:strategy config:system config:target config:throttleMs config:undoDepth config:unit config:unnest config:where"><code><span class="cmt">// Placeholders for the things a real page supplies. The point of this block is</span>
6171
+ <pre data-run="js" data-expect="14" data-covers="config:aboveLimit config:adapter config:ageBy config:binary config:bucket config:bucketFn config:buckets config:cacheLimit config:cardinalityLimit config:checkbox config:coalesceMs config:commit config:confirm config:cumulative config:debounce config:decimals config:delimiter config:display config:download config:enabled config:enterMovesDown config:fetch config:fileName config:fill config:fillHandle config:follow config:format config:from config:granularity config:groupBy config:hasChildren config:headerCheckbox config:headers config:hint config:idleMs config:indexLimit config:isMaster config:join config:label config:limit config:limitPer config:lineEnding config:loadChildren config:lock config:lockMs config:markdown config:maxCachedPages config:maxAge config:maxDecimals config:maxRows config:me config:minDecimals config:mode config:onCreate config:open config:orient config:orphans config:pageSize config:palette config:parentKey config:path config:pendingTimeout config:placement config:processCell config:profile config:promoteToMemoryBelow config:provider config:quote config:ranges config:removeMs config:retainSource config:roster config:scale config:select config:space config:start config:strategy config:system config:target config:throttleMs config:undoDepth config:unit config:unnest config:where"><code><span class="cmt">// Placeholders for the things a real page supplies. The point of this block is</span>
6134
6172
  <span class="cmt">// the option names: each one below is a documented key, written where it</span>
6135
6173
  <span class="cmt">// belongs, so a key that was renamed or moved stops matching its interface.</span>
6136
6174
  <span class="kw">const</span> source = {}, other = {}, provider = {}, compute = {}, adapter = {};
@@ -6171,7 +6209,10 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
6171
6209
  <span class="kw">const</span> pagedSourceConfig = { mode: 'paged', pageSize: 100, maxCachedPages: 5, fetch };
6172
6210
 
6173
6211
  <span class="cmt">// A streaming source — StreamSourceConfig</span>
6174
- <span class="kw">const</span> streamSourceConfig = { mode: 'stream', open, coalesceMs: 16, maxRows: 1e6, promoteToMemoryBelow: 5e5 };
6212
+ <span class="kw">const</span> streamSourceConfig = { mode: 'stream', open, coalesceMs: 16, maxRows: 1e6, promoteToMemoryBelow: 5e5,
6213
+ <span class="cmt">// A rolling *time* window beside the count one: keep five minutes, aged by the</span>
6214
+ <span class="cmt">// row's own clock. Omit ageBy and rows age from when they arrived instead.</span>
6215
+ maxAge: 5 * 60 * 1000, ageBy: 'ts' };
6175
6216
 
6176
6217
  <span class="cmt">// A pushdown source — PushdownSourceConfig</span>
6177
6218
  <span class="kw">const</span> pushdownSourceConfig = { adapter, compute, pageSize: 200 };
@@ -6242,7 +6283,7 @@ grid.destroy();
6242
6283
  <p class="section-note">Each documented event is subscribed to and unsubscribed on every build. A consumer
6243
6284
  wiring a handler to a renamed event gets silence, which is indistinguishable from an event that
6244
6285
  has not fired yet — so the name is checked rather than left to be discovered.</p>
6245
- <pre data-run="js" data-expect="107" data-covers="event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:profile:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6286
+ <pre data-run="js" data-expect="108" data-covers="event:find:changed event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:profile:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6246
6287
 
6247
6288
  <span class="cmt">// Every documented event name, checked against the bus that would carry it.</span>
6248
6289
  <span class="cmt">// Subscribing to a name the grid does not know is the failure this catches:</span>
@@ -6259,7 +6300,7 @@ grid.destroy();
6259
6300
  'diff:changed', 'diff:swapped', 'export:progress', 'facet:computed',
6260
6301
  'facet:expanded', 'facet:failed', 'facet:filtered', 'form:closed',
6261
6302
  'form:error', 'form:opened', 'form:saved', 'formatting:changed',
6262
- 'group:toggled', 'header:contextmenu', 'highlight:changed', 'history:applied',
6303
+ 'group:toggled', 'header:contextmenu', 'highlight:changed', 'find:changed', 'history:applied',
6263
6304
  'history:changed', 'licence:changed', 'page:changed', 'permissions:changed',
6264
6305
  'presence:failed', 'presence:joined', 'presence:left', 'presence:lockRefused',
6265
6306
  'presence:published', 'presence:updated', 'presentation:captured', 'presentation:changed',
@@ -6878,6 +6919,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
6878
6919
  <tr><td class="name">labels</td><td class="type">boolean</td><td class="desc">Draw the tick labels. <small>(optional)</small></td></tr>
6879
6920
  <tr><td class="name">every</td><td class="type">number</td><td class="desc">Show every nth category label, on a crowded category axis. <small>(optional)</small></td></tr>
6880
6921
  <tr><td class="name">rotate</td><td class="type">boolean | 'auto'</td><td class="desc">Force the category labels' rotation rather than deciding it. <small>(optional)</small></td></tr>
6922
+ <tr><td class="name">window</td><td class="type">Pick&lt;WindowSpec, 'kind' | 'span'&gt;</td><td class="desc">A rolling window for the axis domain (BACKLOG-0001036), in the shipped `WindowSpec` vocabulary that rolling statistics already use. Only `{ kind: 'time', span }` applies to an axis: the domain becomes the last `span` milliseconds ending **now**, so the chart keeps scrolling left while the feed is silent — the thing a count window cannot do, because with no rows arriving nothing changes. Advanced on a low-frequency clock (a quarter of the window, between 50 ms and 1 s), never per frame, and stopped when the chart is destroyed or its document is hidden. Needs a continuous x axis carrying wall-clock times; `{ kind: 'count' }` is the source's `maxRows` and is refused here rather than given a second meaning. <small>(optional)</small></td></tr>
6881
6923
  </tbody>
6882
6924
  </table>
6883
6925
  </div>
@@ -8085,6 +8127,92 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8085
8127
  </tbody>
8086
8128
  </table>
8087
8129
  </div>
8130
+ <h3 id="type-FindApi">FindApi</h3>
8131
+ <p class="section-note">In-grid find (BACKLOG-0001018): locate text and step through where it occurs without filtering anything away. Matches are a visual overlay — no row is reordered, removed or edited — and coexist with the quick filter.</p>
8132
+ <div class="table-wrap">
8133
+ <table>
8134
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8135
+ <tbody>
8136
+ <tr><td class="name">open</td><td class="type">(text?: string): void</td><td class="desc">Show the bar with focus in its input, optionally seeding the text.</td></tr>
8137
+ <tr><td class="name">close</td><td class="type">(): void</td><td class="desc">Hide the bar and clear every match.</td></tr>
8138
+ <tr><td class="name">clear</td><td class="type">(): void</td><td class="desc">Clear the query and the highlights, leaving the bar as it is.</td></tr>
8139
+ <tr><td class="name">next</td><td class="type">(): FindMatch | null</td><td class="desc">The next match, wrapping from the last to the first, scrolled into view and made the active cell unless an edit is open.</td></tr>
8140
+ <tr><td class="name">prev</td><td class="type">(): FindMatch | null</td><td class="desc">The previous match, wrapping from the first to the last.</td></tr>
8141
+ <tr><td class="name">goTo</td><td class="type">(index: number): FindMatch | null</td><td class="desc">Make the match at a position in `matches()` current.</td></tr>
8142
+ <tr><td class="name">matches</td><td class="type">(): FindMatch[]</td><td class="desc">Every match, in display order: pinned-top rows, then the body, then pinned-bottom rows.</td></tr>
8143
+ <tr><td class="name">count</td><td class="type">(): FindCount</td><td class="desc"></td></tr>
8144
+ <tr><td class="name">current</td><td class="type">(): FindMatch | null</td><td class="desc"></td></tr>
8145
+ <tr><td class="name">state</td><td class="type">(): FindState</td><td class="desc"></td></tr>
8146
+ <tr><td class="name">stateFor</td><td class="type">(key: string, colId: string): 'current' | 'match' | null</td><td class="desc">How a cell is painted: the current match, another match, or nothing.</td></tr>
8147
+ </tbody>
8148
+ </table>
8149
+ </div>
8150
+ <h3 id="type-FindConfig">FindConfig</h3>
8151
+ <p class="section-note">The in-grid find bar's settings (BACKLOG-0001018). `find: true` or an omitted key mounts the bar with these defaults; `find: false` removes the bar and its shortcut while `grid.find` keeps working programmatically.</p>
8152
+ <div class="table-wrap">
8153
+ <table>
8154
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8155
+ <tbody>
8156
+ <tr><td class="name">shortcut</td><td class="type">boolean</td><td class="desc">Bind Ctrl+F (Cmd+F on a Mac) while focus is in the grid. The browser's own find is untouched while focus is anywhere else on the page. Default true. <small>(optional)</small></td></tr>
8157
+ <tr><td class="name">debounce</td><td class="type">number</td><td class="desc">Milliseconds of typing quiet before the bar searches. Default 120. <small>(optional)</small></td></tr>
8158
+ </tbody>
8159
+ </table>
8160
+ </div>
8161
+ <h3 id="type-FindCount">FindCount</h3>
8162
+ <p class="section-note">How many matches there are and which is current. `windowed` is the honest scope flag: over a paged pushdown source only the loaded rows are searched, so `total` counts matches in `loaded` rows out of the `rows` the source reports for the whole matching set.</p>
8163
+ <div class="table-wrap">
8164
+ <table>
8165
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8166
+ <tbody>
8167
+ <tr><td class="name">current</td><td class="type">number</td><td class="desc">1-based position of the current match; 0 when there is none.</td></tr>
8168
+ <tr><td class="name">total</td><td class="type">number</td><td class="desc"></td></tr>
8169
+ <tr><td class="name">complete</td><td class="type">boolean</td><td class="desc">False while the bar's sliced scan is still running, so a partial count is never read as final.</td></tr>
8170
+ <tr><td class="name">windowed</td><td class="type">boolean</td><td class="desc"></td></tr>
8171
+ <tr><td class="name">loaded</td><td class="type">number</td><td class="desc">Rows the search actually read; a windowed source's not-yet-fetched placeholders are not counted.</td></tr>
8172
+ <tr><td class="name">rows</td><td class="type">number</td><td class="desc">The rows the source reports for the whole matching set, when it can say.</td></tr>
8173
+ </tbody>
8174
+ </table>
8175
+ </div>
8176
+ <h3 id="type-FindMatch">FindMatch</h3>
8177
+ <p class="section-note">One matching cell.</p>
8178
+ <div class="table-wrap">
8179
+ <table>
8180
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8181
+ <tbody>
8182
+ <tr><td class="name">key</td><td class="type">string</td><td class="desc"></td></tr>
8183
+ <tr><td class="name">colId</td><td class="type">string</td><td class="desc"></td></tr>
8184
+ <tr><td class="name">index</td><td class="type">number</td><td class="desc">The display index, or -1 for a row pinned to an edge.</td></tr>
8185
+ <tr><td class="name">pinned</td><td class="type">'top' | 'bottom' | null</td><td class="desc">Which sticky strip a pinned row is in; null for a body row.</td></tr>
8186
+ </tbody>
8187
+ </table>
8188
+ </div>
8189
+ <h3 id="type-FindQuery">FindQuery</h3>
8190
+ <p class="section-note">How `grid.find(text, opts)` matches. Defaults: case-insensitive, substring, every visible column, starting from the first row. Find matches the **formatted display text** — what the cell shows, a column `format` included — never a raw value; there is no regular-expression mode.</p>
8191
+ <div class="table-wrap">
8192
+ <table>
8193
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8194
+ <tbody>
8195
+ <tr><td class="name">caseSensitive</td><td class="type">boolean</td><td class="desc">Match letter case exactly. Default false. <small>(optional)</small></td></tr>
8196
+ <tr><td class="name">wholeCell</td><td class="type">boolean</td><td class="desc">The whole cell text must equal the search text rather than contain it. Default false. <small>(optional)</small></td></tr>
8197
+ <tr><td class="name">columns</td><td class="type">string[] | string | null</td><td class="desc">Search only these column ids. Omitted searches every visible column. <small>(optional)</small></td></tr>
8198
+ <tr><td class="name">from</td><td class="type">number</td><td class="desc">The display index to start from: the first match at or after it becomes current. Default 0. <small>(optional)</small></td></tr>
8199
+ </tbody>
8200
+ </table>
8201
+ </div>
8202
+ <h3 id="type-FindState">FindState</h3>
8203
+ <p class="section-note">The current query and whether the bar is showing.</p>
8204
+ <div class="table-wrap">
8205
+ <table>
8206
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8207
+ <tbody>
8208
+ <tr><td class="name">text</td><td class="type">string</td><td class="desc"></td></tr>
8209
+ <tr><td class="name">caseSensitive</td><td class="type">boolean</td><td class="desc"></td></tr>
8210
+ <tr><td class="name">wholeCell</td><td class="type">boolean</td><td class="desc"></td></tr>
8211
+ <tr><td class="name">columns</td><td class="type">string[] | null</td><td class="desc"></td></tr>
8212
+ <tr><td class="name">open</td><td class="type">boolean</td><td class="desc"></td></tr>
8213
+ </tbody>
8214
+ </table>
8215
+ </div>
8088
8216
  <h3 id="type-ForecastPoint">ForecastPoint</h3>
8089
8217
  <p class="section-note">One forecast step: the point estimate and, where a band applies, its interval.</p>
8090
8218
  <div class="table-wrap">
@@ -8224,6 +8352,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8224
8352
  <tr><td class="name">licence</td><td class="type">LicenceApi</td><td class="desc">Licence state, and setting a key after construction. <small>(read-only)</small></td></tr>
8225
8353
  <tr><td class="name">pagination</td><td class="type">PaginationApi</td><td class="desc">Pages, where the grid is paged rather than scrolled. <small>(read-only)</small></td></tr>
8226
8354
  <tr><td class="name">highlight</td><td class="type">HighlightApi</td><td class="desc">Transient emphasis on a row, column or cell. <small>(read-only)</small></td></tr>
8355
+ <tr><td class="name">find</td><td class="type">FindApi</td><td class="desc">In-grid find: locate text without filtering, and step through the matches. <small>(read-only)</small></td></tr>
8227
8356
  <tr><td class="name">redaction</td><td class="type">RedactionApi</td><td class="desc">Values hidden from view and from export. <small>(read-only)</small></td></tr>
8228
8357
  <tr><td class="name">capture</td><td class="type">(opts?: CaptureOptions): Promise&lt;Blob&gt;</td><td class="desc">An image of the grid as drawn, where the module is installed. <small>(optional)</small></td></tr>
8229
8358
  <tr><td class="name">annotate</td><td class="type">AnnotationApi</td><td class="desc">Drawing over the grid, where the module is installed. <small>(optional)</small></td></tr>
@@ -8343,6 +8472,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8343
8472
  <tr><td class="name">columnMenu</td><td class="type">boolean | ((p: ColumnMenuParams, defaults: MenuItem[]) =&gt; MenuItem[] | void)</td><td class="desc">The header's 3-dot menu, and the right-click menu on a column heading. `false` suppresses both. A function supplies custom items, receiving the grid's own so it can add to them rather than reproduce them. Default true. <small>(optional)</small></td></tr>
8344
8473
  <tr><td class="name">rangeChart</td><td class="type"></td><td class="desc">Chart a selected cell range — the spreadsheet "chart this selection" gesture. Off by default, so a grid opts in. The DOM layer draws no charts itself — the charts module is optional and loaded by the host — so this is where the host wires the two together: a function, or an object carrying `onChart`, is called with the grid and the selected range when the reader chooses "Chart selection" from the cell menu. The handler typically calls `chartRange` from `lattice-grid/modules/charts`. `true` offers the item and emits nothing extra; supply a handler to have it actually draw. <small>(optional)</small></td></tr>
8345
8474
  <tr><td class="name">shortcuts</td><td class="type">boolean</td><td class="desc">The `?` keyboard shortcut overlay. `false` suppresses it, for a host that wants `?` for itself. Default true. <small>(optional)</small></td></tr>
8475
+ <tr><td class="name">find</td><td class="type">boolean | FindConfig</td><td class="desc">The in-grid find bar (BACKLOG-0001018): Ctrl+F / Cmd+F with focus in the grid opens it; typing highlights every matching cell in place without filtering a row away; Enter and Shift+Enter step through the matches. `false` removes the bar and its shortcut; the `grid.find` API still works. Default true. <small>(optional)</small></td></tr>
8346
8476
  <tr><td class="name">rowReorder</td><td class="type">boolean | { column?: string }</td><td class="desc">Let a user reorder rows by dragging a handle, or with Alt+Shift+Up/Down. `true` puts the handle in the first visible column; `{ column }` names a different one. The move reorders your data and emits `row:moved`; persisting it is yours, and `rows.data()` afterwards is the new order. Refused, with a reason announced, while a sort, filter or grouping is active, the position a row is dropped at has no single meaning in the underlying order then. <small>(optional)</small></td></tr>
8347
8477
  <tr><td class="name">rowTransfer</td><td class="type">boolean | {</td><td class="desc">Let rows be dragged out of this grid, into it, or both. Off by default: rows leaving a grid is a data change a host has to want, and a mis-drag that silently removed one has no gesture a user would think to undo. `send` and `receive` are both on when the option is present, so one-way is expressed by turning off the direction you do not want, a source grid is `{ receive: false }` and a target is `{ send: false }`. `mode: 'copy'` leaves the row where it was. `group` restricts exchange to grids sharing the same name, so two unrelated grids on a page do not accept each other's rows. The source needs `rowReorder` as well, since that is what draws the handle a drag starts from. <small>(optional)</small></td></tr>
8348
8478
  <tr><td class="name">alignedGrids</td><td class="type">unknown[]</td><td class="desc">Other grids to stay column-aligned with. Column widths, order, visibility and pinning are shared, and horizontal scrolling moves them together. Sort, filters, selection, grouping and the rows themselves stay independent: sharing those would make one grid with extra steps rather than two aligned ones. Declared on the grid created last, since it is the only one that can name the others; the link is peer-based once made. <small>(optional)</small></td></tr>
@@ -9545,7 +9675,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9545
9675
  <table>
9546
9676
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9547
9677
  <tbody>
9548
- <tr><td class="name">toRow</td><td class="type">(row: string | number, align?: 'start' | 'center' | 'end' | 'auto'): void</td><td class="desc">A row key, or a display index. A key survives a sort and is usually what a caller holds; resolving one scans the display order, so prefer an index when scrolling a very large grid repeatedly.</td></tr>
9678
+ <tr><td class="name">toRow</td><td class="type">(row: string | number, align?: 'start' | 'center' | 'end' | 'auto'): void</td><td class="desc">A row key, or a display index. A key survives a sort and is usually what a caller holds; resolving one scans the display order, so prefer an index when scrolling a very large grid repeatedly. The row lands fully visible in the part of the body the pinned strips (pinned rows, sticky group headings, a bottom grand total) do not cover: `end` puts it just above the bottom strip, `start` just below the top one.</td></tr>
9549
9679
  <tr><td class="name">toColumn</td><td class="type">(id: string): void</td><td class="desc"></td></tr>
9550
9680
  <tr><td class="name">toCell</td><td class="type">(row: string | number, colId: string, align?: 'start' | 'center' | 'end' | 'auto'): void</td><td class="desc">Scroll a cell into view, both axes in one call.</td></tr>
9551
9681
  <tr><td class="name">position</td><td class="type">(): { top: number; left: number }</td><td class="desc"></td></tr>
@@ -9774,6 +9904,8 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9774
9904
  <tr><td class="name">mode</td><td class="type">'stream'</td><td class="desc"></td></tr>
9775
9905
  <tr><td class="name">open</td><td class="type">(req: {</td><td class="desc"></td></tr>
9776
9906
  <tr><td class="name">maxRows</td><td class="type">number</td><td class="desc">The most rows to keep. A stream has no end, so an unbounded grid dies overnight; this makes it a sliding window and the oldest rows are dropped. Omit for no limit. Set on the source, not passed to `open`, it bounds what the grid retains rather than what the producer sends. <small>(optional)</small></td></tr>
9907
+ <tr><td class="name">maxAge</td><td class="type">number</td><td class="desc">The longest a row is kept, in milliseconds — a rolling *time* window, sitting beside `maxRows` as a second, independent bound (BACKLOG-0001036). Rows older than the span are evicted through the same path, the same `evicted` counters and the same `stream:evicted` event as the count bound, so an existing readout keeps working. Set both and whichever bites first applies. Eviction continues on a low-frequency timer while the feed is idle, so "the last five minutes" keeps shrinking through a silent period rather than freezing — which is the thing `maxRows` cannot do. Retention is a *bound, not a guillotine*: rows live a little past the span before a block is dropped. Two things add to it. First the eviction slack, ten per cent of the span, exactly as `maxRows` overshoots its count, so the row permutation is rebuilt once per block rather than once per row. Second, when the feed is idle, up to one tick of the eviction timer, which runs at a quarter of the span clamped to between 50 ms and one second. So the real ceiling is roughly `span * 1.1 + tick`, and because the tick has a floor it is proportionally larger the shorter the window: negligible at a five-minute window (about 10%), around 1.25x at ten seconds, and as much as ~1.35x at three. That is the deliberate trade for an idle grid that costs no CPU. Omit for no age limit. <small>(optional)</small></td></tr>
9908
+ <tr><td class="name">ageBy</td><td class="type">string | ((row: unknown) =&gt; unknown)</td><td class="desc">Which clock `maxAge` reads: a column id (or dotted path), or a function of the row returning a `Date`, epoch milliseconds, or an ISO string (BACKLOG-0001036). Given, the window follows the **data's own** clock, so it means what the producer means — and inherits the producer's clock skew. Omitted, `maxAge` falls back to **arrival time**: when the row reached this source. Arrival time needs no timestamp column and cannot be skewed, but it is not event time — a row delayed in transit counts as young. A row whose time value cannot be read is never aged out. <small>(optional)</small></td></tr>
9777
9909
  <tr><td class="name">promoteToMemoryBelow</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
9778
9910
  <tr><td class="name">coalesceMs</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
9779
9911
  </tbody>
@@ -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.47.0</p>
440
+ <p class="rail__sub">Developer guide · v1.49.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -506,6 +506,7 @@
506
506
  <a href="#rules-guide">Conditional formatting</a>
507
507
  <a href="#formatting-guide">Formatting a user can change</a>
508
508
  <a href="#quickfilter-guide">Quick filter</a>
509
+ <a href="#find">Find</a>
509
510
  <a href="#charts-guide">In-cell charts</a>
510
511
  </div>
511
512
  <div class="rail__group">
@@ -3149,8 +3150,17 @@ createGrid(el, {
3149
3150
  <p>Column and table names are checked against an identifier pattern rather than escaped, and
3150
3151
  a name that fails is refused. Integers past the safe range are kept as strings instead of
3151
3152
  being rounded into a plausible lie.</p>
3153
+ <p>A timestamp or date filter is bound through a typed placeholder — <code>"ts" &gt;=
3154
+ CAST(? AS TIMESTAMP)</code> — because a prepared statement binds an ISO string as
3155
+ <code>VARCHAR</code> and DuckDB will not compare that with a <code>TIMESTAMP</code>,
3156
+ <code>TIMESTAMP WITH TIME ZONE</code> or <code>DATE</code> column. The adapter reads the
3157
+ column types once from the engine (<code>DESCRIBE</code>) and falls back to the grid column's
3158
+ declared type, so both a typed and an untyped grid column over a timestamp filter correctly,
3159
+ including a time window. Send instants ending in <code>Z</code>, as the grid's date filter
3160
+ does: the cast is the engine's, and its zone rules apply (see the reference for the
3161
+ <code>TIMESTAMPTZ</code> and naive-string cases).</p>
3152
3162
  <p><code>demo/duckdb.html</code> runs this against a Parquet file of several million readings
3153
- with no server involved.</p>
3163
+ with no server involved, including a time-window filter on its <code>TIMESTAMP</code> column.</p>
3154
3164
  </div>
3155
3165
 
3156
3166
  <h3 id="fulldataset">Whole-dataset statistics over a remote source</h3>
@@ -4460,6 +4470,96 @@ grid.updates.log({ since: Date.now() - 60000 }); <span class="cmt">// what arr
4460
4470
  left up overnight holds every row it was ever sent. Set <code>source.maxRows</code> and the
4461
4471
  stream becomes a sliding window, dropping the oldest as new ones arrive and reporting how
4462
4472
  many it let go through <code>evicted</code> on the progress report.</p>
4473
+ </div>
4474
+
4475
+ <h3 id="time-window-guide">A time window, not just a row count</h3>
4476
+ <p class="lead-in">
4477
+ <code>maxRows</code> is a <em>count</em> window. <code>maxAge</code> is a <em>time</em>
4478
+ window. They are different promises, and a live feed usually wants the second one.
4479
+ </p>
4480
+ <div class="example">
4481
+ <p class="example__label">Keep the last five minutes, and show the last five minutes</p>
4482
+ <pre><code>const grid = createGrid(el, {
4483
+ columns,
4484
+ source: {
4485
+ mode: 'stream',
4486
+ open,
4487
+ maxAge: 5 * 60 * 1000, <span class="cmt">// keep five minutes of rows</span>
4488
+ ageBy: 'ts', <span class="cmt">// ...aged by this column; omit for arrival time</span>
4489
+ maxRows: 20000, <span class="cmt">// ...and never more than this many, whichever bites first</span>
4490
+ },
4491
+ });
4492
+
4493
+ createChart({
4494
+ grid, container, type: 'line', x: 'ts', y: { col: 'value', fn: 'avg' },
4495
+ <span class="cmt">// The x domain is the last five minutes ending *now*, so the chart</span>
4496
+ <span class="cmt">// keeps scrolling left even while the feed is silent.</span>
4497
+ axis: { x: { window: { kind: 'time', span: 5 * 60 * 1000 } } },
4498
+ });</code></pre>
4499
+ </div>
4500
+ <div class="why">
4501
+ <p><strong>A count window drifts, and it freezes.</strong> <code>maxRows</code> equals
4502
+ &ldquo;the last five minutes&rdquo; only while the feed rate is steady: a burst silently
4503
+ shrinks the window to two minutes, a quiet spell stretches it to twenty, and the x axis
4504
+ changes span under the reader. Worse, when the feed goes quiet nothing is evicted and the
4505
+ chart stops moving, even though time is still passing &mdash; and the silence is usually the
4506
+ thing worth seeing. <code>maxAge</code> is a span of wall clock, so it means the same thing
4507
+ whatever the feed is doing.</p>
4508
+ <p><strong>Two bounds, one eviction path.</strong> <code>maxAge</code> and
4509
+ <code>maxRows</code> are independent and compose: both are applied on the same pass and
4510
+ whichever bites first is simply the one that drops rows. Neither is silently ignored.
4511
+ Age eviction reuses the count bound&rsquo;s machinery outright, so <code>evicted</code> on
4512
+ the progress report and the <code>stream:evicted</code> event carry age evictions exactly as
4513
+ they always carried count evictions &mdash; an existing &ldquo;dropped off the back of the
4514
+ window&rdquo; readout keeps working with nothing changed.</p>
4515
+ <p><strong>The span is a bound, not a guillotine.</strong> A row lives a little past the span
4516
+ before it goes, and two things add to that. First the eviction slack, ten per cent of the
4517
+ span &mdash; exactly the overshoot <code>maxRows</code> already allows on its count &mdash; so
4518
+ the row permutation is rebuilt once per block rather than once per arriving row. Second, when
4519
+ the feed is idle, up to one tick of the eviction timer, which runs at a quarter of the span
4520
+ clamped to between 50&nbsp;ms and one second. The real ceiling is therefore about
4521
+ <code>span &times; 1.1 + tick</code>, and because the tick has a floor it is
4522
+ <em>proportionally larger the shorter the window</em>: about 10% over at a five-minute
4523
+ window, around 1.25&times; at ten seconds, and as much as ~1.35&times; at three. That is the
4524
+ deliberate price of an idle grid that costs no CPU, and it is why the number to reach for is
4525
+ the window you want the reader to see rather than a hard retention limit. The chart&rsquo;s
4526
+ domain is exact either way &mdash; it ends at <em>now</em> &mdash; so the extra rows sit off
4527
+ the left edge rather than being drawn.</p>
4528
+ <p><strong>Which clock: <code>ageBy</code>, or arrival.</strong> Given
4529
+ <code>ageBy</code> &mdash; a column id, a dotted path, or a function of the row returning a
4530
+ <code>Date</code>, epoch milliseconds or an ISO string &mdash; the window follows the
4531
+ <em>data&rsquo;s own</em> clock, so it means what the producer means. That also inherits the
4532
+ producer&rsquo;s clock skew: if their clock runs five minutes fast, their rows live five
4533
+ minutes longer than yours. Omit <code>ageBy</code> and rows age from <strong>arrival
4534
+ time</strong>, when the row reached the source. Arrival time needs no timestamp column and
4535
+ cannot be skewed, but it is not event time &mdash; a row delayed in transit is treated as
4536
+ young. A synthetic or metric feed usually wants arrival; a log or event feed usually wants
4537
+ <code>ageBy</code>. A row whose time value cannot be read is never aged out: dropping data
4538
+ because a timestamp was malformed is the worse failure.</p>
4539
+ <p><strong>Out of order is handled, not reordered.</strong> With <code>ageBy</code> the
4540
+ row&rsquo;s clock need not be monotonic in arrival order, so eviction scans the window rather
4541
+ than walking the head; a late row that is already older than the span is dropped on the same
4542
+ pass it arrived on and counted as evicted, rather than being painted and then withdrawn a
4543
+ moment later. Nothing is re-sorted: a row&rsquo;s <em>position</em> is still arrival order,
4544
+ only its <em>retention</em> is decided by its time.</p>
4545
+ <p><strong>The chart axis rolls independently.</strong> The axis takes the same
4546
+ <code>WindowSpec</code> vocabulary rolling statistics use &mdash;
4547
+ <code>window: { kind: 'time', span }</code> &mdash; and its domain ends at <em>now</em>
4548
+ rather than at the newest point, which is what makes the chart keep scrolling with zero new
4549
+ rows. It works with or without <code>maxAge</code> on the source; set both to the same span
4550
+ and the retained data and the drawn domain agree. Only <code>kind: 'time'</code> applies to
4551
+ an axis: a count window over a chart is the source&rsquo;s <code>maxRows</code>, and
4552
+ <code>kind: 'count'</code> is refused with a warning rather than quietly given a second
4553
+ meaning. The x column has to be continuous and carry wall-clock times &mdash; a banded or
4554
+ categorical axis has no domain to roll.</p>
4555
+ <p><strong>Idle costs nothing.</strong> Both halves advance on a plain interval &mdash; a
4556
+ quarter of the window, clamped to between 50&nbsp;ms and one second &mdash; and never on an
4557
+ animation frame. The source&rsquo;s wake returns after a single number comparison unless a
4558
+ row is actually due, and the chart&rsquo;s wake does nothing at all when the document is
4559
+ hidden or the chart is detached. Both timers are cleared on destroy. Measured over a
4560
+ five-minute window holding 50,000 rows with no feed at all, the window&rsquo;s CPU cost is
4561
+ inside the run-to-run noise of the same source with no bound set: under 0.04% of one core
4562
+ (<code>bench/idle-window.mjs</code>).</p>
4463
4563
  <p><strong>The log keeps the raw sequence, not the merged one.</strong> Merging is right for
4464
4564
  applying a backlog quickly and wrong for looking at what happened, because the intermediate
4465
4565
  states are exactly what a time scrubber would move between. It survives the flush,
@@ -5084,6 +5184,7 @@ grid.annotate.use(null); <span class="cmt">// hand the grid
5084
5184
  <tr><td class="name"><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd></td><td class="desc">Move focus to the column header</td></tr>
5085
5185
  <tr><td class="name"><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd></td><td class="desc">Move focus to the tool panel</td></tr>
5086
5186
  <tr><td class="name"><kbd>Shift</kbd>+<kbd>F10</kbd> <span class="sep">or</span> <kbd>ContextMenu</kbd></td><td class="desc">Open the context menu for the focused cell</td></tr>
5187
+ <tr><td class="name"><kbd>Ctrl</kbd>+<kbd>F</kbd></td><td class="desc">Find in the grid</td></tr>
5087
5188
  <tr><td class="name"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>ArrowUp</kbd> <span class="sep">or</span> <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>ArrowDown</kbd></td><td class="desc">Move the focused row, when row reorder is enabled</td></tr>
5088
5189
  <tr><th colspan="2">On a column heading</th></tr>
5089
5190
  <tr><td class="name"><kbd>ArrowLeft</kbd> <span class="sep">or</span> <kbd>ArrowRight</kbd></td><td class="desc">Move between headings</td></tr>
@@ -5415,6 +5516,7 @@ grid.edit.pasteInto(text); <span class="cmt">// Excel's t
5415
5516
  <tr><td class="sig">Enter</td><td class="desc">Start editing; commit and step down.</td></tr>
5416
5517
  <tr><td class="sig">Tab</td><td class="desc">Commit and step across.</td></tr>
5417
5518
  <tr><td class="sig">Escape</td><td class="desc">Cancel the edit; restore a maximised grid once nothing else wants it.</td></tr>
5519
+ <tr><td class="sig">Ctrl/Cmd + F</td><td class="desc">Open the <a href="#find">find bar</a>; in it, Enter / Shift+Enter step through the matches and Escape closes.</td></tr>
5418
5520
  <tr><td class="sig">Space</td><td class="desc">Toggle the row's selection.</td></tr>
5419
5521
  <tr><td class="sig">Home / End, Page Up / Down</td><td class="desc">Jump; with Ctrl, to the ends of the grid.</td></tr>
5420
5522
  </tbody>
@@ -6065,6 +6167,86 @@ grid.highlight.clear();</code></pre>
6065
6167
  it. A highlight belongs to the row rather than the element, so it survives scrolling, sorting
6066
6168
  and paging.
6067
6169
  </p>
6170
+
6171
+ <h2 id="find">Find</h2>
6172
+ <p class="lead-in">
6173
+ The quick filter answers "show me only the rows that contain X". Find answers a different
6174
+ question — "where is X?" — and leaves every other row exactly where it was, so you keep your
6175
+ place and the neighbours that give a value its meaning. Press <kbd>Ctrl</kbd>+<kbd>F</kbd>
6176
+ (<kbd>Cmd</kbd>+<kbd>F</kbd> on a Mac) with focus in the grid: a bar opens above the header
6177
+ with focus in its input, every cell whose <em>displayed</em> text matches lights up in place,
6178
+ the bar reads "N of M", <kbd>Enter</kbd> and <kbd>Shift</kbd>+<kbd>Enter</kbd> step through
6179
+ the matches with wrap, and <kbd>Escape</kbd> closes it and clears the marks.
6180
+ </p>
6181
+ <div class="example">
6182
+ <p class="example__label">The same thing from code</p>
6183
+ <pre data-run="js" data-expect="2 matches, 3 rows shown, current a, events true" data-covers="method:find config:find config:shortcut config:debounce event:find:changed"><code>const { createHeadlessGrid } = await import('../packages/core/src/index.js');
6184
+ const grid = createHeadlessGrid({
6185
+ rowKey: 'id',
6186
+ find: { shortcut: true, debounce: 0 }, // the bar's settings; `find: false` removes the bar
6187
+ columns: [
6188
+ { field: 'name' },
6189
+ { field: 'price', format: (p) =&gt; `$${p.value.toFixed(2)}` },
6190
+ ],
6191
+ rows: [
6192
+ { id: 'a', name: 'Acme', price: 3.5 },
6193
+ { id: 'b', name: 'Beta', price: 13.25 },
6194
+ { id: 'c', name: 'Acme Two', price: 3.75 },
6195
+ ],
6196
+ });
6197
+ let events = 0;
6198
+ grid.on('find:changed', () =&gt; { events += 1; });
6199
+
6200
+ const count = grid.find('$3.'); // the formatted text: two prices begin "$3."
6201
+ const first = grid.find.current(); // { key: 'a', colId: 'price', index: 0, pinned: null }
6202
+ grid.find.next(); // row c; next() again wraps back to a
6203
+ const rowsStillShown = grid.rows.count(); // 3 — find never removes a row
6204
+
6205
+ grid.destroy();
6206
+ return `${count.total} matches, ${rowsStillShown} rows shown, current ${first.key}, events ${events &gt; 0}`;</code></pre>
6207
+ </div>
6208
+ <p class="lead-in">
6209
+ <code>grid.find(text, opts)</code> searches now and returns a <code>FindCount</code>; it is
6210
+ callable like <code>grid.highlight</code>. The options are a <code>FindQuery</code>:
6211
+ <code>caseSensitive</code>, <code>wholeCell</code>, <code>columns</code> (an id or a list of
6212
+ ids; omitted searches every visible column) and <code>from</code> (the display index the
6213
+ first current match is chosen at or after). Then <code>find.next()</code>,
6214
+ <code>find.prev()</code> and <code>find.goTo(i)</code> move the current match and scroll it
6215
+ into view; <code>find.matches()</code>, <code>find.count()</code>,
6216
+ <code>find.current()</code> and <code>find.state()</code> read the result;
6217
+ <code>find.open(text?)</code>, <code>find.close()</code> and <code>find.clear()</code> drive
6218
+ the bar; <code>find.stateFor(key, colId)</code> is what the painter asks. Every change fires
6219
+ <code>find:changed</code> with the query, the open flag and the count.
6220
+ </p>
6221
+ <div class="table-wrap">
6222
+ <table>
6223
+ <thead><tr><th>Rule</th><th>What it means</th></tr></thead>
6224
+ <tbody>
6225
+ <tr><td class="sig">Display text</td><td class="desc">Find matches what the cell <em>shows</em> — a column <code>format</code>, a unit type, a lookup label — never the raw value. Searching <code>$3.</code> finds prices formatted that way. There is no regular-expression mode; the quick filter has one.</td></tr>
6226
+ <tr><td class="sig">An overlay, not a filter</td><td class="desc">No row is reordered, removed or edited. Matches are painted as <code>.lat-cell--find</code>, the current one also as <code>.lat-cell--find-current</code>, coloured by <code>--lattice-find-match</code> and <code>--lattice-find-current</code>. Find and the quick filter coexist: both may be active, and find re-runs over whatever the filter leaves.</td></tr>
6227
+ <tr><td class="sig">Pinned rows and columns</td><td class="desc">Rows pinned to either edge (and a bottom grand total) are searched and painted like any other; a pinned match has <code>index: -1</code> and <code>pinned: 'top' | 'bottom'</code>. Pinned columns are cells like any other.</td></tr>
6228
+ <tr><td class="sig">Virtualised rows</td><td class="desc">Matches are computed from the row model, not the DOM, so a match five thousand rows down is counted without rendering it; stepping to it scrolls it into view, and the paint follows the render.</td></tr>
6229
+ <tr><td class="sig">The active cell</td><td class="desc">Stepping to a match makes it the active cell, so <kbd>Enter</kbd> in the grid edits it — except while an edit is already open, when the match is scrolled and painted and the editor is left alone.</td></tr>
6230
+ <tr><td class="sig">Windowed sources</td><td class="desc">A paged pushdown source (OData, DuckDB, REST) holds only its loaded rows client-side, so only those are searched. The count says so — "N of M <em>in loaded rows</em>", and <code>FindCount.windowed</code> is true with <code>loaded</code> and <code>rows</code> beside it — rather than presenting a page-one count as the whole. Pushing find to the adapter is a follow-up, not a v1 promise.</td></tr>
6231
+ <tr><td class="sig">Large grids</td><td class="desc">Typing is scanned in per-frame slices from the row at the top of the viewport, so the matches on screen appear after the first slice and the grid stays interactive; the count reads "N of M so far" and <code>count.complete</code> is false until the scan finishes. <code>grid.find(text)</code> scans to completion before returning, so its answer is final.</td></tr>
6232
+ <tr><td class="sig">The browser's find</td><td class="desc"><kbd>Ctrl</kbd>+<kbd>F</kbd> is claimed only while focus is inside the grid and not in a text field, so the page's own find works everywhere else and an open cell editor keeps it. <code>find: { shortcut: false }</code> leaves the binding to the page and keeps the bar reachable through <code>find.open()</code>; <code>find: false</code> removes the bar altogether.</td></tr>
6233
+ <tr><td class="sig">Accessibility</td><td class="desc">The bar is a <code>role="search"</code> landmark; every control is a native input, button or select with a catalogue name, so nothing needs a mouse. The count is announced through a polite <code>role="status"</code> line once per completed search ("3 of 12 matches", "No matches in loaded rows"); the current match becomes the focused cell when no edit is open, so a screen reader reads it. The strings are in every bundled locale.</td></tr>
6234
+ <tr><td class="sig">Keys in the bar</td><td class="desc">Only <kbd>Escape</kbd> and <kbd>Enter</kbd> in the input are consumed by the bar. Everything else — <kbd>Tab</kbd> between its controls, <kbd>Enter</kbd> and <kbd>Space</kbd> on its buttons, a page's own <kbd>Ctrl</kbd>+<kbd>S</kbd> — propagates as it would from any form control, so a host's document-level shortcuts still see it; the grid's own keyboard and range layers stand aside for a key aimed at the bar, which is what keeps Tab from being read as "next cell" and Enter from opening an editor.</td></tr>
6235
+ <tr><td class="sig">Pinned strips</td><td class="desc">Stepping to a match scrolls it fully into the part of the body the pinned strips do not cover — below pinned-top rows and sticky group headings, above pinned-bottom rows and a bottom grand total. That is <code>grid.scroll.toRow</code>'s behaviour for every caller, not only find.</td></tr>
6236
+ </tbody>
6237
+ </table>
6238
+ </div>
6239
+ <div class="example">
6240
+ <p class="example__label">Configuration</p>
6241
+ <pre><code>find: false <span class="cmt">// no bar, no Ctrl+F; grid.find(text) still works</span>
6242
+ find: { shortcut: false } <span class="cmt">// bar via grid.find.open() only</span>
6243
+ find: { debounce: 250 } <span class="cmt">// a slower typist, or a slower grid</span>
6244
+
6245
+ grid.find('acme', { caseSensitive: true, wholeCell: false, columns: ['customer'] });
6246
+ grid.find.count(); <span class="cmt">// { current, total, complete, windowed, loaded, rows }</span>
6247
+ grid.on('find:changed', (e) =&gt; status.textContent = `${e.count.current} of ${e.count.total}`);</code></pre>
6248
+ </div>
6249
+
6068
6250
  <h2 id="views-guide">Saved views</h2>
6069
6251
  <p class="lead-in">
6070
6252
  A view is a named grid state: sort, filters, grouping, column order, widths, visibility.
@@ -6437,6 +6619,7 @@ grid.import.apply(preview);</code></pre>
6437
6619
  <tr><td class="name">processCell</td><td class="desc">(optional)</td></tr>
6438
6620
  <tr><td class="name">promoteToMemoryBelow</td><td class="desc">(optional)</td></tr>
6439
6621
  <tr><td class="name">quickFilterText</td><td class="desc">Initial quick-filter term. Equivalent to grid.filters.quick(text).</td></tr>
6622
+ <tr><td class="name">find</td><td class="desc">The in-grid find bar (Ctrl+F): false removes it, { shortcut, debounce } tunes it. See <a href="#find">Find</a>.</td></tr>
6440
6623
  <tr><td class="name">quote</td><td class="desc">(optional)</td></tr>
6441
6624
  <tr><td class="name">removeMs</td><td class="desc">Silence after which a peer is dropped. (optional)</td></tr>
6442
6625
  <tr><td class="name">showHeader</td><td class="desc">Draw the column headings at all. false removes the row, and removes it from the accessibility tree rather than only from view. Distinct from showColumnFunctions, which keeps the headings and drops only their sort, filter and menu controls.</td></tr>
@@ -6773,6 +6956,7 @@ el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);</code></pre>
6773
6956
  <tbody>
6774
6957
  <tr><td class="name">formatting:changed</td><td class="desc">A conditional formatting rule was added, edited, reordered or restated.</td></tr>
6775
6958
  <tr><td class="name">highlight:changed</td><td class="desc">A highlight was added or cleared.</td></tr>
6959
+ <tr><td class="name">find:changed</td><td class="desc">The find query, its matches, the current match or the bar's open state changed; carries the FindCount, partial while the sliced scan runs.</td></tr>
6776
6960
  <tr><td class="name">permissions:changed</td><td class="desc">The context moved and every column re-resolved.</td></tr>
6777
6961
  <tr><td class="name">presentation:captured</td><td class="desc">A PNG was taken.</td></tr>
6778
6962
  <tr><td class="name">presentation:changed</td><td class="desc">The options of a running presentation changed.</td></tr>