@svgrid/grid 2.2.28 → 2.2.29

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 (60) hide show
  1. package/dist/GridMenus.svelte +148 -100
  2. package/dist/SvGrid.controller.svelte.d.ts +14 -1
  3. package/dist/SvGrid.controller.svelte.js +112 -28
  4. package/dist/SvGrid.css +15 -34
  5. package/dist/SvGrid.svelte +49 -21
  6. package/dist/SvGrid.types.d.ts +20 -5
  7. package/dist/SvListBox.svelte +42 -3
  8. package/dist/SvListBox.svelte.d.ts +10 -1
  9. package/dist/cdn/GridMenus-Da_p7SKS.js +486 -0
  10. package/dist/cdn/GridMenus-Ds3OpzZT.js +490 -0
  11. package/dist/cdn/{src-BxNEslEo.js → src-BLJfdyL0.js} +4014 -3869
  12. package/dist/cdn/{src-C4yKQZ5t.js → src-DYDCiC0D.js} +7036 -6891
  13. package/dist/cdn/svgrid.js +8 -8
  14. package/dist/cdn/svgrid.svelte-external.js +8 -8
  15. package/dist/column-groups.js +8 -10
  16. package/dist/column-id.d.ts +9 -0
  17. package/dist/column-id.js +25 -0
  18. package/dist/core.js +2 -1
  19. package/dist/createListbox.svelte.d.ts +15 -2
  20. package/dist/createListbox.svelte.js +27 -6
  21. package/dist/editing.d.ts +1 -0
  22. package/dist/editing.js +45 -10
  23. package/dist/filtering/excel-filters.d.ts +30 -10
  24. package/dist/filtering/excel-filters.js +96 -15
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +2 -2
  27. package/dist/menus.d.ts +2 -0
  28. package/dist/menus.js +25 -0
  29. package/dist/row-resize.js +40 -0
  30. package/dist/selection.js +1 -1
  31. package/package.json +1 -1
  32. package/src/GridMenus.svelte +148 -100
  33. package/src/SvGrid.controller.svelte.ts +123 -27
  34. package/src/SvGrid.css +15 -34
  35. package/src/SvGrid.svelte +49 -21
  36. package/src/SvGrid.types.ts +20 -5
  37. package/src/SvListBox.svelte +42 -3
  38. package/src/column-groups.test.ts +48 -0
  39. package/src/column-groups.ts +8 -10
  40. package/src/column-id.ts +30 -0
  41. package/src/core.ts +2 -1
  42. package/src/createListbox.svelte.ts +39 -7
  43. package/src/editing.test.ts +90 -0
  44. package/src/editing.ts +46 -12
  45. package/src/filtering/excel-filters.test.ts +44 -1
  46. package/src/filtering/excel-filters.ts +91 -14
  47. package/src/index.ts +2 -0
  48. package/src/menus.test.ts +40 -4
  49. package/src/menus.ts +27 -0
  50. package/src/row-resize.test.ts +100 -0
  51. package/src/row-resize.ts +36 -0
  52. package/src/selection.ts +1 -1
  53. package/src/svgrid.api-extensions.test.ts +8 -3
  54. package/src/svgrid.cell-dom-ids.test.ts +96 -0
  55. package/src/svgrid.conditional-stat-scope.test.ts +111 -0
  56. package/src/svgrid.filter-menu-listbox.svelte.test.ts +308 -0
  57. package/src/svgrid.filter-menu-scroll.test.ts +3 -1
  58. package/src/svgrid.group-header-ids.test.ts +121 -0
  59. package/dist/cdn/GridMenus-BbzEvTYO.js +0 -421
  60. package/dist/cdn/GridMenus-E220X2kM.js +0 -417
@@ -3,6 +3,7 @@
3
3
  applyExcelFilter,
4
4
  normalizeForFilter,
5
5
  splitInTokens,
6
+ trailingInToken,
6
7
  createColumnVirtualizer,
7
8
  createCoreRowModel,
8
9
  createExpandedRowModel,
@@ -61,6 +62,7 @@
61
62
  type ResolvedCellFormat,
62
63
  } from "./conditional-formatting";
63
64
  import SvGridDropdown from "./SvGridDropdown.svelte";
