@svgrid/grid 1.1.0 → 1.1.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 (117) hide show
  1. package/dist/GridMenus.svelte +205 -127
  2. package/dist/SvGrid.controller.svelte.d.ts +44 -1
  3. package/dist/SvGrid.controller.svelte.js +223 -38
  4. package/dist/SvGrid.css +2012 -1709
  5. package/dist/SvGrid.helpers.test.d.ts +1 -0
  6. package/dist/SvGrid.helpers.test.js +298 -0
  7. package/dist/SvGrid.svelte +2346 -2043
  8. package/dist/SvGrid.types.d.ts +91 -4
  9. package/dist/aligned-grids.d.ts +6 -0
  10. package/dist/aligned-grids.js +84 -0
  11. package/dist/aligned-grids.test.d.ts +1 -0
  12. package/dist/aligned-grids.test.js +75 -0
  13. package/dist/build-api.coverage.test.d.ts +20 -0
  14. package/dist/build-api.coverage.test.js +505 -0
  15. package/dist/build-api.js +59 -29
  16. package/dist/cell-render.test.d.ts +1 -0
  17. package/dist/cell-render.test.js +338 -0
  18. package/dist/chart-export.test.d.ts +1 -0
  19. package/dist/chart-export.test.js +302 -0
  20. package/dist/chart.coverage.test.d.ts +1 -0
  21. package/dist/chart.coverage.test.js +748 -0
  22. package/dist/clipboard.js +88 -24
  23. package/dist/clipboard.test.d.ts +1 -0
  24. package/dist/clipboard.test.js +700 -0
  25. package/dist/collaboration.coverage.test.d.ts +1 -0
  26. package/dist/collaboration.coverage.test.js +200 -0
  27. package/dist/column-groups.d.ts +19 -0
  28. package/dist/column-groups.js +62 -0
  29. package/dist/column-groups.test.d.ts +1 -0
  30. package/dist/column-groups.test.js +56 -0
  31. package/dist/column-types.d.ts +10 -0
  32. package/dist/column-types.js +63 -0
  33. package/dist/column-types.test.d.ts +1 -0
  34. package/dist/column-types.test.js +62 -0
  35. package/dist/columns.test.d.ts +1 -0
  36. package/dist/columns.test.js +625 -0
  37. package/dist/core.d.ts +85 -0
  38. package/dist/editing.d.ts +7 -0
  39. package/dist/editing.js +191 -5
  40. package/dist/editing.test.d.ts +1 -0
  41. package/dist/editing.test.js +732 -0
  42. package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
  43. package/dist/editors/cell-editors.coverage.test.js +139 -0
  44. package/dist/facet-buckets.test.d.ts +1 -0
  45. package/dist/facet-buckets.test.js +296 -0
  46. package/dist/filter-operators.test.d.ts +1 -0
  47. package/dist/filter-operators.test.js +135 -0
  48. package/dist/hyperformula-adapter.test.d.ts +1 -0
  49. package/dist/hyperformula-adapter.test.js +205 -0
  50. package/dist/index.d.ts +5 -2
  51. package/dist/index.js +4 -1
  52. package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
  53. package/dist/keyboard-handlers.coverage.test.js +495 -0
  54. package/dist/keyboard-handlers.js +7 -2
  55. package/dist/menus.js +1 -0
  56. package/dist/menus.test.d.ts +1 -0
  57. package/dist/menus.test.js +560 -0
  58. package/dist/named-views.coverage.test.d.ts +1 -0
  59. package/dist/named-views.coverage.test.js +180 -0
  60. package/dist/row-drag.d.ts +49 -0
  61. package/dist/row-drag.js +221 -0
  62. package/dist/row-drag.test.d.ts +1 -0
  63. package/dist/row-drag.test.js +142 -0
  64. package/dist/row-resize.test.d.ts +1 -0
  65. package/dist/row-resize.test.js +329 -0
  66. package/dist/scroll-sync.js +3 -0
  67. package/dist/scroll-sync.test.d.ts +1 -0
  68. package/dist/scroll-sync.test.js +290 -0
  69. package/dist/selection.d.ts +7 -1
  70. package/dist/selection.js +76 -36
  71. package/dist/selection.multi-range.test.d.ts +1 -0
  72. package/dist/selection.multi-range.test.js +55 -0
  73. package/dist/selection.test.d.ts +1 -0
  74. package/dist/selection.test.js +647 -0
  75. package/dist/server-data-source.coverage.test.d.ts +1 -0
  76. package/dist/server-data-source.coverage.test.js +154 -0
  77. package/dist/spreadsheet.d.ts +30 -0
  78. package/dist/spreadsheet.js +48 -0
  79. package/dist/spreadsheet.test.d.ts +1 -0
  80. package/dist/spreadsheet.test.js +446 -0
  81. package/dist/sv-grid-scrollbar.js +13 -1
  82. package/dist/svgrid-wrapper.types.d.ts +19 -0
  83. package/dist/svgrid.behavior.test.js +20 -0
  84. package/dist/svgrid.interaction.test.js +31 -0
  85. package/dist/svgrid.new-features.wrapper.test.js +34 -2
  86. package/dist/test-setup.js +9 -3
  87. package/dist/virtualization/scroll-scaling.d.ts +17 -0
  88. package/dist/virtualization/scroll-scaling.js +35 -0
  89. package/dist/virtualization/scroll-scaling.test.js +42 -1
  90. package/package.json +2 -1
  91. package/src/GridMenus.svelte +205 -127
  92. package/src/SvGrid.controller.svelte.ts +2352 -2195
  93. package/src/SvGrid.css +2012 -1747
  94. package/src/SvGrid.svelte +2346 -2047
  95. package/src/SvGrid.types.ts +537 -456
  96. package/src/aligned-grids.test.ts +80 -0
  97. package/src/aligned-grids.ts +87 -0
  98. package/src/build-api.ts +683 -663
  99. package/src/clipboard.test.ts +49 -0
  100. package/src/clipboard.ts +51 -23
  101. package/src/column-groups.test.ts +59 -0
  102. package/src/column-groups.ts +80 -0
  103. package/src/column-types.test.ts +68 -0
  104. package/src/column-types.ts +82 -0
  105. package/src/core.ts +78 -0
  106. package/src/editing.ts +669 -513
  107. package/src/index.ts +12 -0
  108. package/src/menus.ts +1 -0
  109. package/src/row-drag.test.ts +168 -0
  110. package/src/row-drag.ts +255 -0
  111. package/src/scroll-sync.ts +2 -0
  112. package/src/selection.multi-range.test.ts +61 -0
  113. package/src/selection.ts +71 -37
  114. package/src/spreadsheet.test.ts +489 -445
  115. package/src/spreadsheet.ts +304 -246
  116. package/src/svgrid-wrapper.types.ts +19 -0
  117. package/src/svgrid.new-features.wrapper.test.ts +2 -2
