@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
@@ -16,6 +16,7 @@ import { createRowDrag, } from "./row-drag";
16
16
  import { createAlignedGrids, } from "./aligned-grids";
17
17
  import { resolveColumnTypes, } from "./column-types";
18
18
  import { computeColumnGroupMeta, hiddenLeavesForCollapse, } from "./column-groups";
19
+ import { resolveColumnId } from "./column-id";
19
20
  import { createGridApi, } from "./build-api";
20
21
  import { createClipboard, } from "./clipboard";
21
22
  import { resolveGridMessages } from "./grid-messages";
@@ -23,6 +24,7 @@ import { getPivotEngine, hasPivotEngine } from "./pivot-view.svelte";
23
24
  import { filterOperatorOptions, fallbackOperatorOption, TEXT_OPERATORS, NUMBER_OPERATORS, DATE_OPERATORS, CHECKBOX_OPERATORS, operatorOption, operatorsForColumn, defaultOperatorFor, operatorLabelFor, } from "./filter-operators";
24
25
  import { isBucketableColumn, buildBuckets, isInBucket, } from "./facet-buckets";
25
26
  import { getColumnBaseValue, isGroupRow, toolPanelHeaderLabel, formatSummaryNumeric, getColumnAlign, getPinnedCellValue, getColumnAccessorValue, columnDefMatchesId, } from "./cell-values";
27
+ import { untrack } from "svelte";
26
28
  /**
27
29
  * Conservative fallback for the browser's max element height, used during SSR
28
30
  * or if runtime detection fails. 8M is below every known engine cap (Firefox
@@ -145,7 +147,15 @@ function observeSizeRaf(el, cb) {
145
147
  observer.disconnect();
146
148
  };
147
149
  }
148
- export function createSvGridController(rawProps) {
150
+ export function createSvGridController(rawProps, domIdBase) {
151
+ /**
152
+ * Base for every DOM id this grid mints (`<base>_cell_<row>_<col>`). The view
153
+ * passes Svelte's per-instance `$props.id()`, which is stable across SSR and
154
+ * hydration; two `<SvGrid>` instances on one page used to emit identical cell
155
+ * ids, so `aria-activedescendant` on the second pointed into the first (#77).
156
+ * Falls back to the historical literal for direct controller construction.
157
+ */
158
+ const gridDomId = domIdBase ?? "svgrid";
149
159
  // Runtime option overrides set via the imperative api (`api.setOption(key, value)`).
150
160
  // Every controller read of a prop goes through the `props` proxy below (and the view
151
161
  // reads controller-resolved gates like `_features` / `editingEnabled`, not the raw
