@svgrid/grid 2.2.28 → 2.2.30

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
package/src/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;
package/src/SvGrid.svelte CHANGED
@@ -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"
@@ -1370,11 +1370,18 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1370
1370
  conditionalFormats?: ReadonlyArray<ConditionalFormat<TData>>;
1371
1371
  /**
1372
1372
  * Which rows feed the min/max range that `colorScale` / `dataBar` formats
1373
- * scale against. `visible` (default): the currently displayed rows (after
1374
- * filtering + paging), so the heat map adapts to what's on screen. `all`:
1375
- * the full unfiltered dataset, for a scale that stays put as you filter.
1376
- */
1377
- conditionalStatScope?: "visible" | "all";
1373
+ * scale against.
1374
+ *
1375
+ * - `filtered` (default): every row that survives the filters, ignoring the
1376
+ * page slice. The scale follows what you filtered to but does not shift
1377
+ * when you page, so the same value keeps the same colour on page 1 and
1378
+ * page 2.
1379
+ * - `visible`: only the rows currently on screen. Rescales per page - use it
1380
+ * when you want each page's heat map normalized to that page.
1381
+ * - `all`: the full unfiltered dataset, for a scale that stays put as you
1382
+ * filter.
1383
+ */
1384
+ conditionalStatScope?: "filtered" | "visible" | "all";
1378
1385
  onCellValueChange?: (event: {
1379
1386
  rowIndex: number;
1380
1387
  columnId: string;
@@ -1589,6 +1596,14 @@ export type CellEditState = {
1589
1596
  columnId: string;
1590
1597
  editorType: CellEditorType;
1591
1598
  value: unknown;
1599
+ /**
1600
+ * The row's underlying data object, captured when the edit started. The
1601
+ * commit path resolves the row by id first; this is the fallback for when
1602
+ * the row has left the row model mid-edit (a filter typed into the filter
1603
+ * row while an editor is open), so the typed value still lands instead of
1604
+ * being silently dropped.
1605
+ */
1606
+ rowRef?: unknown;
1592
1607
  } | null;
1593
1608
  export type FilterOperator =
1594
1609
  | "contains"
@@ -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>
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { computeColumnGroupMeta, hiddenLeavesForCollapse } from './column-groups'
3
+ import { resolveColumnId } from './column-id'
3
4
 
4
5
  // A group "Q1" with an always-on Total plus Jan/Feb that only show when open.
5
6
  const columns = [
@@ -57,3 +58,50 @@ describe('hiddenLeavesForCollapse', () => {
57
58
  expect(hidden.apr).toBeUndefined() // 'open' shows when expanded
58
59
  })
59
60
  })
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Id agreement with the engine
64
+ // ---------------------------------------------------------------------------
65
+
66
+ describe('unnamed columns', () => {
67
+ // Columns with neither `id` nor `field` get an id synthesized from their
68
+ // position. The engine, this module, and the group-header derivation all
69
+ // have to synthesize the SAME one - they used to disagree (`q1_d_1` here vs
70
+ // `q1_1_1` in the engine), so `columnGroupShow` on an unnamed column looked
71
+ // wired up but never actually hid anything.
72
+ const unnamed = [
73
+ { field: 'name' },
74
+ {
75
+ id: 'q1',
76
+ header: 'Q1',
77
+ columns: [
78
+ { field: 'jan', columnGroupShow: 'open' },
79
+ { header: 'Closed', columnGroupShow: 'closed' },
80
+ { header: 'Always' },
81
+ ],
82
+ },
83
+ ]
84
+
85
+ it('keys leafControl by the id the engine assigns', () => {
86
+ const meta = computeColumnGroupMeta(unnamed)
87
+ // `<parentId>_<depth>_<index>`: depth 1 under "q1", index 1 in its siblings.
88
+ expect(meta.leafControl.has('q1_1_1')).toBe(true)
89
+ expect(meta.leafControl.get('q1_1_1')).toEqual({ groupId: 'q1', show: 'closed' })
90
+ // The untagged sibling is not controlled, and no stale `_d_` key survives.
91
+ expect(meta.leafControl.has('q1_1_2')).toBe(false)
92
+ expect([...meta.leafControl.keys()].some((k) => k.includes('_d_'))).toBe(false)
93
+ })
94
+
95
+ it('hides an unnamed closed-mode leaf once the group is expanded', () => {
96
+ const meta = computeColumnGroupMeta(unnamed)
97
+ expect(hiddenLeavesForCollapse(meta, new Set<string>())).toEqual({ q1_1_1: true })
98
+ expect(hiddenLeavesForCollapse(meta, new Set(['q1']))).toEqual({ jan: true })
99
+ })
100
+
101
+ it('matches resolveColumnId at every depth', () => {
102
+ expect(resolveColumnId({ field: 'jan' }, 'q1', 1, 0)).toBe('jan')
103
+ expect(resolveColumnId({ id: 'q1' }, undefined, 0, 1)).toBe('q1')
104
+ expect(resolveColumnId({ header: 'X' }, 'q1', 1, 2)).toBe('q1_1_2')
105
+ expect(resolveColumnId({ header: 'X' }, undefined, 0, 3)).toBe('col_0_3')
106
+ })
107
+ })
@@ -20,12 +20,7 @@ export type ColumnGroupMeta = {
20
20
  leafControl: Map<string, { groupId: string; show: ColumnGroupShow }>;
21
21
  };
22
22
 
23
- // Mirror the id resolution used by the group-header derivation so group ids and
24
- // leaf ids line up across both.
25
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
- function idOf(def: any, parentId: string | undefined, ix: number): string {
27
- return def.id ?? def.field ?? `${parentId ?? "col"}_d_${ix}`;
28
- }
23
+ import { resolveColumnId } from "./column-id";
29
24
 
30
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
26
  export function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroupMeta {
@@ -33,10 +28,13 @@ export function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroup
33
28
  const defaultOpen = new Map<string, boolean>();
34
29
  const leafControl = new Map<string, { groupId: string; show: ColumnGroupShow }>();
35
30
 
31
+ // `depth` is threaded because it is part of the id of an unnamed column -
32
+ // drop it and the ids here stop matching the ones the engine assigns, so
33
+ // `leafControl` lookups silently miss for those columns.
36
34
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
- function walk(defs: ReadonlyArray<any>, parentId: string | undefined, controllingGroup: string | undefined) {
35
+ function walk(defs: ReadonlyArray<any>, parentId: string | undefined, controllingGroup: string | undefined, depth: number) {
38
36
  defs.forEach((def, ix) => {
39
- const id = idOf(def, parentId, ix);
37
+ const id = resolveColumnId(def, parentId, depth, ix);
40
38
  if (def.columns?.length) {
41
39
  // A group is collapsible when any DIRECT child declares columnGroupShow.
42
40
  const collapsible = def.columns.some(
@@ -47,7 +45,7 @@ export function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroup
47
45
  collapsibleGroupIds.add(id);
48
46
  defaultOpen.set(id, def.openByDefault === true);
49
47
  }
50
- walk(def.columns, id, collapsible ? id : controllingGroup);
48
+ walk(def.columns, id, collapsible ? id : controllingGroup, depth + 1);
51
49
  } else {
52
50
  const show = def.columnGroupShow;
53
51
  if ((show === "open" || show === "closed") && controllingGroup) {
@@ -57,7 +55,7 @@ export function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroup
57
55
  });
58
56
  }
59
57
 
60
- walk(columns, undefined, undefined);
58
+ walk(columns, undefined, undefined, 0);
61
59
  return { collapsibleGroupIds, defaultOpen, leafControl };
62
60
  }
63
61
 
@@ -0,0 +1,30 @@
1
+ // The single definition of a column's id.
2
+ //
3
+ // A column def normally names itself with `id` or `field`. When it does
4
+ // neither, the id is synthesized from where the def sits in the tree. That
5
+ // synthesized form is what makes this module necessary: it used to be spelled
6
+ // out separately in the engine, in the collapsible-group metadata, and in the
7
+ // group-header derivation, and the copies did not agree. The engine keyed
8
+ // columns by `<parent>_<depth>_<index>` while the other two used
9
+ // `<parent>_d_<index>`, so for an unnamed column every cross-lookup missed -
10
+ // `columnGroupShow` never hid it, and its group header measured nothing.
11
+ //
12
+ // Anything that needs a column's id derives it here.
13
+
14
+ /**
15
+ * The id the grid engine assigns to a column def.
16
+ *
17
+ * @param def the column def (only `id` / `field` are read)
18
+ * @param parentId id of the enclosing column group, if any
19
+ * @param depth nesting depth of `def` - 0 at the top level
20
+ * @param index position of `def` within its own sibling array
21
+ */
22
+ export function resolveColumnId(
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ def: any,
25
+ parentId: string | undefined,
26
+ depth: number,
27
+ index: number,
28
+ ): string {
29
+ return def.id ?? def.field ?? `${parentId ?? "col"}_${depth}_${index}`;
30
+ }
package/src/core.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SparklineConfig } from './sparkline'
2
+ import { resolveColumnId } from './column-id'
2
3
 
3
4
  export type RowData = Record<string, unknown>
4
5
  export type Updater<T> = T | ((prev: T) => T)
@@ -913,7 +914,7 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
913
914
  ): Array<Column<TData>> => {
914
915
  const leaves: Array<Column<TData>> = []
915
916
  defs.forEach((columnDef, index) => {
916
- const id = columnDef.id ?? columnDef.field ?? `${parentId ?? 'col'}_${depth}_${index}`
917
+ const id = resolveColumnId(columnDef, parentId, depth, index)
917
918
  if (columnDef.columns?.length) {
918
919
  leaves.push(...build(columnDef.columns, depth + 1, id))
919
920
  return
@@ -25,14 +25,25 @@
25
25
  import { createTypeaheadBuffer, isTypeaheadKey, nextTypeaheadIndex, type ListOption } from './list-option'
26
26
  import { editorAria, type EditorAriaState } from './editor-contract'
27
27
 
28
- export type ListboxValue = string | number | Array<string | number> | null
28
+ /**
29
+ * A `Set` is accepted (and echoed back) in multi-select mode so a caller whose
30
+ * selection is already set-shaped - a filter checklist whose default state is
31
+ * "every value selected" - never has to materialize it as an array to drive the
32
+ * listbox, or to spread it back out again on every click.
33
+ */
34
+ export type ListboxValue =
35
+ | string
36
+ | number
37
+ | ReadonlyArray<string | number>
38
+ | ReadonlySet<string | number>
39
+ | null
29
40
 
30
41
  /** Reactive inputs are passed as getters so the core tracks live prop changes
31
42
  * (the same controlled pattern Svelte headless libraries use). */
32
43
  export type ListboxConfig = {
33
44
  options: () => ReadonlyArray<ListOption>
34
45
  value: () => ListboxValue
35
- onChange?: (value: string | number | Array<string | number>) => void
46
+ onChange?: (value: string | number | Array<string | number> | Set<string | number>) => void
36
47
  multiple?: () => boolean
37
48
  disabled?: () => boolean
38
49
  ariaLabel?: () => string | undefined
@@ -95,8 +106,23 @@ let uid = 0
95
106
 
96
107
  /** Normalize a listbox value to an array (pure). */
97
108
  export function toSelectedArray(value: ListboxValue, multiple: boolean): Array<string | number> {
98
- if (multiple) return Array.isArray(value) ? [...value] : value == null ? [] : [value]
99
- return value == null ? [] : [value as string | number]
109
+ if (value == null) return []
110
+ if (value instanceof Set || Array.isArray(value)) {
111
+ const all = [...(value as Iterable<string | number>)]
112
+ return multiple ? all : all.slice(0, 1)
113
+ }
114
+ return [value as string | number]
115
+ }
116
+
117
+ /**
118
+ * Membership set for a listbox value (pure). A `Set` value is reused as-is, so
119
+ * `isSelected` stays O(1) without copying - the difference between a linear
120
+ * scan per rendered row and a hash hit when the selection runs to thousands of
121
+ * entries.
122
+ */
123
+ export function toSelectedSet(value: ListboxValue, multiple: boolean): ReadonlySet<string | number> {
124
+ if (value instanceof Set) return value
125
+ return new Set(toSelectedArray(value, multiple))
100
126
  }
101
127
 
102
128
  export function createListbox(config: ListboxConfig): Listbox {
@@ -106,6 +132,9 @@ export function createListbox(config: ListboxConfig): Listbox {
106
132
  const disabled = () => config.disabled?.() ?? false
107
133
 
108
134
  let active = $state(0)
135
+ // `selectedSet` drives membership; `selected` (the ordered array) stays a
136
+ // lazy derived so a large selection is only flattened when someone reads it.
137
+ const selectedSet = $derived(toSelectedSet(config.value(), multiple()))
109
138
  const selected = $derived(toSelectedArray(config.value(), multiple()))
110
139
  const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
111
140
 
@@ -115,7 +144,7 @@ export function createListbox(config: ListboxConfig): Listbox {
115
144
  if (!o || o.disabled) active = enabledIdx[0] ?? 0
116
145
  })
117
146
 
118
- const isSelected = (value: string | number) => selected.includes(value)
147
+ const isSelected = (value: string | number) => selectedSet.has(value)
119
148
  const isActive = (index: number) => index === active
120
149
  const optionId = (index: number) => `${id}-opt-${index}`
121
150
 
@@ -129,9 +158,12 @@ export function createListbox(config: ListboxConfig): Listbox {
129
158
  if (!o || o.disabled || disabled()) return
130
159
  active = index
131
160
  if (multiple()) {
132
- const set = new Set(selected)
161
+ const set = new Set(selectedSet)
133
162
  set.has(o.value) ? set.delete(o.value) : set.add(o.value)
134
- config.onChange?.([...set])
163
+ // Echo back the shape we were handed. Toggling one option must not
164
+ // disturb selected values that aren't currently in `options` (search has
165
+ // narrowed the list, say) - they ride along in the set untouched.
166
+ config.onChange?.(config.value() instanceof Set ? set : [...set])
135
167
  } else {
136
168
  config.onChange?.(o.value)
137
169
  }