@@ -63,6 +63,24 @@ function makeCtx(opts: FakeOptions = {}) {
63
63
  },
64
64
  _rows: buildRows(),
65
65
  selectionRange: { anchor: null, focus: null },
66
+ selectionRanges: [] as Array<{ anchor: any; focus: any }>,
67
+ // Mirrors the real controller's getSelectionRects: committed ranges + active.
68
+ getSelectionRects(): Array<{ minRow: number; maxRow: number; minCol: number; maxCol: number }> {
69
+ const rects: Array<{ minRow: number; maxRow: number; minCol: number; maxCol: number }> = []
70
+ const norm = (r: { anchor: any; focus: any }) =>
71
+ r?.anchor && r?.focus
72
+ ? {
73
+ minRow: Math.min(r.anchor.rowIndex, r.focus.rowIndex),
74
+ maxRow: Math.max(r.anchor.rowIndex, r.focus.rowIndex),
75
+ minCol: Math.min(r.anchor.colIndex, r.focus.colIndex),
76
+ maxCol: Math.max(r.anchor.colIndex, r.focus.colIndex),
77
+ }
78
+ : null
79
+ for (const r of ctx.selectionRanges) { const n = norm(r); if (n) rects.push(n) }
80
+ const active = norm(ctx.selectionRange)
81
+ if (active) rects.push(active)
82
+ return rects
83
+ },
66
84
  fillDrag: null,