@@ -620,16 +630,13 @@ export function createSvGridController(rawProps) {
620
630
  if (depth === 0)
621
631
  return [];
622
632
  const leafEntries = [];
623
- function buildId(def, parentId, fallbackIx) {
624
- return def.id ?? def.field ?? `${parentId ?? 'col'}_d_${fallbackIx}`;
625
- }
626
633
  // Leaves hidden by a collapsed/expanded group are skipped everywhere here,
627
634
  // so group colSpan + widthPx exclude them and stay aligned with the leaves
628
635
  // the body actually renders.
629
636
  const hiddenLeaf = hiddenByGroupCollapse;
630
637
  function collectLeaves(defs, parentId, depthHere) {
631
638
  defs.forEach((def, ix) => {
632
- const id = buildId(def, parentId, ix);
639
+ const id = resolveColumnId(def, parentId, depthHere, ix);
633
640
  if (def.columns?.length) {
634
641
  collectLeaves(def.columns, id, depthHere + 1);
635
642
  }
@@ -639,17 +646,22 @@ export function createSvGridController(rawProps) {
639
646
  });
640
647
  }
641
648
  collectLeaves(userCols, undefined, 0);
642
- function indexTree(defs, parentId, cursor) {
649
+ function indexTree(defs, parentId, cursor, depthHere) {
643
650
  const nodes = [];
644
- for (const def of defs) {
645
- const id = buildId(def, parentId, nodes.length);
651
+ // Walk by array index, not by a count of pushed nodes: the `continue`
652
+ // below skips hidden leaves without pushing, so a counter would drift and
653
+ // hand the next unnamed sibling a different id than `collectLeaves` and
654
+ // the engine give it (#63).
655
+ for (let ix = 0; ix < defs.length; ix += 1) {
656
+ const def = defs[ix];
657
+ const id = resolveColumnId(def, parentId, depthHere, ix);
646
658
  // Skip leaves the collapse state hides, so leaf indices/colSpans match
647
659
  // `leafEntries` (and the body's rendered columns) exactly.
648
660
  if (!def.columns?.length && hiddenLeaf[id])
649
661
  continue;
650
662
  const leafStart = cursor.leaf;
651
663
  if (def.columns?.length) {
652
- indexTree(def.columns, id, cursor);
664
+ indexTree(def.columns, id, cursor, depthHere + 1);
653
665
  }
654
666
  else {
655
667
  cursor.leaf += 1;
@@ -660,7 +672,7 @@ export function createSvGridController(rawProps) {
660
672
  return nodes;
661
673
  }
662
674
  const cursor = { leaf: 0 };
663
- const topNodes = indexTree(userCols, undefined, cursor);
675
+ const topNodes = indexTree(userCols, undefined, cursor, 0);
664
676
  function nodesAtDepth(nodes, currentDepth, targetDepth) {
665
677
  if (currentDepth === targetDepth)
666
678
  return nodes;
@@ -668,7 +680,7 @@ export function createSvGridController(rawProps) {
668
680
  for (const n of nodes) {
669
681
  if (n.def.columns?.length) {
670
682
  const childCursor = { leaf: n.leafStart };
671
- const children = indexTree(n.def.columns, n.id, childCursor);
683
+ const children = indexTree(n.def.columns, n.id, childCursor, currentDepth + 1);
672
684
  out.push(...nodesAtDepth(children, currentDepth + 1, targetDepth));
673
685
  }
674
686
  else {
@@ -760,10 +772,13 @@ export function createSvGridController(rawProps) {
760
772
  const formats = props.conditionalFormats;
761
773
  if (!formats?.length || !formatsNeedingStats(formats))
762
774
  return map;
763
- // `visible` (default): the currently displayed rows (filtered + paged),
764
- // so the heat map reflects what the user is looking at. `all`: the full
765
- // unfiltered dataset, for a stable scale independent of filter/paging.
766
- const scanAll = props.conditionalStatScope === "all";
775
+ // `filtered` (default): every row that survives the filters, ignoring the
776
+ // page slice - so a value keeps the same colour as you page through (#61).
777
+ // `visible`: only the rows on screen, rescaling per page. `all`: the full
778
+ // unfiltered dataset, for a scale that stays put as you filter.
779
+ const scope = props.conditionalStatScope ?? "filtered";
780
+ const scanAll = scope === "all";
781
+ const scanRows = scope === "visible" ? allRows : allRowsBeforePagination;
767
782
  for (const column of allColumns) {
768
783
  const needs = formats.some((f) => formatNeedsStats(f) && (!f.columns || f.columns.includes(column.id)));
769
784
  if (!needs)
@@ -779,7 +794,7 @@ export function createSvGridController(rawProps) {
779
794
  }
780
795
  })()
781
796
  : (function* () {
782
- for (const row of allRows) {
797
+ for (const row of scanRows) {
783
798
  yield fieldFn
784
799
  ? fieldFn(row.original)
785
800
  : field
@@ -1740,7 +1755,7 @@ export function createSvGridController(rawProps) {
1740
1755
  const inCols = active.colIndex >= 0 && active.colIndex < allColumns.length;
1741
1756
  if (!inRows || !inCols)
1742
1757
  return null;
1743
- return getGridCellDomId("svgrid", active.rowIndex, active.colIndex);
1758
+ return getGridCellDomId(gridDomId, active.rowIndex, active.colIndex);
1744
1759
  });
1745
1760
  // Above this many cells (rows x columns) the summary aggregation is
1746
1761
  // deferred one animation frame so it never blocks first paint - a
@@ -1808,7 +1823,7 @@ export function createSvGridController(rawProps) {
1808
1823
  grid.setActiveCell({
1809
1824
  rowIndex: 0,
1810
1825
  colIndex: 0,
1811
- cellId: getGridCellDomId("svgrid", 0, 0),
1826
+ cellId: getGridCellDomId(gridDomId, 0, 0),
1812
1827
  });
1813
1828
  });
1814
1829
  $effect(() => {
@@ -2166,13 +2181,55 @@ export function createSvGridController(rawProps) {
2166
2181
  }
2167
2182
  return Array.from(seen).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
2168
2183
  }
2184
+ /**
2185
+ * Facet values for the open menu, SNAPSHOTTED when it opens.
2186
+ *
2187
+ * `facetValuesForColumn` walks every row and natural-sorts the distinct
2188
+ * values, so deriving it live re-ran that whole scan on every data change.
2189
+ * On a streaming grid (the trading-desk demo replaces its row array a couple
2190
+ * of times a second) that meant rebuilding a 10k-entry set + sort - and
2191
+ * re-keying the rendered value list - continuously for as long as the menu
2192
+ * sat open. The offered values are frozen for the life of the popover
2193
+ * instead, which is also what a spreadsheet's filter checklist does.
2194
+ * Filtering itself is unaffected: `valueFilters` matches live row values.
2195
+ *
2196
+ * The cache is a plain local, not `$state`, so writing it here can't feed
2197
+ * back into this derived.
2198
+ */
2199
+ // Stable identities for the closed-menu case, so the derives below don't hand
2200
+ // out a fresh empty array/set on every read.
2201
+ const EMPTY_FACETS = [];
2202
+ const EMPTY_FACET_SET = new Set();
2203
+ let facetCache = null;
2169
2204
  const columnMenuFacetValues = $derived.by(() => {
2170
2205
  // The funnel popover drives via `filterMenuFor`; the column menu's Filter
2171
2206
  // tab drives via `columnMenuFor`. Support whichever is open.
2172
2207
  const columnId = filterMenuFor ?? columnMenuFor;
2173
2208
  if (!columnId)
2174
- return [];
2175
- return facetValuesForColumn(columnId);
2209
+ return EMPTY_FACETS;
2210
+ // Server-provided values land asynchronously, so they stay TRACKED and
2211
+ // re-snapshot when they arrive. Locally derived values are read untracked
2212
+ // so a live data tick can't retrigger the scan.
2213
+ const source = props.serverFilterValues
2214
+ ? (serverFacetValues[columnId] ?? null)
2215
+ : null;
2216
+ if (facetCache?.columnId === columnId && facetCache.source === source) {
2217
+ return facetCache.values;
2218
+ }
2219
+ facetCache = {
2220
+ columnId,
2221
+ source,
2222
+ values: untrack(() => facetValuesForColumn(columnId)),
2223
+ };
2224
+ return facetCache.values;
2225
+ });
2226
+ // Drop the snapshot when the menu closes, so reopening the SAME column
2227
+ // re-scans and picks up whatever the data has become meanwhile. This has to
2228
+ // be an effect rather than a branch in the derived above: the derived is
2229
+ // lazy, and nothing reads it while the menu is shut.
2230
+ $effect(() => {
2231
+ if (!(filterMenuFor ?? columnMenuFor))
2232
+ facetCache = null;
2176
2233
  });
2177
2234
  const columnMenuVisibleFacets = $derived.by(() => {
2178
2235
  const query = columnMenuSearch.trim().toLowerCase();
@@ -2180,20 +2237,40 @@ export function createSvGridController(rawProps) {
2180
2237
  return columnMenuFacetValues;
2181
2238
  return columnMenuFacetValues.filter((value) => value.toLowerCase().includes(query));
2182
2239
  });
2240
+ /** Visible facets as listbox options ((Blanks) label for the empty value). */
2241
+ const columnMenuFacetOptions = $derived(columnMenuVisibleFacets.map((value) => ({
2242
+ value,
2243
+ label: value === "" ? "(Blanks)" : value,
2244
+ })));
2245
+ /**
2246
+ * Checked facets for the open menu. An ABSENT `valueFilters` entry means
2247
+ * "everything checked", so that case materializes the set once here rather
2248
+ * than every consumer re-deriving it.
2249
+ */
2250
+ const columnMenuSelectedFacets = $derived.by(() => {
2251
+ const columnId = filterMenuFor ?? columnMenuFor;
2252
+ if (!columnId)
2253
+ return EMPTY_FACET_SET;
2254
+ return valueFilters[columnId] ?? new Set(columnMenuFacetValues);
2255
+ });
2183
2256
  // Distinct values offered in the `in` / `notIn` suggestions dropdown for the
2184
- // active chip input, narrowed by whatever the user has typed. Capped so a
2185
- // high-cardinality column can't render thousands of rows into the popover.
2186
- const IN_SUGGEST_LIMIT = 200;
2257
+ // active chip input, narrowed by whatever the user has typed. Uncapped - the
2258
+ // dropdown windows its rows, so a high-cardinality column costs a list of
2259
+ // strings, not thousands of nodes.
2187
2260
  const inSuggestValues = $derived.by(() => {
2188
2261
  if (!inSuggestFor)
2189
- return [];
2262
+ return EMPTY_FACETS;
2190
2263
  const query = inSuggestQuery.trim().toLowerCase();
2191
2264
  const all = facetValuesForColumn(inSuggestFor);
2192
- const matched = query
2265
+ return query
2193
2266
  ? all.filter((value) => value.toLowerCase().includes(query))
2194
2267
  : all;
2195
- return matched.slice(0, IN_SUGGEST_LIMIT);
2196
2268
  });
2269
+ /** `in` / `notIn` suggestions as listbox options. */
2270
+ const inSuggestOptions = $derived(inSuggestValues.map((value) => ({
2271
+ value,
2272
+ label: value === "" ? "(Blanks)" : value,
2273
+ })));
2197
2274
  // Fire onApiReady exactly once when the grid is first ready. Wrapping in
2198
2275
  // an effect that tracks `props.onApiReady` was racy - every parent render
2199
2276
  // creates a new inline arrow, the effect re-fired, and any synchronous
@@ -2332,6 +2409,7 @@ export function createSvGridController(rawProps) {
2332
2409
  get inSuggestQuery() { return inSuggestQuery; },
2333
2410
  set inSuggestQuery(v) { inSuggestQuery = v; },
2334
2411
  get inSuggestValues() { return inSuggestValues; },
2412
+ get inSuggestOptions() { return inSuggestOptions; },
2335
2413
  get facetValuesForColumn() { return facetValuesForColumn; },
2336
2414
  get chooseColumnsPos() { return chooseColumnsPos; },
2337
2415
  set chooseColumnsPos(v) { chooseColumnsPos = v; },
@@ -2621,6 +2699,7 @@ export function createSvGridController(rawProps) {
2621
2699
  get columnWindowRightSpacer() { return columnWindowRightSpacer; },
2622
2700
  get activeCell() { return activeCell; },
2623
2701
  get activeDescendantId() { return activeDescendantId; },
2702
+ get gridDomId() { return gridDomId; },
2624
2703
  get formatSummaryNumeric() { return formatSummaryNumeric; },
2625
2704
  get computeSummaries() { return computeSummaries; },
2626
2705
  get SUMMARY_DEFER_CELL_LIMIT() { return SUMMARY_DEFER_CELL_LIMIT; },
@@ -2703,6 +2782,7 @@ export function createSvGridController(rawProps) {
2703
2782
  get applyHistoryStep() { return applyHistoryStep; },
2704
2783
  get updateEditingCellValue() { return updateEditingCellValue; },
2705
2784
  get onEditorKeyDown() { return onEditorKeyDown; },
2785
+ get commitAndMoveByTab() { return commitAndMoveByTab; },
2706
2786
  get focusOnMount() { return focusOnMount; },
2707
2787
  get onHeaderSortClick() { return onHeaderSortClick; },
2708
2788
  get onGridKeyDown() { return onGridKeyDown; },
@@ -2743,6 +2823,7 @@ export function createSvGridController(rawProps) {
2743
2823
  get addFilterToken() { return addFilterToken; },
2744
2824
  get removeFilterToken() { return removeFilterToken; },
2745
2825
  get toggleFilterToken() { return toggleFilterToken; },
2826
+ get setFilterTokens() { return setFilterTokens; },
2746
2827
  get sortColumnFromMenu() { return sortColumnFromMenu; },
2747
2828
  get clearColumnSort() { return clearColumnSort; },
2748
2829
  get groupByColumnFromMenu() { return groupByColumnFromMenu; },
@@ -2754,8 +2835,11 @@ export function createSvGridController(rawProps) {
2754
2835
  get serverFacetLoading() { return serverFacetLoading; },
2755
2836
  get columnMenuFacetValues() { return columnMenuFacetValues; },
2756
2837
  get columnMenuVisibleFacets() { return columnMenuVisibleFacets; },
2838
+ get columnMenuFacetOptions() { return columnMenuFacetOptions; },
2839
+ get columnMenuSelectedFacets() { return columnMenuSelectedFacets; },
2757
2840
  get isFacetChecked() { return isFacetChecked; },
2758
2841
  get toggleFacetValue() { return toggleFacetValue; },
2842
+ get setFacetSelection() { return setFacetSelection; },
2759
2843
  get isAllFacetsChecked() { return isAllFacetsChecked; },
2760
2844
  get toggleAllFacets() { return toggleAllFacets; },
2761
2845
  get clearColumnFilter() { return clearColumnFilter; },
@@ -2769,9 +2853,9 @@ export function createSvGridController(rawProps) {
2769
2853
  const { showTooltipFor, hideTooltip, flushScheduledScrollSync, scheduleScrollSync, onBodyScroll } = createScrollSync(ctx);
2770
2854
  const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard(ctx);
2771
2855
  const { computeSummaries, hasRenderedColumn } = createSummaries(ctx);
2772
- 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(ctx);
2856
+ 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(ctx);
2773
2857
  const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender(ctx);
2774
- const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing(ctx);
2858
+ const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, commitAndMoveByTab, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing(ctx);
2775
2859
  const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection(ctx);
2776
2860
  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(ctx);
2777
2861
  const { onRowDragStart, onRowDragOver, onRowDragLeave, onRowDrop, onRowsContainerDragOver, onRowsContainerDrop, onRowDragEnd, destroyRowDrag } = createRowDrag(ctx);
package/dist/SvGrid.css CHANGED
@@ -1899,35 +1899,15 @@ select.sv-grid-fr-editor {
1899
1899
 
1900
1900
  /* In / Not-in value-suggestions dropdown (anchored under the chip input).
1901
1901
  Defined AFTER .sv-grid-menu so its tighter sizing wins - the element
1902
- carries both classes. Compact rows keep a long value list scannable. */
1902
+ carries both classes. The rows come from SvListBox, which owns its own
1903
+ scrolling (windowed past ~40 values), so this is just the frame. */
1903
1904
  .sv-grid-menu.sv-grid-in-suggest {
1904
1905
  min-width: 140px;
1905
1906
  max-width: 260px;
1906
- max-height: 220px;
1907
- overflow-y: auto;
1907
+ padding: 0;
1908
+ overflow: visible;
1908
1909
  font-size: 12px;
1909
1910
  }
1910
- .sv-grid-in-suggest-item {
1911
- gap: 6px;
1912
- padding: 3px 8px;
1913
- border-radius: 4px;
1914
- line-height: 1.4;
1915
- }
1916
- .sv-grid-in-suggest-item[aria-selected="true"] {
1917
- color: var(--sg-accent, #0b63f3);
1918
- font-weight: 600;
1919
- }
1920
- .sv-grid-in-suggest-check {
1921
- flex: none;
1922
- width: 12px;
1923
- text-align: center;
1924
- color: var(--sg-accent, #0b63f3);
1925
- }
1926
- .sv-grid-in-suggest-label {
1927
- overflow: hidden;
1928
- text-overflow: ellipsis;
1929
- white-space: nowrap;
1930
- }
1931
1911
 
1932
1912
  .sv-grid-menu-item[aria-checked="true"] {
1933
1913
  background: var(--sg-selection-bg, #eaf2ff);
@@ -2062,12 +2042,13 @@ select.sv-grid-fr-editor {
2062
2042
  padding: 2px 7px !important;
2063
2043
  }
2064
2044
 
2065
- .sv-grid-facet-list {
2066
- max-height: 200px;
2067
- overflow: auto;
2045
+ /* The value / column checklists are SvListBox instances (windowed past ~40
2046
+ rows). They draw their own box off the shared --sg-input-border/--sg-border
2047
+ tokens, so all the menu adds is scroll containment - without it a wheel past
2048
+ the end of a non-virtual list scrolls the page behind the popover, which
2049
+ closes it. */
2050
+ .sv-grid-menu .sv-listbox {
2068
2051
  overscroll-behavior: contain;
2069
- border: 1px solid var(--sg-border, #eaeef4);
2070
- border-radius: 5px;
2071
2052
  }
2072
2053
 
2073
2054
  .sv-grid-facet {
@@ -2082,9 +2063,11 @@ select.sv-grid-fr-editor {
2082
2063
  background: var(--sg-row-hover-bg, #f4f6fa);
2083
2064
  }
2084
2065
 
2066
+ /* Sits directly above the checklist, which draws its own frame - so no rule
2067
+ here, or the two borders stack. */
2085
2068
  .sv-grid-facet-all {
2086
- border-bottom: 1px solid var(--sg-border, #eaeef4);
2087
2069
  font-weight: 600;
2070
+ padding-inline-start: 3px;
2088
2071
  }
2089
2072
 
2090
2073
  .sv-grid-facet input {
@@ -2464,11 +2447,9 @@ select.sv-grid-fr-editor {
2464
2447
  .sv-grid-choose-columns-menu {
2465
2448
  width: 240px;
2466
2449
  }
2467
- .sv-grid-choose-columns-menu .sv-grid-facet-list {
2468
- max-height: 280px;
2469
- }
2470
2450
 
2471
- /* Native checkboxes in the facet list pick up the theme accent. */
2451
+ /* Native checkbox on the "(Select all)" row picks up the theme accent. The
2452
+ checklist rows below it draw SvListBox's own box, not a native input. */
2472
2453
  .sv-grid-facet input[type="checkbox"] {
2473
2454
  accent-color: var(--sg-accent, #2563eb);
2474
2455
  width: 14px;
@@ -56,6 +56,7 @@
56
56
  } from "./SvGrid.helpers";
57
57
  import {
58
58
  splitInTokens,
59
+ trailingInToken,
59
60
  joinInTokens,
60
61
  } from "./filtering/excel-filters";
61
62
  import { createSvGridController } from "./SvGrid.controller.svelte";
@@ -68,7 +69,13 @@
68
69
  import { getBoardView } from "./board-view.svelte";
69
70
  import { getChartView } from "./chart-view.svelte";
70
71
  let props: Props<TFeatures, TData> = $props();
71
- const ctrl = createSvGridController(props);
72
+ // Per-instance base for every DOM id the grid mints. `$props.id()` is stable
73
+ // across SSR and hydration, so two grids on one page no longer collide on
74
+ // `svgrid_cell_0_0` and point `aria-activedescendant` at each other (#77).
75
+ // `$props.id()` must be its own declaration initializer - it can't be passed
76
+ // inline as a call argument.
77
+ const gridInstanceId = $props.id();
78
+ const ctrl = createSvGridController(props, gridInstanceId);
72
79
  // Effective props: the controller's `override ?? prop` proxy that backs
73
80
  // `api.setOption(...)`. Reading `opt.X` (instead of `props.X`) makes a view-direct
74
81
  // prop honor a runtime override. Svelte binds template `props.X` reads to the prop
@@ -151,6 +158,10 @@
151
158
  ctrl.contextMenuFor ||
152
159
  ctrl.filterMenuFor ||
153
160
  ctrl.operatorMenuFor ||
161
+ // The value-suggestions dropdown lives in the overlay too, and it can be
162
+ // the FIRST thing a user opens (focusing an in/notIn filter input), so it
163
+ // has to pull the chunk in rather than assume a menu already did.
164
+ ctrl.inSuggestFor ||
154
165
  ctrl.chooseColumnsPos;
155
166
  if (menuOpen && !MenusOverlay)
156
167
  import("./GridMenus.svelte").then((m) => (MenusOverlay = m.default));
@@ -474,7 +485,6 @@
474
485
  const closeInSuggest = $derived(ctrl.closeInSuggest);
475
486
  const addFilterToken = $derived(ctrl.addFilterToken);
476
487
  const removeFilterToken = $derived(ctrl.removeFilterToken);
477
- const facetValuesForColumn = $derived(ctrl.facetValuesForColumn);
478
488
  const onWindowKeydown = $derived(ctrl.onWindowKeydown);
479
489
 
480
490
  // True when a `regex` filter's pattern won't compile - used to flag the
@@ -522,6 +532,21 @@
522
532
  if (ctrl.inSuggestFor !== columnId) openInSuggest(input, columnId);
523
533
  }
524
534
 
535
+ // The tool-panel / filter-menu `in` / `notIn` inputs hold the WHOLE token
536
+ // list ("AAPL, MSFT"), unlike the chip input which holds one token. So the
537
+ // suggestion query is the trailing fragment being typed, not the full value.
538
+ function onSetOpFilterFocus(event: FocusEvent, columnId: string): void {
539
+ const input = event.currentTarget as HTMLInputElement;
540
+ openInSuggest(input, columnId);
541
+ ctrl.inSuggestQuery = trailingInToken(input.value);
542
+ }
543
+
544
+ function onSetOpFilterInput(event: Event, columnId: string): void {
545
+ const input = event.currentTarget as HTMLInputElement;
546
+ ctrl.inSuggestQuery = trailingInToken(input.value);
547
+ if (ctrl.inSuggestFor !== columnId) openInSuggest(input, columnId);
548
+ }
549
+
525
550
  // Overflow layout for the `in` / `notIn` chip row: render every chip, then
526
551
  // measure how many fit on one line and collapse the rest into the "+N" pill.
527
552
  // A guess-by-width heuristic can't work (labels vary), so we measure real
@@ -1280,10 +1305,14 @@
1280
1305
  return;
1281
1306
  }
1282
1307
  // Tab and Ctrl/Cmd+Enter both commit. Plain Enter inserts a newline.
1283
- if (
1284
- event.key === "Tab" ||
1285
- (event.key === "Enter" && (event.ctrlKey || event.metaKey))
1286
- ) {
1308
+ // Tab goes through the shared helper so it advances the active cell
1309
+ // like every other editor does (#48).
1310
+ if (event.key === "Tab") {
1311
+ event.preventDefault();
1312
+ ctrl.commitAndMoveByTab(event.shiftKey);
1313
+ return;
1314
+ }
1315
+ if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
1287
1316
  event.preventDefault();
1288
1317
  saveEditingCell();
1289
1318
  ctrl.gridRootEl?.focus({ preventScroll: true });
@@ -2583,7 +2612,7 @@
2583
2612
  ),
2584
2613
  }}
2585
2614
  {...getGridCellA11yProps({
2586
- id: getGridCellDomId("svgrid", rowIndex, colIndex),
2615
+ id: getGridCellDomId(ctrl.gridDomId, rowIndex, colIndex),
2587
2616
  rowIndex: rowIndex + 1,
2588
2617
  colIndex: colIndex + 1,
2589
2618
  selected: isRowSelected(row.id),
@@ -2833,7 +2862,7 @@
2833
2862
  ),
2834
2863
  }}
2835
2864
  {...getGridCellA11yProps({
2836
- id: getGridCellDomId("svgrid", rowIndex, colIndex),
2865
+ id: getGridCellDomId(ctrl.gridDomId, rowIndex, colIndex),
2837
2866
  rowIndex: rowIndex + 1,
2838
2867
  colIndex: colIndex + 1,
2839
2868
  selected: isRowSelected(row.id),
@@ -3273,10 +3302,13 @@
3273
3302
  </select>
3274
3303
  {#if active !== "isBlank" && active !== "isNotBlank"}
3275
3304
  {@const tpIsSetOp = active === "in" || active === "notIn"}
3305
+ <!-- in/notIn drives the shared value-suggestions dropdown
3306
+ (a windowed checklist) rather than a native datalist,
3307
+ which capped at 200 values and couldn't show which
3308
+ are already selected. -->
3276
3309
  <input
3277
3310
  class="sv-grid-tp-filter-input"
3278
3311
  type={active === "regex" ? "text" : fType}
3279
- list={tpIsSetOp ? `sv-tp-in-list-${column.id}` : undefined}
3280
3312
  placeholder={active === "between"
3281
3313
  ? "From"
3282
3314
  : tpIsSetOp
@@ -3285,19 +3317,15 @@
3285
3317
  ? "Pattern…"
3286
3318
  : "Filter…"}
3287
3319
  value={filterMenuValues[column.id]?.value ?? ""}
3288
- oninput={(e) =>
3289
- updateFilterMenuValue(
3290
- column.id,
3291
- e.currentTarget.value,
3292
- )}
3320
+ onfocus={tpIsSetOp
3321
+ ? (e) => onSetOpFilterFocus(e, column.id)
3322
+ : undefined}
3323
+ onblur={tpIsSetOp ? () => closeInSuggest() : undefined}
3324
+ oninput={(e) => {
3325
+ updateFilterMenuValue(column.id, e.currentTarget.value);
3326
+ if (tpIsSetOp) onSetOpFilterInput(e, column.id);
3327
+ }}
3293
3328
  />
3294
- {#if tpIsSetOp}
3295
- <datalist id={`sv-tp-in-list-${column.id}`}>
3296
- {#each facetValuesForColumn(column.id).slice(0, 200) as v (v)}
3297
- <option value={v}></option>
3298
- {/each}
3299
- </datalist>
3300
- {/if}
3301
3329
  {#if active === "between"}
3302
3330
  <input
3303
3331
  class="sv-grid-tp-filter-input"
@@ -1339,11 +1339,18 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1339
1339
  conditionalFormats?: ReadonlyArray<ConditionalFormat<TData>>;
1340
1340
  /**
1341
1341
  * Which rows feed the min/max range that `colorScale` / `dataBar` formats
1342
- * scale against. `visible` (default): the currently displayed rows (after
1343
- * filtering + paging), so the heat map adapts to what's on screen. `all`:
1344
- * the full unfiltered dataset, for a scale that stays put as you filter.
1345
- */
1346
- conditionalStatScope?: "visible" | "all";
1342
+ * scale against.
1343
+ *
1344
+ * - `filtered` (default): every row that survives the filters, ignoring the
1345
+ * page slice. The scale follows what you filtered to but does not shift
1346
+ * when you page, so the same value keeps the same colour on page 1 and
1347
+ * page 2.
1348
+ * - `visible`: only the rows currently on screen. Rescales per page - use it
1349
+ * when you want each page's heat map normalized to that page.
1350
+ * - `all`: the full unfiltered dataset, for a scale that stays put as you
1351
+ * filter.
1352
+ */
1353
+ conditionalStatScope?: "filtered" | "visible" | "all";
1347
1354
  onCellValueChange?: (event: {
1348
1355
  rowIndex: number;
1349
1356
  columnId: string;
@@ -1562,6 +1569,14 @@ export type CellEditState = {
1562
1569
  columnId: string;
1563
1570
  editorType: CellEditorType;
1564
1571
  value: unknown;
1572
+ /**
1573
+ * The row's underlying data object, captured when the edit started. The
1574
+ * commit path resolves the row by id first; this is the fallback for when
1575
+ * the row has left the row model mid-edit (a filter typed into the filter
1576
+ * row while an editor is open), so the typed value still lands instead of
1577
+ * being silently dropped.
1578
+ */
1579
+ rowRef?: unknown;
1565
1580
  } | null;
1566
1581
  export type FilterOperator = "contains" | "notContains" | "equals" | "notEquals" | "startsWith" | "endsWith" | "regex" | "in" | "notIn" | "greaterThan" | "lessThan" | "between" | "isBlank" | "isNotBlank";
1567
1582
  export type FilterOption = {
@@ -23,9 +23,18 @@
23
23
 
24
24
  type Props = SvEditorProps & {
25
25
  options: ReadonlyArray<ListOption>
26
- value?: string | number | Array<string | number> | null
26
+ value?: string | number | ReadonlyArray<string | number> | ReadonlySet<string | number> | null
27
27
  onChange?: (value: any) => void
28
28
  multiple?: boolean
29
+ /**
30
+ * Checklist styling: each row draws a checkbox and the selected-row accent
31
+ * highlight is suppressed, because a list that starts fully selected (a
32
+ * filter checklist) would otherwise paint every row. State reads off the
33
+ * box; `aria-selected` still carries it for assistive tech.
34
+ */
35
+ checkbox?: boolean
36
+ /** Stretch to the container width instead of the default 220px. */
37
+ block?: boolean
29
38
  /** Visible height in rows before scrolling. */
30
39
  rows?: number
31
40
  /** Window the list (render only visible rows) for large option sets. */
@@ -46,6 +55,8 @@
46
55
  value = null,
47
56
  onChange,
48
57
  multiple = false,
58
+ checkbox = false,
59
+ block = false,
49
60
  disabled = false,
50
61
  size = 'md',
51
62
  rows = 7,
@@ -152,13 +163,15 @@
152
163
  })
153
164
  </script>
154
165
 
155
- <SvField id={uid} {label} {hint} {error} {required} {dir}>
166
+ <SvField id={uid} {label} {hint} {error} {required} {dir} {block}>
156
167
  <!-- Div-based listbox (role comes from `lb.rootProps()`). -->
157
168
  <div
158
169
  bind:this={listEl}
159
170
  class="sv-listbox sv-listbox--{size}"
160
171
  class:is-invalid={invalid}
161
172
  class:is-virtual={useVirtual}
173
+ class:is-checkbox={checkbox}
174
+ class:is-block={block}
162
175
  style:--sv-rows={rows}
163
176
  style:--sv-row-h={`${rowHeightBase}px`}
164
177
  onwheel={useVirtual ? scroller.onWheel : undefined}
@@ -211,7 +224,11 @@
211
224
  </SvField>
212
225
 
213
226
  {#snippet optionInner(opt: ListOption)}
214
- {#if multiple}<span class="sv-listbox__check" aria-hidden="true">{isSel(opt) ? '✓' : ''}</span>{/if}
227
+ {#if checkbox}
228
+ <span class="sv-listbox__box" class:is-on={isSel(opt)} aria-hidden="true"></span>
229
+ {:else if multiple}
230
+ <span class="sv-listbox__check" aria-hidden="true">{isSel(opt) ? '✓' : ''}</span>
231
+ {/if}
215
232
  {#if item ?? itemTemplate}{@render (item ?? itemTemplate)!(opt)}{:else}<span class="sv-listbox__label">{opt.label}</span>{/if}
216
233
  {/snippet}
217
234
 
@@ -249,6 +266,7 @@
249
266
  padding: 0; overflow: hidden; position: relative;
250
267
  height: calc(var(--sv-rows, 7) * var(--sv-row-h, 32px) + 8px);
251
268
  }
269
+ .sv-listbox.is-block { width: 100%; }
252
270
  .sv-listbox--sm { --_fs: 12px; }
253
271
  .sv-listbox--lg { --_fs: 15px; }
254
272
  .sv-listbox:focus-visible { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
@@ -284,4 +302,25 @@
284
302
  }
285
303
  .sv-listbox__check { width: 14px; text-align: center; color: var(--_accent); }
286
304
  .sv-listbox__label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
305
+
306
+ /* ---- Checklist variant ---- */
307
+ .sv-listbox__box {
308
+ width: 14px; height: 14px; flex: none; box-sizing: border-box; position: relative;
309
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1));
310
+ border-radius: 3px; background: var(--sg-input-bg, #fff);
311
+ }
312
+ .sv-listbox__box.is-on { background: var(--_accent); border-color: var(--_accent); }
313
+ .sv-listbox__box.is-on::after {
314
+ content: ''; position: absolute; left: 4px; top: 1px; width: 3px; height: 7px;
315
+ border: solid var(--sg-input-bg, #fff); border-width: 0 2px 2px 0; transform: rotate(45deg);
316
+ }
317
+ /* The box carries the state, so selected rows keep neutral chrome - only the
318
+ roving highlight tints a row. */
319
+ .sv-listbox.is-checkbox .sv-listbox__opt.is-selected {
320
+ background: none; color: inherit; font-weight: inherit;
321
+ }
322
+ .sv-listbox.is-checkbox .sv-listbox__opt.is-active,
323
+ .sv-listbox.is-checkbox .sv-listbox__opt.is-selected.is-active {
324
+ background: var(--sg-row-hover-bg, #f1f5f9);
325
+ }
287
326
  </style>