@svgrid/grid 2.0.0 → 2.0.1

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 (61) hide show
  1. package/dist/FlexRender.svelte +96 -96
  2. package/dist/GridFooter.svelte +178 -178
  3. package/dist/SvGrid.css +2376 -2376
  4. package/dist/SvGrid.svelte +2764 -2764
  5. package/dist/SvGridDropdown.svelte +666 -666
  6. package/dist/cdn/svgrid.js +1 -1
  7. package/dist/cdn/svgrid.svelte-external.js +1 -1
  8. package/package.json +85 -85
  9. package/src/FlexRender.svelte +96 -96
  10. package/src/GridFooter.svelte +178 -178
  11. package/src/SvGrid.controller.svelte.ts +2553 -2553
  12. package/src/SvGrid.css +2376 -2376
  13. package/src/SvGrid.svelte +2764 -2764
  14. package/src/SvGrid.types.ts +944 -944
  15. package/src/SvGridDropdown.svelte +666 -666
  16. package/src/a11y.contract.test.ts +49 -49
  17. package/src/a11y.test.ts +59 -59
  18. package/src/a11y.ts +61 -61
  19. package/src/build-api.ts +798 -798
  20. package/src/cell-formatting.ts +169 -169
  21. package/src/cell-render.ts +469 -469
  22. package/src/collaboration.test.ts +104 -104
  23. package/src/collaboration.ts +167 -167
  24. package/src/core.performance.test.ts +30 -30
  25. package/src/core.ts +1111 -1111
  26. package/src/createGrid.svelte.ts +42 -42
  27. package/src/createGrid.test.ts +10 -10
  28. package/src/createGridState.svelte.ts +17 -17
  29. package/src/editing.test.ts +859 -859
  30. package/src/editing.ts +675 -675
  31. package/src/export-data-api.test.ts +126 -126
  32. package/src/export-format.test.ts +107 -107
  33. package/src/export-format.ts +598 -598
  34. package/src/flex-render.ts +3 -3
  35. package/src/index.ts +463 -463
  36. package/src/keyboard.test.ts +59 -59
  37. package/src/keyboard.ts +97 -97
  38. package/src/menus.ts +582 -582
  39. package/src/merge-objects.ts +48 -48
  40. package/src/render-component.ts +28 -28
  41. package/src/selection.test.ts +754 -754
  42. package/src/selection.ts +600 -600
  43. package/src/server-data-source.test.ts +289 -289
  44. package/src/server-data-source.ts +413 -413
  45. package/src/sparkline.test.ts +68 -68
  46. package/src/sparkline.ts +169 -169
  47. package/src/spreadsheet.test.ts +489 -489
  48. package/src/spreadsheet.ts +304 -304
  49. package/src/static-functions.ts +11 -11
  50. package/src/subscribe.ts +38 -38
  51. package/src/svgrid-wrapper.types.ts +439 -439
  52. package/src/svgrid.behavior.test.ts +706 -706
  53. package/src/svgrid.features.test.ts +157 -157
  54. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  55. package/src/svgrid.wrapper.test.ts +40 -40
  56. package/src/virtualization/column-virtualizer.test.ts +27 -27
  57. package/src/virtualization/column-virtualizer.ts +30 -30
  58. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  59. package/src/virtualization/types.ts +30 -30
  60. package/src/virtualization/virtualizer.test.ts +47 -47
  61. package/src/virtualization/virtualizer.ts +296 -296