65
+ import SvListBox from "./SvListBox.svelte";
64
66
  import type {
65
67
  Props,
66
68
  SelectionPoint,
@@ -129,9 +131,6 @@
129
131
  const filterRowValues = $derived(ctrl.filterRowValues);
130
132
  const inSuggestFor = $derived(ctrl.inSuggestFor);
131
133
  const inSuggestPos = $derived(ctrl.inSuggestPos);
132
- const inSuggestValues = $derived(ctrl.inSuggestValues);
133
- const toggleFilterToken = $derived(ctrl.toggleFilterToken);
134
- const facetValuesForColumn = $derived(ctrl.facetValuesForColumn);
135
134
 
136
135
  // Patch a column's menu filter (used for the optional 2nd condition + join
137
136
  // of multi-condition filtering). Merges into the existing entry, creating a
@@ -174,10 +173,60 @@
174
173
  const clearGroupingFromMenu = $derived(ctrl.clearGroupingFromMenu);
175
174
  const columnMenuFacetValues = $derived(ctrl.columnMenuFacetValues);
176
175
  const columnMenuVisibleFacets = $derived(ctrl.columnMenuVisibleFacets);
177
- const isFacetChecked = $derived(ctrl.isFacetChecked);
178
- const toggleFacetValue = $derived(ctrl.toggleFacetValue);
176
+ const columnMenuFacetOptions = $derived(ctrl.columnMenuFacetOptions);
177
+ const columnMenuSelectedFacets = $derived(ctrl.columnMenuSelectedFacets);
178
+ const setFacetSelection = $derived(ctrl.setFacetSelection);
179
+ const setFilterTokens = $derived(ctrl.setFilterTokens);
180
+ const inSuggestOptions = $derived(ctrl.inSuggestOptions);
181
+ const openInSuggest = $derived(ctrl.openInSuggest);
182
+ const closeInSuggest = $derived(ctrl.closeInSuggest);
179
183
  const isAllFacetsChecked = $derived(ctrl.isAllFacetsChecked);
180
184
  const toggleAllFacets = $derived(ctrl.toggleAllFacets);
185
+
186
+ /**
187
+ * Above this many rows the checklists switch to the windowed listbox. Short
188
+ * lists stay fully rendered so the popover hugs its content instead of
189
+ * reserving a fixed `rows`-tall box.
190
+ */
191
+ const FACET_VIRTUAL_THRESHOLD = 40;
192
+
193
+ // The filter panel's `in` / `notIn` box holds the WHOLE token list
194
+ // ("AAPL, MSFT"), so the suggestion query is the trailing fragment being
195
+ // typed - not the full value, which would match nothing.
196
+ function onSetOpFilterFocus(input: HTMLInputElement, columnId: string) {
197
+ openInSuggest(input, columnId);
198
+ ctrl.inSuggestQuery = trailingInToken(input.value);
199
+ }
200
+
201
+ function onSetOpFilterInput(input: HTMLInputElement, columnId: string) {
202
+ ctrl.inSuggestQuery = trailingInToken(input.value);
203
+ if (ctrl.inSuggestFor !== columnId) openInSuggest(input, columnId);
204
+ }
205
+
206
+ /** Columns as checklist options, labelled by their header when it's a string. */
207
+ function columnVisibilityOptions(columns: Array<Column<TData>>) {
208
+ return columns.map((column) => ({
209
+ value: column.id,
210
+ label: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
211
+ }));
212
+ }
213
+
214
+ /** Currently visible column ids - the checklist's selection set. */
215
+ function visibleColumnIds(columns: Array<Column<TData>>) {
216
+ const set = new Set<string | number>();
217
+ for (const column of columns) if (!ctrl.hiddenColumns[column.id]) set.add(column.id);
218
+ return set;
219
+ }
220
+
221
+ /** Write a checklist selection back as the complementary `hiddenColumns` map. */
222
+ function applyColumnVisibility(columns: Array<Column<TData>>, visible: Set<string>) {
223
+ const next: Record<string, boolean> = { ...ctrl.hiddenColumns };
224
+ for (const column of columns) {
225
+ if (visible.has(column.id)) delete next[column.id];
226
+ else next[column.id] = true;
227
+ }
228
+ ctrl.hiddenColumns = next;
229
+ }
181
230
  const clearColumnFilter = $derived(ctrl.clearColumnFilter);
182
231
  const contextMenuFor = $derived(ctrl.contextMenuFor);
183
232
  const contextMenuPos = $derived(ctrl.contextMenuPos);
@@ -234,10 +283,12 @@
234
283
  {#if menuActiveOperator !== "isBlank" && menuActiveOperator !== "isNotBlank"}
235
284
  {@const isSetOp =
236
285
  menuActiveOperator === "in" || menuActiveOperator === "notIn"}
286
+ <!-- in/notIn drives the shared value-suggestions dropdown (a windowed
287
+ checklist) rather than a native datalist, which capped at 200
288
+ values and couldn't show which are already selected. -->
237
289
  <input
238
290
  class="sv-grid-menu-condition-value"
239
291
  type={menuActiveOperator === "regex" ? "text" : menuInputType}
240
- list={isSetOp ? `sv-in-list-${colId}` : undefined}
241
292
  value={filterMenuValues[colId]?.value ?? ""}
242
293
  placeholder={menuActiveOperator === "between"
243
294
  ? "From"
@@ -246,15 +297,16 @@
246
297
  : menuActiveOperator === "regex"
247
298
  ? "Pattern..."
248
299
  : "Filter value..."}
249
- oninput={(event) => updateFilterMenuValue(colId, (event.currentTarget as HTMLInputElement).value)}
300
+ onfocus={isSetOp
301
+ ? (event) => onSetOpFilterFocus(event.currentTarget as HTMLInputElement, colId)
302
+ : undefined}
303
+ onblur={isSetOp ? () => closeInSuggest() : undefined}
304
+ oninput={(event) => {
305
+ const input = event.currentTarget as HTMLInputElement;
306
+ updateFilterMenuValue(colId, input.value);
307
+ if (isSetOp) onSetOpFilterInput(input, colId);
308
+ }}
250
309
  />
251
- {#if isSetOp}
252
- <datalist id={`sv-in-list-${colId}`}>
253
- {#each facetValuesForColumn(colId).slice(0, 200) as v (v)}
254
- <option value={v}></option>
255
- {/each}
256
- </datalist>
257
- {/if}
258
310
  {#if menuActiveOperator === "between"}
259
311
  <input
260
312
  class="sv-grid-menu-condition-value"
@@ -323,16 +375,27 @@
323
375
  <input type="checkbox" checked={isAllFacetsChecked(colId)} onchange={() => toggleAllFacets(colId)} />
324
376
  <span class="sv-grid-facet-label">(Select all)</span>
325
377
  </label>
326
- <div class="sv-grid-facet-list">
327
- {#each columnMenuVisibleFacets as value (value)}
328
- <label class="sv-grid-facet">
329
- <input type="checkbox" checked={isFacetChecked(colId, value)} onchange={() => toggleFacetValue(colId, value)} />
330
- <span class="sv-grid-facet-label">{value === "" ? "(Blanks)" : value}</span>
331
- </label>
332
- {:else}
333
- <div class="sv-grid-facet-empty">No values</div>
334
- {/each}
335
- </div>
378
+ <!-- Windowed checklist. A high-cardinality column (10k distinct symbols
379
+ on a live feed) would otherwise mount a label + checkbox per value -
380
+ and 10k focusable checkboxes are 10k tab stops. SvListBox renders
381
+ only the visible rows and is one roving-tabindex stop. -->
382
+ {#if columnMenuFacetOptions.length}
383
+ <SvListBox
384
+ block
385
+ checkbox
386
+ multiple
387
+ virtual={columnMenuFacetOptions.length > FACET_VIRTUAL_THRESHOLD}
388
+ rows={7}
389
+ rowHeight={24}
390
+ size="sm"
391
+ ariaLabel="Filter values"
392
+ options={columnMenuFacetOptions}
393
+ value={columnMenuSelectedFacets}
394
+ onChange={(next) => setFacetSelection(colId, next as Set<string>)}
395
+ />
396
+ {:else}
397
+ <div class="sv-grid-facet-empty">No values</div>
398
+ {/if}
336
399
  {#if columnMenuFacetValues.length > columnMenuVisibleFacets.length}
337
400
  <div class="sv-grid-facet-note">
338
401
  Showing {columnMenuVisibleFacets.length} of {columnMenuFacetValues.length}
@@ -383,30 +446,21 @@
383
446
  {#if columnMenuTabsEnabled && menuTab === "filter" && menuCanFilter && showColumnFiltersEffective}
384
447
  {@render filterPanelBody(menuColumnId)}
385
448
  {:else if columnMenuTabsEnabled && menuTab === "columns"}
449
+ {@const tabColumns = grid.getAllColumns()}
386
450
  <div class="sv-grid-menu-filter-head">Visible columns</div>
387
- <div class="sv-grid-facet-list">
388
- {#each grid.getAllColumns() as column (column.id)}
389
- <label class="sv-grid-facet">
390
- <input
391
- type="checkbox"
392
- checked={!ctrl.hiddenColumns[column.id]}
393
- onchange={(event) => {
394
- const visible = (event.currentTarget as HTMLInputElement).checked;
395
- if (visible) {
396
- const next = { ...ctrl.hiddenColumns };
397
- delete next[column.id];
398
- ctrl.hiddenColumns = next;
399
- } else {
400
- ctrl.hiddenColumns = { ...ctrl.hiddenColumns, [column.id]: true };
401
- }
402
- }}
403
- />
404
- <span class="sv-grid-facet-label"
405
- >{typeof column.columnDef.header === "string" ? column.columnDef.header : column.id}</span
406
- >
407
- </label>
408
- {/each}
409
- </div>
451
+ <SvListBox
452
+ block
453
+ checkbox
454
+ multiple
455
+ virtual={tabColumns.length > FACET_VIRTUAL_THRESHOLD}
456
+ rows={9}
457
+ rowHeight={24}
458
+ size="sm"
459
+ ariaLabel="Visible columns"
460
+ options={columnVisibilityOptions(tabColumns)}
461
+ value={visibleColumnIds(tabColumns)}
462
+ onChange={(next) => applyColumnVisibility(tabColumns, next as Set<string>)}
463
+ />
410
464
  {:else}
411
465
  {#if menuCanSort}
412
466
  <button
@@ -570,32 +624,19 @@
570
624
  style={`left: ${chooseColumnsPos.x}px; top: ${chooseColumnsPos.y}px;`}
571
625
  >
572
626
  <div class="sv-grid-menu-filter-head">Visible columns</div>
573
- <div class="sv-grid-facet-list">
574
- {#each everyColumn as column (column.id)}
575
- <label class="sv-grid-facet">
576
- <input
577
- type="checkbox"
578
- checked={!ctrl.hiddenColumns[column.id]}
579
- onchange={(event) => {
580
- const visible = (event.currentTarget as HTMLInputElement)
581
- .checked;
582
- if (visible) {
583
- const next = { ...ctrl.hiddenColumns };
584
- delete next[column.id];
585
- ctrl.hiddenColumns = next;
586
- } else {
587
- ctrl.hiddenColumns = { ...ctrl.hiddenColumns, [column.id]: true };
588
- }
589
- }}
590
- />
591
- <span class="sv-grid-facet-label"
592
- >{typeof column.columnDef.header === "string"
593
- ? column.columnDef.header
594
- : column.id}</span
595
- >
596
- </label>
597
- {/each}
598
- </div>
627
+ <SvListBox
628
+ block
629
+ checkbox
630
+ multiple
631
+ virtual={everyColumn.length > FACET_VIRTUAL_THRESHOLD}
632
+ rows={11}
633
+ rowHeight={24}
634
+ size="sm"
635
+ ariaLabel="Visible columns"
636
+ options={columnVisibilityOptions(everyColumn)}
637
+ value={visibleColumnIds(everyColumn)}
638
+ onChange={(next) => applyColumnVisibility(everyColumn, next as Set<string>)}
639
+ />
599
640
  </div>
600
641
  {/if}
601
642
 
@@ -632,42 +673,49 @@
632
673
 
633
674
  {#if inSuggestFor}
634
675
  {@const suggestColId = inSuggestFor}
635
- {@const selectedTokens = new Set(
676
+ {@const selectedTokens = new Set<string | number>(
636
677
  splitInTokens(filterRowValues[suggestColId] ?? ""),
637
678
  )}
638
- <!-- Value suggestions for an `in` / `notIn` chip input. Items use
639
- `onmousedown` (preventDefault) so clicking one keeps focus in the
640
- chip input and the dropdown stays open for multi-select; the input's
641
- own blur handler closes it when focus truly leaves. -->
679
+ <!-- Value suggestions for an `in` / `notIn` input. The wrapper swallows
680
+ `mousedown` so clicking a row keeps focus in the input and the
681
+ dropdown stays open for multi-select; the input's own blur handler
682
+ closes it when focus truly leaves. The list still picks on `click`.
683
+ Tokens the query is currently hiding ride along in the set, so a
684
+ click never silently drops them. -->
685
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
642
686
  <div
643
687
  class="sv-grid-menu sv-grid-in-suggest"
644
- role="listbox"
645
- aria-multiselectable="true"
646
- aria-label="Filter values"
647
688
  style={`left: ${inSuggestPos.x}px; top: ${inSuggestPos.y}px;`}
689
+ onmousedown={(event) => event.preventDefault()}
648
690
  >
649
- {#each inSuggestValues as value (value)}
650
- {@const checked = selectedTokens.has(value)}
651
- <button
652
- type="button"
653
- class="sv-grid-menu-item sv-grid-in-suggest-item"
654
- role="option"
655
- aria-selected={checked}
656
- onmousedown={(event) => {
657
- event.preventDefault();
658
- toggleFilterToken(suggestColId, value);
691
+ {#if inSuggestOptions.length}
692
+ <SvListBox
693
+ block
694
+ checkbox
695
+ multiple
696
+ virtual={inSuggestOptions.length > FACET_VIRTUAL_THRESHOLD}
697
+ rows={8}
698
+ rowHeight={26}
699
+ size="sm"
700
+ ariaLabel="Filter values"
701
+ options={inSuggestOptions}
702
+ value={selectedTokens}
703
+ onChange={(next) => {
704
+ const picked = new Set(next as Set<string>);
705
+ // Boxes that hold the whole list ("AAPL, MS") leave a half-typed
706
+ // fragment in the token set. The pick REPLACES that fragment
707
+ // rather than landing beside it as a stray filter value. The chip
708
+ // input keeps its fragment out of `filterRowValues`, so this is a
709
+ // no-op there.
710
+ const fragment = trailingInToken(filterRowValues[suggestColId] ?? "");
711
+ if (fragment) picked.delete(fragment);
712
+ setFilterTokens(suggestColId, [...picked]);
713
+ ctrl.inSuggestQuery = "";
659
714
  }}
660
- >
661
- <span class="sv-grid-in-suggest-check" aria-hidden="true"
662
- >{checked ? "✓" : ""}</span
663
- >
664
- <span class="sv-grid-in-suggest-label"
665
- >{value === "" ? "(Blanks)" : value}</span
666
- >
667
- </button>
715
+ />
668
716
  {:else}
669
717
  <div class="sv-grid-facet-empty">No values</div>
670
- {/each}
718
+ {/if}
671
719
  </div>
672
720
  {/if}
673
721
 
@@ -87,6 +87,7 @@ import {
87
87
  computeColumnGroupMeta,
88
88
  hiddenLeavesForCollapse,
89
89
  } from "./column-groups";
90
+ import { resolveColumnId } from "./column-id";
90
91
  import {
91
92
  createGridApi,
92
93
  } from "./build-api";
@@ -123,6 +124,7 @@ import {
123
124
  getColumnAccessorValue,
124
125
  columnDefMatchesId,
125
126
  } from "./cell-values";
127
+ import { untrack } from "svelte";
126
128
 
127
129
  /**
128
130
  * Conservative fallback for the browser's max element height, used during SSR
@@ -270,7 +272,16 @@ export type SvGridController<
270
272
  export function createSvGridController<
271
273
  TFeatures extends TableFeatures = TableFeatures,
272
274
  TData extends RowData = RowData,
273
- >(rawProps: Props<TFeatures, TData>) {
275
+ >(rawProps: Props<TFeatures, TData>, domIdBase?: string) {
276
+
277
+ /**
278
+ * Base for every DOM id this grid mints (`<base>_cell_<row>_<col>`). The view
279
+ * passes Svelte's per-instance `$props.id()`, which is stable across SSR and
280
+ * hydration; two `<SvGrid>` instances on one page used to emit identical cell
281
+ * ids, so `aria-activedescendant` on the second pointed into the first (#77).
282
+ * Falls back to the historical literal for direct controller construction.
283
+ */
284
+ const gridDomId = domIdBase ?? "svgrid";
274
285
 
275
286
  // Runtime option overrides set via the imperative api (`api.setOption(key, value)`).
276
287
  // Every controller read of a prop goes through the `props` proxy below (and the view
@@ -867,9 +878,6 @@ export function createSvGridController<
867
878
  // compute pixel widths for group cells.
868
879
  type LeafEntry = { id: string; widthPx: number };
869
880
  const leafEntries: LeafEntry[] = [];
870
- function buildId(def: ColumnDef<any, TData>, parentId: string | undefined, fallbackIx: number): string {
871
- return def.id ?? def.field ?? `${parentId ?? 'col'}_d_${fallbackIx}`;
872
- }
873
881
  // Leaves hidden by a collapsed/expanded group are skipped everywhere here,
874
882
  // so group colSpan + widthPx exclude them and stay aligned with the leaves
875
883
  // the body actually renders.
@@ -880,7 +888,7 @@ export function createSvGridController<
880
888
  depthHere: number,
881
889
  ): void {
882
890
  defs.forEach((def, ix) => {
883
- const id = buildId(def, parentId, ix);
891
+ const id = resolveColumnId(def, parentId, depthHere, ix);
884
892
  if (def.columns?.length) {
885
893
  collectLeaves(def.columns, id, depthHere + 1);
886
894
  } else if (!hiddenLeaf[id]) {
@@ -897,16 +905,22 @@ export function createSvGridController<
897
905
  defs: Array<ColumnDef<any, TData>>,
898
906
  parentId: string | undefined,
899
907
  cursor: { leaf: number },
908
+ depthHere: number,
900
909
  ): NodeAt[] {
901
910
  const nodes: NodeAt[] = [];
902
- for (const def of defs) {
903
- const id = buildId(def, parentId, nodes.length);
911
+ // Walk by array index, not by a count of pushed nodes: the `continue`
912
+ // below skips hidden leaves without pushing, so a counter would drift and
913
+ // hand the next unnamed sibling a different id than `collectLeaves` and
914
+ // the engine give it (#63).
915
+ for (let ix = 0; ix < defs.length; ix += 1) {
916
+ const def = defs[ix]!;
917
+ const id = resolveColumnId(def, parentId, depthHere, ix);
904
918
  // Skip leaves the collapse state hides, so leaf indices/colSpans match
905
919
  // `leafEntries` (and the body's rendered columns) exactly.
906
920
  if (!def.columns?.length && hiddenLeaf[id]) continue;
907
921
  const leafStart = cursor.leaf;
908
922
  if (def.columns?.length) {
909
- indexTree(def.columns, id, cursor);
923
+ indexTree(def.columns, id, cursor, depthHere + 1);
910
924
  } else {
911
925
  cursor.leaf += 1;
912
926
  }
@@ -916,7 +930,7 @@ export function createSvGridController<
916
930
  return nodes;
917
931
  }
918
932
  const cursor = { leaf: 0 };
919
- const topNodes = indexTree(userCols, undefined, cursor);
933
+ const topNodes = indexTree(userCols, undefined, cursor, 0);
920
934
 
921
935
  function nodesAtDepth(
922
936
  nodes: NodeAt[],
@@ -928,7 +942,7 @@ export function createSvGridController<
928
942
  for (const n of nodes) {
929
943
  if (n.def.columns?.length) {
930
944
  const childCursor = { leaf: n.leafStart };
931
- const children = indexTree(n.def.columns, n.id, childCursor);
945
+ const children = indexTree(n.def.columns, n.id, childCursor, currentDepth + 1);
932
946
  out.push(...nodesAtDepth(children, currentDepth + 1, targetDepth));
933
947
  } else {
934
948
  // Leaf reached early - emit a placeholder at this row so the
@@ -1033,10 +1047,13 @@ export function createSvGridController<
1033
1047
  const map = new Map<string, ColumnStat>();
1034
1048
  const formats = props.conditionalFormats;
1035
1049
  if (!formats?.length || !formatsNeedingStats(formats)) return map;
1036
- // `visible` (default): the currently displayed rows (filtered + paged),
1037
- // so the heat map reflects what the user is looking at. `all`: the full
1038
- // unfiltered dataset, for a stable scale independent of filter/paging.
1039
- const scanAll = props.conditionalStatScope === "all";
1050
+ // `filtered` (default): every row that survives the filters, ignoring the
1051
+ // page slice - so a value keeps the same colour as you page through (#61).
1052
+ // `visible`: only the rows on screen, rescaling per page. `all`: the full
1053
+ // unfiltered dataset, for a scale that stays put as you filter.
1054
+ const scope = props.conditionalStatScope ?? "filtered";
1055
+ const scanAll = scope === "all";
1056
+ const scanRows = scope === "visible" ? allRows : allRowsBeforePagination;
1040
1057
  for (const column of allColumns) {
1041
1058
  const needs = formats.some(
1042
1059
  (f) => formatNeedsStats(f) && (!f.columns || f.columns.includes(column.id)),
@@ -1054,7 +1071,7 @@ export function createSvGridController<
1054
1071
  }
1055
1072
  })()
1056
1073
  : (function* () {
1057
- for (const row of allRows) {
1074
+ for (const row of scanRows) {
1058
1075
  yield fieldFn
1059
1076
  ? fieldFn(row.original)
1060
1077
  : field
@@ -2063,7 +2080,7 @@ export function createSvGridController<
2063
2080
  const inRows = active.rowIndex >= 0 && active.rowIndex < allRows.length;
2064
2081
  const inCols = active.colIndex >= 0 && active.colIndex < allColumns.length;
2065
2082
  if (!inRows || !inCols) return null;
2066
- return getGridCellDomId("svgrid", active.rowIndex, active.colIndex);
2083
+ return getGridCellDomId(gridDomId, active.rowIndex, active.colIndex);
2067
2084
  });
2068
2085
 
2069
2086
 
@@ -2135,7 +2152,7 @@ export function createSvGridController<
2135
2152
  grid.setActiveCell({
2136
2153
  rowIndex: 0,
2137
2154
  colIndex: 0,
2138
- cellId: getGridCellDomId("svgrid", 0, 0),
2155
+ cellId: getGridCellDomId(gridDomId, 0, 0),
2139
2156
  });
2140
2157
  });
2141
2158
 
@@ -2592,12 +2609,58 @@ export function createSvGridController<
2592
2609
  );
2593
2610
  }
2594
2611
 
2612
+ /**
2613
+ * Facet values for the open menu, SNAPSHOTTED when it opens.
2614
+ *
2615
+ * `facetValuesForColumn` walks every row and natural-sorts the distinct
2616
+ * values, so deriving it live re-ran that whole scan on every data change.
2617
+ * On a streaming grid (the trading-desk demo replaces its row array a couple
2618
+ * of times a second) that meant rebuilding a 10k-entry set + sort - and
2619
+ * re-keying the rendered value list - continuously for as long as the menu
2620
+ * sat open. The offered values are frozen for the life of the popover
2621
+ * instead, which is also what a spreadsheet's filter checklist does.
2622
+ * Filtering itself is unaffected: `valueFilters` matches live row values.
2623
+ *
2624
+ * The cache is a plain local, not `$state`, so writing it here can't feed
2625
+ * back into this derived.
2626
+ */
2627
+ // Stable identities for the closed-menu case, so the derives below don't hand
2628
+ // out a fresh empty array/set on every read.
2629
+ const EMPTY_FACETS: Array<string> = [];
2630
+ const EMPTY_FACET_SET: ReadonlySet<string> = new Set<string>();
2631
+ let facetCache: {
2632
+ columnId: string;
2633
+ source: Array<string> | null;
2634
+ values: Array<string>;
2635
+ } | null = null;
2595
2636
  const columnMenuFacetValues = $derived.by(() => {
2596
2637
  // The funnel popover drives via `filterMenuFor`; the column menu's Filter
2597
2638
  // tab drives via `columnMenuFor`. Support whichever is open.
2598
2639
  const columnId = filterMenuFor ?? columnMenuFor;
2599
- if (!columnId) return [] as Array<string>;
2600
- return facetValuesForColumn(columnId);
2640
+ if (!columnId) return EMPTY_FACETS;
2641
+ // Server-provided values land asynchronously, so they stay TRACKED and
2642
+ // re-snapshot when they arrive. Locally derived values are read untracked
2643
+ // so a live data tick can't retrigger the scan.
2644
+ const source = props.serverFilterValues
2645
+ ? (serverFacetValues[columnId] ?? null)
2646
+ : null;
2647
+ if (facetCache?.columnId === columnId && facetCache.source === source) {
2648
+ return facetCache.values;
2649
+ }
2650
+ facetCache = {
2651
+ columnId,
2652
+ source,
2653
+ values: untrack(() => facetValuesForColumn(columnId)),
2654
+ };
2655
+ return facetCache.values;
2656
+ });
2657
+
2658
+ // Drop the snapshot when the menu closes, so reopening the SAME column
2659
+ // re-scans and picks up whatever the data has become meanwhile. This has to
2660
+ // be an effect rather than a branch in the derived above: the derived is
2661
+ // lazy, and nothing reads it while the menu is shut.
2662
+ $effect(() => {
2663
+ if (!(filterMenuFor ?? columnMenuFor)) facetCache = null;
2601
2664
  });
2602
2665
 
2603
2666
  const columnMenuVisibleFacets = $derived.by(() => {
@@ -2608,20 +2671,46 @@ export function createSvGridController<
2608
2671
  );
2609
2672
  });
2610
2673
 
2674
+ /** Visible facets as listbox options ((Blanks) label for the empty value). */
2675
+ const columnMenuFacetOptions = $derived(
2676
+ columnMenuVisibleFacets.map((value) => ({
2677
+ value,
2678
+ label: value === "" ? "(Blanks)" : value,
2679
+ })),
2680
+ );
2681
+
2682
+ /**
2683
+ * Checked facets for the open menu. An ABSENT `valueFilters` entry means
2684
+ * "everything checked", so that case materializes the set once here rather
2685
+ * than every consumer re-deriving it.
2686
+ */
2687
+ const columnMenuSelectedFacets = $derived.by(() => {
2688
+ const columnId = filterMenuFor ?? columnMenuFor;
2689
+ if (!columnId) return EMPTY_FACET_SET;
2690
+ return valueFilters[columnId] ?? new Set(columnMenuFacetValues);
2691
+ });
2692
+
2611
2693
  // Distinct values offered in the `in` / `notIn` suggestions dropdown for the
2612
- // active chip input, narrowed by whatever the user has typed. Capped so a
2613
- // high-cardinality column can't render thousands of rows into the popover.
2614
- const IN_SUGGEST_LIMIT = 200;
2694
+ // active chip input, narrowed by whatever the user has typed. Uncapped - the
2695
+ // dropdown windows its rows, so a high-cardinality column costs a list of
2696
+ // strings, not thousands of nodes.
2615
2697
  const inSuggestValues = $derived.by(() => {
2616
- if (!inSuggestFor) return [] as Array<string>;
2698
+ if (!inSuggestFor) return EMPTY_FACETS;
2617
2699
  const query = inSuggestQuery.trim().toLowerCase();
2618
2700
  const all = facetValuesForColumn(inSuggestFor);
2619
- const matched = query
2701
+ return query
2620
2702
  ? all.filter((value) => value.toLowerCase().includes(query))
2621
2703
  : all;
2622
- return matched.slice(0, IN_SUGGEST_LIMIT);
2623
2704
  });
2624
2705
 
2706
+ /** `in` / `notIn` suggestions as listbox options. */
2707
+ const inSuggestOptions = $derived(
2708
+ inSuggestValues.map((value) => ({
2709
+ value,
2710
+ label: value === "" ? "(Blanks)" : value,
2711
+ })),
2712
+ );
2713
+
2625
2714
 
2626
2715
 
2627
2716
 
@@ -2767,6 +2856,7 @@ export function createSvGridController<
2767
2856
  get inSuggestQuery() { return inSuggestQuery; },
2768
2857
  set inSuggestQuery(v) { inSuggestQuery = v as never; },
2769
2858
  get inSuggestValues() { return inSuggestValues; },
2859
+ get inSuggestOptions() { return inSuggestOptions; },
2770
2860
  get facetValuesForColumn() { return facetValuesForColumn; },
2771
2861
  get chooseColumnsPos() { return chooseColumnsPos; },
2772
2862
  set chooseColumnsPos(v) { chooseColumnsPos = v as never; },
@@ -3052,6 +3142,7 @@ export function createSvGridController<
3052
3142
  get columnWindowRightSpacer() { return columnWindowRightSpacer; },
3053
3143
  get activeCell() { return activeCell; },
3054
3144
  get activeDescendantId() { return activeDescendantId; },
3145
+ get gridDomId() { return gridDomId; },
3055
3146
  get formatSummaryNumeric() { return formatSummaryNumeric; },
3056
3147
  get computeSummaries() { return computeSummaries; },
3057
3148
  get SUMMARY_DEFER_CELL_LIMIT() { return SUMMARY_DEFER_CELL_LIMIT; },
@@ -3134,6 +3225,7 @@ export function createSvGridController<
3134
3225
  get applyHistoryStep() { return applyHistoryStep; },
3135
3226
  get updateEditingCellValue() { return updateEditingCellValue; },
3136
3227
  get onEditorKeyDown() { return onEditorKeyDown; },
3228
+ get commitAndMoveByTab() { return commitAndMoveByTab; },
3137
3229
  get focusOnMount() { return focusOnMount; },
3138
3230
  get onHeaderSortClick() { return onHeaderSortClick; },
3139
3231
  get onGridKeyDown() { return onGridKeyDown; },
@@ -3174,6 +3266,7 @@ export function createSvGridController<
3174
3266
  get addFilterToken() { return addFilterToken; },
3175
3267
  get removeFilterToken() { return removeFilterToken; },
3176
3268
  get toggleFilterToken() { return toggleFilterToken; },
3269
+ get setFilterTokens() { return setFilterTokens; },
3177
3270
  get sortColumnFromMenu() { return sortColumnFromMenu; },
3178
3271
  get clearColumnSort() { return clearColumnSort; },
3179
3272
  get groupByColumnFromMenu() { return groupByColumnFromMenu; },
@@ -3185,8 +3278,11 @@ export function createSvGridController<
3185
3278
  get serverFacetLoading() { return serverFacetLoading; },
3186
3279
  get columnMenuFacetValues() { return columnMenuFacetValues; },
3187
3280
  get columnMenuVisibleFacets() { return columnMenuVisibleFacets; },
3281
+ get columnMenuFacetOptions() { return columnMenuFacetOptions; },
3282
+ get columnMenuSelectedFacets() { return columnMenuSelectedFacets; },
3188
3283
  get isFacetChecked() { return isFacetChecked; },
3189
3284
  get toggleFacetValue() { return toggleFacetValue; },
3285
+ get setFacetSelection() { return setFacetSelection; },
3190
3286
  get isAllFacetsChecked() { return isAllFacetsChecked; },
3191
3287
  get toggleAllFacets() { return toggleAllFacets; },
3192
3288
  get clearColumnFilter() { return clearColumnFilter; },
@@ -3200,9 +3296,9 @@ export function createSvGridController<
3200
3296
  const { showTooltipFor, hideTooltip, flushScheduledScrollSync, scheduleScrollSync, onBodyScroll } = createScrollSync<TFeatures, TData>(ctx);
3201
3297
  const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard<TFeatures, TData>(ctx);
3202
3298
  const { computeSummaries, hasRenderedColumn } = createSummaries<TFeatures, TData>(ctx);
3203
- const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, addFilterToken, removeFilterToken, toggleFilterToken, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, openInSuggest, closeInSuggest, openChooseColumns, openColumnMenu, openFilterMenu, openOperatorMenu, sortColumnFromMenu, clearColumnSort, groupByColumnFromMenu, clearGroupingFromMenu, isFacetChecked, toggleFacetValue, isAllFacetsChecked, toggleAllFacets, clearColumnFilter, changePage, goToPage, setPageSize, openContextMenu, closeContextMenu, contextMenuItems, saveComment, removeComment, closeCommentEditor } = createMenus<TFeatures, TData>(ctx);
3299
+ const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, addFilterToken, removeFilterToken, toggleFilterToken, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, openInSuggest, closeInSuggest, openChooseColumns, openColumnMenu, openFilterMenu, openOperatorMenu, sortColumnFromMenu, clearColumnSort, groupByColumnFromMenu, clearGroupingFromMenu, isFacetChecked, toggleFacetValue, setFacetSelection, setFilterTokens, isAllFacetsChecked, toggleAllFacets, clearColumnFilter, changePage, goToPage, setPageSize, openContextMenu, closeContextMenu, contextMenuItems, saveComment, removeComment, closeCommentEditor } = createMenus<TFeatures, TData>(ctx);
3204
3300
  const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
3205
- const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing<TFeatures, TData>(ctx);
3301
+ const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, commitAndMoveByTab, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing<TFeatures, TData>(ctx);
3206
3302
  const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
3207
3303
  const { cellPinStyle, isColumnPinned, getCurrentColumnOrder, emitColumnOrder, setColumnOrderInternal, applyColumnDrop, onColumnHeaderDragStart, onColumnHeaderDragOver, onColumnHeaderDragLeave, onColumnHeaderDrop, onColumnHeaderDragEnd, pinColumnLeft, pinColumnRight, unpinColumn, toggleColumnVisibleInPanel, moveColumnInPanel, toggleGroupInPanel, getColumnBaseWidth, getColumnWidth, startColumnResize, onColumnResizeMove, endColumnResize, measureText, autosizeColumn, autosizeAllColumns, resetColumns } = createColumns<TFeatures, TData>(ctx);
3208
3304
  const { onRowDragStart, onRowDragOver, onRowDragLeave, onRowDrop, onRowsContainerDragOver, onRowsContainerDrop, onRowDragEnd, destroyRowDrag } = createRowDrag<TFeatures, TData>(ctx);