67
85
  editedCellValues: {},
68
86
  userHasActivatedCell: false,
@@ -528,6 +546,37 @@ describe('copySelectionToClipboard', () => {
528
546
  expect(writeText).toHaveBeenCalledWith('a0\tb0\na1\tb1')
529
547
  })
530
548
 
549
+ it('prepends a header row when copyHeadersToClipboard is set', () => {
550
+ const ctx = makeCtx({
551
+ data: [
552
+ { a: 'a0', b: 'b0' },
553
+ { a: 'a1', b: 'b1' },
554
+ ],
555
+ })
556
+ ctx.props.copyHeadersToClipboard = true
557
+ ctx.selectionRange = {
558
+ anchor: { rowIndex: 0, colIndex: 0 },
559
+ focus: { rowIndex: 1, colIndex: 1 },
560
+ }
561
+ const cb = createClipboard(ctx)
562
+ cb.copySelectionToClipboard()
563
+ expect(writeText).toHaveBeenCalledWith('a\tb\na0\tb0\na1\tb1')
564
+ })
565
+
566
+ it('runs each value through processCellForClipboard', () => {
567
+ const ctx = makeCtx({
568
+ data: [{ a: 'a0', b: 'b0' }],
569
+ })
570
+ ctx.props.processCellForClipboard = ({ value }: { value: unknown }) => `[${value}]`
571
+ ctx.selectionRange = {
572
+ anchor: { rowIndex: 0, colIndex: 0 },
573
+ focus: { rowIndex: 0, colIndex: 1 },
574
+ }
575
+ const cb = createClipboard(ctx)
576
+ cb.copySelectionToClipboard()
577
+ expect(writeText).toHaveBeenCalledWith('[a0]\t[b0]')
578
+ })
579
+
531
580
  it('emits empty strings for missing columns and nullish values', () => {
532
581
  const ctx = makeCtx({
533
582
  columns: [{ id: 'a', field: 'a' }],
package/src/clipboard.ts CHANGED
@@ -415,32 +415,60 @@ export function createClipboard<
415
415
  }
416
416
 
417
417
  function copySelectionToClipboard() {
418
- const anchor = ctx.selectionRange.anchor;
419
- const focus = ctx.selectionRange.focus;
420
- if (!anchor || !focus) return;
421
- const minRow = Math.min(anchor.rowIndex, focus.rowIndex);
422
- const maxRow = Math.max(anchor.rowIndex, focus.rowIndex);
423
- const minCol = Math.min(anchor.colIndex, focus.colIndex);
424
- const maxCol = Math.max(anchor.colIndex, focus.colIndex);
425
- const lines: Array<string> = [];
426
- for (let r = minRow; r <= maxRow; r += 1) {
427
- const row = ctx.allRows[r];
428
- if (!row || isGroupRow(row)) continue;
429
- const cells: Array<string> = [];
430
- for (let c = minCol; c <= maxCol; c += 1) {
431
- const column = ctx.allColumns[c];
432
- if (!column) {
433
- cells.push("");
434
- continue;
418
+ // Copy every selected rectangle. Each range becomes a TSV block; multiple
419
+ // ranges are stacked and separated by a blank line (Sheets-style).
420
+ const rects = ctx.getSelectionRects() as Array<{
421
+ minRow: number;
422
+ maxRow: number;
423
+ minCol: number;
424
+ maxCol: number;
425
+ }>;
426
+ if (!rects.length) return;
427
+ // Optional: prepend a header row (Excel "copy with headers") and/or run
428
+ // each value through a consumer hook before it hits the clipboard.
429
+ const withHeaders = ctx.props.copyHeadersToClipboard === true;
430
+ const processCell = ctx.props.processCellForClipboard as
431
+ | ((params: { value: unknown; column: unknown; row: unknown; rowIndex: number; columnId: string }) => unknown)
432
+ | undefined;
433
+ const blocks: Array<string> = [];
434
+ for (const rect of rects) {
435
+ const lines: Array<string> = [];
436
+ if (withHeaders) {
437
+ const header: Array<string> = [];
438
+ for (let c = rect.minCol; c <= rect.maxCol; c += 1) {
439
+ const column = ctx.allColumns[c];
440
+ header.push(column ? toolPanelHeaderLabel(column) : "");
441
+ }
442
+ lines.push(header.join("\t"));
443
+ }
444
+ for (let r = rect.minRow; r <= rect.maxRow; r += 1) {
445
+ const row = ctx.allRows[r];
446
+ if (!row || isGroupRow(row)) continue;
447
+ const cells: Array<string> = [];
448
+ for (let c = rect.minCol; c <= rect.maxCol; c += 1) {
449
+ const column = ctx.allColumns[c];
450
+ if (!column) {
451
+ cells.push("");
452
+ continue;
453
+ }
454
+ const base = getColumnBaseValue(row, column);
455
+ let value: unknown = ctx.getCellDisplayValue(row.id, column.id, base);
456
+ if (processCell) {
457
+ value = processCell({
458
+ value,
459
+ column,
460
+ row: row.original,
461
+ rowIndex: r,
462
+ columnId: column.id,
463
+ });
464
+ }
465
+ cells.push(String(value ?? ""));
435
466
  }
436
- const base = getColumnBaseValue(row, column);
437
- const display = ctx.getCellDisplayValue(row.id, column.id, base);
438
- cells.push(String(display ?? ""));
467
+ lines.push(cells.join("\t"));
439
468
  }
440
- lines.push(cells.join("\t"));
469
+ blocks.push(lines.join("\n"));
441
470
  }
442
- const text = lines.join("\n");
443
- writeClipboardText(text);
471
+ writeClipboardText(blocks.join("\n\n"));
444
472
  }
445
473
 
446
474
  /**
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { computeColumnGroupMeta, hiddenLeavesForCollapse } from './column-groups'
3
+
4
+ // A group "Q1" with an always-on Total plus Jan/Feb that only show when open.
5
+ const columns = [
6
+ { field: 'name' },
7
+ {
8
+ id: 'q1',
9
+ header: 'Q1',
10
+ columns: [
11
+ { field: 'q1Total' },
12
+ { field: 'jan', columnGroupShow: 'open' },
13
+ { field: 'feb', columnGroupShow: 'open' },
14
+ ],
15
+ },
16
+ {
17
+ id: 'q2',
18
+ header: 'Q2',
19
+ openByDefault: true,
20
+ columns: [
21
+ { field: 'q2Total', columnGroupShow: 'closed' },
22
+ { field: 'apr', columnGroupShow: 'open' },
23
+ ],
24
+ },
25
+ ]
26
+
27
+ describe('computeColumnGroupMeta', () => {
28
+ it('marks groups with columnGroupShow children as collapsible', () => {
29
+ const meta = computeColumnGroupMeta(columns)
30
+ expect([...meta.collapsibleGroupIds].sort()).toEqual(['q1', 'q2'])
31
+ expect(meta.defaultOpen.get('q1')).toBe(false)
32
+ expect(meta.defaultOpen.get('q2')).toBe(true)
33
+ })
34
+ it('maps controlled leaves to their group + show mode', () => {
35
+ const meta = computeColumnGroupMeta(columns)
36
+ expect(meta.leafControl.get('jan')).toEqual({ groupId: 'q1', show: 'open' })
37
+ expect(meta.leafControl.get('q2Total')).toEqual({ groupId: 'q2', show: 'closed' })
38
+ // untagged always-on column is not controlled
39
+ expect(meta.leafControl.has('q1Total')).toBe(false)
40
+ expect(meta.leafControl.has('name')).toBe(false)
41
+ })
42
+ })
43
+
44
+ describe('hiddenLeavesForCollapse', () => {
45
+ const meta = computeColumnGroupMeta(columns)
46
+ it('hides open-columns while collapsed, shows them while expanded', () => {
47
+ const collapsed = new Set(['q1'])
48
+ const hidden = hiddenLeavesForCollapse(meta, collapsed)
49
+ expect(hidden.jan).toBe(true)
50
+ expect(hidden.feb).toBe(true)
51
+ expect(hidden.q1Total).toBeUndefined() // always on
52
+ })
53
+ it('hides closed-columns while expanded', () => {
54
+ const collapsed = new Set<string>() // q2 expanded
55
+ const hidden = hiddenLeavesForCollapse(meta, collapsed)
56
+ expect(hidden.q2Total).toBe(true) // 'closed' hides when expanded
57
+ expect(hidden.apr).toBeUndefined() // 'open' shows when expanded
58
+ })
59
+ })
@@ -0,0 +1,80 @@
1
+ // Collapsible column groups (AG-Grid `columnGroupShow`). A column group can
2
+ // carry a collapse toggle: its child columns tagged `columnGroupShow: 'open'`
3
+ // show only when the group is expanded, `'closed'` show only when collapsed,
4
+ // and untagged children always show.
5
+ //
6
+ // This module derives, from the user's column tree, the pure metadata the
7
+ // controller needs: which groups are collapsible, their default state, and each
8
+ // controlled leaf's group + show-mode. The controller turns that into a hidden
9
+ // map (merged into column visibility) AND feeds the same hidden set into the
10
+ // group-header colspan/width math, so headers stay aligned with the leaves.
11
+
12
+ export type ColumnGroupShow = "open" | "closed";
13
+
14
+ export type ColumnGroupMeta = {
15
+ /** Group ids that render a collapse toggle. */
16
+ collapsibleGroupIds: Set<string>;
17
+ /** Group id -> whether it starts expanded (openByDefault). */
18
+ defaultOpen: Map<string, boolean>;
19
+ /** Leaf column id -> the group controlling it + its show mode. */
20
+ leafControl: Map<string, { groupId: string; show: ColumnGroupShow }>;
21
+ };
22
+
23
+ // Mirror the id resolution used by the group-header derivation so group ids and
24
+ // leaf ids line up across both.
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ function idOf(def: any, parentId: string | undefined, ix: number): string {
27
+ return def.id ?? def.field ?? `${parentId ?? "col"}_d_${ix}`;
28
+ }
29
+
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ export function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroupMeta {
32
+ const collapsibleGroupIds = new Set<string>();
33
+ const defaultOpen = new Map<string, boolean>();
34
+ const leafControl = new Map<string, { groupId: string; show: ColumnGroupShow }>();
35
+
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ function walk(defs: ReadonlyArray<any>, parentId: string | undefined, controllingGroup: string | undefined) {
38
+ defs.forEach((def, ix) => {
39
+ const id = idOf(def, parentId, ix);
40
+ if (def.columns?.length) {
41
+ // A group is collapsible when any DIRECT child declares columnGroupShow.
42
+ const collapsible = def.columns.some(
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ (c: any) => c.columnGroupShow === "open" || c.columnGroupShow === "closed",
45
+ );
46
+ if (collapsible) {
47
+ collapsibleGroupIds.add(id);
48
+ defaultOpen.set(id, def.openByDefault === true);
49
+ }
50
+ walk(def.columns, id, collapsible ? id : controllingGroup);
51
+ } else {
52
+ const show = def.columnGroupShow;
53
+ if ((show === "open" || show === "closed") && controllingGroup) {
54
+ leafControl.set(id, { groupId: controllingGroup, show });
55
+ }
56
+ }
57
+ });
58
+ }
59
+
60
+ walk(columns, undefined, undefined);
61
+ return { collapsibleGroupIds, defaultOpen, leafControl };
62
+ }
63
+
64
+ /**
65
+ * Which leaf ids are hidden right now, given the set of collapsed group ids.
66
+ * `'open'` leaves hide while their group is collapsed; `'closed'` leaves hide
67
+ * while it is expanded.
68
+ */
69
+ export function hiddenLeavesForCollapse(
70
+ meta: ColumnGroupMeta,
71
+ collapsed: ReadonlySet<string>,
72
+ ): Record<string, boolean> {
73
+ const hidden: Record<string, boolean> = {};
74
+ for (const [leafId, ctrl] of meta.leafControl) {
75
+ const isCollapsed = collapsed.has(ctrl.groupId);
76
+ const hide = ctrl.show === "open" ? isCollapsed : !isCollapsed;
77
+ if (hide) hidden[leafId] = true;
78
+ }
79
+ return hidden;
80
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { inferCellDataType, resolveColumnTypes } from './column-types'
3
+
4
+ describe('inferCellDataType', () => {
5
+ it('classifies primitives', () => {
6
+ expect(inferCellDataType(42)).toBe('number')
7
+ expect(inferCellDataType(true)).toBe('boolean')
8
+ expect(inferCellDataType(new Date())).toBe('date')
9
+ expect(inferCellDataType('2026-06-27')).toBe('dateString')
10
+ expect(inferCellDataType('2026-06-27T10:00:00Z')).toBe('dateString')
11
+ expect(inferCellDataType('hello')).toBe('text')
12
+ })
13
+ it('returns undefined for null/undefined so nothing is forced', () => {
14
+ expect(inferCellDataType(null)).toBeUndefined()
15
+ expect(inferCellDataType(undefined)).toBeUndefined()
16
+ })
17
+ })
18
+
19
+ describe('resolveColumnTypes', () => {
20
+ it('maps explicit cellDataType to editorType + format', () => {
21
+ const cols = [
22
+ { field: 'name', cellDataType: 'text' },
23
+ { field: 'age', cellDataType: 'number' },
24
+ { field: 'active', cellDataType: 'boolean' },
25
+ { field: 'joined', cellDataType: 'date' },
26
+ { field: 'iso', cellDataType: 'dateString' },
27
+ ]
28
+ const out = resolveColumnTypes(cols, undefined, false)
29
+ expect(out[0].editorType).toBe('text')
30
+ expect(out[1].editorType).toBe('number')
31
+ expect(out[2].editorType).toBe('checkbox')
32
+ expect(out[3].editorType).toBe('date')
33
+ expect(out[3].format).toEqual({ type: 'date' })
34
+ expect(out[4].editorType).toBe('date')
35
+ expect(out[4].format).toBeUndefined()
36
+ })
37
+
38
+ it('never overrides an explicit editorType or format', () => {
39
+ const cols = [{ field: 'age', cellDataType: 'number', editorType: 'text', format: { type: 'currency' } }]
40
+ const out = resolveColumnTypes(cols, undefined, false)
41
+ expect(out[0].editorType).toBe('text')
42
+ expect(out[0].format).toEqual({ type: 'currency' })
43
+ })
44
+
45
+ it('infers from a sample row only when infer=true and nothing is declared', () => {
46
+ const cols = [
47
+ { field: 'age' },
48
+ { field: 'name' },
49
+ { field: 'when' },
50
+ { field: 'flag' },
51
+ ]
52
+ const sample = { age: 30, name: 'Ada', when: '2026-01-02', flag: false }
53
+ const off = resolveColumnTypes(cols, sample, false)
54
+ expect(off[0].editorType).toBeUndefined() // inference off
55
+
56
+ const on = resolveColumnTypes(cols, sample, true)
57
+ expect(on[0].editorType).toBe('number')
58
+ expect(on[1].editorType).toBe('text')
59
+ expect(on[2].editorType).toBe('date')
60
+ expect(on[3].editorType).toBe('checkbox')
61
+ })
62
+
63
+ it('recurses into header groups', () => {
64
+ const cols = [{ header: 'Group', columns: [{ field: 'age', cellDataType: 'number' }] }]
65
+ const out = resolveColumnTypes(cols, undefined, false)
66
+ expect(out[0].columns[0].editorType).toBe('number')
67
+ })
68
+ })
@@ -0,0 +1,82 @@
1
+ // cellDataType resolution. A column's `cellDataType` (or grid-level
2
+ // `inferColumnTypes`) is a high-level shorthand that fills in `editorType`,
3
+ // `format`, and - transitively, via `getColumnAlign` and the filter-operator
4
+ // tables, which both key off `editorType` - alignment and filter operators.
5
+ //
6
+ // Anything set explicitly on the ColumnDef always wins; this only fills gaps.
7
+ // Resolution happens once when columns are ingested, producing plain ColumnDef
8
+ // objects, so every downstream reader (editing, filtering, formatting,
9
+ // alignment) sees a normal column with no new code paths.
10
+ export type CellDataType = "text" | "number" | "boolean" | "date" | "dateString";
11
+
12
+ type TypeDefaults = {
13
+ editorType: string;
14
+ format?: { type: string };
15
+ };
16
+
17
+ function defaultsFor(dt: CellDataType): TypeDefaults {
18
+ switch (dt) {
19
+ case "number":
20
+ return { editorType: "number" };
21
+ case "boolean":
22
+ return { editorType: "checkbox" };
23
+ case "date":
24
+ return { editorType: "date", format: { type: "date" } };
25
+ case "dateString":
26
+ // ISO date strings ('2026-06-27') - date editor, but no Date coercion.
27
+ return { editorType: "date" };
28
+ case "text":
29
+ default:
30
+ return { editorType: "text" };
31
+ }
32
+ }
33
+
34
+ /** Best-effort inference of a column's data type from a sample value. */
35
+ export function inferCellDataType(value: unknown): CellDataType | undefined {
36
+ if (value == null) return undefined;
37
+ if (typeof value === "number" && Number.isFinite(value)) return "number";
38
+ if (typeof value === "boolean") return "boolean";
39
+ if (value instanceof Date) return "date";
40
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}([T\s]|$)/.test(value)) {
41
+ return "dateString";
42
+ }
43
+ return "text";
44
+ }
45
+
46
+ /**
47
+ * Resolve `cellDataType` (explicit or, when `infer` is on, inferred from
48
+ * `sampleRow`) into concrete `editorType` / `format` defaults. Explicit fields
49
+ * on the ColumnDef are preserved. Columns that resolve to no type are returned
50
+ * untouched. Nested column groups are resolved recursively.
51
+ */
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ export function resolveColumnTypes(
54
+ columns: ReadonlyArray<any>,
55
+ sampleRow: unknown,
56
+ infer: boolean,
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ ): any[] {
59
+ return columns.map((col) => {
60
+ // Recurse into header groups first.
61
+ if (col.columns && col.columns.length) {
62
+ return { ...col, columns: resolveColumnTypes(col.columns, sampleRow, infer) };
63
+ }
64
+ // An explicit editorType means the author already chose - don't override.
65
+ const hasExplicitEditor = col.editorType != null;
66
+ let dt: CellDataType | undefined = col.cellDataType;
67
+ if (!dt && infer && !hasExplicitEditor && sampleRow != null) {
68
+ const field = col.field;
69
+ const raw =
70
+ col.accessorFn?.(sampleRow) ??
71
+ (typeof field === "string" ? (sampleRow as Record<string, unknown>)[field] : undefined);
72
+ dt = inferCellDataType(raw);
73
+ }
74
+ if (!dt) return col;
75
+ const d = defaultsFor(dt);
76
+ return {
77
+ ...col,
78
+ editorType: col.editorType ?? d.editorType,
79
+ format: col.format ?? d.format,
80
+ };
81
+ });
82
+ }
package/src/core.ts CHANGED
@@ -32,6 +32,32 @@ export type CellContext<TData extends RowData> = {
32
32
  getValue: () => unknown
33
33
  }
34
34
 
35
+ /** Params passed to a column's `colSpan(...)` / `rowSpan(...)` callbacks. */
36
+ export type CellSpanParams<TData extends RowData = RowData> = {
37
+ /** The row's underlying data object. */
38
+ data: TData
39
+ /** Display-row index in the current (filtered/sorted) row set. */
40
+ rowIndex: number
41
+ /** The column's id. */
42
+ columnId: string
43
+ /** The cell's base value for this column. */
44
+ value: unknown
45
+ }
46
+
47
+ /** Params passed to a column's `valueParser(...)` on edit commit. */
48
+ export type ValueParserParams<TData extends RowData = RowData> = {
49
+ /** The value after built-in per-`editorType` coercion. */
50
+ newValue: unknown
51
+ /** The cell's previous value. */
52
+ oldValue: unknown
53
+ /** The raw string the editor produced (pre-coercion). */
54
+ rawInput: string
55
+ /** The row's underlying data object. */
56
+ data: TData
57
+ /** The column's id. */
58
+ columnId: string
59
+ }
60
+
35
61
  /**
36
62
  * Context passed to a custom `cellEditor` snippet/component. Three write
37
63
  * helpers cover the lifecycle:
@@ -153,6 +179,45 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
153
179
  footer?: ColumnDefTemplate<HeaderContext<TData>>
154
180
  cell?: ColumnDefTemplate<CellContext<TData>>
155
181
  columns?: Array<ColumnDef<TFeatures, TData>>
182
+ /**
183
+ * Declarative cell spanning (merged cells). Return how many COLUMNS this
184
+ * cell spans to the right (1 = no span). Value-driven, AG-Grid-style. Feed
185
+ * `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses
186
+ * the same real `colspan`/`rowspan` merge engine (no separate code path).
187
+ */
188
+ colSpan?: (params: CellSpanParams<TData>) => number
189
+ /**
190
+ * Declarative cell spanning (merged cells). Return how many ROWS this cell
191
+ * spans downward (1 = no span). See `colSpan` for how to apply.
192
+ */
193
+ rowSpan?: (params: CellSpanParams<TData>) => number
194
+ /**
195
+ * High-level data type for the column. A convenience that resolves to the
196
+ * right `editorType`, alignment, date `format`, and filter operators without
197
+ * setting each by hand:
198
+ * 'text' → text editor, left-aligned
199
+ * 'number' → number editor, right-aligned, numeric filter operators
200
+ * 'boolean' → checkbox editor, centered
201
+ * 'date' → date editor (Date values), right-aligned, `{ type: 'date' }` format
202
+ * 'dateString' → date editor for ISO date STRINGS (e.g. '2026-06-27')
203
+ * Anything you set explicitly (`editorType`, `align`, `format`) still wins -
204
+ * `cellDataType` only fills the gaps. Grid-level `inferColumnTypes` infers
205
+ * this from the first data row for columns that declare neither.
206
+ */
207
+ cellDataType?: 'text' | 'number' | 'boolean' | 'date' | 'dateString'
208
+ /**
209
+ * For a column INSIDE a collapsible column group: `'open'` shows this column
210
+ * only while the group is expanded, `'closed'` only while collapsed. Omit to
211
+ * always show it. Setting it on any direct child gives the parent group a
212
+ * collapse toggle. Pair with `openByDefault` on the group.
213
+ */
214
+ columnGroupShow?: 'open' | 'closed'
215
+ /**
216
+ * For a GROUP column (one with `columns: [...]`): start the group expanded.
217
+ * Defaults to `false` (collapsed), matching AG Grid - so only the always-on
218
+ * and `columnGroupShow: 'closed'` children show until the user expands it.
219
+ */
220
+ openByDefault?: boolean
156
221
  editorType?:
157
222
  | 'text'
158
223
  | 'number'
@@ -201,6 +266,19 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
201
266
  * `false`.
202
267
  */
203
268
  editable?: boolean | ((context: CellContext<TData>) => boolean)
269
+ /**
270
+ * Transform the committed edit value before it is written to the row.
271
+ * Runs after the built-in per-`editorType` coercion, so `newValue` is
272
+ * already type-parsed; return the final value to store (e.g. round a
273
+ * number, uppercase a code, look up an id). AG-Grid-style `valueParser`.
274
+ */
275
+ valueParser?: (params: ValueParserParams<TData>) => unknown
276
+ /**
277
+ * Briefly flash / highlight this column's cell when its value changes
278
+ * (streaming feeds, edits, server pushes). `true` uses the default flash;
279
+ * pass `{ className }` to apply your own animation class instead.
280
+ */
281
+ cellFlash?: boolean | { className?: string }
204
282
  /**
205
283
  * When `false`, this column never shows a sort indicator and clicking
206
284
  * its header is a no-op - `api.setSort(thisColumn, ...)` is also