@@ -1,2553 +1,2553 @@
1
- import {
2
- applyExcelFilter,
3
- normalizeForFilter,
4
- createColumnVirtualizer,
5
- createCoreRowModel,
6
- createExpandedRowModel,
7
- createFilteredRowModel,
8
- createGroupedRowModel,
9
- createSvelteVirtualizer,
10
- createSortedRowModel,
11
- createSvGrid,
12
- getGridCellDomId,
13
- sortFns,
14
- tableFeatures,
15
- rowSortingFeature,
16
- columnFilteringFeature,
17
- columnGroupingFeature,
18
- type CellEditorOption,
19
- type Column,
20
- type ColumnDef,
21
- type Row,
22
- type RowData,
23
- type TableFeatures,
24
- } from "./index";
25
- import {
26
- createRowScrollScaling,
27
- resolveMaxDomHeight,
28
- } from "./virtualization/scroll-scaling";
29
- import "./sv-grid-scrollbar";
30
- import {
31
- computeColumnStat,
32
- formatsNeedingStats,
33
- type ColumnStat,
34
- } from "./conditional-formatting";
35
- import SvGridDropdown from "./SvGridDropdown.svelte";
36
- import type {
37
- Props,
38
- SelectionRange,
39
- CellEditState,
40
- FilterOperator,
41
- MenuPosition,
42
- ContextMenuTarget,
43
- } from "./SvGrid.types";
44
- import {
45
- rawToNumber,
46
- } from "./SvGrid.helpers";
47
- import { createFeatures } from "./features";
48
- import {
49
- createScrollSync,
50
- } from "./scroll-sync";
51
- import {
52
- createKeyboard,
53
- } from "./keyboard-handlers";
54
- import {
55
- createSummaries,
56
- } from "./summaries";
57
- import {
58
- createMenus,
59
- } from "./menus";
60
- import {
61
- createCellRender,
62
- } from "./cell-render";
63
- import {
64
- createEditing,
65
- } from "./editing";
66
- import {
67
- createSelection,
68
- } from "./selection";
69
- import {
70
- createColumns,
71
- } from "./columns";
72
- import {
73
- createRowDrag,
74
- } from "./row-drag";
75
- import {
76
- createAlignedGrids,
77
- } from "./aligned-grids";
78
- import {
79
- resolveColumnTypes,
80
- } from "./column-types";
81
- import {
82
- computeColumnGroupMeta,
83
- hiddenLeavesForCollapse,
84
- } from "./column-groups";
85
- import {
86
- createGridApi,
87
- } from "./build-api";
88
- import {
89
- createClipboard,
90
- } from "./clipboard";
91
- import {
92
- filterOperatorOptions,
93
- fallbackOperatorOption,
94
- TEXT_OPERATORS,
95
- NUMBER_OPERATORS,
96
- DATE_OPERATORS,
97
- CHECKBOX_OPERATORS,
98
- operatorOption,
99
- operatorsForColumn,
100
- defaultOperatorFor,
101
- operatorLabelFor,
102
- } from "./filter-operators";
103
- import {
104
- type FacetBucket,
105
- isBucketableColumn,
106
- buildBuckets,
107
- isInBucket,
108
- } from "./facet-buckets";
109
- import {
110
- getColumnBaseValue,
111
- isGroupRow,
112
- toolPanelHeaderLabel,
113
- formatSummaryNumeric,
114
- getColumnAlign,
115
- getPinnedCellValue,
116
- getColumnAccessorValue,
117
- columnDefMatchesId,
118
- } from "./cell-values";
119
-
120
- /**
121
- * Conservative fallback for the browser's max element height, used during SSR
122
- * or if runtime detection fails. 8M is below every known engine cap (Firefox
123
- * ~17.9M, Chrome/Safari ~33.5M) so it is always safe, if coarser than needed.
124
- */
125
- const MAX_DOM_SCROLL_HEIGHT_FALLBACK = 8_000_000;
126
-
127
- /**
128
- * The browser's actual maximum *scrollable* element height in CSS px. Browsers
129
- * clamp how tall a single element may be, and the cap is lower on mobile /
130
- * high-DPR devices (the physical limit is in device px, so a 3x-DPR phone has
131
- * ~1/3 the CSS-px cap of a 1x desktop). Past that cap a scroll container
132
- * silently clamps its `scrollHeight` and the tail rows of a huge virtualized
133
- * grid become unreachable.
134
- *
135
- * We measure two signals from one offscreen probe and keep the smaller (see
136
- * `resolveMaxDomHeight`): the probe's clamped `offsetHeight`, AND the
137
- * `scrollHeight` a real `overflow:auto` container exposes for it. The second
138
- * matters because mobile WebKit/Blink can report a generous `offsetHeight` yet
139
- * expose a smaller scrollable range - trusting the layout height alone is what
140
- * stranded the last rows on phones. Using a real scroll container also folds in
141
- * DPR clamping for free. Cached for the page lifetime; constant per browser.
142
- */
143
- let detectedMaxDomHeight: number | null = null;
144
- /**
145
- * Seed the `hiddenColumns` map from any column def marked `visible: false`.
146
- * Walks groups so a hidden group hides all of its leaf columns. Keyed by the
147
- * same id `setColumnVisible` uses (`id ?? field`), so user toggles afterward
148
- * stay consistent. Run once at mount; prop changes don't re-apply it.
149
- */
150
- function initialHiddenColumns<
151
- TFeatures extends TableFeatures,
152
- TData extends RowData,
153
- >(
154
- defs: ReadonlyArray<ColumnDef<TFeatures, TData>>,
155
- ): Record<string, boolean> {
156
- const hidden: Record<string, boolean> = {};
157
- const walk = (
158
- cols: ReadonlyArray<ColumnDef<TFeatures, TData>>,
159
- inheritedHidden: boolean,
160
- ) => {
161
- for (const def of cols) {
162
- const hide = inheritedHidden || def.visible === false;
163
- if (def.columns?.length) {
164
- walk(def.columns, hide);
165
- } else if (hide) {
166
- const id = def.id ?? def.field;
167
- if (id) hidden[id] = true;
168
- }
169
- }
170
- };
171
- walk(defs, false);
172
- return hidden;
173
- }
174
-
175
- function getMaxDomScrollHeight(): number {
176
- // Escape hatch: a page may pin the cap via `window.__svgridMaxDomHeight`.
177
- // Checked before the cache so it always wins. Two uses: reproducing a
178
- // phone's lower element-height limit on desktop (and our e2e coverage of
179
- // the huge-list path), and overriding detection on a device where it reads
180
- // wrong. A non-positive / non-finite value is ignored.
181
- if (typeof window !== "undefined") {
182
- const forced = (window as unknown as { __svgridMaxDomHeight?: unknown })
183
- .__svgridMaxDomHeight;
184
- if (typeof forced === "number" && Number.isFinite(forced) && forced > 0) {
185
- return forced;
186
- }
187
- }
188
- if (detectedMaxDomHeight != null) return detectedMaxDomHeight;
189
- if (typeof document === "undefined" || !document.body) {
190
- return MAX_DOM_SCROLL_HEIGHT_FALLBACK;
191
- }
192
- try {
193
- // The wrapper is itself an `overflow:auto` scroll container (kept tiny and
194
- // offscreen so it never affects page layout or scroll), so we can read the
195
- // height it actually exposes as scrollable - not just the probe's layout
196
- // height. On high-DPR mobile the two diverge and the scrollable one is the
197
- // limit that matters.
198
- const wrap = document.createElement("div");
199
- wrap.style.cssText =
200
- "position:fixed;top:0;left:-9999px;width:1px;height:100px;overflow:auto;visibility:hidden;pointer-events:none;";
201
- const probe = document.createElement("div");
202
- probe.style.cssText = "width:1px;height:1000000000px;";
203
- wrap.appendChild(probe);
204
- document.body.appendChild(wrap);
205
- const layoutCap = probe.offsetHeight;
206
- const scrollCap = wrap.scrollHeight;
207
- document.body.removeChild(wrap);
208
- detectedMaxDomHeight = resolveMaxDomHeight(
209
- layoutCap,
210
- scrollCap,
211
- MAX_DOM_SCROLL_HEIGHT_FALLBACK,
212
- );
213
- } catch {
214
- detectedMaxDomHeight = MAX_DOM_SCROLL_HEIGHT_FALLBACK;
215
- }
216
- return detectedMaxDomHeight;
217
- }
218
-
219
- /**
220
- * Observe an element's size, but run the callback on the next animation frame
221
- * and coalesce bursts into a single call. This is what keeps the benign but
222
- * noisy "ResizeObserver loop completed with undelivered notifications" warning
223
- * out of the console: the browser emits it when an observer callback
224
- * synchronously mutates layout in a way that would require another notification
225
- * within the same delivery cycle - which our callbacks do (they bump reactive
226
- * versions / remeasure, driving a re-layout of the observed element). Deferring
227
- * the work to the next frame lets the current delivery finish cleanly, so the
228
- * loop never spans a single cycle. This is especially visible when swapping the
229
- * whole grid (e.g. switching demos), which remounts everything at once.
230
- * Returns a disconnect function suitable for an $effect cleanup.
231
- */
232
- function observeSizeRaf(el: Element, cb: () => void): () => void {
233
- let frame = 0;
234
- const observer = new ResizeObserver(() => {
235
- if (frame) return;
236
- frame = requestAnimationFrame(() => {
237
- frame = 0;
238
- cb();
239
- });
240
- });
241
- observer.observe(el);
242
- return () => {
243
- if (frame) cancelAnimationFrame(frame);
244
- observer.disconnect();
245
- };
246
- }
247
-
248
- /**
249
- * SvGrid controller. The component's entire reactive core - every $state,
250
- * $derived, $effect and handler - lives here so SvGrid.svelte can stay a thin
251
- * view. Instantiated once during the component's init (so $effect attaches to
252
- * the component lifecycle) and consumed through the returned getters.
253
- */
254
- export type SvGridController<
255
- TFeatures extends TableFeatures = TableFeatures,
256
- TData extends RowData = RowData,
257
- > = ReturnType<typeof createSvGridController<TFeatures, TData>>;
258
-
259
- export function createSvGridController<
260
- TFeatures extends TableFeatures = TableFeatures,
261
- TData extends RowData = RowData,
262
- >(props: Props<TFeatures, TData>) {
263
-
264
- // Resolved capability gates. Capabilities are OFF by default - a bare
265
- // grid is a plain read-only table, and each power feature is opted into
266
- // via its shortcut (`editable` / `pageable` / `groupable`) or the matching
267
- // fine-grained prop (`enableInlineEditing` / `showPagination` /
268
- // `showGroupingControls`). The shortcut wins when set; otherwise the
269
- // fine-grained prop wins; otherwise the capability is off. (Sorting and
270
- // filtering follow the same opt-in model already - they require their
271
- // feature, injected by `sortable` / `filterable`.)
272
- const editingEnabled = $derived(
273
- props.editable ?? props.enableInlineEditing ?? false,
274
- );
275
- const paginationEnabled = $derived(
276
- props.pageable ?? props.showPagination ?? false,
277
- );
278
- const groupingControlsEnabled = $derived(
279
- props.groupable ?? props.showGroupingControls ?? false,
280
- );
281
-
282
- let globalFilter = $state("");
283
- let scrollContainer: HTMLDivElement | null = $state(null);
284
- let gridRootEl: HTMLElement | null = $state(null);
285
- let filterRowValues = $state<Record<string, string>>({});
286
- let filterMenuValues = $state<
287
- Record<
288
- string,
289
- {
290
- operator: FilterOperator;
291
- value: string;
292
- valueTo?: string;
293
- // Optional second condition + join for multi-condition filtering
294
- // within a single column (AND / OR).
295
- operator2?: FilterOperator;
296
- value2?: string;
297
- valueTo2?: string;
298
- join?: "AND" | "OR";
299
- }
300
- >
301
- >({});
302
- let verticalScrollbarEl: HTMLElement | null = $state(null);
303
- let horizontalScrollbarEl: HTMLElement | null = $state(null);
304
- let scrollVersion = $state(0);
305
- /**
306
- * Separate state from `scrollVersion`: only bumped by the ResizeObserver
307
- * when the shell's CSS size changes. The virtualizer effects below depend
308
- * on this instead of `scrollVersion` so they DON'T re-run on every scroll
309
- * event - `scrollVersion` fires constantly during a drag.
310
- */
311
- let viewportVersion = $state(0);
312
- let lastResetSignature = "";
313
- let pendingScrollTop = 0;
314
- let pendingScrollLeft = 0;
315
- let scrollSyncRaf: number | null = null;
316
- let selectionRange = $state<SelectionRange>({ anchor: null, focus: null });
317
- // Extra committed ranges for multi-range (Ctrl+drag) selection. The
318
- // `selectionRange` above is always the ACTIVE range being manipulated; these
319
- // are the finished ones. Full selection = these + the active range.
320
- let selectionRanges = $state.raw<SelectionRange[]>([]);
321
- let isDraggingSelection = $state(false);
322
- /** Excel-style fill handle drag state. While non-null we paint a "fill
323
- * preview" overlay on cells between the source range and the pointer
324
- * cell; on pointerup we extrapolate the source pattern into them. */
325
- let fillDrag = $state<{
326
- sourceMinRow: number;
327
- sourceMaxRow: number;
328
- sourceMinCol: number;
329
- sourceMaxCol: number;
330
- targetRow: number;
331
- targetCol: number;
332
- } | null>(null);
333
- let activeAtPointerDown: { rowIndex: number; colIndex: number } | null = null;
334
- let editingCell = $state<CellEditState>(null);
335
- // Full-row editing: the row currently in whole-row edit + its per-column
336
- // draft (keyed by column id). Null when not in full-row mode.
337
- let fullRowEdit = $state<{ rowId: string; draft: Record<string, unknown> } | null>(null);
338
- let editedCellValues = $state<Record<string, unknown>>({});
339
-
340
- // ---- Undo / redo (history + pointer model) ---------------------------
341
- // VSCode-style: one ordered history array, plus a pointer to the index
342
- // of the NEXT undo step. Avoids the dual-stack edge cases where
343
- // multiple undo-redo cycles can lose entries.
344
- // exported for the editing slice (undo/redo)
345
- type HistoryStep = {
346
- rowId: string
347
- columnId: string
348
- field: string
349
- before: unknown
350
- after: unknown
351
- }
352
- const UNDO_LIMIT = 200
353
- let history = $state<HistoryStep[]>([])
354
- /** Index in `history` of the LAST applied step. -1 means "nothing applied".
355
- * undo() decrements; redo() increments. New edits truncate everything
356
- * past the pointer (the classic "you can't redo after editing" rule). */
357
- let historyPtr = $state(-1)
358
- /** Bumps on every undo / redo / record so $derived consumers can
359
- * observe via the api without subscribing to history directly. */
360
- let historyVersion = $state(0)
361
-
362
- // ---- Hover tooltip (custom popover, not native title=) ---------------
363
- // Triggered by per-column `tooltip` field OR per-cell `notes` prop.
364
- // Renders below / above the cell with smart edge clamping; opens on
365
- // pointerenter after a brief delay so it doesn't flash during scroll.
366
- type TooltipState = { text: string; x: number; y: number; below: boolean }
367
- let tooltip = $state<TooltipState | null>(null)
368
- let tooltipTimer: number | null = null
369
-
370
- // ---- Find-in-grid ----------------------------------------------------
371
- let findOpen = $state(false)
372
- let findQuery = $state('')
373
- let findHitIndex = $state(0)
374
- type FindHit = { rowIndex: number; colIndex: number; columnId: string }
375
- const findHits = $derived.by<FindHit[]>(() => {
376
- const q = findQuery.trim().toLowerCase()
377
- if (!q || !findOpen) return []
378
- const out: FindHit[] = []
379
- for (let r = 0; r < allRows.length; r += 1) {
380
- const row = allRows[r]
381
- if (!row) continue
382
- for (let c = 0; c < allColumns.length; c += 1) {
383
- const col = allColumns[c]
384
- if (!col) continue
385
- const v = row.getCellValueByColumnId(col.id)
386
- if (v == null) continue
387
- const s = String(v).toLowerCase()
388
- if (s.includes(q)) out.push({ rowIndex: r, colIndex: c, columnId: col.id })
389
- }
390
- }
391
- return out
392
- })
393
- let theadEl: HTMLElement | null = $state(null);
394
- let headerHeight = $state(0);
395
- /** When an edit starts: true selects all text, false places the caret at the end. */
396
- let editorSelectAll = true;
397
- /** Per-column width overrides set by the resize handles. */
398
- let columnWidths = $state<Record<string, number>>({});
399
- let resizingColumnId = $state<string | null>(null);
400
- let resizeStartX = 0;
401
- let resizeStartWidth = 0;
402
- const MIN_COLUMN_WIDTH = 40;
403
- /** Columns pinned to the left or right edge of the grid (sticky positioning).
404
- * Seeded from `props.initialColumnPinning` so demos / tests can show the
405
- * feature on first render without driving the column menu in JS. */
406
- let columnPinning = $state<{ left: Array<string>; right: Array<string> }>({
407
- left: [...(props.initialColumnPinning?.left ?? [])],
408
- right: [...(props.initialColumnPinning?.right ?? [])],
409
- });
410
- let columnVirtualizerVersion = $state(0);
411
- let gridStateVersion = $state(0);
412
- // Bumps only when a row-model-affecting slice changes (see the store
413
- // subscription below) - the row-model derivation depends on THIS, not the
414
- // catch-all gridStateVersion, so navigation doesn't rebuild 1M rows.
415
- let dataStateVersion = $state(0);
416
- const selectionColumnWidth = 44;
417
- const rowNumberColumnWidth = $derived(props.rowNumberWidth ?? 56);
418
- const showRowNumbersEffective = $derived(props.showRowNumbers ?? false);
419
- let columnMenuFor = $state<string | null>(null);
420
- let columnMenuTab = $state<"general" | "filter" | "columns">("general");
421
- let columnMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
422
- let columnMenuSearch = $state("");
423
- let filterMenuFor = $state<string | null>(null);
424
- let filterMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
425
- let operatorMenuFor = $state<string | null>(null);
426
- let operatorMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
427
- let chooseColumnsPos = $state<MenuPosition | null>(null);
428
- let contextMenuFor = $state<ContextMenuTarget<TData> | null>(null);
429
- let contextMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
430
- // Editable comments: internal overlay (rowId -> columnId -> note) merged on
431
- // top of props.notes for immediate feedback, plus the open-editor state.
432
- let noteOverrides = $state<Record<string, Record<string, string>>>({});
433
- let commentEditFor = $state<{ rowId: string; columnId: string; x: number; y: number } | null>(null);
434
- let commentDraft = $state("");
435
- let valueFilters = $state<Record<string, Set<string>>>({});
436
- const viewportWidth = $derived.by(() => {
437
- viewportVersion;
438
- return scrollContainer ? scrollContainer.clientWidth : 0;
439
- });
440
- const viewportHeight = $derived.by(() => {
441
- viewportVersion;
442
- return scrollContainer ? scrollContainer.clientHeight : 0;
443
- });
444
-
445
- // --- responsive (narrow-container) mode ---
446
- const responsiveBreakpoint = $derived(
447
- props.responsive && typeof props.responsive === "object" && props.responsive.breakpoint != null
448
- ? props.responsive.breakpoint
449
- : 640,
450
- );
451
- // Below the breakpoint: un-pin columns (pan the whole grid), suspend
452
- // fitColumns, and hide `hideBelow` columns. Guarded on width > 0 so it never
453
- // triggers before the grid has measured.
454
- const isNarrowResponsive = $derived(
455
- !!props.responsive && viewportWidth > 0 && viewportWidth < responsiveBreakpoint,
456
- );
457
- const EMPTY_PINNING = { left: [] as string[], right: [] as string[] };
458
- const effectivePinning = $derived(isNarrowResponsive ? EMPTY_PINNING : columnPinning);
459
- // A column with `hideBelow: N` is dropped while `responsive` is on and the
460
- // grid is narrower than N px (reads viewportWidth so it re-runs on resize).
461
- function isHiddenByResponsive(column: { columnDef?: { hideBelow?: number } }): boolean {
462
- if (!props.responsive) return false;
463
- const hb = column.columnDef?.hideBelow;
464
- return hb != null && viewportWidth > 0 && viewportWidth < hb;
465
- }
466
- const scrollMetrics = $derived.by(() => {
467
- scrollVersion;
468
- viewportVersion;
469
- // Track the virtualizers' versions too so when data loads or row /
470
- // column counts change, scrollMetrics re-reads the DOM's grown
471
- // scrollHeight / scrollWidth. Without these deps the scrollbar
472
- // receives a stale `content-size` ≈ 0, its hidden-check trips, it
473
- // sets `pointer-events: none`, and the user can't drag it. The
474
- // identifiers below are declared further down - derived callbacks
475
- // run lazily, so by the time this fires they're in scope.
476
- virtualizer.version;
477
- columnVirtualizerVersion;
478
- return {
479
- scrollTop: scrollContainer?.scrollTop ?? 0,
480
- scrollLeft: scrollContainer?.scrollLeft ?? 0,
481
- clientHeight: scrollContainer?.clientHeight ?? 0,
482
- clientWidth: scrollContainer?.clientWidth ?? 0,
483
- scrollHeight: scrollContainer?.scrollHeight ?? 0,
484
- scrollWidth: scrollContainer?.scrollWidth ?? 0,
485
- };
486
- });
487
- /** Vertical overflow from the virtualizer's authoritative total size,
488
- * NOT from `scrollMetrics.scrollHeight` alone. Reading DOM dimensions
489
- * during a Svelte derived runs BEFORE the browser paints - the table
490
- * hasn't laid out the new rows yet, so `scrollHeight` is briefly 0
491
- * even after data loads. That made the overflow flag return false,
492
- * hid the scrollbar, and broke dragging.
493
- *
494
- * However, virtualizer.getTotalSize() uses rowHeight * numRows which
495
- * underestimates when variable-height rows are present (e.g. master-detail
496
- * expanded rows). We therefore take the MAX of the two sources:
497
- * - virtualizer.getTotalSize(): correct at initial load (before first paint)
498
- * - scrollMetrics.scrollHeight: correct after detail rows expand (DOM is live,
499
- * ResizeObserver on gridRootEl already bumps scrollVersion at that point) */
500
- const hasVerticalOverflow = $derived.by(() => {
501
- virtualizer.version;
502
- const virtualizerSize = virtualizer.getTotalSize();
503
- // scrollMetrics.scrollHeight is 0 before initial paint; once the table
504
- // is in the DOM it reflects the true content height including expanded rows.
505
- const domSize = scrollMetrics.scrollHeight;
506
- return Math.max(virtualizerSize, domSize) > viewportHeight + 1;
507
- });
508
-
509
- // Effective filter-UI flags. Each show* prop wins when explicitly set;
510
- // otherwise the `filterMode` prop (default 'menu') picks exactly one surface.
511
- const showGlobalFilterEffective = $derived(
512
- props.showGlobalFilter ?? (props.filterMode ?? "menu") === "global",
513
- );
514
- const showFilterRowEffective = $derived(
515
- props.showFilterRow ?? (props.filterMode ?? "menu") === "row",
516
- );
517
- const showColumnFiltersEffective = $derived(
518
- props.showColumnFilters ?? (props.filterMode ?? "menu") === "menu",
519
- );
520
- // The inline "floating filter" input under each header duplicates the
521
- // column menu's funnel popover when both are active, so it requires an
522
- // explicit opt-in via the `showColumnFilters` prop.
523
- const showInlineColumnFilterEffective = $derived(
524
- props.showColumnFilters === true,
525
- );
526
-
527
- // Effective selection-surface flags. `selectionMode` defaults to 'both' so
528
- // existing consumers keep their current behaviour.
529
- const showRowSelectionEffective = $derived(
530
- props.showRowSelection ??
531
- ((props.selectionMode ?? "both") === "row" ||
532
- (props.selectionMode ?? "both") === "both"),
533
- );
534
- const enableCellSelectionEffective = $derived(
535
- props.enableCellSelection ??
536
- ((props.selectionMode ?? "both") === "cell" ||
537
- (props.selectionMode ?? "both") === "both"),
538
- );
539
-
540
-
541
-
542
- // Internal source-of-truth for data and column defs. Seeded from props and
543
- // re-synced whenever the parent passes a new array; the imperative API
544
- // mutates these so add/remove operations don't need a callback round-trip.
545
- // svelte-ignore state_referenced_locally
546
- let internalData = $state.raw<ReadonlyArray<TData>>(props.data);
547
- // Resolve `cellDataType` / `inferColumnTypes` into concrete editorType +
548
- // format defaults once, up front, so every downstream reader sees a normal
549
- // column. Explicit fields on the ColumnDef always win.
550
- // svelte-ignore state_referenced_locally
551
- const resolveCols = (cols: Array<ColumnDef<TFeatures, TData>>) =>
552
- resolveColumnTypes(cols, props.data?.[0], props.inferColumnTypes === true);
553
- // svelte-ignore state_referenced_locally
554
- let internalColumns = $state.raw<Array<ColumnDef<TFeatures, TData>>>(
555
- resolveCols(props.columns),
556
- );
557
- // svelte-ignore state_referenced_locally
558
- let hiddenColumns = $state<Record<string, boolean>>(
559
- initialHiddenColumns(props.columns),
560
- );
561
-
562
- // Collapsible column groups (columnGroupShow). Meta is derived from the tree;
563
- // `collapsedColumnGroups` is the live set of collapsed group ids, seeded once
564
- // from each collapsible group's `openByDefault` (default: collapsed).
565
- const columnGroupMeta = $derived(computeColumnGroupMeta(props.columns as unknown as Array<any>));
566
- // svelte-ignore state_referenced_locally
567
- let collapsedColumnGroups = $state<Set<string>>(
568
- (() => {
569
- const meta = computeColumnGroupMeta(props.columns as unknown as Array<any>);
570
- const s = new Set<string>();
571
- for (const id of meta.collapsibleGroupIds) if (!meta.defaultOpen.get(id)) s.add(id);
572
- return s;
573
- })(),
574
- );
575
- // Leaf ids hidden right now because their group is collapsed/expanded.
576
- const hiddenByGroupCollapse = $derived(
577
- hiddenLeavesForCollapse(columnGroupMeta, collapsedColumnGroups),
578
- );
579
- function toggleColumnGroup(groupId: string) {
580
- const next = new Set(collapsedColumnGroups);
581
- if (next.has(groupId)) next.delete(groupId);
582
- else next.add(groupId);
583
- collapsedColumnGroups = next;
584
- }
585
- function isColumnGroupCollapsed(groupId: string) {
586
- return collapsedColumnGroups.has(groupId);
587
- }
588
-
589
- $effect(() => {
590
- // When the consumer replaces `data` (e.g. a "Reset" button), drop any
591
- // accumulated cell-edit overrides - otherwise `getCellDisplayValue`
592
- // would keep returning the old edited values from `editedCellValues`
593
- // even though the underlying data has been replaced.
594
- internalData = props.data;
595
- editedCellValues = {};
596
- });
597
- $effect(() => {
598
- internalColumns = resolveCols(props.columns);
599
- });
600
-
601
- // Captured ONCE at mount: `externalSort` is a structural choice (tree vs
602
- // flat data) so toggling it after mount is not supported. Reading it here
603
- // - outside the getter below - guarantees the pass-through sort is wired
604
- // in before `createSvGrid` first reads `_rowModels`.
605
- // svelte-ignore state_referenced_locally
606
- const externalSortEnabled = props.externalSort === true;
607
- // Same one-shot capture for external filtering. Server-side mode means
608
- // the wrapper records filter state but does not actually filter rows.
609
- // svelte-ignore state_referenced_locally
610
- const externalFilterEnabled = props.externalFilter === true;
611
- // Server-side pagination: the footer reads rowCount/pageIndex from props and
612
- // emits onPaginationChange instead of slicing locally. Controlled by the
613
- // consumer. Reactive (unlike sort/filter) so pageIndex/rowCount can change.
614
- const externalPaginationEnabled = $derived(props.externalPagination === true);
615
- const passthroughSortedRowModel = ({ rows }: { rows: Array<Row<TData>> }) =>
616
- rows;
617
-
618
-
619
- const grid = createSvGrid({
620
- get _features() {
621
- return resolveEffectiveFeatures();
622
- },
623
- get _rowModels() {
624
- // Pagination is intentionally NOT in the grid's row-model pipeline.
625
- // The wrapper applies its own filters (filterMenuValues, globalFilter,
626
- // valueFilters) on top of `grid.getRowModel().rows`. If pagination
627
- // ran first, those filters would only see the visible page. Instead
628
- // the wrapper paginates last - see `allRows` below.
629
- return {
630
- coreRowModel: createCoreRowModel<TData>(),
631
- filteredRowModel: createFilteredRowModel<TData>(),
632
- // External-sort mode: pass the rows through untouched so the consumer
633
- // controls ordering (e.g. tree data that must preserve hierarchy).
634
- sortedRowModel: externalSortEnabled
635
- ? passthroughSortedRowModel
636
- : createSortedRowModel<TData>(sortFns),
637
- groupedRowModel: createGroupedRowModel<TData>(),
638
- expandedRowModel: createExpandedRowModel<TData>(),
639
- };
640
- },
641
- get columns() {
642
- return internalColumns;
643
- },
644
- get data() {
645
- return internalData;
646
- },
647
- get getRowId() {
648
- return props.getRowId;
649
- },
650
- state: {
651
- columnFilters: [],
652
- grouping: [],
653
- sorting: [],
654
- // svelte-ignore state_referenced_locally
655
- pagination: { pageIndex: 0, pageSize: props.pageSize ?? 10 },
656
- rowSelection: {},
657
- expanded: {},
658
- activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
659
- },
660
- });
661
-
662
- // `gridStateVersion` bumps on EVERY store change (incl. moving the active
663
- // cell). `dataStateVersion` bumps ONLY when a slice that actually changes the
664
- // row model changes (sort / filter / pagination / grouping / expansion /
665
- // selection) - so the O(rows) row-model derivation does NOT re-run on plain
666
- // keyboard navigation. Without this, arrow-keying a 1,000,000-row grid re-ran
667
- // the entire core->filter->sort->group pipeline on every keystroke.
668
- let prevDataSlices:
669
- | { sorting: unknown; columnFilters: unknown; pagination: unknown; grouping: unknown; expanded: unknown; rowSelection: unknown }
670
- | null = null;
671
- $effect(() => {
672
- const unsubscribe = grid.store.subscribe(() => {
673
- gridStateVersion += 1;
674
- const s = grid.getState();
675
- if (
676
- !prevDataSlices ||
677
- prevDataSlices.sorting !== s.sorting ||
678
- prevDataSlices.columnFilters !== s.columnFilters ||
679
- prevDataSlices.pagination !== s.pagination ||
680
- prevDataSlices.grouping !== s.grouping ||
681
- prevDataSlices.expanded !== s.expanded ||
682
- prevDataSlices.rowSelection !== s.rowSelection
683
- ) {
684
- dataStateVersion += 1;
685
- prevDataSlices = {
686
- sorting: s.sorting,
687
- columnFilters: s.columnFilters,
688
- pagination: s.pagination,
689
- grouping: s.grouping,
690
- expanded: s.expanded,
691
- rowSelection: s.rowSelection,
692
- };
693
- }
694
- });
695
- return unsubscribe;
696
- });
697
-
698
- /**
699
- * The grid's columns reordered so left-pinned columns come first and
700
- * right-pinned columns come last. All other code (rendering, keyboard nav,
701
- * active cell) operates on this ordered view.
702
- */
703
- /**
704
- * User-driven column order (drag-to-reorder OR `api.setColumnOrder`).
705
- * Stored as a flat list of column ids. Empty = use the natural order
706
- * from the columns prop. The pin grouping is applied on top of this.
707
- */
708
- let userColumnOrder = $state<string[]>([...(props.columnOrder ?? [])]);
709
- // Re-seed on prop change so consumers can drive order from outside.
710
- let lastSeededOrder = "";
711
- $effect(() => {
712
- const incoming = props.columnOrder
713
- ? [...props.columnOrder].join("|")
714
- : "";
715
- if (incoming === lastSeededOrder) return;
716
- lastSeededOrder = incoming;
717
- userColumnOrder = props.columnOrder ? [...props.columnOrder] : [];
718
- });
719
-
720
- const allColumns = $derived.by(() => {
721
- let raw = grid
722
- .getAllColumns()
723
- .filter(
724
- (column) =>
725
- !hiddenColumns[column.id] &&
726
- !hiddenByGroupCollapse[column.id] &&
727
- !isHiddenByResponsive(column),
728
- );
729
- // Apply user reorder (if any). Unknown ids in userColumnOrder are
730
- // skipped; columns not in userColumnOrder keep their original
731
- // relative order after the user-ordered ones.
732
- if (userColumnOrder.length > 0) {
733
- const byId = new Map(raw.map((c) => [c.id, c]));
734
- const seen = new Set<string>();
735
- const ordered: Column<TData>[] = [];
736
- for (const id of userColumnOrder) {
737
- const c = byId.get(id);
738
- if (c && !seen.has(id)) { ordered.push(c); seen.add(id); }
739
- }
740
- for (const c of raw) {
741
- if (!seen.has(c.id)) ordered.push(c);
742
- }
743
- raw = ordered;
744
- }
745
- const leftIds = effectivePinning.left;
746
- const rightIds = effectivePinning.right;
747
- if (!leftIds.length && !rightIds.length) return raw;
748
- const pinned = new Set([...leftIds, ...rightIds]);
749
- const findById = (id: string) => raw.find((column) => column.id === id);
750
- const left = leftIds
751
- .map(findById)
752
- .filter((c): c is Column<TData> => Boolean(c));
753
- const right = rightIds
754
- .map(findById)
755
- .filter((c): c is Column<TData> => Boolean(c));
756
- const unpinned = raw.filter((column) => !pinned.has(column.id));
757
- return [...left, ...unpinned, ...right];
758
- });
759
-
760
- /** Header groups reordered to match {@link allColumns}. */
761
- const headerGroups = $derived.by(() => {
762
- const base = grid.getHeaderGroups();
763
- if (!base.length) return base;
764
- const byId = new Map(
765
- base[0]!.headers.map((header) => [header.column.id, header]),
766
- );
767
- const headers: (typeof base)[number]["headers"] = [];
768
- for (const column of allColumns) {
769
- const header = byId.get(column.id);
770
- if (header) headers.push(header);
771
- }
772
- return [{ id: base[0]!.id, headers }];
773
- });
774
-
775
- /**
776
- * Group-header rows (PIVOT-style multi-level headers). When the
777
- * consumer's column tree has `columns: [...]` nesting, we render extra
778
- * header rows ABOVE the standard leaf-header row, each row showing one
779
- * level of group labels with a colSpan covering the leaves underneath.
780
- *
781
- * For flat column lists this returns [] and no extra rows render -
782
- * existing demos are unaffected.
783
- *
784
- * Each entry's `widthPx` precomputes the cell's pixel width as the sum
785
- * of its leaf widths so the cells line up exactly with the columns
786
- * below, even when the consumer mixes columns of different widths.
787
- */
788
- type GroupHeaderCell = {
789
- key: string;
790
- label: string;
791
- colSpan: number;
792
- widthPx: number;
793
- /** First leaf-column index this cell spans. */
794
- firstLeafIndex: number;
795
- /** True for the placeholder cells that fill the column above an
796
- * early-bottoming leaf (e.g. the row-label column to the left of a
797
- * multi-level value tree). They render as empty cells so the
798
- * layout stays aligned without showing duplicate labels. */
799
- isPlaceholder: boolean;
800
- /** Set when this group cell has a collapse toggle. */
801
- groupId?: string;
802
- collapsible: boolean;
803
- collapsed: boolean;
804
- };
805
- const groupHeaderRows = $derived.by(() => {
806
- const userCols: Array<ColumnDef<any, TData>> =
807
- (props.columns as unknown as Array<ColumnDef<any, TData>>) ?? [];
808
-
809
- // 1. Find max depth in the user-provided column tree.
810
- function maxDepth(defs: Array<ColumnDef<any, TData>>): number {
811
- let m = 0;
812
- for (const d of defs) {
813
- if (d.columns?.length) {
814
- m = Math.max(m, 1 + maxDepth(d.columns));
815
- }
816
- }
817
- return m;
818
- }
819
- const depth = maxDepth(userCols);
820
- if (depth === 0) return [] as Array<{ id: string; cells: GroupHeaderCell[] }>;
821
-
822
- // 2. Resolve each LEAF column def -> its id + leaf-column index in
823
- // `allColumns`. Walks the same tree the engine walked. Used to
824
- // compute pixel widths for group cells.
825
- type LeafEntry = { id: string; widthPx: number };
826
- const leafEntries: LeafEntry[] = [];
827
- function buildId(def: ColumnDef<any, TData>, parentId: string | undefined, fallbackIx: number): string {
828
- return def.id ?? def.field ?? `${parentId ?? 'col'}_d_${fallbackIx}`;
829
- }
830
- // Leaves hidden by a collapsed/expanded group are skipped everywhere here,
831
- // so group colSpan + widthPx exclude them and stay aligned with the leaves
832
- // the body actually renders.
833
- const hiddenLeaf = hiddenByGroupCollapse;
834
- function collectLeaves(
835
- defs: Array<ColumnDef<any, TData>>,
836
- parentId: string | undefined,
837
- depthHere: number,
838
- ): void {
839
- defs.forEach((def, ix) => {
840
- const id = buildId(def, parentId, ix);
841
- if (def.columns?.length) {
842
- collectLeaves(def.columns, id, depthHere + 1);
843
- } else if (!hiddenLeaf[id]) {
844
- leafEntries.push({ id, widthPx: getColumnWidth(id) });
845
- }
846
- });
847
- }
848
- collectLeaves(userCols, undefined, 0);
849
-
850
- // 3. Emit per-depth group cells. We walk the tree per row, summing
851
- // leaf widths under each node for colSpan + widthPx.
852
- type NodeAt = { def: ColumnDef<any, TData>; id: string; leafStart: number; leafEnd: number };
853
- function indexTree(
854
- defs: Array<ColumnDef<any, TData>>,
855
- parentId: string | undefined,
856
- cursor: { leaf: number },
857
- ): NodeAt[] {
858
- const nodes: NodeAt[] = [];
859
- for (const def of defs) {
860
- const id = buildId(def, parentId, nodes.length);
861
- // Skip leaves the collapse state hides, so leaf indices/colSpans match
862
- // `leafEntries` (and the body's rendered columns) exactly.
863
- if (!def.columns?.length && hiddenLeaf[id]) continue;
864
- const leafStart = cursor.leaf;
865
- if (def.columns?.length) {
866
- indexTree(def.columns, id, cursor);
867
- } else {
868
- cursor.leaf += 1;
869
- }
870
- const leafEnd = cursor.leaf;
871
- nodes.push({ def, id, leafStart, leafEnd });
872
- }
873
- return nodes;
874
- }
875
- const cursor = { leaf: 0 };
876
- const topNodes = indexTree(userCols, undefined, cursor);
877
-
878
- function nodesAtDepth(
879
- nodes: NodeAt[],
880
- currentDepth: number,
881
- targetDepth: number,
882
- ): NodeAt[] {
883
- if (currentDepth === targetDepth) return nodes;
884
- const out: NodeAt[] = [];
885
- for (const n of nodes) {
886
- if (n.def.columns?.length) {
887
- const childCursor = { leaf: n.leafStart };
888
- const children = indexTree(n.def.columns, n.id, childCursor);
889
- out.push(...nodesAtDepth(children, currentDepth + 1, targetDepth));
890
- } else {
891
- // Leaf reached early - emit a placeholder at this row so the
892
- // column above it stays empty (the leaf itself renders in the
893
- // bottom leaf-header row, not here).
894
- out.push(n);
895
- }
896
- }
897
- return out;
898
- }
899
-
900
- function sumLeafWidths(from: number, to: number): number {
901
- let sum = 0;
902
- for (let i = from; i < to; i += 1) sum += leafEntries[i]?.widthPx ?? 0;
903
- return sum;
904
- }
905
-
906
- const rows: Array<{ id: string; cells: GroupHeaderCell[] }> = [];
907
- for (let d = 0; d < depth; d += 1) {
908
- const at = nodesAtDepth(topNodes, 0, d);
909
- const cells: GroupHeaderCell[] = at.map((n) => {
910
- const isLeafEarly = !n.def.columns?.length;
911
- const headerText =
912
- typeof n.def.header === 'string' ? n.def.header : '';
913
- const collapsible = columnGroupMeta.collapsibleGroupIds.has(n.id);
914
- return {
915
- key: `${n.id}_d${d}`,
916
- label: isLeafEarly ? '' : headerText,
917
- colSpan: Math.max(1, n.leafEnd - n.leafStart),
918
- widthPx: sumLeafWidths(n.leafStart, n.leafEnd),
919
- firstLeafIndex: n.leafStart,
920
- isPlaceholder: isLeafEarly,
921
- groupId: collapsible ? n.id : undefined,
922
- collapsible,
923
- collapsed: collapsible && collapsedColumnGroups.has(n.id),
924
- };
925
- });
926
- rows.push({ id: `gh_${d}`, cells });
927
- }
928
- return rows;
929
- });
930
-
931
- /** Cumulative pixel offsets for left- and right-pinned columns. */
932
- const pinnedOffsets = $derived.by(() => {
933
- const rowNumberWidth = showRowNumbersEffective ? rowNumberColumnWidth : 0;
934
- const selectionWidth = showRowSelectionEffective ? selectionColumnWidth : 0;
935
- const left: Record<string, number> = {};
936
- let leftAcc = rowNumberWidth + selectionWidth;
937
- for (const id of effectivePinning.left) {
938
- left[id] = leftAcc;
939
- leftAcc += getColumnWidth(id);
940
- }
941
- const right: Record<string, number> = {};
942
- let rightAcc = 0;
943
- for (let i = effectivePinning.right.length - 1; i >= 0; i -= 1) {
944
- const id = effectivePinning.right[i];
945
- if (!id) continue;
946
- right[id] = rightAcc;
947
- rightAcc += getColumnWidth(id);
948
- }
949
- return { left, right };
950
- });
951
-
952
-
953
-
954
- // ---- Column reorder (drag headers) ----------------------------------
955
- // Live drag state for the built-in header drag-to-reorder. Only set
956
- // when `props.enableColumnReorder` is true.
957
- let colDragId = $state<string | null>(null);
958
- let colDropOnId = $state<string | null>(null);
959
- let colDropSide = $state<"before" | "after" | null>(null);
960
-
961
- // Live drag state for managed row dragging. Only meaningful while a row is
962
- // being dragged (`props.rowDragManaged`). `rowDropIndex` is the visible row
963
- // index currently hovered; `rowDropSide` says which edge the drop line paints.
964
- let rowDragActive = $state<boolean>(false);
965
- let rowDropIndex = $state<number | null>(null);
966
- let rowDropSide = $state<"before" | "after" | null>(null);
967
-
968
-
969
-
970
-
971
-
972
-
973
-
974
-
975
-
976
- // ---- Conditional formatting --------------------------------------------
977
- // True when the feature is in use; gates the per-cell positioning context
978
- // (cells are otherwise non-relative for scroll performance).
979
- const hasConditionalFormats = $derived(
980
- (props.conditionalFormats?.length ?? 0) > 0,
981
- );
982
- // Per-column numeric min/max, needed only by colorScale / dataBar formats.
983
- // Lazy: this derived never runs unless `conditionalFormats` is set.
984
- const conditionalColumnStats = $derived.by(() => {
985
- const map = new Map<string, ColumnStat>();
986
- const formats = props.conditionalFormats;
987
- if (!formats?.length || !formatsNeedingStats(formats)) return map;
988
- for (const column of allColumns) {
989
- const needs = formats.some(
990
- (f) =>
991
- (f.type === "colorScale" || f.type === "dataBar") &&
992
- (!f.columns || f.columns.includes(column.id)),
993
- );
994
- if (!needs) continue;
995
- const def = column.columnDef;
996
- const fieldFn = def.fieldFn;
997
- const field = def.field;
998
- const stat = computeColumnStat(
999
- (function* () {
1000
- for (const row of allRows) {
1001
- yield fieldFn
1002
- ? fieldFn(row.original)
1003
- : field
1004
- ? (row.original as Record<string, unknown>)[field]
1005
- : row.getCellValueByColumnId(column.id);
1006
- }
1007
- })(),
1008
- );
1009
- if (stat) map.set(column.id, stat);
1010
- }
1011
- return map;
1012
- });
1013
-
1014
-
1015
-
1016
-
1017
-
1018
-
1019
- const sortDirectionByColumn = $derived.by(() => {
1020
- gridStateVersion;
1021
- const directions: Record<string, false | "asc" | "desc"> = {};
1022
- for (const column of allColumns)
1023
- directions[column.id] = column.getIsSorted();
1024
- return directions;
1025
- });
1026
-
1027
- const groupingColumns = $derived.by(() => {
1028
- gridStateVersion;
1029
- return grid.getState().grouping ?? [];
1030
- });
1031
-
1032
- const paginationState = $derived.by(() => {
1033
- gridStateVersion;
1034
- return grid.getState().pagination ?? { pageIndex: 0, pageSize: 10 };
1035
- });
1036
-
1037
-
1038
- /**
1039
- * Rows AFTER all filtering but BEFORE pagination. Used by the pager to
1040
- * compute the correct "X to Y of Z" range and total page count when
1041
- * filters reduce the dataset.
1042
- */
1043
- const allRowsBeforePagination = $derived.by(() => {
1044
- // Depend on dataStateVersion (row-model-affecting store changes) NOT
1045
- // gridStateVersion - so moving the active cell / selection does not force
1046
- // this O(rows) pipeline to re-run. Filter-input state (globalFilter etc.)
1047
- // and internalData are read below and tracked as their own dependencies.
1048
- dataStateVersion;
1049
- // Touch internalData + internalColumns so the row model re-derives when
1050
- // the consumer replaces the data array (e.g. via a "Reset" button).
1051
- void internalData;
1052
- void internalColumns;
1053
- const rawRows = grid.getRowModel().rows;
1054
- // External-filter mode: the consumer fetched / pre-filtered the rows
1055
- // themselves (server-side data sources). Skip every local filter pass
1056
- // so the data isn't double-filtered against the visible page.
1057
- if (externalFilterEnabled) return rawRows;
1058
-
1059
- let rows = rawRows;
1060
- if (globalFilter.trim()) {
1061
- const needle = normalizeForFilter(globalFilter, props.filterLocale);
1062
- rows = rows.filter((row) =>
1063
- row
1064
- .getAllCells()
1065
- .some((cell) =>
1066
- normalizeForFilter(String(cell.getValue() ?? ""), props.filterLocale)
1067
- .includes(needle),
1068
- ),
1069
- );
1070
- }
1071
-
1072
- // A single condition is "active" if it has the value(s) it needs.
1073
- const condActive = (op: FilterOperator, value: string, valueTo?: string): boolean => {
1074
- if (op === "isBlank") return true;
1075
- if (op === "between") return value.trim().length > 0 && (valueTo ?? "").trim().length > 0;
1076
- return value.trim().length > 0;
1077
- };
1078
- const evalCond = (
1079
- cellValue: unknown,
1080
- columnId: string,
1081
- op: FilterOperator,
1082
- value: string,
1083
- valueTo?: string,
1084
- ): boolean =>
1085
- applyExcelFilter(
1086
- cellValue,
1087
- { id: columnId, operator: op, value, valueTo: op === "between" ? valueTo : undefined },
1088
- { locale: props.filterLocale },
1089
- );
1090
- // A column filter is active if either of its (up to two) conditions is.
1091
- const menuFilters = Object.entries(filterMenuValues).filter(([_, f]) => {
1092
- const a = condActive(f.operator, f.value, f.valueTo);
1093
- const b = !!f.operator2 && condActive(f.operator2, f.value2 ?? "", f.valueTo2);
1094
- return a || b;
1095
- });
1096
- if (menuFilters.length) {
1097
- rows = rows.filter((row) =>
1098
- menuFilters.every(([columnId, f]) => {
1099
- const cellValue = getRowColumnValue(row, columnId);
1100
- const aActive = condActive(f.operator, f.value, f.valueTo);
1101
- const bActive = !!f.operator2 && condActive(f.operator2, f.value2 ?? "", f.valueTo2);
1102
- const ra = aActive ? evalCond(cellValue, columnId, f.operator, f.value, f.valueTo) : null;
1103
- const rb = bActive
1104
- ? evalCond(cellValue, columnId, f.operator2 as FilterOperator, f.value2 ?? "", f.valueTo2)
1105
- : null;
1106
- if (ra === null) return rb ?? true;
1107
- if (rb === null) return ra;
1108
- return f.join === "OR" ? ra || rb : ra && rb;
1109
- }),
1110
- );
1111
- }
1112
-
1113
- const valueFilterEntries = Object.entries(valueFilters);
1114
- if (valueFilterEntries.length) {
1115
- // Resolve bucket defs up front so we don't re-hit the derived map
1116
- // for every row × column combination. Columns without bucketing
1117
- // map to `null` here and fall through to exact-value matching.
1118
- const bucketEntries = valueFilterEntries.map(([columnId, allowed]) => ({
1119
- columnId,
1120
- allowed,
1121
- buckets: facetBucketsByColumn.get(columnId) ?? null,
1122
- }));
1123
- rows = rows.filter((row) =>
1124
- bucketEntries.every(({ columnId, allowed, buckets }) => {
1125
- const raw = getRowColumnValue(row, columnId);
1126
- if (buckets) {
1127
- // Range-bucketed filter: find which bucket this row's value
1128
- // falls into and check whether that bucket's label is allowed.
1129
- const isDate = buckets[0]!.isDate;
1130
- const num = rawToNumber(raw, isDate);
1131
- if (!Number.isFinite(num)) return false;
1132
- for (const bucket of buckets) {
1133
- if (isInBucket(num, bucket)) return allowed.has(bucket.label);
1134
- }
1135
- return false;
1136
- }
1137
- return allowed.has(String(raw ?? ""));
1138
- }),
1139
- );
1140
- }
1141
-
1142
- return rows;
1143
- });
1144
-
1145
- /**
1146
- * Visible rows for the current page. Applied last so filters operate on
1147
- * the full dataset rather than the current page (see the comment above
1148
- * `_rowModels`).
1149
- */
1150
- const allRows = $derived.by(() => {
1151
- const rows = allRowsBeforePagination;
1152
- // External pagination: `data` already IS the current page - never slice.
1153
- if (!paginationEnabled || externalPaginationEnabled) return rows;
1154
- const { pageIndex, pageSize } = paginationState;
1155
- const start = pageIndex * pageSize;
1156
- return rows.slice(start, start + pageSize);
1157
- });
1158
-
1159
- // When a filter reduces the dataset, the stored pageIndex can point beyond
1160
- // the last valid page. Reset to page 0 so the grid never shows a blank body.
1161
- // Skipped for external pagination, where the consumer owns pageIndex.
1162
- $effect(() => {
1163
- if (!paginationEnabled || externalPaginationEnabled) return;
1164
- const { pageIndex, pageSize } = paginationState;
1165
- const pageCount = Math.ceil(allRowsBeforePagination.length / pageSize);
1166
- if (pageCount > 0 && pageIndex >= pageCount) {
1167
- grid.setPagination({ pageIndex: 0, pageSize });
1168
- }
1169
- });
1170
-
1171
- // Footer-facing pagination values. In external mode they come from the
1172
- // consumer-controlled props; otherwise from the local row model + state.
1173
- const paginationTotalRows = $derived(
1174
- externalPaginationEnabled ? (props.rowCount ?? 0) : allRowsBeforePagination.length,
1175
- );
1176
- const paginationPageIndex = $derived(
1177
- externalPaginationEnabled ? (props.pageIndex ?? 0) : paginationState.pageIndex,
1178
- );
1179
- const paginationPageSize = $derived(
1180
- externalPaginationEnabled ? (props.pageSize ?? 10) : paginationState.pageSize,
1181
- );
1182
- const rowSelectionState = $derived.by(() => {
1183
- gridStateVersion;
1184
- return grid.getState().rowSelection ?? {};
1185
- });
1186
-
1187
- // Forward selection changes to the consumer. Skips the very first invocation
1188
- // (the initial empty state) so consumers don't get a spurious callback on mount.
1189
- let lastSelectionSerialized = "";
1190
- $effect(() => {
1191
- const serialized = JSON.stringify(rowSelectionState);
1192
- if (serialized === lastSelectionSerialized) return;
1193
- lastSelectionSerialized = serialized;
1194
- const callback = props.onRowSelectionChange;
1195
- if (!callback) return;
1196
- const data = internalData;
1197
- const selectedRows: TData[] = [];
1198
- for (let i = 0; i < data.length; i++) {
1199
- if (rowSelectionState[String(i)]) selectedRows.push(data[i] as TData);
1200
- }
1201
- callback(rowSelectionState, selectedRows);
1202
- });
1203
-
1204
- // Forward cell-selection rectangle changes to the consumer. Same
1205
- // dedupe pattern - fires only when the serialized rectangle changes
1206
- // so consumers don't see spurious callbacks during re-renders.
1207
- let lastCellRangeSerialized = "";
1208
- $effect(() => {
1209
- const a = selectionRange.anchor;
1210
- const f = selectionRange.focus;
1211
- const ranges: Array<[number, number, number, number]> =
1212
- a && f
1213
- ? [[
1214
- Math.min(a.rowIndex, f.rowIndex),
1215
- Math.min(a.colIndex, f.colIndex),
1216
- Math.max(a.rowIndex, f.rowIndex),
1217
- Math.max(a.colIndex, f.colIndex),
1218
- ]]
1219
- : [];
1220
- const serialized = JSON.stringify(ranges);
1221
- if (serialized === lastCellRangeSerialized) return;
1222
- lastCellRangeSerialized = serialized;
1223
- props.onCellSelectionChange?.(ranges);
1224
- });
1225
-
1226
- // ---- Status bar: live aggregates of the selected cell range -----------
1227
- const statusBarEnabled = $derived(
1228
- props.statusBar != null && props.statusBar !== false,
1229
- );
1230
- const statusBarAggregates = $derived(
1231
- typeof props.statusBar === "object" && props.statusBar.aggregates
1232
- ? props.statusBar.aggregates
1233
- : (["count", "sum", "avg", "min", "max"] as const),
1234
- );
1235
- const statusBarStats = $derived.by(() => {
1236
- if (!statusBarEnabled) return null;
1237
- const a = selectionRange.anchor;
1238
- const f = selectionRange.focus;
1239
- if (!a || !f) return null;
1240
- const minR = Math.min(a.rowIndex, f.rowIndex);
1241
- const maxR = Math.max(a.rowIndex, f.rowIndex);
1242
- const minC = Math.min(a.colIndex, f.colIndex);
1243
- const maxC = Math.max(a.colIndex, f.colIndex);
1244
- let count = 0;
1245
- let numericCount = 0;
1246
- let sum = 0;
1247
- let min = Number.POSITIVE_INFINITY;
1248
- let max = Number.NEGATIVE_INFINITY;
1249
- for (let r = minR; r <= maxR; r += 1) {
1250
- const row = allRows[r];
1251
- if (!row || isGroupRow(row)) continue;
1252
- for (let c = minC; c <= maxC; c += 1) {
1253
- const col = allColumns[c];
1254
- if (!col) continue;
1255
- count += 1;
1256
- const base = getColumnBaseValue(row, col);
1257
- const v = getCellDisplayValue(row.id, col.id, base);
1258
- if (v == null || v === "") continue;
1259
- const n = Number(v);
1260
- if (!Number.isFinite(n)) continue;
1261
- numericCount += 1;
1262
- sum += n;
1263
- if (n < min) min = n;
1264
- if (n > max) max = n;
1265
- }
1266
- }
1267
- if (count <= 1) return null;
1268
- return {
1269
- count,
1270
- numericCount,
1271
- sum,
1272
- avg: numericCount ? sum / numericCount : 0,
1273
- min: numericCount ? min : 0,
1274
- max: numericCount ? max : 0,
1275
- };
1276
- });
1277
-
1278
-
1279
- // ---- Tool panel (docked columns sidebar) -------------------------------
1280
- // svelte-ignore state_referenced_locally
1281
- let toolPanelOpen = $state(props.toolPanelDefaultOpen === true);
1282
- // svelte-ignore state_referenced_locally
1283
- let toolPanelTab = $state<"columns" | "filters">(props.toolPanelDefaultTab ?? "columns");
1284
- const toolPanelEnabled = $derived(props.toolPanel === true);
1285
- // Every column (including hidden ones) in the user's current order, so the
1286
- // panel can toggle/reorder anything. Group columns are flagged live.
1287
- const toolPanelColumns = $derived.by(() => {
1288
- gridStateVersion;
1289
- const all = grid.getAllColumns();
1290
- if (!userColumnOrder.length) return all;
1291
- const byId = new Map(all.map((c) => [c.id, c]));
1292
- const ordered: Column<TData>[] = [];
1293
- const seen = new Set<string>();
1294
- for (const id of userColumnOrder) {
1295
- const c = byId.get(id);
1296
- if (c && !seen.has(id)) {
1297
- ordered.push(c);
1298
- seen.add(id);
1299
- }
1300
- }
1301
- for (const c of all) if (!seen.has(c.id)) ordered.push(c);
1302
- return ordered;
1303
- });
1304
-
1305
-
1306
- // Forward sort-clause changes to the consumer. Same dedupe pattern as the
1307
- // selection callback above - fires only when the serialized clauses change.
1308
- let lastSortingSerialized = "";
1309
- $effect(() => {
1310
- gridStateVersion;
1311
- const sorting = (grid.getState().sorting ?? []) as Array<{
1312
- id: string;
1313
- desc: boolean;
1314
- }>;
1315
- const serialized = JSON.stringify(sorting);
1316
- if (serialized === lastSortingSerialized) return;
1317
- lastSortingSerialized = serialized;
1318
- props.onSortingChange?.(sorting);
1319
- });
1320
-
1321
- // Forward filter-state changes to the consumer. Consolidates the three
1322
- // wrapper-managed filter stores (global text, per-column operator filters,
1323
- // facet checklists) into one shape so server-side consumers can build a
1324
- // single query. Skipped entirely when no callback is registered to avoid
1325
- // serializing on every keystroke.
1326
- let lastFiltersSerialized = "";
1327
- $effect(() => {
1328
- if (!props.onFiltersChange) return;
1329
- const menuEntries = Object.entries(filterMenuValues)
1330
- .filter(([, f]) => {
1331
- if (f.operator === "isBlank") return true;
1332
- if (f.operator === "between") {
1333
- return f.value.trim().length > 0 && (f.valueTo ?? "").trim().length > 0;
1334
- }
1335
- return f.value.trim().length > 0;
1336
- })
1337
- .map(([id, f]) => ({
1338
- id,
1339
- operator: f.operator,
1340
- value: f.value,
1341
- ...(f.operator === "between" && f.valueTo
1342
- ? { valueTo: f.valueTo }
1343
- : {}),
1344
- }));
1345
- const valueEntries = Object.entries(valueFilters).map(([id, allowed]) => ({
1346
- id,
1347
- operator: "equals" as FilterOperator,
1348
- value: "",
1349
- selectedValues: Array.from(allowed).sort(),
1350
- }));
1351
- const merged = new Map<
1352
- string,
1353
- {
1354
- id: string;
1355
- operator: FilterOperator;
1356
- value: string;
1357
- selectedValues?: Array<string>;
1358
- }
1359
- >();
1360
- for (const entry of menuEntries) merged.set(entry.id, entry);
1361
- for (const entry of valueEntries) {
1362
- const existing = merged.get(entry.id);
1363
- merged.set(
1364
- entry.id,
1365
- existing
1366
- ? { ...existing, selectedValues: entry.selectedValues }
1367
- : entry,
1368
- );
1369
- }
1370
- const payload = {
1371
- global: globalFilter,
1372
- columns: Array.from(merged.values()),
1373
- };
1374
- const serialized = JSON.stringify(payload);
1375
- if (serialized === lastFiltersSerialized) return;
1376
- lastFiltersSerialized = serialized;
1377
- props.onFiltersChange(payload);
1378
- });
1379
-
1380
- const virtualizer = createSvelteVirtualizer({
1381
- count: 0,
1382
- estimateSize: 36,
1383
- overscan: 8,
1384
- viewportHeight: 520,
1385
- scrollOffset: 0,
1386
- });
1387
- const columnVirtualizer = createColumnVirtualizer({
1388
- count: 0,
1389
- viewportWidth: 0,
1390
- overscan: 3,
1391
- estimateSize: () => 140,
1392
- });
1393
- columnVirtualizer.subscribe(() => {
1394
- columnVirtualizerVersion += 1;
1395
- });
1396
-
1397
- const rowVirtualizationEnabled = $derived(
1398
- (props.virtualization ?? true) && allRows.length > 0,
1399
- );
1400
- const columnVirtualizationEnabled = $derived(
1401
- (props.columnVirtualization ?? true) && allColumns.length > 0,
1402
- );
1403
- const virtualRows = $derived.by(() => {
1404
- virtualizer.version;
1405
- return virtualizer.getVirtualItems();
1406
- });
1407
- const virtualRowTotalSize = $derived.by(() => {
1408
- virtualizer.version;
1409
- return virtualizer.getTotalSize();
1410
- });
1411
- const virtualRowStart = $derived.by(() => virtualRows[0]?.start ?? 0);
1412
- const virtualRowEnd = $derived.by(
1413
- () => virtualRows[virtualRows.length - 1]?.end ?? 0,
1414
- );
1415
- const virtualRowBottomSpacer = $derived.by(() =>
1416
- Math.max(virtualRowTotalSize - virtualRowEnd, 0),
1417
- );
1418
-
1419
- // --- Huge-list scroll scaling -----------------------------------------
1420
- // Browsers cap how tall a single element may be, and mobile caps sit well
1421
- // below desktop. Past a few hundred thousand rows the true content height
1422
- // (count * rowHeight) exceeds that cap, the scroll container silently
1423
- // clamps its scrollHeight, and the last rows become unreachable - e.g. a
1424
- // 1,000,000-row grid that only scrolls to ~994,000 on a phone.
1425
- //
1426
- // When the true height exceeds the browser's max element height we cap the
1427
- // DOM scroll height and map between the limited DOM scroll range and the
1428
- // full logical range (the "scaling" technique from react-virtualized): the
1429
- // spacers are sized in the capped DOM space, while the virtualizer keeps
1430
- // working in true logical pixels. We detect the real per-browser cap at
1431
- // runtime (Chrome ~33.5M, Firefox ~17.9M, mobile lower) rather than guess a
1432
- // constant, so scaling activates only when genuinely needed and stays as
1433
- // fine-grained as the browser allows. For normal-sized grids scaling is
1434
- // inert and every value below reduces to the original behavior.
1435
- // Build the scaling mapping from the current true height + detected browser
1436
- // cap + viewport. The pure, unit-tested math lives in
1437
- // ./virtualization/scroll-scaling; here we only feed it reactive inputs.
1438
- // Inert (identity) for normal-sized grids.
1439
- const rowScrollScaling = $derived(
1440
- createRowScrollScaling(
1441
- virtualRowTotalSize,
1442
- getMaxDomScrollHeight(),
1443
- viewportHeight,
1444
- ),
1445
- );
1446
- const rowDomTotalSize = $derived(rowScrollScaling.domTotal);
1447
- const rowScrollScalingActive = $derived(rowScrollScaling.active);
1448
- // Map a DOM scrollTop to the virtualizer's logical scroll offset, and back.
1449
- function domToLogicalRowOffset(domTop: number): number {
1450
- return rowScrollScaling.domToLogical(domTop);
1451
- }
1452
- function logicalToDomRowOffset(logical: number): number {
1453
- return rowScrollScaling.logicalToDom(logical);
1454
- }
1455
- // px the logical row positions must shift to land inside the capped DOM
1456
- // coordinate space (0 when not scaling). Derived from the virtualizer's
1457
- // OWN committed scroll offset - not the live DOM scrollTop - so the spacer
1458
- // shift and the rendered window are always computed from the same state and
1459
- // can never skew by a frame (which would jitter at extreme scale).
1460
- const rowOffsetAdjustment = $derived.by(() => {
1461
- if (!rowScrollScalingActive) return 0;
1462
- virtualizer.version;
1463
- const logical = virtualizer.getState().scrollOffset;
1464
- return logical - rowScrollScaling.logicalToDom(logical);
1465
- });
1466
- // Spacer heights in DOM space. With scaling inert these equal the original
1467
- // virtualRowStart / virtualRowBottomSpacer.
1468
- const rowTopSpacer = $derived(Math.max(virtualRowStart - rowOffsetAdjustment, 0));
1469
- const rowBottomSpacer = $derived(
1470
- Math.max(rowDomTotalSize - (virtualRowEnd - rowOffsetAdjustment), 0),
1471
- );
1472
- const virtualColumns = $derived.by(() => {
1473
- columnVirtualizerVersion;
1474
- return columnVirtualizer.getVirtualItems();
1475
- });
1476
- const virtualColumnTotalSize = $derived.by(() => {
1477
- columnVirtualizerVersion;
1478
- return columnVirtualizer.getTotalSize();
1479
- });
1480
- const renderedColumnItems = $derived.by(() => {
1481
- if (!columnVirtualizationEnabled) {
1482
- let start = 0;
1483
- return allColumns.map((column, index) => {
1484
- const size = getColumnWidth(column.id);
1485
- const item = { index, key: index, size, start, end: start + size };
1486
- start += size;
1487
- return item;
1488
- });
1489
- }
1490
- // Pinned columns are position:sticky, so they only stay pinned while their
1491
- // cell is in the DOM. Plain column virtualization drops them once they leave
1492
- // the scroll window, and the pinned column vanishes. Because allColumns is
1493
- // ordered [pinnedLeft, unpinned, pinnedRight], we keep the rendered window
1494
- // CONTIGUOUS from the pinned-left prefix (index 0) through the pinned-right
1495
- // suffix (last index) whenever those exist. The pinned cells are then always
1496
- // rendered - the existing single-spacer layout positions everything, so no
1497
- // markup changes are needed. (Cost: with a pinned side, the columns between
1498
- // that edge and the window are also rendered; negligible for typical grids,
1499
- // and correctness beats shaving a few off-screen cells.)
1500
- const window = virtualColumns;
1501
- const hasLeft = effectivePinning.left.length > 0;
1502
- const hasRight = effectivePinning.right.length > 0;
1503
- if ((!hasLeft && !hasRight) || window.length === 0) return window;
1504
-
1505
- const firstIdx = window[0]!.index;
1506
- const lastIdx = window[window.length - 1]!.index;
1507
- const startIndex = hasLeft ? 0 : firstIdx;
1508
- const endIndex = hasRight ? allColumns.length - 1 : lastIdx;
1509
- if (startIndex === firstIdx && endIndex === lastIdx) return window;
1510
-
1511
- const items: Array<{ index: number; key: number; size: number; start: number; end: number }> = [];
1512
- let offset = 0;
1513
- for (let i = 0; i < startIndex; i += 1) offset += getColumnWidth(allColumns[i]!.id);
1514
- for (let i = startIndex; i <= endIndex; i += 1) {
1515
- const size = getColumnWidth(allColumns[i]!.id);
1516
- items.push({ index: i, key: i, size, start: offset, end: offset + size });
1517
- offset += size;
1518
- }
1519
- return items;
1520
- });
1521
- const renderedColumns = $derived.by(() =>
1522
- renderedColumnItems
1523
- .map((item) => ({ item, column: allColumns[item.index] }))
1524
- .filter(hasRenderedColumn),
1525
- );
1526
- const totalColumnWidth = $derived.by(() => {
1527
- if (columnVirtualizationEnabled) return virtualColumnTotalSize;
1528
- let total = 0;
1529
- for (const column of allColumns) total += getColumnWidth(column.id);
1530
- return total;
1531
- });
1532
- /** Horizontal overflow derived from the SOURCE OF TRUTH (column widths
1533
- * + leading sticky columns) compared to the viewport. We can't use
1534
- * `totalColumnWidth` here when column virtualization is on - that
1535
- * returns the column virtualizer's cached total, which only updates
1536
- * on `setOptions()` / scroll, NOT when `fittedColumnWidths` finishes
1537
- * scaling on first measure. Reading `getColumnWidth(c.id)` for every
1538
- * column instead is reactive to both `columnWidths` and
1539
- * `fittedColumnWidths`, so the overflow decision settles in the same
1540
- * render where fit-scaling lands - no race, no scrollbar flash. */
1541
- const hasHorizontalOverflow = $derived.by(() => {
1542
- const fixedCols =
1543
- (showRowNumbersEffective ? rowNumberColumnWidth : 0) +
1544
- (showRowSelectionEffective ? selectionColumnWidth : 0);
1545
- let total = fixedCols;
1546
- for (const column of allColumns) total += getColumnWidth(column.id);
1547
- // +1 to tolerate sub-pixel rounding residue from `fitColumns`.
1548
- return total > viewportWidth + 1;
1549
- });
1550
- const columnWindowStart = $derived.by(
1551
- () => renderedColumnItems[0]?.start ?? 0,
1552
- );
1553
- const columnWindowEnd = $derived.by(
1554
- () => renderedColumnItems[renderedColumnItems.length - 1]?.end ?? 0,
1555
- );
1556
- const columnWindowRightSpacer = $derived.by(() =>
1557
- Math.max(totalColumnWidth - columnWindowEnd, 0),
1558
- );
1559
-
1560
- const activeCell = $derived.by(() => {
1561
- gridStateVersion;
1562
- return (
1563
- grid.getState().activeCell ?? { rowIndex: 0, colIndex: 0, cellId: null }
1564
- );
1565
- });
1566
-
1567
- const activeDescendantId = $derived.by(() => {
1568
- const active = activeCell;
1569
- const inRows = active.rowIndex >= 0 && active.rowIndex < allRows.length;
1570
- const inCols = active.colIndex >= 0 && active.colIndex < allColumns.length;
1571
- if (!inRows || !inCols) return null;
1572
- return getGridCellDomId("svgrid", active.rowIndex, active.colIndex);
1573
- });
1574
-
1575
-
1576
-
1577
- // Above this many cells (rows x columns) the summary aggregation is
1578
- // deferred one animation frame so it never blocks first paint - a
1579
- // 100k x 50 grid would otherwise spend seconds summing reactive cells
1580
- // before the grid ever appears. Smaller grids compute inline so the
1581
- // footer is correct on the first frame (no flicker).
1582
- const SUMMARY_DEFER_CELL_LIMIT = 50_000;
1583
-
1584
- let summaryByColumn = $state<Record<string, string>>({});
1585
- $effect(() => {
1586
- // Re-aggregate whenever the data / columns / edits change. We depend on
1587
- // `allRows` / `allColumns` / `editedCellValues` DIRECTLY - not the
1588
- // catch-all `gridStateVersion` - because that version bumps on EVERY store
1589
- // change, including moving the active cell or selection. `allRows` stays
1590
- // referentially stable across those (sort/filter/paginate produce a new
1591
- // rows array; navigation does not), so this now skips the
1592
- // O(rows x cols) aggregation on plain keyboard navigation - which was
1593
- // making arrow-key movement crawl on huge grids (e.g. 1,000,000 rows).
1594
- void editedCellValues;
1595
- const rows = allRows;
1596
- const columns = allColumns;
1597
- if (!(props.enableRowSummaries ?? true)) {
1598
- summaryByColumn = {};
1599
- return;
1600
- }
1601
- if (
1602
- rows.length * columns.length <= SUMMARY_DEFER_CELL_LIMIT ||
1603
- typeof requestAnimationFrame === "undefined"
1604
- ) {
1605
- summaryByColumn = computeSummaries(rows, columns);
1606
- return;
1607
- }
1608
- // Large grid: paint first, total a frame later.
1609
- let cancelled = false;
1610
- const handle = requestAnimationFrame(() => {
1611
- if (!cancelled) summaryByColumn = computeSummaries(rows, columns);
1612
- });
1613
- return () => {
1614
- cancelled = true;
1615
- cancelAnimationFrame(handle);
1616
- };
1617
- });
1618
-
1619
- $effect(() => {
1620
- if (!theadEl) return;
1621
- headerHeight = theadEl.offsetHeight;
1622
- return observeSizeRaf(theadEl, () => {
1623
- headerHeight = theadEl?.offsetHeight ?? 0;
1624
- });
1625
- });
1626
-
1627
- // Bump scrollVersion when the table's layout size changes so scrollbar
1628
- // visibility (and the thumb math that depends on scroll metrics) updates
1629
- // after column resize / show-hide / add-remove.
1630
- $effect(() => {
1631
- if (!gridRootEl) return;
1632
- return observeSizeRaf(gridRootEl, () => {
1633
- scrollVersion += 1;
1634
- });
1635
- });
1636
-
1637
- $effect(() => {
1638
- if (!allRows.length || !allColumns.length) return;
1639
- const active = grid.getState().activeCell;
1640
- if (active?.cellId) return;
1641
- grid.setActiveCell({
1642
- rowIndex: 0,
1643
- colIndex: 0,
1644
- cellId: getGridCellDomId("svgrid", 0, 0),
1645
- });
1646
- });
1647
-
1648
- $effect(() => {
1649
- // Only reset scroll + selection + editing when the COLUMN SCHEMA
1650
- // changes. Data length is too weak a signal:
1651
- // - Streaming inserts grow the length and shouldn't move scroll.
1652
- // - Filter / delete events shrink the length and shouldn't either
1653
- // (the user's spot in the data is what they care about).
1654
- // - Sort changes preserve length but mean "start from the top",
1655
- // so callers who want that should drive it explicitly via
1656
- // api.scrollToTop() (or the equivalent).
1657
- // The columns ARE a schema change: existing scroll/selection
1658
- // coordinates are no longer meaningful when the grid's column set
1659
- // is replaced, so we still reset there.
1660
- const colCount = props.columns.length;
1661
- const nextSignature = `cols:${colCount}`;
1662
- if (nextSignature === lastResetSignature) return;
1663
- const isFirstRender = lastResetSignature === "";
1664
- lastResetSignature = nextSignature;
1665
- if (isFirstRender) return;
1666
-
1667
- selectionRange = { anchor: null, focus: null };
1668
- selectionRanges = [];
1669
- editingCell = null;
1670
- if (scrollContainer) {
1671
- scrollContainer.scrollTop = 0;
1672
- scrollContainer.scrollLeft = 0;
1673
- scrollVersion += 1;
1674
- }
1675
- virtualizer.setScrollOffset(0);
1676
- columnVirtualizer.setHorizontalOffset(0);
1677
- });
1678
-
1679
- // Wire scroll-change listeners SEPARATELY for each scrollbar - bundling
1680
- // them in one effect with `if (!vertical || !horizontal) return` was
1681
- // the bug behind "vertical scrollbar can't be dragged": with overflow
1682
- // gating, demos without horizontal overflow never mount the horizontal
1683
- // scrollbar, the combined guard tripped, and the vertical listener
1684
- // never got attached either. Each scrollbar is now independent.
1685
- $effect(() => {
1686
- if (!scrollContainer || !verticalScrollbarEl) return;
1687
- const el = verticalScrollbarEl;
1688
- const onVertical = (event: Event) => {
1689
- const container = scrollContainer;
1690
- if (!container) return;
1691
- const customEvent = event as CustomEvent<{ value: number }>;
1692
- container.scrollTop = customEvent.detail.value;
1693
- scheduleScrollSync(container.scrollTop, container.scrollLeft);
1694
- };
1695
- el.addEventListener("scroll-change", onVertical as EventListener);
1696
- return () =>
1697
- el.removeEventListener("scroll-change", onVertical as EventListener);
1698
- });
1699
-
1700
- $effect(() => {
1701
- if (!scrollContainer || !horizontalScrollbarEl) return;
1702
- const el = horizontalScrollbarEl;
1703
- const onHorizontal = (event: Event) => {
1704
- const container = scrollContainer;
1705
- if (!container) return;
1706
- const customEvent = event as CustomEvent<{ value: number }>;
1707
- container.scrollLeft = customEvent.detail.value;
1708
- scheduleScrollSync(container.scrollTop, container.scrollLeft);
1709
- };
1710
- el.addEventListener("scroll-change", onHorizontal as EventListener);
1711
- return () =>
1712
- el.removeEventListener("scroll-change", onHorizontal as EventListener);
1713
- });
1714
-
1715
- $effect(() => {
1716
- // When containerHeight is a string (e.g. "100%") the actual pixel height
1717
- // depends on the parent layout - read it from the live scroll container.
1718
- // We track `viewportVersion` (only bumped by the ResizeObserver below)
1719
- // instead of `scrollVersion` so this effect does NOT re-run on every
1720
- // scroll event - which would otherwise re-call setOptions hundreds of
1721
- // times during a drag.
1722
- viewportVersion;
1723
- const viewportHeight =
1724
- typeof props.containerHeight === "string"
1725
- ? (scrollContainer?.clientHeight ?? 520)
1726
- : (props.containerHeight ?? 520);
1727
- const rh = props.rowHeight;
1728
- virtualizer.setOptions({
1729
- count: allRows.length,
1730
- estimateSize: typeof rh === "function" ? rh : (rh ?? 30),
1731
- overscan: props.overscan ?? 8,
1732
- viewportHeight,
1733
- });
1734
- });
1735
-
1736
- // Track size changes of the shell so the virtualizer's viewport, the
1737
- // fit-columns scale, and anything else that depends on the container
1738
- // width/height stays in sync. Always attached (window resize / parent
1739
- // layout shift / sidebar collapse can change the size whether the
1740
- // consumer passed a numeric or "100%" containerHeight).
1741
- /** True after the first ResizeObserver tick - i.e. once the grid has
1742
- * measured its real container size and `fitColumns` has had a chance
1743
- * to scale the columns to that width. Used to gate the scrollbar
1744
- * visibility: rendering it before this flips paints a horizontal
1745
- * scrollbar for ONE frame (based on the base column widths summing
1746
- * larger than the viewport), then immediately hides it once fit
1747
- * scaling kicks in - visible as a "flashing horizontal scrollbar"
1748
- * every time a demo first loads. */
1749
- let hasMeasured = $state(false);
1750
-
1751
- $effect(() => {
1752
- if (!scrollContainer) return;
1753
- return observeSizeRaf(scrollContainer, () => {
1754
- viewportVersion += 1;
1755
- if (!hasMeasured) hasMeasured = true;
1756
- });
1757
- });
1758
-
1759
- $effect(() => {
1760
- // Reading columnWidths here registers it as a reactive dependency so
1761
- // the effect re-runs when the user resizes a column. We pass a fresh
1762
- // closure each run; the virtualizer sees a new function reference and
1763
- // re-derives its layout from the current per-column widths.
1764
- columnWidths;
1765
- columnVirtualizer.setOptions({
1766
- count: allColumns.length,
1767
- estimateSize: (index: number) => {
1768
- const column = allColumns[index];
1769
- return column ? getColumnWidth(column.id) : (props.columnWidth ?? 140);
1770
- },
1771
- overscan: props.columnOverscan ?? 3,
1772
- viewportHeight: viewportWidth,
1773
- });
1774
- });
1775
-
1776
- $effect(() => {
1777
- if (!scrollContainer) return;
1778
- if (rowVirtualizationEnabled)
1779
- virtualizer.setScrollOffset(domToLogicalRowOffset(scrollContainer.scrollTop));
1780
- if (columnVirtualizationEnabled)
1781
- columnVirtualizer.setHorizontalOffset(scrollContainer.scrollLeft);
1782
- });
1783
-
1784
- // Re-arms once the user scrolls away from the bottom, so a long lazy-load
1785
- // run only fires `onScrollBottomReached` once per arrival at the end.
1786
- let scrollBottomArmed = true;
1787
-
1788
-
1789
-
1790
-
1791
-
1792
-
1793
-
1794
-
1795
-
1796
-
1797
-
1798
-
1799
-
1800
- /** Cached normalized options keyed by columnId - only used when the column
1801
- * has a static (non-function) `editorOptions`. Dynamic (per-row) options
1802
- * are resolved on every call because they can change as other cells in
1803
- * the same row change (the whole point of cascading editors). */
1804
- const editorOptionsCache: Record<string, CellEditorOption[]> = {};
1805
-
1806
-
1807
-
1808
-
1809
-
1810
-
1811
-
1812
-
1813
-
1814
-
1815
-
1816
-
1817
-
1818
-
1819
- const headerSelectionState = $derived.by(() => {
1820
- gridStateVersion;
1821
- const selectable = allRows.filter((row) => !isGroupRow(row));
1822
- if (!selectable.length) return "none";
1823
- let selected = 0;
1824
- for (const row of selectable) if (rowSelectionState[row.id]) selected += 1;
1825
- if (selected === 0) return "none";
1826
- return selected === selectable.length ? "all" : "some";
1827
- });
1828
-
1829
-
1830
- /** True once a real interaction (click, keyboard nav, or a public-API
1831
- * call) has activated a cell. Distinct from `activeCell.cellId`, which
1832
- * the on-mount seed effect populates straight on the grid state without
1833
- * going through `setActiveCell` - so it can't tell a seeded (0,0) apart
1834
- * from a user-focused (0,0). The fill handle keys off this flag so it
1835
- * stays hidden until the user actually selects something. */
1836
- let userHasActivatedCell = $state(false);
1837
-
1838
-
1839
-
1840
-
1841
- /**
1842
- * Per-column fitted widths when `fitColumns` is on. Computed in one pass
1843
- * so the LAST auto-sized column can absorb the rounding residue and make
1844
- * the total match the target viewport width exactly. Without this the
1845
- * per-column `Math.round` calls leave a 2-6 px residue and the user sees
1846
- * a small horizontal scrollbar even though every column is "fitted".
1847
- *
1848
- * User-resized columns (entries in `columnWidths`) are taken at face
1849
- * value and only the auto-sized columns share the scale + residue.
1850
- *
1851
- * Returns `null` when fit scaling is not in effect (off, no room, total
1852
- * already >= target). Callers then fall back to the base width.
1853
- */
1854
- const fittedColumnWidths = $derived.by(() => {
1855
- // Track viewport size (not scrollVersion) so we don't recompute on
1856
- // every scroll - only when the container actually resizes.
1857
- viewportVersion;
1858
- // Narrow responsive mode pans instead of scaling, so skip fit scaling.
1859
- if (!props.fitColumns || isNarrowResponsive) return null;
1860
- const cols = grid.getAllColumns().filter((c) => !hiddenColumns[c.id]);
1861
- if (!cols.length) return null;
1862
- const rowNumberWidth = showRowNumbersEffective ? rowNumberColumnWidth : 0;
1863
- const selectionWidth = showRowSelectionEffective ? selectionColumnWidth : 0;
1864
- // Reserve the custom vertical scrollbar's width when it's visible. It
1865
- // overlays the right 16px of the viewport (absolute, z-index 40) and
1866
- // does NOT shrink clientWidth, so without this the last fitted column
1867
- // slides under it and its right-aligned content (e.g. a number column)
1868
- // is hidden behind the opaque scrollbar.
1869
- const scrollbarWidth = hasVerticalOverflow ? 16 : 0;
1870
- const target =
1871
- (scrollContainer?.clientWidth ?? 0) -
1872
- rowNumberWidth -
1873
- selectionWidth -
1874
- scrollbarWidth;
1875
- if (target <= 0) return null;
1876
-
1877
- // Split base widths into pinned (user-resized) and scalable.
1878
- let pinnedTotal = 0;
1879
- let scalableBase = 0;
1880
- const scalableIds: string[] = [];
1881
- for (const c of cols) {
1882
- const w = getColumnBaseWidth(c.id);
1883
- if (columnWidths[c.id] !== undefined) pinnedTotal += w;
1884
- else {
1885
- scalableBase += w;
1886
- scalableIds.push(c.id);
1887
- }
1888
- }
1889
- const scalableTarget = target - pinnedTotal;
1890
- if (scalableTarget <= 0 || scalableBase <= 0) return null;
1891
- // Within 1px of target - no scaling needed.
1892
- if (Math.abs(scalableBase - scalableTarget) <= 1) return null;
1893
- // Shrink only by a modest amount (≥85% of natural). Beyond that, leave
1894
- // natural widths and let the user scroll - squashing every column
1895
- // tighter would hide content.
1896
- const scale = scalableTarget / scalableBase;
1897
- if (scale < 0.85) return null;
1898
-
1899
- const widths: Record<string, number> = {};
1900
- let runningSum = 0;
1901
- for (let i = 0; i < scalableIds.length - 1; i += 1) {
1902
- const id = scalableIds[i]!;
1903
- const w = Math.max(
1904
- MIN_COLUMN_WIDTH,
1905
- Math.round(getColumnBaseWidth(id) * scale),
1906
- );
1907
- widths[id] = w;
1908
- runningSum += w;
1909
- }
1910
- // The last scalable column absorbs whatever the previous rounding left
1911
- // behind, so `sum(widths) === scalableTarget` exactly.
1912
- const lastId = scalableIds[scalableIds.length - 1]!;
1913
- widths[lastId] = Math.max(MIN_COLUMN_WIDTH, scalableTarget - runningSum);
1914
- return widths;
1915
- });
1916
-
1917
-
1918
- let resizePendingWidth = 0;
1919
- let resizeRaf: number | null = null;
1920
-
1921
-
1922
-
1923
-
1924
-
1925
-
1926
-
1927
-
1928
- /** Where the fill handle should render: the bottom-right cell of the
1929
- * selection range (or the active cell if there's no range). Returns
1930
- * null when cell selection is off or there is no anchored selection. */
1931
- const fillHandleCell = $derived.by(() => {
1932
- if (!(props.enableCellSelection ?? false)) return null;
1933
- const anchor = selectionRange.anchor;
1934
- const focus = selectionRange.focus;
1935
- if (anchor && focus) {
1936
- return {
1937
- rowIndex: Math.max(anchor.rowIndex, focus.rowIndex),
1938
- colIndex: Math.max(anchor.colIndex, focus.colIndex),
1939
- };
1940
- }
1941
- const a = activeCell;
1942
- // Only show the handle once the user (or the public API) has actually
1943
- // activated a cell. The on-mount seed writes activeCell (0,0) directly
1944
- // to the grid state, so `cellId` alone can't gate this - see
1945
- // `userHasActivatedCell`.
1946
- if (!userHasActivatedCell || !a) return null;
1947
- return { rowIndex: a.rowIndex, colIndex: a.colIndex };
1948
- });
1949
-
1950
-
1951
-
1952
-
1953
-
1954
-
1955
-
1956
-
1957
-
1958
-
1959
-
1960
-
1961
-
1962
-
1963
-
1964
-
1965
-
1966
-
1967
-
1968
-
1969
-
1970
-
1971
-
1972
-
1973
-
1974
-
1975
-
1976
-
1977
-
1978
-
1979
-
1980
-
1981
-
1982
-
1983
-
1984
-
1985
-
1986
-
1987
-
1988
-
1989
-
1990
-
1991
-
1992
-
1993
- /**
1994
- * Lazily-created canvas used to measure text width via the 2D context.
1995
- * Canvas measurement bypasses the cell's `overflow: hidden; white-space:
1996
- * nowrap` constraint, which makes the body's `scrollWidth` useless here.
1997
- */
1998
- let measureCanvas: HTMLCanvasElement | null = null;
1999
-
2000
-
2001
-
2002
-
2003
-
2004
-
2005
-
2006
-
2007
-
2008
-
2009
-
2010
-
2011
- /**
2012
- * Range buckets for the value-facet list.
2013
- *
2014
- * Numeric and date columns with many distinct values would otherwise
2015
- * paint thousands of single-value checkboxes in the filter menu -
2016
- * unusable. When a column's `editorType` is `'number' | 'date' |
2017
- * 'datetime'` AND it has more than BUCKET_THRESHOLD distinct values,
2018
- * we collapse the facet list into BUCKET_COUNT equal-width ranges
2019
- * (e.g. "1,000 - 1,500") and let the user check those.
2020
- *
2021
- * The bucket structure carries the numeric bounds so the row filter
2022
- * can re-test each row's value against the selected ranges without
2023
- * re-doing the bucket math.
2024
- */
2025
-
2026
-
2027
-
2028
-
2029
-
2030
-
2031
-
2032
- /** Buckets for every column that should be bucketed, computed once and
2033
- * reused by both the facet UI and the row filter. Computing them lazily
2034
- * in a $derived means columns with no filter menu open and no active
2035
- * filter never pay the iteration cost. */
2036
- const facetBucketsByColumn = $derived.by(() => {
2037
- const map = new Map<string, Array<FacetBucket>>();
2038
- for (const column of allColumns) {
2039
- const meta = isBucketableColumn(column);
2040
- if (!meta) continue;
2041
- const buckets = buildBuckets(column, meta.isDate, props.data, getColumnAccessorValue);
2042
- if (buckets) map.set(column.id, buckets);
2043
- }
2044
- return map;
2045
- });
2046
-
2047
- // Server-side set-filter values: when a column's filter menu opens and the
2048
- // consumer provides `serverFilterValues`, fetch the distinct values from the
2049
- // server once (cached per column) instead of deriving them from the loaded
2050
- // page - so the checklist shows every value, not just what's on screen.
2051
- let serverFacetValues = $state<Record<string, Array<string>>>({});
2052
- let serverFacetLoading = $state<string | null>(null);
2053
- $effect(() => {
2054
- const columnId = filterMenuFor ?? columnMenuFor;
2055
- const fetcher = props.serverFilterValues;
2056
- if (!columnId || !fetcher || serverFacetValues[columnId]) return;
2057
- serverFacetLoading = columnId;
2058
- let cancelled = false;
2059
- void fetcher(columnId)
2060
- .then((values) => {
2061
- if (cancelled) return;
2062
- serverFacetValues = { ...serverFacetValues, [columnId]: values };
2063
- serverFacetLoading = null;
2064
- })
2065
- .catch(() => {
2066
- if (!cancelled) serverFacetLoading = null;
2067
- });
2068
- return () => {
2069
- cancelled = true;
2070
- };
2071
- });
2072
-
2073
- const columnMenuFacetValues = $derived.by(() => {
2074
- // The funnel popover drives via `filterMenuFor`; the column menu's Filter
2075
- // tab drives via `columnMenuFor`. Support whichever is open.
2076
- const columnId = filterMenuFor ?? columnMenuFor;
2077
- if (!columnId) return [] as Array<string>;
2078
- // Server-provided distinct values win (fetched + cached above).
2079
- if (props.serverFilterValues) return serverFacetValues[columnId] ?? [];
2080
- const column = allColumns.find((entry) => entry.id === columnId);
2081
- if (!column) return [] as Array<string>;
2082
- // Range-bucketed facets for numeric / date columns with many values.
2083
- const buckets = facetBucketsByColumn.get(columnId);
2084
- if (buckets) return buckets.map((b) => b.label);
2085
- // Default: distinct-value facets.
2086
- const seen = new Set<string>();
2087
- for (const rowData of props.data) {
2088
- seen.add(String(getColumnAccessorValue(rowData, column) ?? ""));
2089
- }
2090
- return Array.from(seen).sort((a, b) =>
2091
- a.localeCompare(b, undefined, { numeric: true }),
2092
- );
2093
- });
2094
-
2095
- const columnMenuVisibleFacets = $derived.by(() => {
2096
- const query = columnMenuSearch.trim().toLowerCase();
2097
- if (!query) return columnMenuFacetValues;
2098
- return columnMenuFacetValues.filter((value) =>
2099
- value.toLowerCase().includes(query),
2100
- );
2101
- });
2102
-
2103
-
2104
-
2105
-
2106
-
2107
-
2108
-
2109
-
2110
-
2111
- // Fire onApiReady exactly once when the grid is first ready. Wrapping in
2112
- // an effect that tracks `props.onApiReady` was racy - every parent render
2113
- // creates a new inline arrow, the effect re-fired, and any synchronous
2114
- // state mutation inside the callback (e.g. `api.setGroupBy(...)`) created
2115
- // an infinite update loop. Now it's a true mount-once notification.
2116
- let apiNotified = false;
2117
- $effect(() => {
2118
- if (apiNotified) return;
2119
- const cb = props.onApiReady;
2120
- if (!cb) return;
2121
- apiNotified = true;
2122
- cb(buildApi());
2123
- });
2124
-
2125
- const ctx = {
2126
- get props() { return props; },
2127
- get editingEnabled() { return editingEnabled; },
2128
- get paginationEnabled() { return paginationEnabled; },
2129
- get groupingControlsEnabled() { return groupingControlsEnabled; },
2130
- get globalFilter() { return globalFilter; },
2131
- set globalFilter(v) { globalFilter = v as never; },
2132
- get scrollContainer() { return scrollContainer; },
2133
- set scrollContainer(v) { scrollContainer = v as never; },
2134
- get gridRootEl() { return gridRootEl; },
2135
- set gridRootEl(v) { gridRootEl = v as never; },
2136
- get filterRowValues() { return filterRowValues; },
2137
- set filterRowValues(v) { filterRowValues = v as never; },
2138
- get filterMenuValues() { return filterMenuValues; },
2139
- set filterMenuValues(v) { filterMenuValues = v as never; },
2140
- get verticalScrollbarEl() { return verticalScrollbarEl; },
2141
- set verticalScrollbarEl(v) { verticalScrollbarEl = v as never; },
2142
- get horizontalScrollbarEl() { return horizontalScrollbarEl; },
2143
- set horizontalScrollbarEl(v) { horizontalScrollbarEl = v as never; },
2144
- get scrollVersion() { return scrollVersion; },
2145
- set scrollVersion(v) { scrollVersion = v as never; },
2146
- get viewportVersion() { return viewportVersion; },
2147
- set viewportVersion(v) { viewportVersion = v as never; },
2148
- get lastResetSignature() { return lastResetSignature; },
2149
- set lastResetSignature(v) { lastResetSignature = v as never; },
2150
- get pendingScrollTop() { return pendingScrollTop; },
2151
- set pendingScrollTop(v) { pendingScrollTop = v as never; },
2152
- get pendingScrollLeft() { return pendingScrollLeft; },
2153
- set pendingScrollLeft(v) { pendingScrollLeft = v as never; },
2154
- get scrollSyncRaf() { return scrollSyncRaf; },
2155
- set scrollSyncRaf(v) { scrollSyncRaf = v as never; },
2156
- get selectionRange() { return selectionRange; },
2157
- set selectionRange(v) { selectionRange = v as never; },
2158
- get selectionRanges() { return selectionRanges; },
2159
- set selectionRanges(v) { selectionRanges = v as never; },
2160
- get isDraggingSelection() { return isDraggingSelection; },
2161
- set isDraggingSelection(v) { isDraggingSelection = v as never; },
2162
- get fillDrag() { return fillDrag; },
2163
- set fillDrag(v) { fillDrag = v as never; },
2164
- get activeAtPointerDown() { return activeAtPointerDown; },
2165
- set activeAtPointerDown(v) { activeAtPointerDown = v as never; },
2166
- get editingCell() { return editingCell; },
2167
- set editingCell(v) { editingCell = v as never; },
2168
- get fullRowEdit() { return fullRowEdit; },
2169
- set fullRowEdit(v) { fullRowEdit = v as never; },
2170
- get editedCellValues() { return editedCellValues; },
2171
- set editedCellValues(v) { editedCellValues = v as never; },
2172
- get UNDO_LIMIT() { return UNDO_LIMIT; },
2173
- get history() { return history; },
2174
- set history(v) { history = v as never; },
2175
- get historyPtr() { return historyPtr; },
2176
- set historyPtr(v) { historyPtr = v as never; },
2177
- get historyVersion() { return historyVersion; },
2178
- set historyVersion(v) { historyVersion = v as never; },
2179
- get tooltip() { return tooltip; },
2180
- set tooltip(v) { tooltip = v as never; },
2181
- get tooltipTimer() { return tooltipTimer; },
2182
- set tooltipTimer(v) { tooltipTimer = v as never; },
2183
- get showTooltipFor() { return showTooltipFor; },
2184
- get hideTooltip() { return hideTooltip; },
2185
- get findOpen() { return findOpen; },
2186
- set findOpen(v) { findOpen = v as never; },
2187
- get findQuery() { return findQuery; },
2188
- set findQuery(v) { findQuery = v as never; },
2189
- get findHitIndex() { return findHitIndex; },
2190
- set findHitIndex(v) { findHitIndex = v as never; },
2191
- get findHits() { return findHits; },
2192
- get theadEl() { return theadEl; },
2193
- set theadEl(v) { theadEl = v as never; },
2194
- get headerHeight() { return headerHeight; },
2195
- set headerHeight(v) { headerHeight = v as never; },
2196
- get editorSelectAll() { return editorSelectAll; },
2197
- set editorSelectAll(v) { editorSelectAll = v as never; },
2198
- get columnWidths() { return columnWidths; },
2199
- set columnWidths(v) { columnWidths = v as never; },
2200
- get resizingColumnId() { return resizingColumnId; },
2201
- set resizingColumnId(v) { resizingColumnId = v as never; },
2202
- get resizeStartX() { return resizeStartX; },
2203
- set resizeStartX(v) { resizeStartX = v as never; },
2204
- get resizeStartWidth() { return resizeStartWidth; },
2205
- set resizeStartWidth(v) { resizeStartWidth = v as never; },
2206
- get MIN_COLUMN_WIDTH() { return MIN_COLUMN_WIDTH; },
2207
- get columnPinning() { return columnPinning; },
2208
- set columnPinning(v) { columnPinning = v as never; },
2209
- get effectivePinning() { return effectivePinning; },
2210
- get isNarrowResponsive() { return isNarrowResponsive; },
2211
- get columnVirtualizerVersion() { return columnVirtualizerVersion; },
2212
- set columnVirtualizerVersion(v) { columnVirtualizerVersion = v as never; },
2213
- get gridStateVersion() { return gridStateVersion; },
2214
- set gridStateVersion(v) { gridStateVersion = v as never; },
2215
- get selectionColumnWidth() { return selectionColumnWidth; },
2216
- get rowNumberColumnWidth() { return rowNumberColumnWidth; },
2217
- get showRowNumbersEffective() { return showRowNumbersEffective; },
2218
- get filterOperatorOptions() { return filterOperatorOptions; },
2219
- get TEXT_OPERATORS() { return TEXT_OPERATORS; },
2220
- get NUMBER_OPERATORS() { return NUMBER_OPERATORS; },
2221
- get DATE_OPERATORS() { return DATE_OPERATORS; },
2222
- get CHECKBOX_OPERATORS() { return CHECKBOX_OPERATORS; },
2223
- get columnMenuFor() { return columnMenuFor; },
2224
- get columnMenuTab() { return columnMenuTab; },
2225
- set columnMenuTab(v) { columnMenuTab = v as never; },
2226
- set columnMenuFor(v) { columnMenuFor = v as never; },
2227
- get columnMenuPos() { return columnMenuPos; },
2228
- set columnMenuPos(v) { columnMenuPos = v as never; },
2229
- get columnMenuSearch() { return columnMenuSearch; },
2230
- set columnMenuSearch(v) { columnMenuSearch = v as never; },
2231
- get filterMenuFor() { return filterMenuFor; },
2232
- set filterMenuFor(v) { filterMenuFor = v as never; },
2233
- get filterMenuPos() { return filterMenuPos; },
2234
- set filterMenuPos(v) { filterMenuPos = v as never; },
2235
- get operatorMenuFor() { return operatorMenuFor; },
2236
- set operatorMenuFor(v) { operatorMenuFor = v as never; },
2237
- get operatorMenuPos() { return operatorMenuPos; },
2238
- set operatorMenuPos(v) { operatorMenuPos = v as never; },
2239
- get chooseColumnsPos() { return chooseColumnsPos; },
2240
- set chooseColumnsPos(v) { chooseColumnsPos = v as never; },
2241
- get contextMenuFor() { return contextMenuFor; },
2242
- set contextMenuFor(v) { contextMenuFor = v as never; },
2243
- get contextMenuPos() { return contextMenuPos; },
2244
- set contextMenuPos(v) { contextMenuPos = v as never; },
2245
- get noteOverrides() { return noteOverrides; },
2246
- set noteOverrides(v) { noteOverrides = v as never; },
2247
- get commentEditFor() { return commentEditFor; },
2248
- set commentEditFor(v) { commentEditFor = v as never; },
2249
- get commentDraft() { return commentDraft; },
2250
- set commentDraft(v) { commentDraft = v as never; },
2251
- get valueFilters() { return valueFilters; },
2252
- set valueFilters(v) { valueFilters = v as never; },
2253
- get viewportWidth() { return viewportWidth; },
2254
- get viewportHeight() { return viewportHeight; },
2255
- get scrollMetrics() { return scrollMetrics; },
2256
- get hasVerticalOverflow() { return hasVerticalOverflow; },
2257
- get showGlobalFilterEffective() { return showGlobalFilterEffective; },
2258
- get showFilterRowEffective() { return showFilterRowEffective; },
2259
- get showColumnFiltersEffective() { return showColumnFiltersEffective; },
2260
- get showInlineColumnFilterEffective() { return showInlineColumnFilterEffective; },
2261
- get showRowSelectionEffective() { return showRowSelectionEffective; },
2262
- get enableCellSelectionEffective() { return enableCellSelectionEffective; },
2263
- get flushScheduledScrollSync() { return flushScheduledScrollSync; },
2264
- get scheduleScrollSync() { return scheduleScrollSync; },
2265
- get internalData() { return internalData; },
2266
- set internalData(v) { internalData = v as never; },
2267
- get internalColumns() { return internalColumns; },
2268
- set internalColumns(v) { internalColumns = v as never; },
2269
- get hiddenColumns() { return hiddenColumns; },
2270
- set hiddenColumns(v) { hiddenColumns = v as never; },
2271
- get toggleColumnGroup() { return toggleColumnGroup; },
2272
- get isColumnGroupCollapsed() { return isColumnGroupCollapsed; },
2273
- get externalSortEnabled() { return externalSortEnabled; },
2274
- get externalFilterEnabled() { return externalFilterEnabled; },
2275
- get passthroughSortedRowModel() { return passthroughSortedRowModel; },
2276
- get resolveEffectiveFeatures() { return resolveEffectiveFeatures; },
2277
- get grid() { return grid; },
2278
- get userColumnOrder() { return userColumnOrder; },
2279
- set userColumnOrder(v) { userColumnOrder = v as never; },
2280
- get lastSeededOrder() { return lastSeededOrder; },
2281
- set lastSeededOrder(v) { lastSeededOrder = v as never; },
2282
- get allColumns() { return allColumns; },
2283
- get headerGroups() { return headerGroups; },
2284
- get groupHeaderRows() { return groupHeaderRows; },
2285
- get pinnedOffsets() { return pinnedOffsets; },
2286
- get cellPinStyle() { return cellPinStyle; },
2287
- get isColumnPinned() { return isColumnPinned; },
2288
- get colDragId() { return colDragId; },
2289
- set colDragId(v) { colDragId = v as never; },
2290
- get colDropOnId() { return colDropOnId; },
2291
- set colDropOnId(v) { colDropOnId = v as never; },
2292
- get colDropSide() { return colDropSide; },
2293
- set colDropSide(v) { colDropSide = v as never; },
2294
- get rowDragActive() { return rowDragActive; },
2295
- set rowDragActive(v) { rowDragActive = v as never; },
2296
- get rowDropIndex() { return rowDropIndex; },
2297
- set rowDropIndex(v) { rowDropIndex = v as never; },
2298
- get rowDropSide() { return rowDropSide; },
2299
- set rowDropSide(v) { rowDropSide = v as never; },
2300
- get onRowDragStart() { return onRowDragStart; },
2301
- get onRowDragOver() { return onRowDragOver; },
2302
- get onRowDragLeave() { return onRowDragLeave; },
2303
- get onRowDrop() { return onRowDrop; },
2304
- get onRowsContainerDragOver() { return onRowsContainerDragOver; },
2305
- get onRowsContainerDrop() { return onRowsContainerDrop; },
2306
- get onRowDragEnd() { return onRowDragEnd; },
2307
- get broadcastAlignedScroll() { return broadcastAlignedScroll; },
2308
- get getCurrentColumnOrder() { return getCurrentColumnOrder; },
2309
- get emitColumnOrder() { return emitColumnOrder; },
2310
- get setColumnOrderInternal() { return setColumnOrderInternal; },
2311
- get applyColumnDrop() { return applyColumnDrop; },
2312
- get onColumnHeaderDragStart() { return onColumnHeaderDragStart; },
2313
- get onColumnHeaderDragOver() { return onColumnHeaderDragOver; },
2314
- get onColumnHeaderDragLeave() { return onColumnHeaderDragLeave; },
2315
- get onColumnHeaderDrop() { return onColumnHeaderDrop; },
2316
- get onColumnHeaderDragEnd() { return onColumnHeaderDragEnd; },
2317
- get pinColumnLeft() { return pinColumnLeft; },
2318
- get pinColumnRight() { return pinColumnRight; },
2319
- get unpinColumn() { return unpinColumn; },
2320
- get getColumnBaseValue() { return getColumnBaseValue; },
2321
- get hasConditionalFormats() { return hasConditionalFormats; },
2322
- get conditionalColumnStats() { return conditionalColumnStats; },
2323
- get cellConditionalFormat() { return cellConditionalFormat; },
2324
- get isGroupRow() { return isGroupRow; },
2325
- get isCellEditable() { return isCellEditable; },
2326
- get isCellEditableAt() { return isCellEditableAt; },
2327
- get sortDirectionByColumn() { return sortDirectionByColumn; },
2328
- get groupingColumns() { return groupingColumns; },
2329
- get paginationState() { return paginationState; },
2330
- get externalPaginationEnabled() { return externalPaginationEnabled; },
2331
- get paginationTotalRows() { return paginationTotalRows; },
2332
- get paginationPageIndex() { return paginationPageIndex; },
2333
- get paginationPageSize() { return paginationPageSize; },
2334
- get getRowColumnValue() { return getRowColumnValue; },
2335
- get allRowsBeforePagination() { return allRowsBeforePagination; },
2336
- get allRows() { return allRows; },
2337
- get rowSelectionState() { return rowSelectionState; },
2338
- get lastSelectionSerialized() { return lastSelectionSerialized; },
2339
- set lastSelectionSerialized(v) { lastSelectionSerialized = v as never; },
2340
- get lastCellRangeSerialized() { return lastCellRangeSerialized; },
2341
- set lastCellRangeSerialized(v) { lastCellRangeSerialized = v as never; },
2342
- get statusBarEnabled() { return statusBarEnabled; },
2343
- get statusBarAggregates() { return statusBarAggregates; },
2344
- get statusBarStats() { return statusBarStats; },
2345
- get toolPanelOpen() { return toolPanelOpen; },
2346
- set toolPanelOpen(v) { toolPanelOpen = v as never; },
2347
- get toolPanelTab() { return toolPanelTab; },
2348
- set toolPanelTab(v) { toolPanelTab = v as never; },
2349
- get toolPanelEnabled() { return toolPanelEnabled; },
2350
- get toolPanelColumns() { return toolPanelColumns; },
2351
- get toolPanelHeaderLabel() { return toolPanelHeaderLabel; },
2352
- get toggleColumnVisibleInPanel() { return toggleColumnVisibleInPanel; },
2353
- get moveColumnInPanel() { return moveColumnInPanel; },
2354
- get toggleGroupInPanel() { return toggleGroupInPanel; },
2355
- get lastSortingSerialized() { return lastSortingSerialized; },
2356
- set lastSortingSerialized(v) { lastSortingSerialized = v as never; },
2357
- get lastFiltersSerialized() { return lastFiltersSerialized; },
2358
- set lastFiltersSerialized(v) { lastFiltersSerialized = v as never; },
2359
- get virtualizer() { return virtualizer; },
2360
- get columnVirtualizer() { return columnVirtualizer; },
2361
- get rowVirtualizationEnabled() { return rowVirtualizationEnabled; },
2362
- get columnVirtualizationEnabled() { return columnVirtualizationEnabled; },
2363
- get virtualRows() { return virtualRows; },
2364
- get virtualRowTotalSize() { return virtualRowTotalSize; },
2365
- get virtualRowStart() { return virtualRowStart; },
2366
- get virtualRowEnd() { return virtualRowEnd; },
2367
- get virtualRowBottomSpacer() { return virtualRowBottomSpacer; },
2368
- get rowDomTotalSize() { return rowDomTotalSize; },
2369
- get rowScrollScalingActive() { return rowScrollScalingActive; },
2370
- get rowTopSpacer() { return rowTopSpacer; },
2371
- get rowBottomSpacer() { return rowBottomSpacer; },
2372
- get domToLogicalRowOffset() { return domToLogicalRowOffset; },
2373
- get logicalToDomRowOffset() { return logicalToDomRowOffset; },
2374
- get virtualColumns() { return virtualColumns; },
2375
- get virtualColumnTotalSize() { return virtualColumnTotalSize; },
2376
- get renderedColumnItems() { return renderedColumnItems; },
2377
- get hasRenderedColumn() { return hasRenderedColumn; },
2378
- get renderedColumns() { return renderedColumns; },
2379
- get totalColumnWidth() { return totalColumnWidth; },
2380
- get hasHorizontalOverflow() { return hasHorizontalOverflow; },
2381
- get columnWindowStart() { return columnWindowStart; },
2382
- get columnWindowEnd() { return columnWindowEnd; },
2383
- get columnWindowRightSpacer() { return columnWindowRightSpacer; },
2384
- get activeCell() { return activeCell; },
2385
- get activeDescendantId() { return activeDescendantId; },
2386
- get formatSummaryNumeric() { return formatSummaryNumeric; },
2387
- get computeSummaries() { return computeSummaries; },
2388
- get SUMMARY_DEFER_CELL_LIMIT() { return SUMMARY_DEFER_CELL_LIMIT; },
2389
- get summaryByColumn() { return summaryByColumn; },
2390
- set summaryByColumn(v) { summaryByColumn = v as never; },
2391
- get hasMeasured() { return hasMeasured; },
2392
- set hasMeasured(v) { hasMeasured = v as never; },
2393
- get scrollBottomArmed() { return scrollBottomArmed; },
2394
- set scrollBottomArmed(v) { scrollBottomArmed = v as never; },
2395
- get onBodyScroll() { return onBodyScroll; },
2396
- get computeRowClass() { return computeRowClass; },
2397
- get computeCellClass() { return computeCellClass; },
2398
- get computeCellTooltip() { return computeCellTooltip; },
2399
- get computeCellValidity() { return computeCellValidity; },
2400
- get computeCellNote() { return computeCellNote; },
2401
- get getCellDisplayValue() { return getCellDisplayValue; },
2402
- get getColumnAlign() { return getColumnAlign; },
2403
- get editorOptionsCache() { return editorOptionsCache; },
2404
- get getColumnEditorOptions() { return getColumnEditorOptions; },
2405
- get formatListCellValue() { return formatListCellValue; },
2406
- get formatCellValue() { return formatCellValue; },
2407
- get getPinnedCellValue() { return getPinnedCellValue; },
2408
- get formatPinnedValue() { return formatPinnedValue; },
2409
- get computePinnedCellClass() { return computePinnedCellClass; },
2410
- get isRowSelected() { return isRowSelected; },
2411
- get toggleRowSelectionById() { return toggleRowSelectionById; },
2412
- get headerSelectionState() { return headerSelectionState; },
2413
- get toggleSelectAllRows() { return toggleSelectAllRows; },
2414
- get userHasActivatedCell() { return userHasActivatedCell; },
2415
- set userHasActivatedCell(v) { userHasActivatedCell = v as never; },
2416
- get setActiveCell() { return setActiveCell; },
2417
- get scrollActiveCellIntoView() { return scrollActiveCellIntoView; },
2418
- get getColumnBaseWidth() { return getColumnBaseWidth; },
2419
- get fittedColumnWidths() { return fittedColumnWidths; },
2420
- get getColumnWidth() { return getColumnWidth; },
2421
- get resizePendingWidth() { return resizePendingWidth; },
2422
- set resizePendingWidth(v) { resizePendingWidth = v as never; },
2423
- get resizeRaf() { return resizeRaf; },
2424
- set resizeRaf(v) { resizeRaf = v as never; },
2425
- get startColumnResize() { return startColumnResize; },
2426
- get onColumnResizeMove() { return onColumnResizeMove; },
2427
- get endColumnResize() { return endColumnResize; },
2428
- get setSelection() { return setSelection; },
2429
- get extendSelection() { return extendSelection; },
2430
- get isCellInSelectedRange() { return isCellInSelectedRange; },
2431
- get getCellRangeEdges() { return getCellRangeEdges; },
2432
- get getSelectionRects() { return getSelectionRects; },
2433
- get fillHandleCell() { return fillHandleCell; },
2434
- get isInFillPreview() { return isInFillPreview; },
2435
- get fillMarqueeEdges() { return fillMarqueeEdges; },
2436
- get findColumnById() { return findColumnById; },
2437
- get readCellRaw() { return readCellRaw; },
2438
- get writeCellRaw() { return writeCellRaw; },
2439
- get applyFillPattern() { return applyFillPattern; },
2440
- get clearSelectedCellValues() { return clearSelectedCellValues; },
2441
- get startFillDrag() { return startFillDrag; },
2442
- get onFillPointerMove() { return onFillPointerMove; },
2443
- get onFillPointerUp() { return onFillPointerUp; },
2444
- get toggleBooleanCell() { return toggleBooleanCell; },
2445
- get onCellPointerDown() { return onCellPointerDown; },
2446
- get onCellPointerEnter() { return onCellPointerEnter; },
2447
- get endDragSelection() { return endDragSelection; },
2448
- get onWindowPointerMove() { return onWindowPointerMove; },
2449
- get onCellClick() { return onCellClick; },
2450
- get emitCellDoubleClick() { return emitCellDoubleClick; },
2451
- get copySelectionToClipboard() { return copySelectionToClipboard; },
2452
- get cutSelectionToClipboard() { return cutSelectionToClipboard; },
2453
- get pasteFromClipboard() { return pasteFromClipboard; },
2454
- get onGridPaste() { return onGridPaste; },
2455
- get clearSelectedCells() { return clearSelectedCells; },
2456
- get onCellDoubleClick() { return onCellDoubleClick; },
2457
- get startEditingWithChar() { return startEditingWithChar; },
2458
- get startEditing() { return startEditing; },
2459
- get stopEditing() { return stopEditing; },
2460
- get startFullRowEdit() { return startFullRowEdit; },
2461
- get setFullRowDraft() { return setFullRowDraft; },
2462
- get commitFullRowEdit() { return commitFullRowEdit; },
2463
- get cancelFullRowEdit() { return cancelFullRowEdit; },
2464
- get saveEditingCell() { return saveEditingCell; },
2465
- get applyHistoryStep() { return applyHistoryStep; },
2466
- get updateEditingCellValue() { return updateEditingCellValue; },
2467
- get onEditorKeyDown() { return onEditorKeyDown; },
2468
- get focusOnMount() { return focusOnMount; },
2469
- get onHeaderSortClick() { return onHeaderSortClick; },
2470
- get onGridKeyDown() { return onGridKeyDown; },
2471
- get changePage() { return changePage; },
2472
- get goToPage() { return goToPage; },
2473
- get setPageSize() { return setPageSize; },
2474
- get openContextMenu() { return openContextMenu; },
2475
- get closeContextMenu() { return closeContextMenu; },
2476
- get contextMenuItems() { return contextMenuItems; },
2477
- get saveComment() { return saveComment; },
2478
- get removeComment() { return removeComment; },
2479
- get closeCommentEditor() { return closeCommentEditor; },
2480
- get updateFilterRow() { return updateFilterRow; },
2481
- get updateFilterOperator() { return updateFilterOperator; },
2482
- get updateFilterMenuValue() { return updateFilterMenuValue; },
2483
- get updateFilterMenuValueTo() { return updateFilterMenuValueTo; },
2484
- get toggleCheckboxWithKeyboard() { return toggleCheckboxWithKeyboard; },
2485
- get getColumnAccessorValue() { return getColumnAccessorValue; },
2486
- get fallbackOperatorOption() { return fallbackOperatorOption; },
2487
- get operatorOption() { return operatorOption; },
2488
- get operatorsForColumn() { return operatorsForColumn; },
2489
- get defaultOperatorFor() { return defaultOperatorFor; },
2490
- get operatorLabelFor() { return operatorLabelFor; },
2491
- get isColumnFiltered() { return isColumnFiltered; },
2492
- get closeMenus() { return closeMenus; },
2493
- get measureCanvas() { return measureCanvas; },
2494
- set measureCanvas(v) { measureCanvas = v as never; },
2495
- get measureText() { return measureText; },
2496
- get autosizeColumn() { return autosizeColumn; },
2497
- get autosizeAllColumns() { return autosizeAllColumns; },
2498
- get resetColumns() { return resetColumns; },
2499
- get openChooseColumns() { return openChooseColumns; },
2500
- get openColumnMenu() { return openColumnMenu; },
2501
- get openFilterMenu() { return openFilterMenu; },
2502
- get openOperatorMenu() { return openOperatorMenu; },
2503
- get sortColumnFromMenu() { return sortColumnFromMenu; },
2504
- get clearColumnSort() { return clearColumnSort; },
2505
- get groupByColumnFromMenu() { return groupByColumnFromMenu; },
2506
- get clearGroupingFromMenu() { return clearGroupingFromMenu; },
2507
- get isBucketableColumn() { return isBucketableColumn; },
2508
- get buildBuckets() { return buildBuckets; },
2509
- get isInBucket() { return isInBucket; },
2510
- get facetBucketsByColumn() { return facetBucketsByColumn; },
2511
- get serverFacetLoading() { return serverFacetLoading; },
2512
- get columnMenuFacetValues() { return columnMenuFacetValues; },
2513
- get columnMenuVisibleFacets() { return columnMenuVisibleFacets; },
2514
- get isFacetChecked() { return isFacetChecked; },
2515
- get toggleFacetValue() { return toggleFacetValue; },
2516
- get isAllFacetsChecked() { return isAllFacetsChecked; },
2517
- get toggleAllFacets() { return toggleAllFacets; },
2518
- get clearColumnFilter() { return clearColumnFilter; },
2519
- get onWindowKeydown() { return onWindowKeydown; },
2520
- get columnDefMatchesId() { return columnDefMatchesId; },
2521
- get buildApi() { return buildApi; },
2522
- get apiNotified() { return apiNotified; },
2523
- set apiNotified(v) { apiNotified = v as never; },
2524
- };
2525
- const { resolveEffectiveFeatures } = createFeatures<TFeatures, TData>(ctx);
2526
- const { showTooltipFor, hideTooltip, flushScheduledScrollSync, scheduleScrollSync, onBodyScroll } = createScrollSync<TFeatures, TData>(ctx);
2527
- const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard<TFeatures, TData>(ctx);
2528
- const { computeSummaries, hasRenderedColumn } = createSummaries<TFeatures, TData>(ctx);
2529
- const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, 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);
2530
- const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
2531
- const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing<TFeatures, TData>(ctx);
2532
- const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
2533
- 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);
2534
- const { onRowDragStart, onRowDragOver, onRowDragLeave, onRowDrop, onRowsContainerDragOver, onRowsContainerDrop, onRowDragEnd } = createRowDrag<TFeatures, TData>(ctx);
2535
- const { register: registerAlignedGrid, broadcastScroll: broadcastAlignedScroll, broadcastWidths: broadcastAlignedWidths } = createAlignedGrids<TFeatures, TData>(ctx);
2536
- const { buildApi } = createGridApi<TFeatures, TData>(ctx);
2537
- const { readCellRaw, writeCellRaw, applyFillPattern, clearSelectedCellValues, startFillDrag, onFillPointerMove, onFillPointerUp, toggleBooleanCell, copySelectionToClipboard, clearSelectedCells, cutSelectionToClipboard } = createClipboard(ctx);
2538
-
2539
- // Aligned grids: register in the shared group on mount, and mirror column
2540
- // resizes to peers whenever columnWidths changes. Horizontal-scroll mirroring
2541
- // is driven from onBodyScroll (via ctx.broadcastAlignedScroll).
2542
- $effect(() => {
2543
- if (props.alignedGridGroup == null) return;
2544
- return registerAlignedGrid();
2545
- });
2546
- $effect(() => {
2547
- // Track columnWidths reactively, then broadcast to aligned peers.
2548
- void columnWidths;
2549
- broadcastAlignedWidths();
2550
- });
2551
-
2552
- return ctx;
2553
- }
1
+ import {
2
+ applyExcelFilter,
3
+ normalizeForFilter,
4
+ createColumnVirtualizer,
5
+ createCoreRowModel,
6
+ createExpandedRowModel,
7
+ createFilteredRowModel,
8
+ createGroupedRowModel,
9
+ createSvelteVirtualizer,
10
+ createSortedRowModel,
11
+ createSvGrid,
12
+ getGridCellDomId,
13
+ sortFns,
14
+ tableFeatures,
15
+ rowSortingFeature,
16
+ columnFilteringFeature,
17
+ columnGroupingFeature,
18
+ type CellEditorOption,
19
+ type Column,
20
+ type ColumnDef,
21
+ type Row,
22
+ type RowData,
23
+ type TableFeatures,
24
+ } from "./index";
25
+ import {
26
+ createRowScrollScaling,
27
+ resolveMaxDomHeight,
28
+ } from "./virtualization/scroll-scaling";
29
+ import "./sv-grid-scrollbar";
30
+ import {
31
+ computeColumnStat,
32
+ formatsNeedingStats,
33
+ type ColumnStat,
34
+ } from "./conditional-formatting";
35
+ import SvGridDropdown from "./SvGridDropdown.svelte";
36
+ import type {
37
+ Props,
38
+ SelectionRange,
39
+ CellEditState,
40
+ FilterOperator,
41
+ MenuPosition,
42
+ ContextMenuTarget,
43
+ } from "./SvGrid.types";
44
+ import {
45
+ rawToNumber,
46
+ } from "./SvGrid.helpers";
47
+ import { createFeatures } from "./features";
48
+ import {
49
+ createScrollSync,
50
+ } from "./scroll-sync";
51
+ import {
52
+ createKeyboard,
53
+ } from "./keyboard-handlers";
54
+ import {
55
+ createSummaries,
56
+ } from "./summaries";
57
+ import {
58
+ createMenus,
59
+ } from "./menus";
60
+ import {
61
+ createCellRender,
62
+ } from "./cell-render";
63
+ import {
64
+ createEditing,
65
+ } from "./editing";
66
+ import {
67
+ createSelection,
68
+ } from "./selection";
69
+ import {
70
+ createColumns,
71
+ } from "./columns";
72
+ import {
73
+ createRowDrag,
74
+ } from "./row-drag";
75
+ import {
76
+ createAlignedGrids,
77
+ } from "./aligned-grids";
78
+ import {
79
+ resolveColumnTypes,
80
+ } from "./column-types";
81
+ import {
82
+ computeColumnGroupMeta,
83
+ hiddenLeavesForCollapse,
84
+ } from "./column-groups";
85
+ import {
86
+ createGridApi,
87
+ } from "./build-api";
88
+ import {
89
+ createClipboard,
90
+ } from "./clipboard";
91
+ import {
92
+ filterOperatorOptions,
93
+ fallbackOperatorOption,
94
+ TEXT_OPERATORS,
95
+ NUMBER_OPERATORS,
96
+ DATE_OPERATORS,
97
+ CHECKBOX_OPERATORS,
98
+ operatorOption,
99
+ operatorsForColumn,
100
+ defaultOperatorFor,
101
+ operatorLabelFor,
102
+ } from "./filter-operators";
103
+ import {
104
+ type FacetBucket,
105
+ isBucketableColumn,
106
+ buildBuckets,
107
+ isInBucket,
108
+ } from "./facet-buckets";
109
+ import {
110
+ getColumnBaseValue,
111
+ isGroupRow,
112
+ toolPanelHeaderLabel,
113
+ formatSummaryNumeric,
114
+ getColumnAlign,
115
+ getPinnedCellValue,
116
+ getColumnAccessorValue,
117
+ columnDefMatchesId,
118
+ } from "./cell-values";
119
+
120
+ /**
121
+ * Conservative fallback for the browser's max element height, used during SSR
122
+ * or if runtime detection fails. 8M is below every known engine cap (Firefox
123
+ * ~17.9M, Chrome/Safari ~33.5M) so it is always safe, if coarser than needed.
124
+ */
125
+ const MAX_DOM_SCROLL_HEIGHT_FALLBACK = 8_000_000;
126
+
127
+ /**
128
+ * The browser's actual maximum *scrollable* element height in CSS px. Browsers
129
+ * clamp how tall a single element may be, and the cap is lower on mobile /
130
+ * high-DPR devices (the physical limit is in device px, so a 3x-DPR phone has
131
+ * ~1/3 the CSS-px cap of a 1x desktop). Past that cap a scroll container
132
+ * silently clamps its `scrollHeight` and the tail rows of a huge virtualized
133
+ * grid become unreachable.
134
+ *
135
+ * We measure two signals from one offscreen probe and keep the smaller (see
136
+ * `resolveMaxDomHeight`): the probe's clamped `offsetHeight`, AND the
137
+ * `scrollHeight` a real `overflow:auto` container exposes for it. The second
138
+ * matters because mobile WebKit/Blink can report a generous `offsetHeight` yet
139
+ * expose a smaller scrollable range - trusting the layout height alone is what
140
+ * stranded the last rows on phones. Using a real scroll container also folds in
141
+ * DPR clamping for free. Cached for the page lifetime; constant per browser.
142
+ */
143
+ let detectedMaxDomHeight: number | null = null;
144
+ /**
145
+ * Seed the `hiddenColumns` map from any column def marked `visible: false`.
146
+ * Walks groups so a hidden group hides all of its leaf columns. Keyed by the
147
+ * same id `setColumnVisible` uses (`id ?? field`), so user toggles afterward
148
+ * stay consistent. Run once at mount; prop changes don't re-apply it.
149
+ */
150
+ function initialHiddenColumns<
151
+ TFeatures extends TableFeatures,
152
+ TData extends RowData,
153
+ >(
154
+ defs: ReadonlyArray<ColumnDef<TFeatures, TData>>,
155
+ ): Record<string, boolean> {
156
+ const hidden: Record<string, boolean> = {};
157
+ const walk = (
158
+ cols: ReadonlyArray<ColumnDef<TFeatures, TData>>,
159
+ inheritedHidden: boolean,
160
+ ) => {
161
+ for (const def of cols) {
162
+ const hide = inheritedHidden || def.visible === false;
163
+ if (def.columns?.length) {
164
+ walk(def.columns, hide);
165
+ } else if (hide) {
166
+ const id = def.id ?? def.field;
167
+ if (id) hidden[id] = true;
168
+ }
169
+ }
170
+ };
171
+ walk(defs, false);
172
+ return hidden;
173
+ }
174
+
175
+ function getMaxDomScrollHeight(): number {
176
+ // Escape hatch: a page may pin the cap via `window.__svgridMaxDomHeight`.
177
+ // Checked before the cache so it always wins. Two uses: reproducing a
178
+ // phone's lower element-height limit on desktop (and our e2e coverage of
179
+ // the huge-list path), and overriding detection on a device where it reads
180
+ // wrong. A non-positive / non-finite value is ignored.
181
+ if (typeof window !== "undefined") {
182
+ const forced = (window as unknown as { __svgridMaxDomHeight?: unknown })
183
+ .__svgridMaxDomHeight;
184
+ if (typeof forced === "number" && Number.isFinite(forced) && forced > 0) {
185
+ return forced;
186
+ }
187
+ }
188
+ if (detectedMaxDomHeight != null) return detectedMaxDomHeight;
189
+ if (typeof document === "undefined" || !document.body) {
190
+ return MAX_DOM_SCROLL_HEIGHT_FALLBACK;
191
+ }
192
+ try {
193
+ // The wrapper is itself an `overflow:auto` scroll container (kept tiny and
194
+ // offscreen so it never affects page layout or scroll), so we can read the
195
+ // height it actually exposes as scrollable - not just the probe's layout
196
+ // height. On high-DPR mobile the two diverge and the scrollable one is the
197
+ // limit that matters.
198
+ const wrap = document.createElement("div");
199
+ wrap.style.cssText =
200
+ "position:fixed;top:0;left:-9999px;width:1px;height:100px;overflow:auto;visibility:hidden;pointer-events:none;";
201
+ const probe = document.createElement("div");
202
+ probe.style.cssText = "width:1px;height:1000000000px;";
203
+ wrap.appendChild(probe);
204
+ document.body.appendChild(wrap);
205
+ const layoutCap = probe.offsetHeight;
206
+ const scrollCap = wrap.scrollHeight;
207
+ document.body.removeChild(wrap);
208
+ detectedMaxDomHeight = resolveMaxDomHeight(
209
+ layoutCap,
210
+ scrollCap,
211
+ MAX_DOM_SCROLL_HEIGHT_FALLBACK,
212
+ );
213
+ } catch {
214
+ detectedMaxDomHeight = MAX_DOM_SCROLL_HEIGHT_FALLBACK;
215
+ }
216
+ return detectedMaxDomHeight;
217
+ }
218
+
219
+ /**
220
+ * Observe an element's size, but run the callback on the next animation frame
221
+ * and coalesce bursts into a single call. This is what keeps the benign but
222
+ * noisy "ResizeObserver loop completed with undelivered notifications" warning
223
+ * out of the console: the browser emits it when an observer callback
224
+ * synchronously mutates layout in a way that would require another notification
225
+ * within the same delivery cycle - which our callbacks do (they bump reactive
226
+ * versions / remeasure, driving a re-layout of the observed element). Deferring
227
+ * the work to the next frame lets the current delivery finish cleanly, so the
228
+ * loop never spans a single cycle. This is especially visible when swapping the
229
+ * whole grid (e.g. switching demos), which remounts everything at once.
230
+ * Returns a disconnect function suitable for an $effect cleanup.
231
+ */
232
+ function observeSizeRaf(el: Element, cb: () => void): () => void {
233
+ let frame = 0;
234
+ const observer = new ResizeObserver(() => {
235
+ if (frame) return;
236
+ frame = requestAnimationFrame(() => {
237
+ frame = 0;
238
+ cb();
239
+ });
240
+ });
241
+ observer.observe(el);
242
+ return () => {
243
+ if (frame) cancelAnimationFrame(frame);
244
+ observer.disconnect();
245
+ };
246
+ }
247
+
248
+ /**
249
+ * SvGrid controller. The component's entire reactive core - every $state,
250
+ * $derived, $effect and handler - lives here so SvGrid.svelte can stay a thin
251
+ * view. Instantiated once during the component's init (so $effect attaches to
252
+ * the component lifecycle) and consumed through the returned getters.
253
+ */
254
+ export type SvGridController<
255
+ TFeatures extends TableFeatures = TableFeatures,
256
+ TData extends RowData = RowData,
257
+ > = ReturnType<typeof createSvGridController<TFeatures, TData>>;
258
+
259
+ export function createSvGridController<
260
+ TFeatures extends TableFeatures = TableFeatures,
261
+ TData extends RowData = RowData,
262
+ >(props: Props<TFeatures, TData>) {
263
+
264
+ // Resolved capability gates. Capabilities are OFF by default - a bare
265
+ // grid is a plain read-only table, and each power feature is opted into
266
+ // via its shortcut (`editable` / `pageable` / `groupable`) or the matching
267
+ // fine-grained prop (`enableInlineEditing` / `showPagination` /
268
+ // `showGroupingControls`). The shortcut wins when set; otherwise the
269
+ // fine-grained prop wins; otherwise the capability is off. (Sorting and
270
+ // filtering follow the same opt-in model already - they require their
271
+ // feature, injected by `sortable` / `filterable`.)
272
+ const editingEnabled = $derived(
273
+ props.editable ?? props.enableInlineEditing ?? false,
274
+ );
275
+ const paginationEnabled = $derived(
276
+ props.pageable ?? props.showPagination ?? false,
277
+ );
278
+ const groupingControlsEnabled = $derived(
279
+ props.groupable ?? props.showGroupingControls ?? false,
280
+ );
281
+
282
+ let globalFilter = $state("");
283
+ let scrollContainer: HTMLDivElement | null = $state(null);
284
+ let gridRootEl: HTMLElement | null = $state(null);
285
+ let filterRowValues = $state<Record<string, string>>({});
286
+ let filterMenuValues = $state<
287
+ Record<
288
+ string,
289
+ {
290
+ operator: FilterOperator;
291
+ value: string;
292
+ valueTo?: string;
293
+ // Optional second condition + join for multi-condition filtering
294
+ // within a single column (AND / OR).
295
+ operator2?: FilterOperator;
296
+ value2?: string;
297
+ valueTo2?: string;
298
+ join?: "AND" | "OR";
299
+ }
300
+ >
301
+ >({});
302
+ let verticalScrollbarEl: HTMLElement | null = $state(null);
303
+ let horizontalScrollbarEl: HTMLElement | null = $state(null);
304
+ let scrollVersion = $state(0);
305
+ /**
306
+ * Separate state from `scrollVersion`: only bumped by the ResizeObserver
307
+ * when the shell's CSS size changes. The virtualizer effects below depend
308
+ * on this instead of `scrollVersion` so they DON'T re-run on every scroll
309
+ * event - `scrollVersion` fires constantly during a drag.
310
+ */
311
+ let viewportVersion = $state(0);
312
+ let lastResetSignature = "";
313
+ let pendingScrollTop = 0;
314
+ let pendingScrollLeft = 0;
315
+ let scrollSyncRaf: number | null = null;
316
+ let selectionRange = $state<SelectionRange>({ anchor: null, focus: null });
317
+ // Extra committed ranges for multi-range (Ctrl+drag) selection. The
318
+ // `selectionRange` above is always the ACTIVE range being manipulated; these
319
+ // are the finished ones. Full selection = these + the active range.
320
+ let selectionRanges = $state.raw<SelectionRange[]>([]);
321
+ let isDraggingSelection = $state(false);
322
+ /** Excel-style fill handle drag state. While non-null we paint a "fill
323
+ * preview" overlay on cells between the source range and the pointer
324
+ * cell; on pointerup we extrapolate the source pattern into them. */
325
+ let fillDrag = $state<{
326
+ sourceMinRow: number;
327
+ sourceMaxRow: number;
328
+ sourceMinCol: number;
329
+ sourceMaxCol: number;
330
+ targetRow: number;
331
+ targetCol: number;
332
+ } | null>(null);
333
+ let activeAtPointerDown: { rowIndex: number; colIndex: number } | null = null;
334
+ let editingCell = $state<CellEditState>(null);
335
+ // Full-row editing: the row currently in whole-row edit + its per-column
336
+ // draft (keyed by column id). Null when not in full-row mode.
337
+ let fullRowEdit = $state<{ rowId: string; draft: Record<string, unknown> } | null>(null);
338
+ let editedCellValues = $state<Record<string, unknown>>({});
339
+
340
+ // ---- Undo / redo (history + pointer model) ---------------------------
341
+ // VSCode-style: one ordered history array, plus a pointer to the index
342
+ // of the NEXT undo step. Avoids the dual-stack edge cases where
343
+ // multiple undo-redo cycles can lose entries.
344
+ // exported for the editing slice (undo/redo)
345
+ type HistoryStep = {
346
+ rowId: string
347
+ columnId: string
348
+ field: string
349
+ before: unknown
350
+ after: unknown
351
+ }
352
+ const UNDO_LIMIT = 200
353
+ let history = $state<HistoryStep[]>([])
354
+ /** Index in `history` of the LAST applied step. -1 means "nothing applied".
355
+ * undo() decrements; redo() increments. New edits truncate everything
356
+ * past the pointer (the classic "you can't redo after editing" rule). */
357
+ let historyPtr = $state(-1)
358
+ /** Bumps on every undo / redo / record so $derived consumers can
359
+ * observe via the api without subscribing to history directly. */
360
+ let historyVersion = $state(0)
361
+
362
+ // ---- Hover tooltip (custom popover, not native title=) ---------------
363
+ // Triggered by per-column `tooltip` field OR per-cell `notes` prop.
364
+ // Renders below / above the cell with smart edge clamping; opens on
365
+ // pointerenter after a brief delay so it doesn't flash during scroll.
366
+ type TooltipState = { text: string; x: number; y: number; below: boolean }
367
+ let tooltip = $state<TooltipState | null>(null)
368
+ let tooltipTimer: number | null = null
369
+
370
+ // ---- Find-in-grid ----------------------------------------------------
371
+ let findOpen = $state(false)
372
+ let findQuery = $state('')
373
+ let findHitIndex = $state(0)
374
+ type FindHit = { rowIndex: number; colIndex: number; columnId: string }
375
+ const findHits = $derived.by<FindHit[]>(() => {
376
+ const q = findQuery.trim().toLowerCase()
377
+ if (!q || !findOpen) return []
378
+ const out: FindHit[] = []
379
+ for (let r = 0; r < allRows.length; r += 1) {
380
+ const row = allRows[r]
381
+ if (!row) continue
382
+ for (let c = 0; c < allColumns.length; c += 1) {
383
+ const col = allColumns[c]
384
+ if (!col) continue
385
+ const v = row.getCellValueByColumnId(col.id)
386
+ if (v == null) continue
387
+ const s = String(v).toLowerCase()
388
+ if (s.includes(q)) out.push({ rowIndex: r, colIndex: c, columnId: col.id })
389
+ }
390
+ }
391
+ return out
392
+ })
393
+ let theadEl: HTMLElement | null = $state(null);
394
+ let headerHeight = $state(0);
395
+ /** When an edit starts: true selects all text, false places the caret at the end. */
396
+ let editorSelectAll = true;
397
+ /** Per-column width overrides set by the resize handles. */
398
+ let columnWidths = $state<Record<string, number>>({});
399
+ let resizingColumnId = $state<string | null>(null);
400
+ let resizeStartX = 0;
401
+ let resizeStartWidth = 0;
402
+ const MIN_COLUMN_WIDTH = 40;
403
+ /** Columns pinned to the left or right edge of the grid (sticky positioning).
404
+ * Seeded from `props.initialColumnPinning` so demos / tests can show the
405
+ * feature on first render without driving the column menu in JS. */
406
+ let columnPinning = $state<{ left: Array<string>; right: Array<string> }>({
407
+ left: [...(props.initialColumnPinning?.left ?? [])],
408
+ right: [...(props.initialColumnPinning?.right ?? [])],
409
+ });
410
+ let columnVirtualizerVersion = $state(0);
411
+ let gridStateVersion = $state(0);
412
+ // Bumps only when a row-model-affecting slice changes (see the store
413
+ // subscription below) - the row-model derivation depends on THIS, not the
414
+ // catch-all gridStateVersion, so navigation doesn't rebuild 1M rows.
415
+ let dataStateVersion = $state(0);
416
+ const selectionColumnWidth = 44;
417
+ const rowNumberColumnWidth = $derived(props.rowNumberWidth ?? 56);
418
+ const showRowNumbersEffective = $derived(props.showRowNumbers ?? false);
419
+ let columnMenuFor = $state<string | null>(null);
420
+ let columnMenuTab = $state<"general" | "filter" | "columns">("general");
421
+ let columnMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
422
+ let columnMenuSearch = $state("");
423
+ let filterMenuFor = $state<string | null>(null);
424
+ let filterMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
425
+ let operatorMenuFor = $state<string | null>(null);
426
+ let operatorMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
427
+ let chooseColumnsPos = $state<MenuPosition | null>(null);
428
+ let contextMenuFor = $state<ContextMenuTarget<TData> | null>(null);
429
+ let contextMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
430
+ // Editable comments: internal overlay (rowId -> columnId -> note) merged on
431
+ // top of props.notes for immediate feedback, plus the open-editor state.
432
+ let noteOverrides = $state<Record<string, Record<string, string>>>({});
433
+ let commentEditFor = $state<{ rowId: string; columnId: string; x: number; y: number } | null>(null);
434
+ let commentDraft = $state("");
435
+ let valueFilters = $state<Record<string, Set<string>>>({});
436
+ const viewportWidth = $derived.by(() => {
437
+ viewportVersion;
438
+ return scrollContainer ? scrollContainer.clientWidth : 0;
439
+ });
440
+ const viewportHeight = $derived.by(() => {
441
+ viewportVersion;
442
+ return scrollContainer ? scrollContainer.clientHeight : 0;
443
+ });
444
+
445
+ // --- responsive (narrow-container) mode ---
446
+ const responsiveBreakpoint = $derived(
447
+ props.responsive && typeof props.responsive === "object" && props.responsive.breakpoint != null
448
+ ? props.responsive.breakpoint
449
+ : 640,
450
+ );
451
+ // Below the breakpoint: un-pin columns (pan the whole grid), suspend
452
+ // fitColumns, and hide `hideBelow` columns. Guarded on width > 0 so it never
453
+ // triggers before the grid has measured.
454
+ const isNarrowResponsive = $derived(
455
+ !!props.responsive && viewportWidth > 0 && viewportWidth < responsiveBreakpoint,
456
+ );
457
+ const EMPTY_PINNING = { left: [] as string[], right: [] as string[] };
458
+ const effectivePinning = $derived(isNarrowResponsive ? EMPTY_PINNING : columnPinning);
459
+ // A column with `hideBelow: N` is dropped while `responsive` is on and the
460
+ // grid is narrower than N px (reads viewportWidth so it re-runs on resize).
461
+ function isHiddenByResponsive(column: { columnDef?: { hideBelow?: number } }): boolean {
462
+ if (!props.responsive) return false;
463
+ const hb = column.columnDef?.hideBelow;
464
+ return hb != null && viewportWidth > 0 && viewportWidth < hb;
465
+ }
466
+ const scrollMetrics = $derived.by(() => {
467
+ scrollVersion;
468
+ viewportVersion;
469
+ // Track the virtualizers' versions too so when data loads or row /
470
+ // column counts change, scrollMetrics re-reads the DOM's grown
471
+ // scrollHeight / scrollWidth. Without these deps the scrollbar
472
+ // receives a stale `content-size` ≈ 0, its hidden-check trips, it
473
+ // sets `pointer-events: none`, and the user can't drag it. The
474
+ // identifiers below are declared further down - derived callbacks
475
+ // run lazily, so by the time this fires they're in scope.
476
+ virtualizer.version;
477
+ columnVirtualizerVersion;
478
+ return {
479
+ scrollTop: scrollContainer?.scrollTop ?? 0,
480
+ scrollLeft: scrollContainer?.scrollLeft ?? 0,
481
+ clientHeight: scrollContainer?.clientHeight ?? 0,
482
+ clientWidth: scrollContainer?.clientWidth ?? 0,
483
+ scrollHeight: scrollContainer?.scrollHeight ?? 0,
484
+ scrollWidth: scrollContainer?.scrollWidth ?? 0,
485
+ };
486
+ });
487
+ /** Vertical overflow from the virtualizer's authoritative total size,
488
+ * NOT from `scrollMetrics.scrollHeight` alone. Reading DOM dimensions
489
+ * during a Svelte derived runs BEFORE the browser paints - the table
490
+ * hasn't laid out the new rows yet, so `scrollHeight` is briefly 0
491
+ * even after data loads. That made the overflow flag return false,
492
+ * hid the scrollbar, and broke dragging.
493
+ *
494
+ * However, virtualizer.getTotalSize() uses rowHeight * numRows which
495
+ * underestimates when variable-height rows are present (e.g. master-detail
496
+ * expanded rows). We therefore take the MAX of the two sources:
497
+ * - virtualizer.getTotalSize(): correct at initial load (before first paint)
498
+ * - scrollMetrics.scrollHeight: correct after detail rows expand (DOM is live,
499
+ * ResizeObserver on gridRootEl already bumps scrollVersion at that point) */
500
+ const hasVerticalOverflow = $derived.by(() => {
501
+ virtualizer.version;
502
+ const virtualizerSize = virtualizer.getTotalSize();
503
+ // scrollMetrics.scrollHeight is 0 before initial paint; once the table
504
+ // is in the DOM it reflects the true content height including expanded rows.
505
+ const domSize = scrollMetrics.scrollHeight;
506
+ return Math.max(virtualizerSize, domSize) > viewportHeight + 1;
507
+ });
508
+
509
+ // Effective filter-UI flags. Each show* prop wins when explicitly set;
510
+ // otherwise the `filterMode` prop (default 'menu') picks exactly one surface.
511
+ const showGlobalFilterEffective = $derived(
512
+ props.showGlobalFilter ?? (props.filterMode ?? "menu") === "global",
513
+ );
514
+ const showFilterRowEffective = $derived(
515
+ props.showFilterRow ?? (props.filterMode ?? "menu") === "row",
516
+ );
517
+ const showColumnFiltersEffective = $derived(
518
+ props.showColumnFilters ?? (props.filterMode ?? "menu") === "menu",
519
+ );
520
+ // The inline "floating filter" input under each header duplicates the
521
+ // column menu's funnel popover when both are active, so it requires an
522
+ // explicit opt-in via the `showColumnFilters` prop.
523
+ const showInlineColumnFilterEffective = $derived(
524
+ props.showColumnFilters === true,
525
+ );
526
+
527
+ // Effective selection-surface flags. `selectionMode` defaults to 'both' so
528
+ // existing consumers keep their current behaviour.
529
+ const showRowSelectionEffective = $derived(
530
+ props.showRowSelection ??
531
+ ((props.selectionMode ?? "both") === "row" ||
532
+ (props.selectionMode ?? "both") === "both"),
533
+ );
534
+ const enableCellSelectionEffective = $derived(
535
+ props.enableCellSelection ??
536
+ ((props.selectionMode ?? "both") === "cell" ||
537
+ (props.selectionMode ?? "both") === "both"),
538
+ );
539
+
540
+
541
+
542
+ // Internal source-of-truth for data and column defs. Seeded from props and
543
+ // re-synced whenever the parent passes a new array; the imperative API
544
+ // mutates these so add/remove operations don't need a callback round-trip.
545
+ // svelte-ignore state_referenced_locally
546
+ let internalData = $state.raw<ReadonlyArray<TData>>(props.data);
547
+ // Resolve `cellDataType` / `inferColumnTypes` into concrete editorType +
548
+ // format defaults once, up front, so every downstream reader sees a normal
549
+ // column. Explicit fields on the ColumnDef always win.
550
+ // svelte-ignore state_referenced_locally
551
+ const resolveCols = (cols: Array<ColumnDef<TFeatures, TData>>) =>
552
+ resolveColumnTypes(cols, props.data?.[0], props.inferColumnTypes === true);
553
+ // svelte-ignore state_referenced_locally
554
+ let internalColumns = $state.raw<Array<ColumnDef<TFeatures, TData>>>(
555
+ resolveCols(props.columns),
556
+ );
557
+ // svelte-ignore state_referenced_locally
558
+ let hiddenColumns = $state<Record<string, boolean>>(
559
+ initialHiddenColumns(props.columns),
560
+ );
561
+
562
+ // Collapsible column groups (columnGroupShow). Meta is derived from the tree;
563
+ // `collapsedColumnGroups` is the live set of collapsed group ids, seeded once
564
+ // from each collapsible group's `openByDefault` (default: collapsed).
565
+ const columnGroupMeta = $derived(computeColumnGroupMeta(props.columns as unknown as Array<any>));
566
+ // svelte-ignore state_referenced_locally
567
+ let collapsedColumnGroups = $state<Set<string>>(
568
+ (() => {
569
+ const meta = computeColumnGroupMeta(props.columns as unknown as Array<any>);
570
+ const s = new Set<string>();
571
+ for (const id of meta.collapsibleGroupIds) if (!meta.defaultOpen.get(id)) s.add(id);
572
+ return s;
573
+ })(),
574
+ );
575
+ // Leaf ids hidden right now because their group is collapsed/expanded.
576
+ const hiddenByGroupCollapse = $derived(
577
+ hiddenLeavesForCollapse(columnGroupMeta, collapsedColumnGroups),
578
+ );
579
+ function toggleColumnGroup(groupId: string) {
580
+ const next = new Set(collapsedColumnGroups);
581
+ if (next.has(groupId)) next.delete(groupId);
582
+ else next.add(groupId);
583
+ collapsedColumnGroups = next;
584
+ }
585
+ function isColumnGroupCollapsed(groupId: string) {
586
+ return collapsedColumnGroups.has(groupId);
587
+ }
588
+
589
+ $effect(() => {
590
+ // When the consumer replaces `data` (e.g. a "Reset" button), drop any
591
+ // accumulated cell-edit overrides - otherwise `getCellDisplayValue`
592
+ // would keep returning the old edited values from `editedCellValues`
593
+ // even though the underlying data has been replaced.
594
+ internalData = props.data;
595
+ editedCellValues = {};
596
+ });
597
+ $effect(() => {
598
+ internalColumns = resolveCols(props.columns);
599
+ });
600
+
601
+ // Captured ONCE at mount: `externalSort` is a structural choice (tree vs
602
+ // flat data) so toggling it after mount is not supported. Reading it here
603
+ // - outside the getter below - guarantees the pass-through sort is wired
604
+ // in before `createSvGrid` first reads `_rowModels`.
605
+ // svelte-ignore state_referenced_locally
606
+ const externalSortEnabled = props.externalSort === true;
607
+ // Same one-shot capture for external filtering. Server-side mode means
608
+ // the wrapper records filter state but does not actually filter rows.
609
+ // svelte-ignore state_referenced_locally
610
+ const externalFilterEnabled = props.externalFilter === true;
611
+ // Server-side pagination: the footer reads rowCount/pageIndex from props and
612
+ // emits onPaginationChange instead of slicing locally. Controlled by the
613
+ // consumer. Reactive (unlike sort/filter) so pageIndex/rowCount can change.
614
+ const externalPaginationEnabled = $derived(props.externalPagination === true);
615
+ const passthroughSortedRowModel = ({ rows }: { rows: Array<Row<TData>> }) =>
616
+ rows;
617
+
618
+
619
+ const grid = createSvGrid({
620
+ get _features() {
621
+ return resolveEffectiveFeatures();
622
+ },
623
+ get _rowModels() {
624
+ // Pagination is intentionally NOT in the grid's row-model pipeline.
625
+ // The wrapper applies its own filters (filterMenuValues, globalFilter,
626
+ // valueFilters) on top of `grid.getRowModel().rows`. If pagination
627
+ // ran first, those filters would only see the visible page. Instead
628
+ // the wrapper paginates last - see `allRows` below.
629
+ return {
630
+ coreRowModel: createCoreRowModel<TData>(),
631
+ filteredRowModel: createFilteredRowModel<TData>(),
632
+ // External-sort mode: pass the rows through untouched so the consumer
633
+ // controls ordering (e.g. tree data that must preserve hierarchy).
634
+ sortedRowModel: externalSortEnabled
635
+ ? passthroughSortedRowModel
636
+ : createSortedRowModel<TData>(sortFns),
637
+ groupedRowModel: createGroupedRowModel<TData>(),
638
+ expandedRowModel: createExpandedRowModel<TData>(),
639
+ };
640
+ },
641
+ get columns() {
642
+ return internalColumns;
643
+ },
644
+ get data() {
645
+ return internalData;
646
+ },
647
+ get getRowId() {
648
+ return props.getRowId;
649
+ },
650
+ state: {
651
+ columnFilters: [],
652
+ grouping: [],
653
+ sorting: [],
654
+ // svelte-ignore state_referenced_locally
655
+ pagination: { pageIndex: 0, pageSize: props.pageSize ?? 10 },
656
+ rowSelection: {},
657
+ expanded: {},
658
+ activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
659
+ },
660
+ });
661
+
662
+ // `gridStateVersion` bumps on EVERY store change (incl. moving the active
663
+ // cell). `dataStateVersion` bumps ONLY when a slice that actually changes the
664
+ // row model changes (sort / filter / pagination / grouping / expansion /
665
+ // selection) - so the O(rows) row-model derivation does NOT re-run on plain
666
+ // keyboard navigation. Without this, arrow-keying a 1,000,000-row grid re-ran
667
+ // the entire core->filter->sort->group pipeline on every keystroke.
668
+ let prevDataSlices:
669
+ | { sorting: unknown; columnFilters: unknown; pagination: unknown; grouping: unknown; expanded: unknown; rowSelection: unknown }
670
+ | null = null;
671
+ $effect(() => {
672
+ const unsubscribe = grid.store.subscribe(() => {
673
+ gridStateVersion += 1;
674
+ const s = grid.getState();
675
+ if (
676
+ !prevDataSlices ||
677
+ prevDataSlices.sorting !== s.sorting ||
678
+ prevDataSlices.columnFilters !== s.columnFilters ||
679
+ prevDataSlices.pagination !== s.pagination ||
680
+ prevDataSlices.grouping !== s.grouping ||
681
+ prevDataSlices.expanded !== s.expanded ||
682
+ prevDataSlices.rowSelection !== s.rowSelection
683
+ ) {
684
+ dataStateVersion += 1;
685
+ prevDataSlices = {
686
+ sorting: s.sorting,
687
+ columnFilters: s.columnFilters,
688
+ pagination: s.pagination,
689
+ grouping: s.grouping,
690
+ expanded: s.expanded,
691
+ rowSelection: s.rowSelection,
692
+ };
693
+ }
694
+ });
695
+ return unsubscribe;
696
+ });
697
+
698
+ /**
699
+ * The grid's columns reordered so left-pinned columns come first and
700
+ * right-pinned columns come last. All other code (rendering, keyboard nav,
701
+ * active cell) operates on this ordered view.
702
+ */
703
+ /**
704
+ * User-driven column order (drag-to-reorder OR `api.setColumnOrder`).
705
+ * Stored as a flat list of column ids. Empty = use the natural order
706
+ * from the columns prop. The pin grouping is applied on top of this.
707
+ */
708
+ let userColumnOrder = $state<string[]>([...(props.columnOrder ?? [])]);
709
+ // Re-seed on prop change so consumers can drive order from outside.
710
+ let lastSeededOrder = "";
711
+ $effect(() => {
712
+ const incoming = props.columnOrder
713
+ ? [...props.columnOrder].join("|")
714
+ : "";
715
+ if (incoming === lastSeededOrder) return;
716
+ lastSeededOrder = incoming;
717
+ userColumnOrder = props.columnOrder ? [...props.columnOrder] : [];
718
+ });
719
+
720
+ const allColumns = $derived.by(() => {
721
+ let raw = grid
722
+ .getAllColumns()
723
+ .filter(
724
+ (column) =>
725
+ !hiddenColumns[column.id] &&
726
+ !hiddenByGroupCollapse[column.id] &&
727
+ !isHiddenByResponsive(column),
728
+ );
729
+ // Apply user reorder (if any). Unknown ids in userColumnOrder are
730
+ // skipped; columns not in userColumnOrder keep their original
731
+ // relative order after the user-ordered ones.
732
+ if (userColumnOrder.length > 0) {
733
+ const byId = new Map(raw.map((c) => [c.id, c]));
734
+ const seen = new Set<string>();
735
+ const ordered: Column<TData>[] = [];
736
+ for (const id of userColumnOrder) {
737
+ const c = byId.get(id);
738
+ if (c && !seen.has(id)) { ordered.push(c); seen.add(id); }
739
+ }
740
+ for (const c of raw) {
741
+ if (!seen.has(c.id)) ordered.push(c);
742
+ }
743
+ raw = ordered;
744
+ }
745
+ const leftIds = effectivePinning.left;
746
+ const rightIds = effectivePinning.right;
747
+ if (!leftIds.length && !rightIds.length) return raw;
748
+ const pinned = new Set([...leftIds, ...rightIds]);
749
+ const findById = (id: string) => raw.find((column) => column.id === id);
750
+ const left = leftIds
751
+ .map(findById)
752
+ .filter((c): c is Column<TData> => Boolean(c));
753
+ const right = rightIds
754
+ .map(findById)
755
+ .filter((c): c is Column<TData> => Boolean(c));
756
+ const unpinned = raw.filter((column) => !pinned.has(column.id));
757
+ return [...left, ...unpinned, ...right];
758
+ });
759
+
760
+ /** Header groups reordered to match {@link allColumns}. */
761
+ const headerGroups = $derived.by(() => {
762
+ const base = grid.getHeaderGroups();
763
+ if (!base.length) return base;
764
+ const byId = new Map(
765
+ base[0]!.headers.map((header) => [header.column.id, header]),
766
+ );
767
+ const headers: (typeof base)[number]["headers"] = [];
768
+ for (const column of allColumns) {
769
+ const header = byId.get(column.id);
770
+ if (header) headers.push(header);
771
+ }
772
+ return [{ id: base[0]!.id, headers }];
773
+ });
774
+
775
+ /**
776
+ * Group-header rows (PIVOT-style multi-level headers). When the
777
+ * consumer's column tree has `columns: [...]` nesting, we render extra
778
+ * header rows ABOVE the standard leaf-header row, each row showing one
779
+ * level of group labels with a colSpan covering the leaves underneath.
780
+ *
781
+ * For flat column lists this returns [] and no extra rows render -
782
+ * existing demos are unaffected.
783
+ *
784
+ * Each entry's `widthPx` precomputes the cell's pixel width as the sum
785
+ * of its leaf widths so the cells line up exactly with the columns
786
+ * below, even when the consumer mixes columns of different widths.
787
+ */
788
+ type GroupHeaderCell = {
789
+ key: string;
790
+ label: string;
791
+ colSpan: number;
792
+ widthPx: number;
793
+ /** First leaf-column index this cell spans. */
794
+ firstLeafIndex: number;
795
+ /** True for the placeholder cells that fill the column above an
796
+ * early-bottoming leaf (e.g. the row-label column to the left of a
797
+ * multi-level value tree). They render as empty cells so the
798
+ * layout stays aligned without showing duplicate labels. */
799
+ isPlaceholder: boolean;
800
+ /** Set when this group cell has a collapse toggle. */
801
+ groupId?: string;
802
+ collapsible: boolean;
803
+ collapsed: boolean;
804
+ };
805
+ const groupHeaderRows = $derived.by(() => {
806
+ const userCols: Array<ColumnDef<any, TData>> =
807
+ (props.columns as unknown as Array<ColumnDef<any, TData>>) ?? [];
808
+
809
+ // 1. Find max depth in the user-provided column tree.
810
+ function maxDepth(defs: Array<ColumnDef<any, TData>>): number {
811
+ let m = 0;
812
+ for (const d of defs) {
813
+ if (d.columns?.length) {
814
+ m = Math.max(m, 1 + maxDepth(d.columns));
815
+ }
816
+ }
817
+ return m;
818
+ }
819
+ const depth = maxDepth(userCols);
820
+ if (depth === 0) return [] as Array<{ id: string; cells: GroupHeaderCell[] }>;
821
+
822
+ // 2. Resolve each LEAF column def -> its id + leaf-column index in
823
+ // `allColumns`. Walks the same tree the engine walked. Used to
824
+ // compute pixel widths for group cells.
825
+ type LeafEntry = { id: string; widthPx: number };
826
+ const leafEntries: LeafEntry[] = [];
827
+ function buildId(def: ColumnDef<any, TData>, parentId: string | undefined, fallbackIx: number): string {
828
+ return def.id ?? def.field ?? `${parentId ?? 'col'}_d_${fallbackIx}`;
829
+ }
830
+ // Leaves hidden by a collapsed/expanded group are skipped everywhere here,
831
+ // so group colSpan + widthPx exclude them and stay aligned with the leaves
832
+ // the body actually renders.
833
+ const hiddenLeaf = hiddenByGroupCollapse;
834
+ function collectLeaves(
835
+ defs: Array<ColumnDef<any, TData>>,
836
+ parentId: string | undefined,
837
+ depthHere: number,
838
+ ): void {
839
+ defs.forEach((def, ix) => {
840
+ const id = buildId(def, parentId, ix);
841
+ if (def.columns?.length) {
842
+ collectLeaves(def.columns, id, depthHere + 1);
843
+ } else if (!hiddenLeaf[id]) {
844
+ leafEntries.push({ id, widthPx: getColumnWidth(id) });
845
+ }
846
+ });
847
+ }
848
+ collectLeaves(userCols, undefined, 0);
849
+
850
+ // 3. Emit per-depth group cells. We walk the tree per row, summing
851
+ // leaf widths under each node for colSpan + widthPx.
852
+ type NodeAt = { def: ColumnDef<any, TData>; id: string; leafStart: number; leafEnd: number };
853
+ function indexTree(
854
+ defs: Array<ColumnDef<any, TData>>,
855
+ parentId: string | undefined,
856
+ cursor: { leaf: number },
857
+ ): NodeAt[] {
858
+ const nodes: NodeAt[] = [];
859
+ for (const def of defs) {
860
+ const id = buildId(def, parentId, nodes.length);
861
+ // Skip leaves the collapse state hides, so leaf indices/colSpans match
862
+ // `leafEntries` (and the body's rendered columns) exactly.
863
+ if (!def.columns?.length && hiddenLeaf[id]) continue;
864
+ const leafStart = cursor.leaf;
865
+ if (def.columns?.length) {
866
+ indexTree(def.columns, id, cursor);
867
+ } else {
868
+ cursor.leaf += 1;
869
+ }
870
+ const leafEnd = cursor.leaf;
871
+ nodes.push({ def, id, leafStart, leafEnd });
872
+ }
873
+ return nodes;
874
+ }
875
+ const cursor = { leaf: 0 };
876
+ const topNodes = indexTree(userCols, undefined, cursor);
877
+
878
+ function nodesAtDepth(
879
+ nodes: NodeAt[],
880
+ currentDepth: number,
881
+ targetDepth: number,
882
+ ): NodeAt[] {
883
+ if (currentDepth === targetDepth) return nodes;
884
+ const out: NodeAt[] = [];
885
+ for (const n of nodes) {
886
+ if (n.def.columns?.length) {
887
+ const childCursor = { leaf: n.leafStart };
888
+ const children = indexTree(n.def.columns, n.id, childCursor);
889
+ out.push(...nodesAtDepth(children, currentDepth + 1, targetDepth));
890
+ } else {
891
+ // Leaf reached early - emit a placeholder at this row so the
892
+ // column above it stays empty (the leaf itself renders in the
893
+ // bottom leaf-header row, not here).
894
+ out.push(n);
895
+ }
896
+ }
897
+ return out;
898
+ }
899
+
900
+ function sumLeafWidths(from: number, to: number): number {
901
+ let sum = 0;
902
+ for (let i = from; i < to; i += 1) sum += leafEntries[i]?.widthPx ?? 0;
903
+ return sum;
904
+ }
905
+
906
+ const rows: Array<{ id: string; cells: GroupHeaderCell[] }> = [];
907
+ for (let d = 0; d < depth; d += 1) {
908
+ const at = nodesAtDepth(topNodes, 0, d);
909
+ const cells: GroupHeaderCell[] = at.map((n) => {
910
+ const isLeafEarly = !n.def.columns?.length;
911
+ const headerText =
912
+ typeof n.def.header === 'string' ? n.def.header : '';
913
+ const collapsible = columnGroupMeta.collapsibleGroupIds.has(n.id);
914
+ return {
915
+ key: `${n.id}_d${d}`,
916
+ label: isLeafEarly ? '' : headerText,
917
+ colSpan: Math.max(1, n.leafEnd - n.leafStart),
918
+ widthPx: sumLeafWidths(n.leafStart, n.leafEnd),
919
+ firstLeafIndex: n.leafStart,
920
+ isPlaceholder: isLeafEarly,
921
+ groupId: collapsible ? n.id : undefined,
922
+ collapsible,
923
+ collapsed: collapsible && collapsedColumnGroups.has(n.id),
924
+ };
925
+ });
926
+ rows.push({ id: `gh_${d}`, cells });
927
+ }
928
+ return rows;
929
+ });
930
+
931
+ /** Cumulative pixel offsets for left- and right-pinned columns. */
932
+ const pinnedOffsets = $derived.by(() => {
933
+ const rowNumberWidth = showRowNumbersEffective ? rowNumberColumnWidth : 0;
934
+ const selectionWidth = showRowSelectionEffective ? selectionColumnWidth : 0;
935
+ const left: Record<string, number> = {};
936
+ let leftAcc = rowNumberWidth + selectionWidth;
937
+ for (const id of effectivePinning.left) {
938
+ left[id] = leftAcc;
939
+ leftAcc += getColumnWidth(id);
940
+ }
941
+ const right: Record<string, number> = {};
942
+ let rightAcc = 0;
943
+ for (let i = effectivePinning.right.length - 1; i >= 0; i -= 1) {
944
+ const id = effectivePinning.right[i];
945
+ if (!id) continue;
946
+ right[id] = rightAcc;
947
+ rightAcc += getColumnWidth(id);
948
+ }
949
+ return { left, right };
950
+ });
951
+
952
+
953
+
954
+ // ---- Column reorder (drag headers) ----------------------------------
955
+ // Live drag state for the built-in header drag-to-reorder. Only set
956
+ // when `props.enableColumnReorder` is true.
957
+ let colDragId = $state<string | null>(null);
958
+ let colDropOnId = $state<string | null>(null);
959
+ let colDropSide = $state<"before" | "after" | null>(null);
960
+
961
+ // Live drag state for managed row dragging. Only meaningful while a row is
962
+ // being dragged (`props.rowDragManaged`). `rowDropIndex` is the visible row
963
+ // index currently hovered; `rowDropSide` says which edge the drop line paints.
964
+ let rowDragActive = $state<boolean>(false);
965
+ let rowDropIndex = $state<number | null>(null);
966
+ let rowDropSide = $state<"before" | "after" | null>(null);
967
+
968
+
969
+
970
+
971
+
972
+
973
+
974
+
975
+
976
+ // ---- Conditional formatting --------------------------------------------
977
+ // True when the feature is in use; gates the per-cell positioning context
978
+ // (cells are otherwise non-relative for scroll performance).
979
+ const hasConditionalFormats = $derived(
980
+ (props.conditionalFormats?.length ?? 0) > 0,
981
+ );
982
+ // Per-column numeric min/max, needed only by colorScale / dataBar formats.
983
+ // Lazy: this derived never runs unless `conditionalFormats` is set.
984
+ const conditionalColumnStats = $derived.by(() => {
985
+ const map = new Map<string, ColumnStat>();
986
+ const formats = props.conditionalFormats;
987
+ if (!formats?.length || !formatsNeedingStats(formats)) return map;
988
+ for (const column of allColumns) {
989
+ const needs = formats.some(
990
+ (f) =>
991
+ (f.type === "colorScale" || f.type === "dataBar") &&
992
+ (!f.columns || f.columns.includes(column.id)),
993
+ );
994
+ if (!needs) continue;
995
+ const def = column.columnDef;
996
+ const fieldFn = def.fieldFn;
997
+ const field = def.field;
998
+ const stat = computeColumnStat(
999
+ (function* () {
1000
+ for (const row of allRows) {
1001
+ yield fieldFn
1002
+ ? fieldFn(row.original)
1003
+ : field
1004
+ ? (row.original as Record<string, unknown>)[field]
1005
+ : row.getCellValueByColumnId(column.id);
1006
+ }
1007
+ })(),
1008
+ );
1009
+ if (stat) map.set(column.id, stat);
1010
+ }
1011
+ return map;
1012
+ });
1013
+
1014
+
1015
+
1016
+
1017
+
1018
+
1019
+ const sortDirectionByColumn = $derived.by(() => {
1020
+ gridStateVersion;
1021
+ const directions: Record<string, false | "asc" | "desc"> = {};
1022
+ for (const column of allColumns)
1023
+ directions[column.id] = column.getIsSorted();
1024
+ return directions;
1025
+ });
1026
+
1027
+ const groupingColumns = $derived.by(() => {
1028
+ gridStateVersion;
1029
+ return grid.getState().grouping ?? [];
1030
+ });
1031
+
1032
+ const paginationState = $derived.by(() => {
1033
+ gridStateVersion;
1034
+ return grid.getState().pagination ?? { pageIndex: 0, pageSize: 10 };
1035
+ });
1036
+
1037
+
1038
+ /**
1039
+ * Rows AFTER all filtering but BEFORE pagination. Used by the pager to
1040
+ * compute the correct "X to Y of Z" range and total page count when
1041
+ * filters reduce the dataset.
1042
+ */
1043
+ const allRowsBeforePagination = $derived.by(() => {
1044
+ // Depend on dataStateVersion (row-model-affecting store changes) NOT
1045
+ // gridStateVersion - so moving the active cell / selection does not force
1046
+ // this O(rows) pipeline to re-run. Filter-input state (globalFilter etc.)
1047
+ // and internalData are read below and tracked as their own dependencies.
1048
+ dataStateVersion;
1049
+ // Touch internalData + internalColumns so the row model re-derives when
1050
+ // the consumer replaces the data array (e.g. via a "Reset" button).
1051
+ void internalData;
1052
+ void internalColumns;
1053
+ const rawRows = grid.getRowModel().rows;
1054
+ // External-filter mode: the consumer fetched / pre-filtered the rows
1055
+ // themselves (server-side data sources). Skip every local filter pass
1056
+ // so the data isn't double-filtered against the visible page.
1057
+ if (externalFilterEnabled) return rawRows;
1058
+
1059
+ let rows = rawRows;
1060
+ if (globalFilter.trim()) {
1061
+ const needle = normalizeForFilter(globalFilter, props.filterLocale);
1062
+ rows = rows.filter((row) =>
1063
+ row
1064
+ .getAllCells()
1065
+ .some((cell) =>
1066
+ normalizeForFilter(String(cell.getValue() ?? ""), props.filterLocale)
1067
+ .includes(needle),
1068
+ ),
1069
+ );
1070
+ }
1071
+
1072
+ // A single condition is "active" if it has the value(s) it needs.
1073
+ const condActive = (op: FilterOperator, value: string, valueTo?: string): boolean => {
1074
+ if (op === "isBlank") return true;
1075
+ if (op === "between") return value.trim().length > 0 && (valueTo ?? "").trim().length > 0;
1076
+ return value.trim().length > 0;
1077
+ };
1078
+ const evalCond = (
1079
+ cellValue: unknown,
1080
+ columnId: string,
1081
+ op: FilterOperator,
1082
+ value: string,
1083
+ valueTo?: string,
1084
+ ): boolean =>
1085
+ applyExcelFilter(
1086
+ cellValue,
1087
+ { id: columnId, operator: op, value, valueTo: op === "between" ? valueTo : undefined },
1088
+ { locale: props.filterLocale },
1089
+ );
1090
+ // A column filter is active if either of its (up to two) conditions is.
1091
+ const menuFilters = Object.entries(filterMenuValues).filter(([_, f]) => {
1092
+ const a = condActive(f.operator, f.value, f.valueTo);
1093
+ const b = !!f.operator2 && condActive(f.operator2, f.value2 ?? "", f.valueTo2);
1094
+ return a || b;
1095
+ });
1096
+ if (menuFilters.length) {
1097
+ rows = rows.filter((row) =>
1098
+ menuFilters.every(([columnId, f]) => {
1099
+ const cellValue = getRowColumnValue(row, columnId);
1100
+ const aActive = condActive(f.operator, f.value, f.valueTo);
1101
+ const bActive = !!f.operator2 && condActive(f.operator2, f.value2 ?? "", f.valueTo2);
1102
+ const ra = aActive ? evalCond(cellValue, columnId, f.operator, f.value, f.valueTo) : null;
1103
+ const rb = bActive
1104
+ ? evalCond(cellValue, columnId, f.operator2 as FilterOperator, f.value2 ?? "", f.valueTo2)
1105
+ : null;
1106
+ if (ra === null) return rb ?? true;
1107
+ if (rb === null) return ra;
1108
+ return f.join === "OR" ? ra || rb : ra && rb;
1109
+ }),
1110
+ );
1111
+ }
1112
+
1113
+ const valueFilterEntries = Object.entries(valueFilters);
1114
+ if (valueFilterEntries.length) {
1115
+ // Resolve bucket defs up front so we don't re-hit the derived map
1116
+ // for every row × column combination. Columns without bucketing
1117
+ // map to `null` here and fall through to exact-value matching.
1118
+ const bucketEntries = valueFilterEntries.map(([columnId, allowed]) => ({
1119
+ columnId,
1120
+ allowed,
1121
+ buckets: facetBucketsByColumn.get(columnId) ?? null,
1122
+ }));
1123
+ rows = rows.filter((row) =>
1124
+ bucketEntries.every(({ columnId, allowed, buckets }) => {
1125
+ const raw = getRowColumnValue(row, columnId);
1126
+ if (buckets) {
1127
+ // Range-bucketed filter: find which bucket this row's value
1128
+ // falls into and check whether that bucket's label is allowed.
1129
+ const isDate = buckets[0]!.isDate;
1130
+ const num = rawToNumber(raw, isDate);
1131
+ if (!Number.isFinite(num)) return false;
1132
+ for (const bucket of buckets) {
1133
+ if (isInBucket(num, bucket)) return allowed.has(bucket.label);
1134
+ }
1135
+ return false;
1136
+ }
1137
+ return allowed.has(String(raw ?? ""));
1138
+ }),
1139
+ );
1140
+ }
1141
+
1142
+ return rows;
1143
+ });
1144
+
1145
+ /**
1146
+ * Visible rows for the current page. Applied last so filters operate on
1147
+ * the full dataset rather than the current page (see the comment above
1148
+ * `_rowModels`).
1149
+ */
1150
+ const allRows = $derived.by(() => {
1151
+ const rows = allRowsBeforePagination;
1152
+ // External pagination: `data` already IS the current page - never slice.
1153
+ if (!paginationEnabled || externalPaginationEnabled) return rows;
1154
+ const { pageIndex, pageSize } = paginationState;
1155
+ const start = pageIndex * pageSize;
1156
+ return rows.slice(start, start + pageSize);
1157
+ });
1158
+
1159
+ // When a filter reduces the dataset, the stored pageIndex can point beyond
1160
+ // the last valid page. Reset to page 0 so the grid never shows a blank body.
1161
+ // Skipped for external pagination, where the consumer owns pageIndex.
1162
+ $effect(() => {
1163
+ if (!paginationEnabled || externalPaginationEnabled) return;
1164
+ const { pageIndex, pageSize } = paginationState;
1165
+ const pageCount = Math.ceil(allRowsBeforePagination.length / pageSize);
1166
+ if (pageCount > 0 && pageIndex >= pageCount) {
1167
+ grid.setPagination({ pageIndex: 0, pageSize });
1168
+ }
1169
+ });
1170
+
1171
+ // Footer-facing pagination values. In external mode they come from the
1172
+ // consumer-controlled props; otherwise from the local row model + state.
1173
+ const paginationTotalRows = $derived(
1174
+ externalPaginationEnabled ? (props.rowCount ?? 0) : allRowsBeforePagination.length,
1175
+ );
1176
+ const paginationPageIndex = $derived(
1177
+ externalPaginationEnabled ? (props.pageIndex ?? 0) : paginationState.pageIndex,
1178
+ );
1179
+ const paginationPageSize = $derived(
1180
+ externalPaginationEnabled ? (props.pageSize ?? 10) : paginationState.pageSize,
1181
+ );
1182
+ const rowSelectionState = $derived.by(() => {
1183
+ gridStateVersion;
1184
+ return grid.getState().rowSelection ?? {};
1185
+ });
1186
+
1187
+ // Forward selection changes to the consumer. Skips the very first invocation
1188
+ // (the initial empty state) so consumers don't get a spurious callback on mount.
1189
+ let lastSelectionSerialized = "";
1190
+ $effect(() => {
1191
+ const serialized = JSON.stringify(rowSelectionState);
1192
+ if (serialized === lastSelectionSerialized) return;
1193
+ lastSelectionSerialized = serialized;
1194
+ const callback = props.onRowSelectionChange;
1195
+ if (!callback) return;
1196
+ const data = internalData;
1197
+ const selectedRows: TData[] = [];
1198
+ for (let i = 0; i < data.length; i++) {
1199
+ if (rowSelectionState[String(i)]) selectedRows.push(data[i] as TData);
1200
+ }
1201
+ callback(rowSelectionState, selectedRows);
1202
+ });
1203
+
1204
+ // Forward cell-selection rectangle changes to the consumer. Same
1205
+ // dedupe pattern - fires only when the serialized rectangle changes
1206
+ // so consumers don't see spurious callbacks during re-renders.
1207
+ let lastCellRangeSerialized = "";
1208
+ $effect(() => {
1209
+ const a = selectionRange.anchor;
1210
+ const f = selectionRange.focus;
1211
+ const ranges: Array<[number, number, number, number]> =
1212
+ a && f
1213
+ ? [[
1214
+ Math.min(a.rowIndex, f.rowIndex),
1215
+ Math.min(a.colIndex, f.colIndex),
1216
+ Math.max(a.rowIndex, f.rowIndex),
1217
+ Math.max(a.colIndex, f.colIndex),
1218
+ ]]
1219
+ : [];
1220
+ const serialized = JSON.stringify(ranges);
1221
+ if (serialized === lastCellRangeSerialized) return;
1222
+ lastCellRangeSerialized = serialized;
1223
+ props.onCellSelectionChange?.(ranges);
1224
+ });
1225
+
1226
+ // ---- Status bar: live aggregates of the selected cell range -----------
1227
+ const statusBarEnabled = $derived(
1228
+ props.statusBar != null && props.statusBar !== false,
1229
+ );
1230
+ const statusBarAggregates = $derived(
1231
+ typeof props.statusBar === "object" && props.statusBar.aggregates
1232
+ ? props.statusBar.aggregates
1233
+ : (["count", "sum", "avg", "min", "max"] as const),
1234
+ );
1235
+ const statusBarStats = $derived.by(() => {
1236
+ if (!statusBarEnabled) return null;
1237
+ const a = selectionRange.anchor;
1238
+ const f = selectionRange.focus;
1239
+ if (!a || !f) return null;
1240
+ const minR = Math.min(a.rowIndex, f.rowIndex);
1241
+ const maxR = Math.max(a.rowIndex, f.rowIndex);
1242
+ const minC = Math.min(a.colIndex, f.colIndex);
1243
+ const maxC = Math.max(a.colIndex, f.colIndex);
1244
+ let count = 0;
1245
+ let numericCount = 0;
1246
+ let sum = 0;
1247
+ let min = Number.POSITIVE_INFINITY;
1248
+ let max = Number.NEGATIVE_INFINITY;
1249
+ for (let r = minR; r <= maxR; r += 1) {
1250
+ const row = allRows[r];
1251
+ if (!row || isGroupRow(row)) continue;
1252
+ for (let c = minC; c <= maxC; c += 1) {
1253
+ const col = allColumns[c];
1254
+ if (!col) continue;
1255
+ count += 1;
1256
+ const base = getColumnBaseValue(row, col);
1257
+ const v = getCellDisplayValue(row.id, col.id, base);
1258
+ if (v == null || v === "") continue;
1259
+ const n = Number(v);
1260
+ if (!Number.isFinite(n)) continue;
1261
+ numericCount += 1;
1262
+ sum += n;
1263
+ if (n < min) min = n;
1264
+ if (n > max) max = n;
1265
+ }
1266
+ }
1267
+ if (count <= 1) return null;
1268
+ return {
1269
+ count,
1270
+ numericCount,
1271
+ sum,
1272
+ avg: numericCount ? sum / numericCount : 0,
1273
+ min: numericCount ? min : 0,
1274
+ max: numericCount ? max : 0,
1275
+ };
1276
+ });
1277
+
1278
+
1279
+ // ---- Tool panel (docked columns sidebar) -------------------------------
1280
+ // svelte-ignore state_referenced_locally
1281
+ let toolPanelOpen = $state(props.toolPanelDefaultOpen === true);
1282
+ // svelte-ignore state_referenced_locally
1283
+ let toolPanelTab = $state<"columns" | "filters">(props.toolPanelDefaultTab ?? "columns");
1284
+ const toolPanelEnabled = $derived(props.toolPanel === true);
1285
+ // Every column (including hidden ones) in the user's current order, so the
1286
+ // panel can toggle/reorder anything. Group columns are flagged live.
1287
+ const toolPanelColumns = $derived.by(() => {
1288
+ gridStateVersion;
1289
+ const all = grid.getAllColumns();
1290
+ if (!userColumnOrder.length) return all;
1291
+ const byId = new Map(all.map((c) => [c.id, c]));
1292
+ const ordered: Column<TData>[] = [];
1293
+ const seen = new Set<string>();
1294
+ for (const id of userColumnOrder) {
1295
+ const c = byId.get(id);
1296
+ if (c && !seen.has(id)) {
1297
+ ordered.push(c);
1298
+ seen.add(id);
1299
+ }
1300
+ }
1301
+ for (const c of all) if (!seen.has(c.id)) ordered.push(c);
1302
+ return ordered;
1303
+ });
1304
+
1305
+
1306
+ // Forward sort-clause changes to the consumer. Same dedupe pattern as the
1307
+ // selection callback above - fires only when the serialized clauses change.
1308
+ let lastSortingSerialized = "";
1309
+ $effect(() => {
1310
+ gridStateVersion;
1311
+ const sorting = (grid.getState().sorting ?? []) as Array<{
1312
+ id: string;
1313
+ desc: boolean;
1314
+ }>;
1315
+ const serialized = JSON.stringify(sorting);
1316
+ if (serialized === lastSortingSerialized) return;
1317
+ lastSortingSerialized = serialized;
1318
+ props.onSortingChange?.(sorting);
1319
+ });
1320
+
1321
+ // Forward filter-state changes to the consumer. Consolidates the three
1322
+ // wrapper-managed filter stores (global text, per-column operator filters,
1323
+ // facet checklists) into one shape so server-side consumers can build a
1324
+ // single query. Skipped entirely when no callback is registered to avoid
1325
+ // serializing on every keystroke.
1326
+ let lastFiltersSerialized = "";
1327
+ $effect(() => {
1328
+ if (!props.onFiltersChange) return;
1329
+ const menuEntries = Object.entries(filterMenuValues)
1330
+ .filter(([, f]) => {
1331
+ if (f.operator === "isBlank") return true;
1332
+ if (f.operator === "between") {
1333
+ return f.value.trim().length > 0 && (f.valueTo ?? "").trim().length > 0;
1334
+ }
1335
+ return f.value.trim().length > 0;
1336
+ })
1337
+ .map(([id, f]) => ({
1338
+ id,
1339
+ operator: f.operator,
1340
+ value: f.value,
1341
+ ...(f.operator === "between" && f.valueTo
1342
+ ? { valueTo: f.valueTo }
1343
+ : {}),
1344
+ }));
1345
+ const valueEntries = Object.entries(valueFilters).map(([id, allowed]) => ({
1346
+ id,
1347
+ operator: "equals" as FilterOperator,
1348
+ value: "",
1349
+ selectedValues: Array.from(allowed).sort(),
1350
+ }));
1351
+ const merged = new Map<
1352
+ string,
1353
+ {
1354
+ id: string;
1355
+ operator: FilterOperator;
1356
+ value: string;
1357
+ selectedValues?: Array<string>;
1358
+ }
1359
+ >();
1360
+ for (const entry of menuEntries) merged.set(entry.id, entry);
1361
+ for (const entry of valueEntries) {
1362
+ const existing = merged.get(entry.id);
1363
+ merged.set(
1364
+ entry.id,
1365
+ existing
1366
+ ? { ...existing, selectedValues: entry.selectedValues }
1367
+ : entry,
1368
+ );
1369
+ }
1370
+ const payload = {
1371
+ global: globalFilter,
1372
+ columns: Array.from(merged.values()),
1373
+ };
1374
+ const serialized = JSON.stringify(payload);
1375
+ if (serialized === lastFiltersSerialized) return;
1376
+ lastFiltersSerialized = serialized;
1377
+ props.onFiltersChange(payload);
1378
+ });
1379
+
1380
+ const virtualizer = createSvelteVirtualizer({
1381
+ count: 0,
1382
+ estimateSize: 36,
1383
+ overscan: 8,
1384
+ viewportHeight: 520,
1385
+ scrollOffset: 0,
1386
+ });
1387
+ const columnVirtualizer = createColumnVirtualizer({
1388
+ count: 0,
1389
+ viewportWidth: 0,
1390
+ overscan: 3,
1391
+ estimateSize: () => 140,
1392
+ });
1393
+ columnVirtualizer.subscribe(() => {
1394
+ columnVirtualizerVersion += 1;
1395
+ });
1396
+
1397
+ const rowVirtualizationEnabled = $derived(
1398
+ (props.virtualization ?? true) && allRows.length > 0,
1399
+ );
1400
+ const columnVirtualizationEnabled = $derived(
1401
+ (props.columnVirtualization ?? true) && allColumns.length > 0,
1402
+ );
1403
+ const virtualRows = $derived.by(() => {
1404
+ virtualizer.version;
1405
+ return virtualizer.getVirtualItems();
1406
+ });
1407
+ const virtualRowTotalSize = $derived.by(() => {
1408
+ virtualizer.version;
1409
+ return virtualizer.getTotalSize();
1410
+ });
1411
+ const virtualRowStart = $derived.by(() => virtualRows[0]?.start ?? 0);
1412
+ const virtualRowEnd = $derived.by(
1413
+ () => virtualRows[virtualRows.length - 1]?.end ?? 0,
1414
+ );
1415
+ const virtualRowBottomSpacer = $derived.by(() =>
1416
+ Math.max(virtualRowTotalSize - virtualRowEnd, 0),
1417
+ );
1418
+
1419
+ // --- Huge-list scroll scaling -----------------------------------------
1420
+ // Browsers cap how tall a single element may be, and mobile caps sit well
1421
+ // below desktop. Past a few hundred thousand rows the true content height
1422
+ // (count * rowHeight) exceeds that cap, the scroll container silently
1423
+ // clamps its scrollHeight, and the last rows become unreachable - e.g. a
1424
+ // 1,000,000-row grid that only scrolls to ~994,000 on a phone.
1425
+ //
1426
+ // When the true height exceeds the browser's max element height we cap the
1427
+ // DOM scroll height and map between the limited DOM scroll range and the
1428
+ // full logical range (the "scaling" technique from react-virtualized): the
1429
+ // spacers are sized in the capped DOM space, while the virtualizer keeps
1430
+ // working in true logical pixels. We detect the real per-browser cap at
1431
+ // runtime (Chrome ~33.5M, Firefox ~17.9M, mobile lower) rather than guess a
1432
+ // constant, so scaling activates only when genuinely needed and stays as
1433
+ // fine-grained as the browser allows. For normal-sized grids scaling is
1434
+ // inert and every value below reduces to the original behavior.
1435
+ // Build the scaling mapping from the current true height + detected browser
1436
+ // cap + viewport. The pure, unit-tested math lives in
1437
+ // ./virtualization/scroll-scaling; here we only feed it reactive inputs.
1438
+ // Inert (identity) for normal-sized grids.
1439
+ const rowScrollScaling = $derived(
1440
+ createRowScrollScaling(
1441
+ virtualRowTotalSize,
1442
+ getMaxDomScrollHeight(),
1443
+ viewportHeight,
1444
+ ),
1445
+ );
1446
+ const rowDomTotalSize = $derived(rowScrollScaling.domTotal);
1447
+ const rowScrollScalingActive = $derived(rowScrollScaling.active);
1448
+ // Map a DOM scrollTop to the virtualizer's logical scroll offset, and back.
1449
+ function domToLogicalRowOffset(domTop: number): number {
1450
+ return rowScrollScaling.domToLogical(domTop);
1451
+ }
1452
+ function logicalToDomRowOffset(logical: number): number {
1453
+ return rowScrollScaling.logicalToDom(logical);
1454
+ }
1455
+ // px the logical row positions must shift to land inside the capped DOM
1456
+ // coordinate space (0 when not scaling). Derived from the virtualizer's
1457
+ // OWN committed scroll offset - not the live DOM scrollTop - so the spacer
1458
+ // shift and the rendered window are always computed from the same state and
1459
+ // can never skew by a frame (which would jitter at extreme scale).
1460
+ const rowOffsetAdjustment = $derived.by(() => {
1461
+ if (!rowScrollScalingActive) return 0;
1462
+ virtualizer.version;
1463
+ const logical = virtualizer.getState().scrollOffset;
1464
+ return logical - rowScrollScaling.logicalToDom(logical);
1465
+ });
1466
+ // Spacer heights in DOM space. With scaling inert these equal the original
1467
+ // virtualRowStart / virtualRowBottomSpacer.
1468
+ const rowTopSpacer = $derived(Math.max(virtualRowStart - rowOffsetAdjustment, 0));
1469
+ const rowBottomSpacer = $derived(
1470
+ Math.max(rowDomTotalSize - (virtualRowEnd - rowOffsetAdjustment), 0),
1471
+ );
1472
+ const virtualColumns = $derived.by(() => {
1473
+ columnVirtualizerVersion;
1474
+ return columnVirtualizer.getVirtualItems();
1475
+ });
1476
+ const virtualColumnTotalSize = $derived.by(() => {
1477
+ columnVirtualizerVersion;
1478
+ return columnVirtualizer.getTotalSize();
1479
+ });
1480
+ const renderedColumnItems = $derived.by(() => {
1481
+ if (!columnVirtualizationEnabled) {
1482
+ let start = 0;
1483
+ return allColumns.map((column, index) => {
1484
+ const size = getColumnWidth(column.id);
1485
+ const item = { index, key: index, size, start, end: start + size };
1486
+ start += size;
1487
+ return item;
1488
+ });
1489
+ }
1490
+ // Pinned columns are position:sticky, so they only stay pinned while their
1491
+ // cell is in the DOM. Plain column virtualization drops them once they leave
1492
+ // the scroll window, and the pinned column vanishes. Because allColumns is
1493
+ // ordered [pinnedLeft, unpinned, pinnedRight], we keep the rendered window
1494
+ // CONTIGUOUS from the pinned-left prefix (index 0) through the pinned-right
1495
+ // suffix (last index) whenever those exist. The pinned cells are then always
1496
+ // rendered - the existing single-spacer layout positions everything, so no
1497
+ // markup changes are needed. (Cost: with a pinned side, the columns between
1498
+ // that edge and the window are also rendered; negligible for typical grids,
1499
+ // and correctness beats shaving a few off-screen cells.)
1500
+ const window = virtualColumns;
1501
+ const hasLeft = effectivePinning.left.length > 0;
1502
+ const hasRight = effectivePinning.right.length > 0;
1503
+ if ((!hasLeft && !hasRight) || window.length === 0) return window;
1504
+
1505
+ const firstIdx = window[0]!.index;
1506
+ const lastIdx = window[window.length - 1]!.index;
1507
+ const startIndex = hasLeft ? 0 : firstIdx;
1508
+ const endIndex = hasRight ? allColumns.length - 1 : lastIdx;
1509
+ if (startIndex === firstIdx && endIndex === lastIdx) return window;
1510
+
1511
+ const items: Array<{ index: number; key: number; size: number; start: number; end: number }> = [];
1512
+ let offset = 0;
1513
+ for (let i = 0; i < startIndex; i += 1) offset += getColumnWidth(allColumns[i]!.id);
1514
+ for (let i = startIndex; i <= endIndex; i += 1) {
1515
+ const size = getColumnWidth(allColumns[i]!.id);
1516
+ items.push({ index: i, key: i, size, start: offset, end: offset + size });
1517
+ offset += size;
1518
+ }
1519
+ return items;
1520
+ });
1521
+ const renderedColumns = $derived.by(() =>
1522
+ renderedColumnItems
1523
+ .map((item) => ({ item, column: allColumns[item.index] }))
1524
+ .filter(hasRenderedColumn),
1525
+ );
1526
+ const totalColumnWidth = $derived.by(() => {
1527
+ if (columnVirtualizationEnabled) return virtualColumnTotalSize;
1528
+ let total = 0;
1529
+ for (const column of allColumns) total += getColumnWidth(column.id);
1530
+ return total;
1531
+ });
1532
+ /** Horizontal overflow derived from the SOURCE OF TRUTH (column widths
1533
+ * + leading sticky columns) compared to the viewport. We can't use
1534
+ * `totalColumnWidth` here when column virtualization is on - that
1535
+ * returns the column virtualizer's cached total, which only updates
1536
+ * on `setOptions()` / scroll, NOT when `fittedColumnWidths` finishes
1537
+ * scaling on first measure. Reading `getColumnWidth(c.id)` for every
1538
+ * column instead is reactive to both `columnWidths` and
1539
+ * `fittedColumnWidths`, so the overflow decision settles in the same
1540
+ * render where fit-scaling lands - no race, no scrollbar flash. */
1541
+ const hasHorizontalOverflow = $derived.by(() => {
1542
+ const fixedCols =
1543
+ (showRowNumbersEffective ? rowNumberColumnWidth : 0) +
1544
+ (showRowSelectionEffective ? selectionColumnWidth : 0);
1545
+ let total = fixedCols;
1546
+ for (const column of allColumns) total += getColumnWidth(column.id);
1547
+ // +1 to tolerate sub-pixel rounding residue from `fitColumns`.
1548
+ return total > viewportWidth + 1;
1549
+ });
1550
+ const columnWindowStart = $derived.by(
1551
+ () => renderedColumnItems[0]?.start ?? 0,
1552
+ );
1553
+ const columnWindowEnd = $derived.by(
1554
+ () => renderedColumnItems[renderedColumnItems.length - 1]?.end ?? 0,
1555
+ );
1556
+ const columnWindowRightSpacer = $derived.by(() =>
1557
+ Math.max(totalColumnWidth - columnWindowEnd, 0),
1558
+ );
1559
+
1560
+ const activeCell = $derived.by(() => {
1561
+ gridStateVersion;
1562
+ return (
1563
+ grid.getState().activeCell ?? { rowIndex: 0, colIndex: 0, cellId: null }
1564
+ );
1565
+ });
1566
+
1567
+ const activeDescendantId = $derived.by(() => {
1568
+ const active = activeCell;
1569
+ const inRows = active.rowIndex >= 0 && active.rowIndex < allRows.length;
1570
+ const inCols = active.colIndex >= 0 && active.colIndex < allColumns.length;
1571
+ if (!inRows || !inCols) return null;
1572
+ return getGridCellDomId("svgrid", active.rowIndex, active.colIndex);
1573
+ });
1574
+
1575
+
1576
+
1577
+ // Above this many cells (rows x columns) the summary aggregation is
1578
+ // deferred one animation frame so it never blocks first paint - a
1579
+ // 100k x 50 grid would otherwise spend seconds summing reactive cells
1580
+ // before the grid ever appears. Smaller grids compute inline so the
1581
+ // footer is correct on the first frame (no flicker).
1582
+ const SUMMARY_DEFER_CELL_LIMIT = 50_000;
1583
+
1584
+ let summaryByColumn = $state<Record<string, string>>({});
1585
+ $effect(() => {
1586
+ // Re-aggregate whenever the data / columns / edits change. We depend on
1587
+ // `allRows` / `allColumns` / `editedCellValues` DIRECTLY - not the
1588
+ // catch-all `gridStateVersion` - because that version bumps on EVERY store
1589
+ // change, including moving the active cell or selection. `allRows` stays
1590
+ // referentially stable across those (sort/filter/paginate produce a new
1591
+ // rows array; navigation does not), so this now skips the
1592
+ // O(rows x cols) aggregation on plain keyboard navigation - which was
1593
+ // making arrow-key movement crawl on huge grids (e.g. 1,000,000 rows).
1594
+ void editedCellValues;
1595
+ const rows = allRows;
1596
+ const columns = allColumns;
1597
+ if (!(props.enableRowSummaries ?? true)) {
1598
+ summaryByColumn = {};
1599
+ return;
1600
+ }
1601
+ if (
1602
+ rows.length * columns.length <= SUMMARY_DEFER_CELL_LIMIT ||
1603
+ typeof requestAnimationFrame === "undefined"
1604
+ ) {
1605
+ summaryByColumn = computeSummaries(rows, columns);
1606
+ return;
1607
+ }
1608
+ // Large grid: paint first, total a frame later.
1609
+ let cancelled = false;
1610
+ const handle = requestAnimationFrame(() => {
1611
+ if (!cancelled) summaryByColumn = computeSummaries(rows, columns);
1612
+ });
1613
+ return () => {
1614
+ cancelled = true;
1615
+ cancelAnimationFrame(handle);
1616
+ };
1617
+ });
1618
+
1619
+ $effect(() => {
1620
+ if (!theadEl) return;
1621
+ headerHeight = theadEl.offsetHeight;
1622
+ return observeSizeRaf(theadEl, () => {
1623
+ headerHeight = theadEl?.offsetHeight ?? 0;
1624
+ });
1625
+ });
1626
+
1627
+ // Bump scrollVersion when the table's layout size changes so scrollbar
1628
+ // visibility (and the thumb math that depends on scroll metrics) updates
1629
+ // after column resize / show-hide / add-remove.
1630
+ $effect(() => {
1631
+ if (!gridRootEl) return;
1632
+ return observeSizeRaf(gridRootEl, () => {
1633
+ scrollVersion += 1;
1634
+ });
1635
+ });
1636
+
1637
+ $effect(() => {
1638
+ if (!allRows.length || !allColumns.length) return;
1639
+ const active = grid.getState().activeCell;
1640
+ if (active?.cellId) return;
1641
+ grid.setActiveCell({
1642
+ rowIndex: 0,
1643
+ colIndex: 0,
1644
+ cellId: getGridCellDomId("svgrid", 0, 0),
1645
+ });
1646
+ });
1647
+
1648
+ $effect(() => {
1649
+ // Only reset scroll + selection + editing when the COLUMN SCHEMA
1650
+ // changes. Data length is too weak a signal:
1651
+ // - Streaming inserts grow the length and shouldn't move scroll.
1652
+ // - Filter / delete events shrink the length and shouldn't either
1653
+ // (the user's spot in the data is what they care about).
1654
+ // - Sort changes preserve length but mean "start from the top",
1655
+ // so callers who want that should drive it explicitly via
1656
+ // api.scrollToTop() (or the equivalent).
1657
+ // The columns ARE a schema change: existing scroll/selection
1658
+ // coordinates are no longer meaningful when the grid's column set
1659
+ // is replaced, so we still reset there.
1660
+ const colCount = props.columns.length;
1661
+ const nextSignature = `cols:${colCount}`;
1662
+ if (nextSignature === lastResetSignature) return;
1663
+ const isFirstRender = lastResetSignature === "";
1664
+ lastResetSignature = nextSignature;
1665
+ if (isFirstRender) return;
1666
+
1667
+ selectionRange = { anchor: null, focus: null };
1668
+ selectionRanges = [];
1669
+ editingCell = null;
1670
+ if (scrollContainer) {
1671
+ scrollContainer.scrollTop = 0;
1672
+ scrollContainer.scrollLeft = 0;
1673
+ scrollVersion += 1;
1674
+ }
1675
+ virtualizer.setScrollOffset(0);
1676
+ columnVirtualizer.setHorizontalOffset(0);
1677
+ });
1678
+
1679
+ // Wire scroll-change listeners SEPARATELY for each scrollbar - bundling
1680
+ // them in one effect with `if (!vertical || !horizontal) return` was
1681
+ // the bug behind "vertical scrollbar can't be dragged": with overflow
1682
+ // gating, demos without horizontal overflow never mount the horizontal
1683
+ // scrollbar, the combined guard tripped, and the vertical listener
1684
+ // never got attached either. Each scrollbar is now independent.
1685
+ $effect(() => {
1686
+ if (!scrollContainer || !verticalScrollbarEl) return;
1687
+ const el = verticalScrollbarEl;
1688
+ const onVertical = (event: Event) => {
1689
+ const container = scrollContainer;
1690
+ if (!container) return;
1691
+ const customEvent = event as CustomEvent<{ value: number }>;
1692
+ container.scrollTop = customEvent.detail.value;
1693
+ scheduleScrollSync(container.scrollTop, container.scrollLeft);
1694
+ };
1695
+ el.addEventListener("scroll-change", onVertical as EventListener);
1696
+ return () =>
1697
+ el.removeEventListener("scroll-change", onVertical as EventListener);
1698
+ });
1699
+
1700
+ $effect(() => {
1701
+ if (!scrollContainer || !horizontalScrollbarEl) return;
1702
+ const el = horizontalScrollbarEl;
1703
+ const onHorizontal = (event: Event) => {
1704
+ const container = scrollContainer;
1705
+ if (!container) return;
1706
+ const customEvent = event as CustomEvent<{ value: number }>;
1707
+ container.scrollLeft = customEvent.detail.value;
1708
+ scheduleScrollSync(container.scrollTop, container.scrollLeft);
1709
+ };
1710
+ el.addEventListener("scroll-change", onHorizontal as EventListener);
1711
+ return () =>
1712
+ el.removeEventListener("scroll-change", onHorizontal as EventListener);
1713
+ });
1714
+
1715
+ $effect(() => {
1716
+ // When containerHeight is a string (e.g. "100%") the actual pixel height
1717
+ // depends on the parent layout - read it from the live scroll container.
1718
+ // We track `viewportVersion` (only bumped by the ResizeObserver below)
1719
+ // instead of `scrollVersion` so this effect does NOT re-run on every
1720
+ // scroll event - which would otherwise re-call setOptions hundreds of
1721
+ // times during a drag.
1722
+ viewportVersion;
1723
+ const viewportHeight =
1724
+ typeof props.containerHeight === "string"
1725
+ ? (scrollContainer?.clientHeight ?? 520)
1726
+ : (props.containerHeight ?? 520);
1727
+ const rh = props.rowHeight;
1728
+ virtualizer.setOptions({
1729
+ count: allRows.length,
1730
+ estimateSize: typeof rh === "function" ? rh : (rh ?? 30),
1731
+ overscan: props.overscan ?? 8,
1732
+ viewportHeight,
1733
+ });
1734
+ });
1735
+
1736
+ // Track size changes of the shell so the virtualizer's viewport, the
1737
+ // fit-columns scale, and anything else that depends on the container
1738
+ // width/height stays in sync. Always attached (window resize / parent
1739
+ // layout shift / sidebar collapse can change the size whether the
1740
+ // consumer passed a numeric or "100%" containerHeight).
1741
+ /** True after the first ResizeObserver tick - i.e. once the grid has
1742
+ * measured its real container size and `fitColumns` has had a chance
1743
+ * to scale the columns to that width. Used to gate the scrollbar
1744
+ * visibility: rendering it before this flips paints a horizontal
1745
+ * scrollbar for ONE frame (based on the base column widths summing
1746
+ * larger than the viewport), then immediately hides it once fit
1747
+ * scaling kicks in - visible as a "flashing horizontal scrollbar"
1748
+ * every time a demo first loads. */
1749
+ let hasMeasured = $state(false);
1750
+
1751
+ $effect(() => {
1752
+ if (!scrollContainer) return;
1753
+ return observeSizeRaf(scrollContainer, () => {
1754
+ viewportVersion += 1;
1755
+ if (!hasMeasured) hasMeasured = true;
1756
+ });
1757
+ });
1758
+
1759
+ $effect(() => {
1760
+ // Reading columnWidths here registers it as a reactive dependency so
1761
+ // the effect re-runs when the user resizes a column. We pass a fresh
1762
+ // closure each run; the virtualizer sees a new function reference and
1763
+ // re-derives its layout from the current per-column widths.
1764
+ columnWidths;
1765
+ columnVirtualizer.setOptions({
1766
+ count: allColumns.length,
1767
+ estimateSize: (index: number) => {
1768
+ const column = allColumns[index];
1769
+ return column ? getColumnWidth(column.id) : (props.columnWidth ?? 140);
1770
+ },
1771
+ overscan: props.columnOverscan ?? 3,
1772
+ viewportHeight: viewportWidth,
1773
+ });
1774
+ });
1775
+
1776
+ $effect(() => {
1777
+ if (!scrollContainer) return;
1778
+ if (rowVirtualizationEnabled)
1779
+ virtualizer.setScrollOffset(domToLogicalRowOffset(scrollContainer.scrollTop));
1780
+ if (columnVirtualizationEnabled)
1781
+ columnVirtualizer.setHorizontalOffset(scrollContainer.scrollLeft);
1782
+ });
1783
+
1784
+ // Re-arms once the user scrolls away from the bottom, so a long lazy-load
1785
+ // run only fires `onScrollBottomReached` once per arrival at the end.
1786
+ let scrollBottomArmed = true;
1787
+
1788
+
1789
+
1790
+
1791
+
1792
+
1793
+
1794
+
1795
+
1796
+
1797
+
1798
+
1799
+
1800
+ /** Cached normalized options keyed by columnId - only used when the column
1801
+ * has a static (non-function) `editorOptions`. Dynamic (per-row) options
1802
+ * are resolved on every call because they can change as other cells in
1803
+ * the same row change (the whole point of cascading editors). */
1804
+ const editorOptionsCache: Record<string, CellEditorOption[]> = {};
1805
+
1806
+
1807
+
1808
+
1809
+
1810
+
1811
+
1812
+
1813
+
1814
+
1815
+
1816
+
1817
+
1818
+
1819
+ const headerSelectionState = $derived.by(() => {
1820
+ gridStateVersion;
1821
+ const selectable = allRows.filter((row) => !isGroupRow(row));
1822
+ if (!selectable.length) return "none";
1823
+ let selected = 0;
1824
+ for (const row of selectable) if (rowSelectionState[row.id]) selected += 1;
1825
+ if (selected === 0) return "none";
1826
+ return selected === selectable.length ? "all" : "some";
1827
+ });
1828
+
1829
+
1830
+ /** True once a real interaction (click, keyboard nav, or a public-API
1831
+ * call) has activated a cell. Distinct from `activeCell.cellId`, which
1832
+ * the on-mount seed effect populates straight on the grid state without
1833
+ * going through `setActiveCell` - so it can't tell a seeded (0,0) apart
1834
+ * from a user-focused (0,0). The fill handle keys off this flag so it
1835
+ * stays hidden until the user actually selects something. */
1836
+ let userHasActivatedCell = $state(false);
1837
+
1838
+
1839
+
1840
+
1841
+ /**
1842
+ * Per-column fitted widths when `fitColumns` is on. Computed in one pass
1843
+ * so the LAST auto-sized column can absorb the rounding residue and make
1844
+ * the total match the target viewport width exactly. Without this the
1845
+ * per-column `Math.round` calls leave a 2-6 px residue and the user sees
1846
+ * a small horizontal scrollbar even though every column is "fitted".
1847
+ *
1848
+ * User-resized columns (entries in `columnWidths`) are taken at face
1849
+ * value and only the auto-sized columns share the scale + residue.
1850
+ *
1851
+ * Returns `null` when fit scaling is not in effect (off, no room, total
1852
+ * already >= target). Callers then fall back to the base width.
1853
+ */
1854
+ const fittedColumnWidths = $derived.by(() => {
1855
+ // Track viewport size (not scrollVersion) so we don't recompute on
1856
+ // every scroll - only when the container actually resizes.
1857
+ viewportVersion;
1858
+ // Narrow responsive mode pans instead of scaling, so skip fit scaling.
1859
+ if (!props.fitColumns || isNarrowResponsive) return null;
1860
+ const cols = grid.getAllColumns().filter((c) => !hiddenColumns[c.id]);
1861
+ if (!cols.length) return null;
1862
+ const rowNumberWidth = showRowNumbersEffective ? rowNumberColumnWidth : 0;
1863
+ const selectionWidth = showRowSelectionEffective ? selectionColumnWidth : 0;
1864
+ // Reserve the custom vertical scrollbar's width when it's visible. It
1865
+ // overlays the right 16px of the viewport (absolute, z-index 40) and
1866
+ // does NOT shrink clientWidth, so without this the last fitted column
1867
+ // slides under it and its right-aligned content (e.g. a number column)
1868
+ // is hidden behind the opaque scrollbar.
1869
+ const scrollbarWidth = hasVerticalOverflow ? 16 : 0;
1870
+ const target =
1871
+ (scrollContainer?.clientWidth ?? 0) -
1872
+ rowNumberWidth -
1873
+ selectionWidth -
1874
+ scrollbarWidth;
1875
+ if (target <= 0) return null;
1876
+
1877
+ // Split base widths into pinned (user-resized) and scalable.
1878
+ let pinnedTotal = 0;
1879
+ let scalableBase = 0;
1880
+ const scalableIds: string[] = [];
1881
+ for (const c of cols) {
1882
+ const w = getColumnBaseWidth(c.id);
1883
+ if (columnWidths[c.id] !== undefined) pinnedTotal += w;
1884
+ else {
1885
+ scalableBase += w;
1886
+ scalableIds.push(c.id);
1887
+ }
1888
+ }
1889
+ const scalableTarget = target - pinnedTotal;
1890
+ if (scalableTarget <= 0 || scalableBase <= 0) return null;
1891
+ // Within 1px of target - no scaling needed.
1892
+ if (Math.abs(scalableBase - scalableTarget) <= 1) return null;
1893
+ // Shrink only by a modest amount (≥85% of natural). Beyond that, leave
1894
+ // natural widths and let the user scroll - squashing every column
1895
+ // tighter would hide content.
1896
+ const scale = scalableTarget / scalableBase;
1897
+ if (scale < 0.85) return null;
1898
+
1899
+ const widths: Record<string, number> = {};
1900
+ let runningSum = 0;
1901
+ for (let i = 0; i < scalableIds.length - 1; i += 1) {
1902
+ const id = scalableIds[i]!;
1903
+ const w = Math.max(
1904
+ MIN_COLUMN_WIDTH,
1905
+ Math.round(getColumnBaseWidth(id) * scale),
1906
+ );
1907
+ widths[id] = w;
1908
+ runningSum += w;
1909
+ }
1910
+ // The last scalable column absorbs whatever the previous rounding left
1911
+ // behind, so `sum(widths) === scalableTarget` exactly.
1912
+ const lastId = scalableIds[scalableIds.length - 1]!;
1913
+ widths[lastId] = Math.max(MIN_COLUMN_WIDTH, scalableTarget - runningSum);
1914
+ return widths;
1915
+ });
1916
+
1917
+
1918
+ let resizePendingWidth = 0;
1919
+ let resizeRaf: number | null = null;
1920
+
1921
+
1922
+
1923
+
1924
+
1925
+
1926
+
1927
+
1928
+ /** Where the fill handle should render: the bottom-right cell of the
1929
+ * selection range (or the active cell if there's no range). Returns
1930
+ * null when cell selection is off or there is no anchored selection. */
1931
+ const fillHandleCell = $derived.by(() => {
1932
+ if (!(props.enableCellSelection ?? false)) return null;
1933
+ const anchor = selectionRange.anchor;
1934
+ const focus = selectionRange.focus;
1935
+ if (anchor && focus) {
1936
+ return {
1937
+ rowIndex: Math.max(anchor.rowIndex, focus.rowIndex),
1938
+ colIndex: Math.max(anchor.colIndex, focus.colIndex),
1939
+ };
1940
+ }
1941
+ const a = activeCell;
1942
+ // Only show the handle once the user (or the public API) has actually
1943
+ // activated a cell. The on-mount seed writes activeCell (0,0) directly
1944
+ // to the grid state, so `cellId` alone can't gate this - see
1945
+ // `userHasActivatedCell`.
1946
+ if (!userHasActivatedCell || !a) return null;
1947
+ return { rowIndex: a.rowIndex, colIndex: a.colIndex };
1948
+ });
1949
+
1950
+
1951
+
1952
+
1953
+
1954
+
1955
+
1956
+
1957
+
1958
+
1959
+
1960
+
1961
+
1962
+
1963
+
1964
+
1965
+
1966
+
1967
+
1968
+
1969
+
1970
+
1971
+
1972
+
1973
+
1974
+
1975
+
1976
+
1977
+
1978
+
1979
+
1980
+
1981
+
1982
+
1983
+
1984
+
1985
+
1986
+
1987
+
1988
+
1989
+
1990
+
1991
+
1992
+
1993
+ /**
1994
+ * Lazily-created canvas used to measure text width via the 2D context.
1995
+ * Canvas measurement bypasses the cell's `overflow: hidden; white-space:
1996
+ * nowrap` constraint, which makes the body's `scrollWidth` useless here.
1997
+ */
1998
+ let measureCanvas: HTMLCanvasElement | null = null;
1999
+
2000
+
2001
+
2002
+
2003
+
2004
+
2005
+
2006
+
2007
+
2008
+
2009
+
2010
+
2011
+ /**
2012
+ * Range buckets for the value-facet list.
2013
+ *
2014
+ * Numeric and date columns with many distinct values would otherwise
2015
+ * paint thousands of single-value checkboxes in the filter menu -
2016
+ * unusable. When a column's `editorType` is `'number' | 'date' |
2017
+ * 'datetime'` AND it has more than BUCKET_THRESHOLD distinct values,
2018
+ * we collapse the facet list into BUCKET_COUNT equal-width ranges
2019
+ * (e.g. "1,000 - 1,500") and let the user check those.
2020
+ *
2021
+ * The bucket structure carries the numeric bounds so the row filter
2022
+ * can re-test each row's value against the selected ranges without
2023
+ * re-doing the bucket math.
2024
+ */
2025
+
2026
+
2027
+
2028
+
2029
+
2030
+
2031
+
2032
+ /** Buckets for every column that should be bucketed, computed once and
2033
+ * reused by both the facet UI and the row filter. Computing them lazily
2034
+ * in a $derived means columns with no filter menu open and no active
2035
+ * filter never pay the iteration cost. */
2036
+ const facetBucketsByColumn = $derived.by(() => {
2037
+ const map = new Map<string, Array<FacetBucket>>();
2038
+ for (const column of allColumns) {
2039
+ const meta = isBucketableColumn(column);
2040
+ if (!meta) continue;
2041
+ const buckets = buildBuckets(column, meta.isDate, props.data, getColumnAccessorValue);
2042
+ if (buckets) map.set(column.id, buckets);
2043
+ }
2044
+ return map;
2045
+ });
2046
+
2047
+ // Server-side set-filter values: when a column's filter menu opens and the
2048
+ // consumer provides `serverFilterValues`, fetch the distinct values from the
2049
+ // server once (cached per column) instead of deriving them from the loaded
2050
+ // page - so the checklist shows every value, not just what's on screen.
2051
+ let serverFacetValues = $state<Record<string, Array<string>>>({});
2052
+ let serverFacetLoading = $state<string | null>(null);
2053
+ $effect(() => {
2054
+ const columnId = filterMenuFor ?? columnMenuFor;
2055
+ const fetcher = props.serverFilterValues;
2056
+ if (!columnId || !fetcher || serverFacetValues[columnId]) return;
2057
+ serverFacetLoading = columnId;
2058
+ let cancelled = false;
2059
+ void fetcher(columnId)
2060
+ .then((values) => {
2061
+ if (cancelled) return;
2062
+ serverFacetValues = { ...serverFacetValues, [columnId]: values };
2063
+ serverFacetLoading = null;
2064
+ })
2065
+ .catch(() => {
2066
+ if (!cancelled) serverFacetLoading = null;
2067
+ });
2068
+ return () => {
2069
+ cancelled = true;
2070
+ };
2071
+ });
2072
+
2073
+ const columnMenuFacetValues = $derived.by(() => {
2074
+ // The funnel popover drives via `filterMenuFor`; the column menu's Filter
2075
+ // tab drives via `columnMenuFor`. Support whichever is open.
2076
+ const columnId = filterMenuFor ?? columnMenuFor;
2077
+ if (!columnId) return [] as Array<string>;
2078
+ // Server-provided distinct values win (fetched + cached above).
2079
+ if (props.serverFilterValues) return serverFacetValues[columnId] ?? [];
2080
+ const column = allColumns.find((entry) => entry.id === columnId);
2081
+ if (!column) return [] as Array<string>;
2082
+ // Range-bucketed facets for numeric / date columns with many values.
2083
+ const buckets = facetBucketsByColumn.get(columnId);
2084
+ if (buckets) return buckets.map((b) => b.label);
2085
+ // Default: distinct-value facets.
2086
+ const seen = new Set<string>();
2087
+ for (const rowData of props.data) {
2088
+ seen.add(String(getColumnAccessorValue(rowData, column) ?? ""));
2089
+ }
2090
+ return Array.from(seen).sort((a, b) =>
2091
+ a.localeCompare(b, undefined, { numeric: true }),
2092
+ );
2093
+ });
2094
+
2095
+ const columnMenuVisibleFacets = $derived.by(() => {
2096
+ const query = columnMenuSearch.trim().toLowerCase();
2097
+ if (!query) return columnMenuFacetValues;
2098
+ return columnMenuFacetValues.filter((value) =>
2099
+ value.toLowerCase().includes(query),
2100
+ );
2101
+ });
2102
+
2103
+
2104
+
2105
+
2106
+
2107
+
2108
+
2109
+
2110
+
2111
+ // Fire onApiReady exactly once when the grid is first ready. Wrapping in
2112
+ // an effect that tracks `props.onApiReady` was racy - every parent render
2113
+ // creates a new inline arrow, the effect re-fired, and any synchronous
2114
+ // state mutation inside the callback (e.g. `api.setGroupBy(...)`) created
2115
+ // an infinite update loop. Now it's a true mount-once notification.
2116
+ let apiNotified = false;
2117
+ $effect(() => {
2118
+ if (apiNotified) return;
2119
+ const cb = props.onApiReady;
2120
+ if (!cb) return;
2121
+ apiNotified = true;
2122
+ cb(buildApi());
2123
+ });
2124
+
2125
+ const ctx = {
2126
+ get props() { return props; },
2127
+ get editingEnabled() { return editingEnabled; },
2128
+ get paginationEnabled() { return paginationEnabled; },
2129
+ get groupingControlsEnabled() { return groupingControlsEnabled; },
2130
+ get globalFilter() { return globalFilter; },
2131
+ set globalFilter(v) { globalFilter = v as never; },
2132
+ get scrollContainer() { return scrollContainer; },
2133
+ set scrollContainer(v) { scrollContainer = v as never; },
2134
+ get gridRootEl() { return gridRootEl; },
2135
+ set gridRootEl(v) { gridRootEl = v as never; },
2136
+ get filterRowValues() { return filterRowValues; },
2137
+ set filterRowValues(v) { filterRowValues = v as never; },
2138
+ get filterMenuValues() { return filterMenuValues; },
2139
+ set filterMenuValues(v) { filterMenuValues = v as never; },
2140
+ get verticalScrollbarEl() { return verticalScrollbarEl; },
2141
+ set verticalScrollbarEl(v) { verticalScrollbarEl = v as never; },
2142
+ get horizontalScrollbarEl() { return horizontalScrollbarEl; },
2143
+ set horizontalScrollbarEl(v) { horizontalScrollbarEl = v as never; },
2144
+ get scrollVersion() { return scrollVersion; },
2145
+ set scrollVersion(v) { scrollVersion = v as never; },
2146
+ get viewportVersion() { return viewportVersion; },
2147
+ set viewportVersion(v) { viewportVersion = v as never; },
2148
+ get lastResetSignature() { return lastResetSignature; },
2149
+ set lastResetSignature(v) { lastResetSignature = v as never; },
2150
+ get pendingScrollTop() { return pendingScrollTop; },
2151
+ set pendingScrollTop(v) { pendingScrollTop = v as never; },
2152
+ get pendingScrollLeft() { return pendingScrollLeft; },
2153
+ set pendingScrollLeft(v) { pendingScrollLeft = v as never; },
2154
+ get scrollSyncRaf() { return scrollSyncRaf; },
2155
+ set scrollSyncRaf(v) { scrollSyncRaf = v as never; },
2156
+ get selectionRange() { return selectionRange; },
2157
+ set selectionRange(v) { selectionRange = v as never; },
2158
+ get selectionRanges() { return selectionRanges; },
2159
+ set selectionRanges(v) { selectionRanges = v as never; },
2160
+ get isDraggingSelection() { return isDraggingSelection; },
2161
+ set isDraggingSelection(v) { isDraggingSelection = v as never; },
2162
+ get fillDrag() { return fillDrag; },
2163
+ set fillDrag(v) { fillDrag = v as never; },
2164
+ get activeAtPointerDown() { return activeAtPointerDown; },
2165
+ set activeAtPointerDown(v) { activeAtPointerDown = v as never; },
2166
+ get editingCell() { return editingCell; },
2167
+ set editingCell(v) { editingCell = v as never; },
2168
+ get fullRowEdit() { return fullRowEdit; },
2169
+ set fullRowEdit(v) { fullRowEdit = v as never; },
2170
+ get editedCellValues() { return editedCellValues; },
2171
+ set editedCellValues(v) { editedCellValues = v as never; },
2172
+ get UNDO_LIMIT() { return UNDO_LIMIT; },
2173
+ get history() { return history; },
2174
+ set history(v) { history = v as never; },
2175
+ get historyPtr() { return historyPtr; },
2176
+ set historyPtr(v) { historyPtr = v as never; },
2177
+ get historyVersion() { return historyVersion; },
2178
+ set historyVersion(v) { historyVersion = v as never; },
2179
+ get tooltip() { return tooltip; },
2180
+ set tooltip(v) { tooltip = v as never; },
2181
+ get tooltipTimer() { return tooltipTimer; },
2182
+ set tooltipTimer(v) { tooltipTimer = v as never; },
2183
+ get showTooltipFor() { return showTooltipFor; },
2184
+ get hideTooltip() { return hideTooltip; },
2185
+ get findOpen() { return findOpen; },
2186
+ set findOpen(v) { findOpen = v as never; },
2187
+ get findQuery() { return findQuery; },
2188
+ set findQuery(v) { findQuery = v as never; },
2189
+ get findHitIndex() { return findHitIndex; },
2190
+ set findHitIndex(v) { findHitIndex = v as never; },
2191
+ get findHits() { return findHits; },
2192
+ get theadEl() { return theadEl; },
2193
+ set theadEl(v) { theadEl = v as never; },
2194
+ get headerHeight() { return headerHeight; },
2195
+ set headerHeight(v) { headerHeight = v as never; },
2196
+ get editorSelectAll() { return editorSelectAll; },
2197
+ set editorSelectAll(v) { editorSelectAll = v as never; },
2198
+ get columnWidths() { return columnWidths; },
2199
+ set columnWidths(v) { columnWidths = v as never; },
2200
+ get resizingColumnId() { return resizingColumnId; },
2201
+ set resizingColumnId(v) { resizingColumnId = v as never; },
2202
+ get resizeStartX() { return resizeStartX; },
2203
+ set resizeStartX(v) { resizeStartX = v as never; },
2204
+ get resizeStartWidth() { return resizeStartWidth; },
2205
+ set resizeStartWidth(v) { resizeStartWidth = v as never; },
2206
+ get MIN_COLUMN_WIDTH() { return MIN_COLUMN_WIDTH; },
2207
+ get columnPinning() { return columnPinning; },
2208
+ set columnPinning(v) { columnPinning = v as never; },
2209
+ get effectivePinning() { return effectivePinning; },
2210
+ get isNarrowResponsive() { return isNarrowResponsive; },
2211
+ get columnVirtualizerVersion() { return columnVirtualizerVersion; },
2212
+ set columnVirtualizerVersion(v) { columnVirtualizerVersion = v as never; },
2213
+ get gridStateVersion() { return gridStateVersion; },
2214
+ set gridStateVersion(v) { gridStateVersion = v as never; },
2215
+ get selectionColumnWidth() { return selectionColumnWidth; },
2216
+ get rowNumberColumnWidth() { return rowNumberColumnWidth; },
2217
+ get showRowNumbersEffective() { return showRowNumbersEffective; },
2218
+ get filterOperatorOptions() { return filterOperatorOptions; },
2219
+ get TEXT_OPERATORS() { return TEXT_OPERATORS; },
2220
+ get NUMBER_OPERATORS() { return NUMBER_OPERATORS; },
2221
+ get DATE_OPERATORS() { return DATE_OPERATORS; },
2222
+ get CHECKBOX_OPERATORS() { return CHECKBOX_OPERATORS; },
2223
+ get columnMenuFor() { return columnMenuFor; },
2224
+ get columnMenuTab() { return columnMenuTab; },
2225
+ set columnMenuTab(v) { columnMenuTab = v as never; },
2226
+ set columnMenuFor(v) { columnMenuFor = v as never; },
2227
+ get columnMenuPos() { return columnMenuPos; },
2228
+ set columnMenuPos(v) { columnMenuPos = v as never; },
2229
+ get columnMenuSearch() { return columnMenuSearch; },
2230
+ set columnMenuSearch(v) { columnMenuSearch = v as never; },
2231
+ get filterMenuFor() { return filterMenuFor; },
2232
+ set filterMenuFor(v) { filterMenuFor = v as never; },
2233
+ get filterMenuPos() { return filterMenuPos; },
2234
+ set filterMenuPos(v) { filterMenuPos = v as never; },
2235
+ get operatorMenuFor() { return operatorMenuFor; },
2236
+ set operatorMenuFor(v) { operatorMenuFor = v as never; },
2237
+ get operatorMenuPos() { return operatorMenuPos; },
2238
+ set operatorMenuPos(v) { operatorMenuPos = v as never; },
2239
+ get chooseColumnsPos() { return chooseColumnsPos; },
2240
+ set chooseColumnsPos(v) { chooseColumnsPos = v as never; },
2241
+ get contextMenuFor() { return contextMenuFor; },
2242
+ set contextMenuFor(v) { contextMenuFor = v as never; },
2243
+ get contextMenuPos() { return contextMenuPos; },
2244
+ set contextMenuPos(v) { contextMenuPos = v as never; },
2245
+ get noteOverrides() { return noteOverrides; },
2246
+ set noteOverrides(v) { noteOverrides = v as never; },
2247
+ get commentEditFor() { return commentEditFor; },
2248
+ set commentEditFor(v) { commentEditFor = v as never; },
2249
+ get commentDraft() { return commentDraft; },
2250
+ set commentDraft(v) { commentDraft = v as never; },
2251
+ get valueFilters() { return valueFilters; },
2252
+ set valueFilters(v) { valueFilters = v as never; },
2253
+ get viewportWidth() { return viewportWidth; },
2254
+ get viewportHeight() { return viewportHeight; },
2255
+ get scrollMetrics() { return scrollMetrics; },
2256
+ get hasVerticalOverflow() { return hasVerticalOverflow; },
2257
+ get showGlobalFilterEffective() { return showGlobalFilterEffective; },
2258
+ get showFilterRowEffective() { return showFilterRowEffective; },
2259
+ get showColumnFiltersEffective() { return showColumnFiltersEffective; },
2260
+ get showInlineColumnFilterEffective() { return showInlineColumnFilterEffective; },
2261
+ get showRowSelectionEffective() { return showRowSelectionEffective; },
2262
+ get enableCellSelectionEffective() { return enableCellSelectionEffective; },
2263
+ get flushScheduledScrollSync() { return flushScheduledScrollSync; },
2264
+ get scheduleScrollSync() { return scheduleScrollSync; },
2265
+ get internalData() { return internalData; },
2266
+ set internalData(v) { internalData = v as never; },
2267
+ get internalColumns() { return internalColumns; },
2268
+ set internalColumns(v) { internalColumns = v as never; },
2269
+ get hiddenColumns() { return hiddenColumns; },
2270
+ set hiddenColumns(v) { hiddenColumns = v as never; },
2271
+ get toggleColumnGroup() { return toggleColumnGroup; },
2272
+ get isColumnGroupCollapsed() { return isColumnGroupCollapsed; },
2273
+ get externalSortEnabled() { return externalSortEnabled; },
2274
+ get externalFilterEnabled() { return externalFilterEnabled; },
2275
+ get passthroughSortedRowModel() { return passthroughSortedRowModel; },
2276
+ get resolveEffectiveFeatures() { return resolveEffectiveFeatures; },
2277
+ get grid() { return grid; },
2278
+ get userColumnOrder() { return userColumnOrder; },
2279
+ set userColumnOrder(v) { userColumnOrder = v as never; },
2280
+ get lastSeededOrder() { return lastSeededOrder; },
2281
+ set lastSeededOrder(v) { lastSeededOrder = v as never; },
2282
+ get allColumns() { return allColumns; },
2283
+ get headerGroups() { return headerGroups; },
2284
+ get groupHeaderRows() { return groupHeaderRows; },
2285
+ get pinnedOffsets() { return pinnedOffsets; },
2286
+ get cellPinStyle() { return cellPinStyle; },
2287
+ get isColumnPinned() { return isColumnPinned; },
2288
+ get colDragId() { return colDragId; },
2289
+ set colDragId(v) { colDragId = v as never; },
2290
+ get colDropOnId() { return colDropOnId; },
2291
+ set colDropOnId(v) { colDropOnId = v as never; },
2292
+ get colDropSide() { return colDropSide; },
2293
+ set colDropSide(v) { colDropSide = v as never; },
2294
+ get rowDragActive() { return rowDragActive; },
2295
+ set rowDragActive(v) { rowDragActive = v as never; },
2296
+ get rowDropIndex() { return rowDropIndex; },
2297
+ set rowDropIndex(v) { rowDropIndex = v as never; },
2298
+ get rowDropSide() { return rowDropSide; },
2299
+ set rowDropSide(v) { rowDropSide = v as never; },
2300
+ get onRowDragStart() { return onRowDragStart; },
2301
+ get onRowDragOver() { return onRowDragOver; },
2302
+ get onRowDragLeave() { return onRowDragLeave; },
2303
+ get onRowDrop() { return onRowDrop; },
2304
+ get onRowsContainerDragOver() { return onRowsContainerDragOver; },
2305
+ get onRowsContainerDrop() { return onRowsContainerDrop; },
2306
+ get onRowDragEnd() { return onRowDragEnd; },
2307
+ get broadcastAlignedScroll() { return broadcastAlignedScroll; },
2308
+ get getCurrentColumnOrder() { return getCurrentColumnOrder; },
2309
+ get emitColumnOrder() { return emitColumnOrder; },
2310
+ get setColumnOrderInternal() { return setColumnOrderInternal; },
2311
+ get applyColumnDrop() { return applyColumnDrop; },
2312
+ get onColumnHeaderDragStart() { return onColumnHeaderDragStart; },
2313
+ get onColumnHeaderDragOver() { return onColumnHeaderDragOver; },
2314
+ get onColumnHeaderDragLeave() { return onColumnHeaderDragLeave; },
2315
+ get onColumnHeaderDrop() { return onColumnHeaderDrop; },
2316
+ get onColumnHeaderDragEnd() { return onColumnHeaderDragEnd; },
2317
+ get pinColumnLeft() { return pinColumnLeft; },
2318
+ get pinColumnRight() { return pinColumnRight; },
2319
+ get unpinColumn() { return unpinColumn; },
2320
+ get getColumnBaseValue() { return getColumnBaseValue; },
2321
+ get hasConditionalFormats() { return hasConditionalFormats; },
2322
+ get conditionalColumnStats() { return conditionalColumnStats; },
2323
+ get cellConditionalFormat() { return cellConditionalFormat; },
2324
+ get isGroupRow() { return isGroupRow; },
2325
+ get isCellEditable() { return isCellEditable; },
2326
+ get isCellEditableAt() { return isCellEditableAt; },
2327
+ get sortDirectionByColumn() { return sortDirectionByColumn; },
2328
+ get groupingColumns() { return groupingColumns; },
2329
+ get paginationState() { return paginationState; },
2330
+ get externalPaginationEnabled() { return externalPaginationEnabled; },
2331
+ get paginationTotalRows() { return paginationTotalRows; },
2332
+ get paginationPageIndex() { return paginationPageIndex; },
2333
+ get paginationPageSize() { return paginationPageSize; },
2334
+ get getRowColumnValue() { return getRowColumnValue; },
2335
+ get allRowsBeforePagination() { return allRowsBeforePagination; },
2336
+ get allRows() { return allRows; },
2337
+ get rowSelectionState() { return rowSelectionState; },
2338
+ get lastSelectionSerialized() { return lastSelectionSerialized; },
2339
+ set lastSelectionSerialized(v) { lastSelectionSerialized = v as never; },
2340
+ get lastCellRangeSerialized() { return lastCellRangeSerialized; },
2341
+ set lastCellRangeSerialized(v) { lastCellRangeSerialized = v as never; },
2342
+ get statusBarEnabled() { return statusBarEnabled; },
2343
+ get statusBarAggregates() { return statusBarAggregates; },
2344
+ get statusBarStats() { return statusBarStats; },
2345
+ get toolPanelOpen() { return toolPanelOpen; },
2346
+ set toolPanelOpen(v) { toolPanelOpen = v as never; },
2347
+ get toolPanelTab() { return toolPanelTab; },
2348
+ set toolPanelTab(v) { toolPanelTab = v as never; },
2349
+ get toolPanelEnabled() { return toolPanelEnabled; },
2350
+ get toolPanelColumns() { return toolPanelColumns; },
2351
+ get toolPanelHeaderLabel() { return toolPanelHeaderLabel; },
2352
+ get toggleColumnVisibleInPanel() { return toggleColumnVisibleInPanel; },
2353
+ get moveColumnInPanel() { return moveColumnInPanel; },
2354
+ get toggleGroupInPanel() { return toggleGroupInPanel; },
2355
+ get lastSortingSerialized() { return lastSortingSerialized; },
2356
+ set lastSortingSerialized(v) { lastSortingSerialized = v as never; },
2357
+ get lastFiltersSerialized() { return lastFiltersSerialized; },
2358
+ set lastFiltersSerialized(v) { lastFiltersSerialized = v as never; },
2359
+ get virtualizer() { return virtualizer; },
2360
+ get columnVirtualizer() { return columnVirtualizer; },
2361
+ get rowVirtualizationEnabled() { return rowVirtualizationEnabled; },
2362
+ get columnVirtualizationEnabled() { return columnVirtualizationEnabled; },
2363
+ get virtualRows() { return virtualRows; },
2364
+ get virtualRowTotalSize() { return virtualRowTotalSize; },
2365
+ get virtualRowStart() { return virtualRowStart; },
2366
+ get virtualRowEnd() { return virtualRowEnd; },
2367
+ get virtualRowBottomSpacer() { return virtualRowBottomSpacer; },
2368
+ get rowDomTotalSize() { return rowDomTotalSize; },
2369
+ get rowScrollScalingActive() { return rowScrollScalingActive; },
2370
+ get rowTopSpacer() { return rowTopSpacer; },
2371
+ get rowBottomSpacer() { return rowBottomSpacer; },
2372
+ get domToLogicalRowOffset() { return domToLogicalRowOffset; },
2373
+ get logicalToDomRowOffset() { return logicalToDomRowOffset; },
2374
+ get virtualColumns() { return virtualColumns; },
2375
+ get virtualColumnTotalSize() { return virtualColumnTotalSize; },
2376
+ get renderedColumnItems() { return renderedColumnItems; },
2377
+ get hasRenderedColumn() { return hasRenderedColumn; },
2378
+ get renderedColumns() { return renderedColumns; },
2379
+ get totalColumnWidth() { return totalColumnWidth; },
2380
+ get hasHorizontalOverflow() { return hasHorizontalOverflow; },
2381
+ get columnWindowStart() { return columnWindowStart; },
2382
+ get columnWindowEnd() { return columnWindowEnd; },
2383
+ get columnWindowRightSpacer() { return columnWindowRightSpacer; },
2384
+ get activeCell() { return activeCell; },
2385
+ get activeDescendantId() { return activeDescendantId; },
2386
+ get formatSummaryNumeric() { return formatSummaryNumeric; },
2387
+ get computeSummaries() { return computeSummaries; },
2388
+ get SUMMARY_DEFER_CELL_LIMIT() { return SUMMARY_DEFER_CELL_LIMIT; },
2389
+ get summaryByColumn() { return summaryByColumn; },
2390
+ set summaryByColumn(v) { summaryByColumn = v as never; },
2391
+ get hasMeasured() { return hasMeasured; },
2392
+ set hasMeasured(v) { hasMeasured = v as never; },
2393
+ get scrollBottomArmed() { return scrollBottomArmed; },
2394
+ set scrollBottomArmed(v) { scrollBottomArmed = v as never; },
2395
+ get onBodyScroll() { return onBodyScroll; },
2396
+ get computeRowClass() { return computeRowClass; },
2397
+ get computeCellClass() { return computeCellClass; },
2398
+ get computeCellTooltip() { return computeCellTooltip; },
2399
+ get computeCellValidity() { return computeCellValidity; },
2400
+ get computeCellNote() { return computeCellNote; },
2401
+ get getCellDisplayValue() { return getCellDisplayValue; },
2402
+ get getColumnAlign() { return getColumnAlign; },
2403
+ get editorOptionsCache() { return editorOptionsCache; },
2404
+ get getColumnEditorOptions() { return getColumnEditorOptions; },
2405
+ get formatListCellValue() { return formatListCellValue; },
2406
+ get formatCellValue() { return formatCellValue; },
2407
+ get getPinnedCellValue() { return getPinnedCellValue; },
2408
+ get formatPinnedValue() { return formatPinnedValue; },
2409
+ get computePinnedCellClass() { return computePinnedCellClass; },
2410
+ get isRowSelected() { return isRowSelected; },
2411
+ get toggleRowSelectionById() { return toggleRowSelectionById; },
2412
+ get headerSelectionState() { return headerSelectionState; },
2413
+ get toggleSelectAllRows() { return toggleSelectAllRows; },
2414
+ get userHasActivatedCell() { return userHasActivatedCell; },
2415
+ set userHasActivatedCell(v) { userHasActivatedCell = v as never; },
2416
+ get setActiveCell() { return setActiveCell; },
2417
+ get scrollActiveCellIntoView() { return scrollActiveCellIntoView; },
2418
+ get getColumnBaseWidth() { return getColumnBaseWidth; },
2419
+ get fittedColumnWidths() { return fittedColumnWidths; },
2420
+ get getColumnWidth() { return getColumnWidth; },
2421
+ get resizePendingWidth() { return resizePendingWidth; },
2422
+ set resizePendingWidth(v) { resizePendingWidth = v as never; },
2423
+ get resizeRaf() { return resizeRaf; },
2424
+ set resizeRaf(v) { resizeRaf = v as never; },
2425
+ get startColumnResize() { return startColumnResize; },
2426
+ get onColumnResizeMove() { return onColumnResizeMove; },
2427
+ get endColumnResize() { return endColumnResize; },
2428
+ get setSelection() { return setSelection; },
2429
+ get extendSelection() { return extendSelection; },
2430
+ get isCellInSelectedRange() { return isCellInSelectedRange; },
2431
+ get getCellRangeEdges() { return getCellRangeEdges; },
2432
+ get getSelectionRects() { return getSelectionRects; },
2433
+ get fillHandleCell() { return fillHandleCell; },
2434
+ get isInFillPreview() { return isInFillPreview; },
2435
+ get fillMarqueeEdges() { return fillMarqueeEdges; },
2436
+ get findColumnById() { return findColumnById; },
2437
+ get readCellRaw() { return readCellRaw; },
2438
+ get writeCellRaw() { return writeCellRaw; },
2439
+ get applyFillPattern() { return applyFillPattern; },
2440
+ get clearSelectedCellValues() { return clearSelectedCellValues; },
2441
+ get startFillDrag() { return startFillDrag; },
2442
+ get onFillPointerMove() { return onFillPointerMove; },
2443
+ get onFillPointerUp() { return onFillPointerUp; },
2444
+ get toggleBooleanCell() { return toggleBooleanCell; },
2445
+ get onCellPointerDown() { return onCellPointerDown; },
2446
+ get onCellPointerEnter() { return onCellPointerEnter; },
2447
+ get endDragSelection() { return endDragSelection; },
2448
+ get onWindowPointerMove() { return onWindowPointerMove; },
2449
+ get onCellClick() { return onCellClick; },
2450
+ get emitCellDoubleClick() { return emitCellDoubleClick; },
2451
+ get copySelectionToClipboard() { return copySelectionToClipboard; },
2452
+ get cutSelectionToClipboard() { return cutSelectionToClipboard; },
2453
+ get pasteFromClipboard() { return pasteFromClipboard; },
2454
+ get onGridPaste() { return onGridPaste; },
2455
+ get clearSelectedCells() { return clearSelectedCells; },
2456
+ get onCellDoubleClick() { return onCellDoubleClick; },
2457
+ get startEditingWithChar() { return startEditingWithChar; },
2458
+ get startEditing() { return startEditing; },
2459
+ get stopEditing() { return stopEditing; },
2460
+ get startFullRowEdit() { return startFullRowEdit; },
2461
+ get setFullRowDraft() { return setFullRowDraft; },
2462
+ get commitFullRowEdit() { return commitFullRowEdit; },
2463
+ get cancelFullRowEdit() { return cancelFullRowEdit; },
2464
+ get saveEditingCell() { return saveEditingCell; },
2465
+ get applyHistoryStep() { return applyHistoryStep; },
2466
+ get updateEditingCellValue() { return updateEditingCellValue; },
2467
+ get onEditorKeyDown() { return onEditorKeyDown; },
2468
+ get focusOnMount() { return focusOnMount; },
2469
+ get onHeaderSortClick() { return onHeaderSortClick; },
2470
+ get onGridKeyDown() { return onGridKeyDown; },
2471
+ get changePage() { return changePage; },
2472
+ get goToPage() { return goToPage; },
2473
+ get setPageSize() { return setPageSize; },
2474
+ get openContextMenu() { return openContextMenu; },
2475
+ get closeContextMenu() { return closeContextMenu; },
2476
+ get contextMenuItems() { return contextMenuItems; },
2477
+ get saveComment() { return saveComment; },
2478
+ get removeComment() { return removeComment; },
2479
+ get closeCommentEditor() { return closeCommentEditor; },
2480
+ get updateFilterRow() { return updateFilterRow; },
2481
+ get updateFilterOperator() { return updateFilterOperator; },
2482
+ get updateFilterMenuValue() { return updateFilterMenuValue; },
2483
+ get updateFilterMenuValueTo() { return updateFilterMenuValueTo; },
2484
+ get toggleCheckboxWithKeyboard() { return toggleCheckboxWithKeyboard; },
2485
+ get getColumnAccessorValue() { return getColumnAccessorValue; },
2486
+ get fallbackOperatorOption() { return fallbackOperatorOption; },
2487
+ get operatorOption() { return operatorOption; },
2488
+ get operatorsForColumn() { return operatorsForColumn; },
2489
+ get defaultOperatorFor() { return defaultOperatorFor; },
2490
+ get operatorLabelFor() { return operatorLabelFor; },
2491
+ get isColumnFiltered() { return isColumnFiltered; },
2492
+ get closeMenus() { return closeMenus; },
2493
+ get measureCanvas() { return measureCanvas; },
2494
+ set measureCanvas(v) { measureCanvas = v as never; },
2495
+ get measureText() { return measureText; },
2496
+ get autosizeColumn() { return autosizeColumn; },
2497
+ get autosizeAllColumns() { return autosizeAllColumns; },
2498
+ get resetColumns() { return resetColumns; },
2499
+ get openChooseColumns() { return openChooseColumns; },
2500
+ get openColumnMenu() { return openColumnMenu; },
2501
+ get openFilterMenu() { return openFilterMenu; },
2502
+ get openOperatorMenu() { return openOperatorMenu; },
2503
+ get sortColumnFromMenu() { return sortColumnFromMenu; },
2504
+ get clearColumnSort() { return clearColumnSort; },
2505
+ get groupByColumnFromMenu() { return groupByColumnFromMenu; },
2506
+ get clearGroupingFromMenu() { return clearGroupingFromMenu; },
2507
+ get isBucketableColumn() { return isBucketableColumn; },
2508
+ get buildBuckets() { return buildBuckets; },
2509
+ get isInBucket() { return isInBucket; },
2510
+ get facetBucketsByColumn() { return facetBucketsByColumn; },
2511
+ get serverFacetLoading() { return serverFacetLoading; },
2512
+ get columnMenuFacetValues() { return columnMenuFacetValues; },
2513
+ get columnMenuVisibleFacets() { return columnMenuVisibleFacets; },
2514
+ get isFacetChecked() { return isFacetChecked; },
2515
+ get toggleFacetValue() { return toggleFacetValue; },
2516
+ get isAllFacetsChecked() { return isAllFacetsChecked; },
2517
+ get toggleAllFacets() { return toggleAllFacets; },
2518
+ get clearColumnFilter() { return clearColumnFilter; },
2519
+ get onWindowKeydown() { return onWindowKeydown; },
2520
+ get columnDefMatchesId() { return columnDefMatchesId; },
2521
+ get buildApi() { return buildApi; },
2522
+ get apiNotified() { return apiNotified; },
2523
+ set apiNotified(v) { apiNotified = v as never; },
2524
+ };
2525
+ const { resolveEffectiveFeatures } = createFeatures<TFeatures, TData>(ctx);
2526
+ const { showTooltipFor, hideTooltip, flushScheduledScrollSync, scheduleScrollSync, onBodyScroll } = createScrollSync<TFeatures, TData>(ctx);
2527
+ const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard<TFeatures, TData>(ctx);
2528
+ const { computeSummaries, hasRenderedColumn } = createSummaries<TFeatures, TData>(ctx);
2529
+ const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, 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);
2530
+ const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
2531
+ const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing<TFeatures, TData>(ctx);
2532
+ const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
2533
+ 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);
2534
+ const { onRowDragStart, onRowDragOver, onRowDragLeave, onRowDrop, onRowsContainerDragOver, onRowsContainerDrop, onRowDragEnd } = createRowDrag<TFeatures, TData>(ctx);
2535
+ const { register: registerAlignedGrid, broadcastScroll: broadcastAlignedScroll, broadcastWidths: broadcastAlignedWidths } = createAlignedGrids<TFeatures, TData>(ctx);
2536
+ const { buildApi } = createGridApi<TFeatures, TData>(ctx);
2537
+ const { readCellRaw, writeCellRaw, applyFillPattern, clearSelectedCellValues, startFillDrag, onFillPointerMove, onFillPointerUp, toggleBooleanCell, copySelectionToClipboard, clearSelectedCells, cutSelectionToClipboard } = createClipboard(ctx);
2538
+
2539
+ // Aligned grids: register in the shared group on mount, and mirror column
2540
+ // resizes to peers whenever columnWidths changes. Horizontal-scroll mirroring
2541
+ // is driven from onBodyScroll (via ctx.broadcastAlignedScroll).
2542
+ $effect(() => {
2543
+ if (props.alignedGridGroup == null) return;
2544
+ return registerAlignedGrid();
2545
+ });
2546
+ $effect(() => {
2547
+ // Track columnWidths reactively, then broadcast to aligned peers.
2548
+ void columnWidths;
2549
+ broadcastAlignedWidths();
2550
+ });
2551
+
2552
+ return ctx;
2553
+ }