@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
@@ -0,0 +1,200 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { broadcastChannelTransport, createCollaboration, } from './collaboration';
3
+ const userA = { id: 'a', name: 'Ada', color: '#f00' };
4
+ const userB = { id: 'b', name: 'Bob', color: '#00f' };
5
+ /** A controllable transport whose subscriber we can drive by hand. */
6
+ function manualTransport() {
7
+ const handlers = new Set();
8
+ const posted = [];
9
+ const transport = {
10
+ post: (msg) => {
11
+ posted.push(msg);
12
+ },
13
+ subscribe: (h) => {
14
+ handlers.add(h);
15
+ return () => handlers.delete(h);
16
+ },
17
+ };
18
+ const deliver = (msg) => {
19
+ for (const h of [...handlers])
20
+ h(msg);
21
+ };
22
+ return { transport, posted, deliver, handlers };
23
+ }
24
+ describe('createCollaboration - presence / edit upserts', () => {
25
+ it('hello reply carries the responder own cell', () => {
26
+ const t = manualTransport();
27
+ const a = createCollaboration({ user: userA, transport: t.transport });
28
+ a.setCell({ rowId: 'r0', columnId: 'c0' });
29
+ t.posted.length = 0;
30
+ // a newcomer says hello -> A replies presence with its own cell.
31
+ t.deliver({ kind: 'hello', user: userB });
32
+ const reply = t.posted.find((m) => m.kind === 'presence');
33
+ expect(reply).toEqual({ kind: 'presence', user: userA, cell: { rowId: 'r0', columnId: 'c0' } });
34
+ expect(a.peers().map((p) => p.id)).toEqual(['b']);
35
+ a.dispose();
36
+ });
37
+ it('presence message records the peer cursor', () => {
38
+ const t = manualTransport();
39
+ const onPeersChange = vi.fn();
40
+ const a = createCollaboration({ user: userA, transport: t.transport, onPeersChange });
41
+ t.deliver({ kind: 'presence', user: userB, cell: { rowId: 'r9', columnId: 'name' } });
42
+ expect(a.peers()[0]?.cell).toEqual({ rowId: 'r9', columnId: 'name' });
43
+ expect(onPeersChange).toHaveBeenCalled();
44
+ a.dispose();
45
+ });
46
+ it('edit from another peer sets a cursor AND fires onRemoteEdit', () => {
47
+ const t = manualTransport();
48
+ const onRemoteEdit = vi.fn();
49
+ const a = createCollaboration({ user: userA, transport: t.transport, onRemoteEdit });
50
+ t.deliver({ kind: 'edit', user: userB, rowId: 'r1', columnId: 'age', value: 30 });
51
+ expect(a.peers()[0]?.cell).toEqual({ rowId: 'r1', columnId: 'age' });
52
+ expect(onRemoteEdit).toHaveBeenCalledWith({
53
+ rowId: 'r1',
54
+ columnId: 'age',
55
+ value: 30,
56
+ user: userB,
57
+ });
58
+ a.dispose();
59
+ });
60
+ it('ignores own id on upsert (presence/edit echoed back)', () => {
61
+ const t = manualTransport();
62
+ const onRemoteEdit = vi.fn();
63
+ const a = createCollaboration({ user: userA, transport: t.transport, onRemoteEdit });
64
+ // self-echo: should not appear as a peer and should not call onRemoteEdit
65
+ t.deliver({ kind: 'presence', user: userA, cell: { rowId: 'x', columnId: 'y' } });
66
+ t.deliver({ kind: 'edit', user: userA, rowId: 'x', columnId: 'y', value: 1 });
67
+ expect(a.peers()).toHaveLength(0);
68
+ expect(onRemoteEdit).not.toHaveBeenCalled();
69
+ a.dispose();
70
+ });
71
+ it('bye for an unknown peer does not notify', () => {
72
+ const t = manualTransport();
73
+ const onPeersChange = vi.fn();
74
+ const a = createCollaboration({ user: userA, transport: t.transport, onPeersChange });
75
+ onPeersChange.mockClear();
76
+ t.deliver({ kind: 'bye', userId: 'ghost' });
77
+ expect(onPeersChange).not.toHaveBeenCalled();
78
+ a.dispose();
79
+ });
80
+ it('setCell broadcasts a presence with the new cell', () => {
81
+ const t = manualTransport();
82
+ const a = createCollaboration({ user: userA, transport: t.transport });
83
+ t.posted.length = 0;
84
+ a.setCell(null);
85
+ expect(t.posted).toContainEqual({ kind: 'presence', user: userA, cell: null });
86
+ a.dispose();
87
+ });
88
+ it('sendEdit broadcasts an edit message', () => {
89
+ const t = manualTransport();
90
+ const a = createCollaboration({ user: userA, transport: t.transport });
91
+ t.posted.length = 0;
92
+ a.sendEdit('r5', 'col', 'v');
93
+ expect(t.posted).toContainEqual({
94
+ kind: 'edit',
95
+ user: userA,
96
+ rowId: 'r5',
97
+ columnId: 'col',
98
+ value: 'v',
99
+ });
100
+ a.dispose();
101
+ });
102
+ it('drops messages after dispose and unsubscribes', () => {
103
+ const t = manualTransport();
104
+ const onPeersChange = vi.fn();
105
+ const a = createCollaboration({ user: userA, transport: t.transport, onPeersChange });
106
+ a.dispose();
107
+ onPeersChange.mockClear();
108
+ // After dispose the handler is unsubscribed; even if delivered it no-ops.
109
+ t.deliver({ kind: 'hello', user: userB });
110
+ expect(onPeersChange).not.toHaveBeenCalled();
111
+ expect(a.peers()).toHaveLength(0);
112
+ // second dispose is a no-op
113
+ a.dispose();
114
+ });
115
+ });
116
+ describe('createCollaboration - prune timer', () => {
117
+ beforeEach(() => vi.useFakeTimers());
118
+ afterEach(() => vi.useRealTimers());
119
+ it('prunes peers gone silent past the timeout', () => {
120
+ const t = manualTransport();
121
+ const onPeersChange = vi.fn();
122
+ const a = createCollaboration({
123
+ user: userA,
124
+ transport: t.transport,
125
+ onPeersChange,
126
+ peerTimeoutMs: 3000,
127
+ });
128
+ t.deliver({ kind: 'presence', user: userB, cell: null });
129
+ expect(a.peers()).toHaveLength(1);
130
+ onPeersChange.mockClear();
131
+ // advance past the timeout; prune interval is max(1000, timeout/3) = 1000
132
+ vi.advanceTimersByTime(4000);
133
+ expect(a.peers()).toHaveLength(0);
134
+ expect(onPeersChange).toHaveBeenCalled();
135
+ a.dispose();
136
+ });
137
+ it('prune tick with no expired peers does not notify', () => {
138
+ const t = manualTransport();
139
+ const onPeersChange = vi.fn();
140
+ const a = createCollaboration({
141
+ user: userA,
142
+ transport: t.transport,
143
+ onPeersChange,
144
+ peerTimeoutMs: 9000,
145
+ });
146
+ t.deliver({ kind: 'presence', user: userB, cell: null });
147
+ onPeersChange.mockClear();
148
+ vi.advanceTimersByTime(3000); // one tick, peer still fresh
149
+ expect(onPeersChange).not.toHaveBeenCalled();
150
+ expect(a.peers()).toHaveLength(1);
151
+ a.dispose();
152
+ });
153
+ it('dispose clears the prune timer', () => {
154
+ const clearSpy = vi.spyOn(globalThis, 'clearInterval');
155
+ const t = manualTransport();
156
+ const a = createCollaboration({ user: userA, transport: t.transport });
157
+ a.dispose();
158
+ expect(clearSpy).toHaveBeenCalled();
159
+ clearSpy.mockRestore();
160
+ });
161
+ });
162
+ describe('broadcastChannelTransport', () => {
163
+ it('round-trips a message across two channels of the same name', async () => {
164
+ const tx = broadcastChannelTransport('svgrid-test-chan');
165
+ const rx = broadcastChannelTransport('svgrid-test-chan');
166
+ const received = [];
167
+ const off = rx.subscribe((m) => received.push(m));
168
+ tx.post({ kind: 'hello', user: userA });
169
+ // BroadcastChannel delivery is async (microtask/macrotask).
170
+ await new Promise((r) => setTimeout(r, 10));
171
+ expect(received).toContainEqual({ kind: 'hello', user: userA });
172
+ off();
173
+ });
174
+ it('subscribe returns a working unsubscribe', async () => {
175
+ const tx = broadcastChannelTransport('svgrid-unsub-chan');
176
+ const rx = broadcastChannelTransport('svgrid-unsub-chan');
177
+ const received = [];
178
+ const off = rx.subscribe((m) => received.push(m));
179
+ off();
180
+ tx.post({ kind: 'bye', userId: 'a' });
181
+ await new Promise((r) => setTimeout(r, 10));
182
+ expect(received).toHaveLength(0);
183
+ });
184
+ it('no-ops gracefully when BroadcastChannel is unavailable', () => {
185
+ const original = globalThis.BroadcastChannel;
186
+ // @ts-expect-error - simulate SSR / old environment
187
+ delete globalThis.BroadcastChannel;
188
+ try {
189
+ const tx = broadcastChannelTransport('nope');
190
+ const handler = vi.fn();
191
+ const off = tx.subscribe(handler);
192
+ expect(() => tx.post({ kind: 'bye', userId: 'x' })).not.toThrow();
193
+ expect(() => off()).not.toThrow();
194
+ expect(handler).not.toHaveBeenCalled();
195
+ }
196
+ finally {
197
+ globalThis.BroadcastChannel = original;
198
+ }
199
+ });
200
+ });
@@ -0,0 +1,19 @@
1
+ export type ColumnGroupShow = "open" | "closed";
2
+ export type ColumnGroupMeta = {
3
+ /** Group ids that render a collapse toggle. */
4
+ collapsibleGroupIds: Set<string>;
5
+ /** Group id -> whether it starts expanded (openByDefault). */
6
+ defaultOpen: Map<string, boolean>;
7
+ /** Leaf column id -> the group controlling it + its show mode. */
8
+ leafControl: Map<string, {
9
+ groupId: string;
10
+ show: ColumnGroupShow;
11
+ }>;
12
+ };
13
+ export declare function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroupMeta;
14
+ /**
15
+ * Which leaf ids are hidden right now, given the set of collapsed group ids.
16
+ * `'open'` leaves hide while their group is collapsed; `'closed'` leaves hide
17
+ * while it is expanded.
18
+ */
19
+ export declare function hiddenLeavesForCollapse(meta: ColumnGroupMeta, collapsed: ReadonlySet<string>): Record<string, boolean>;
@@ -0,0 +1,62 @@
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
+ // Mirror the id resolution used by the group-header derivation so group ids and
12
+ // leaf ids line up across both.
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ function idOf(def, parentId, ix) {
15
+ return def.id ?? def.field ?? `${parentId ?? "col"}_d_${ix}`;
16
+ }
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ export function computeColumnGroupMeta(columns) {
19
+ const collapsibleGroupIds = new Set();
20
+ const defaultOpen = new Map();
21
+ const leafControl = new Map();
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ function walk(defs, parentId, controllingGroup) {
24
+ defs.forEach((def, ix) => {
25
+ const id = idOf(def, parentId, ix);
26
+ if (def.columns?.length) {
27
+ // A group is collapsible when any DIRECT child declares columnGroupShow.
28
+ const collapsible = def.columns.some(
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ (c) => c.columnGroupShow === "open" || c.columnGroupShow === "closed");
31
+ if (collapsible) {
32
+ collapsibleGroupIds.add(id);
33
+ defaultOpen.set(id, def.openByDefault === true);
34
+ }
35
+ walk(def.columns, id, collapsible ? id : controllingGroup);
36
+ }
37
+ else {
38
+ const show = def.columnGroupShow;
39
+ if ((show === "open" || show === "closed") && controllingGroup) {
40
+ leafControl.set(id, { groupId: controllingGroup, show });
41
+ }
42
+ }
43
+ });
44
+ }
45
+ walk(columns, undefined, undefined);
46
+ return { collapsibleGroupIds, defaultOpen, leafControl };
47
+ }
48
+ /**
49
+ * Which leaf ids are hidden right now, given the set of collapsed group ids.
50
+ * `'open'` leaves hide while their group is collapsed; `'closed'` leaves hide
51
+ * while it is expanded.
52
+ */
53
+ export function hiddenLeavesForCollapse(meta, collapsed) {
54
+ const hidden = {};
55
+ for (const [leafId, ctrl] of meta.leafControl) {
56
+ const isCollapsed = collapsed.has(ctrl.groupId);
57
+ const hide = ctrl.show === "open" ? isCollapsed : !isCollapsed;
58
+ if (hide)
59
+ hidden[leafId] = true;
60
+ }
61
+ return hidden;
62
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computeColumnGroupMeta, hiddenLeavesForCollapse } from './column-groups';
3
+ // A group "Q1" with an always-on Total plus Jan/Feb that only show when open.
4
+ const columns = [
5
+ { field: 'name' },
6
+ {
7
+ id: 'q1',
8
+ header: 'Q1',
9
+ columns: [
10
+ { field: 'q1Total' },
11
+ { field: 'jan', columnGroupShow: 'open' },
12
+ { field: 'feb', columnGroupShow: 'open' },
13
+ ],
14
+ },
15
+ {
16
+ id: 'q2',
17
+ header: 'Q2',
18
+ openByDefault: true,
19
+ columns: [
20
+ { field: 'q2Total', columnGroupShow: 'closed' },
21
+ { field: 'apr', columnGroupShow: 'open' },
22
+ ],
23
+ },
24
+ ];
25
+ describe('computeColumnGroupMeta', () => {
26
+ it('marks groups with columnGroupShow children as collapsible', () => {
27
+ const meta = computeColumnGroupMeta(columns);
28
+ expect([...meta.collapsibleGroupIds].sort()).toEqual(['q1', 'q2']);
29
+ expect(meta.defaultOpen.get('q1')).toBe(false);
30
+ expect(meta.defaultOpen.get('q2')).toBe(true);
31
+ });
32
+ it('maps controlled leaves to their group + show mode', () => {
33
+ const meta = computeColumnGroupMeta(columns);
34
+ expect(meta.leafControl.get('jan')).toEqual({ groupId: 'q1', show: 'open' });
35
+ expect(meta.leafControl.get('q2Total')).toEqual({ groupId: 'q2', show: 'closed' });
36
+ // untagged always-on column is not controlled
37
+ expect(meta.leafControl.has('q1Total')).toBe(false);
38
+ expect(meta.leafControl.has('name')).toBe(false);
39
+ });
40
+ });
41
+ describe('hiddenLeavesForCollapse', () => {
42
+ const meta = computeColumnGroupMeta(columns);
43
+ it('hides open-columns while collapsed, shows them while expanded', () => {
44
+ const collapsed = new Set(['q1']);
45
+ const hidden = hiddenLeavesForCollapse(meta, collapsed);
46
+ expect(hidden.jan).toBe(true);
47
+ expect(hidden.feb).toBe(true);
48
+ expect(hidden.q1Total).toBeUndefined(); // always on
49
+ });
50
+ it('hides closed-columns while expanded', () => {
51
+ const collapsed = new Set(); // q2 expanded
52
+ const hidden = hiddenLeavesForCollapse(meta, collapsed);
53
+ expect(hidden.q2Total).toBe(true); // 'closed' hides when expanded
54
+ expect(hidden.apr).toBeUndefined(); // 'open' shows when expanded
55
+ });
56
+ });
@@ -0,0 +1,10 @@
1
+ export type CellDataType = "text" | "number" | "boolean" | "date" | "dateString";
2
+ /** Best-effort inference of a column's data type from a sample value. */
3
+ export declare function inferCellDataType(value: unknown): CellDataType | undefined;
4
+ /**
5
+ * Resolve `cellDataType` (explicit or, when `infer` is on, inferred from
6
+ * `sampleRow`) into concrete `editorType` / `format` defaults. Explicit fields
7
+ * on the ColumnDef are preserved. Columns that resolve to no type are returned
8
+ * untouched. Nested column groups are resolved recursively.
9
+ */
10
+ export declare function resolveColumnTypes(columns: ReadonlyArray<any>, sampleRow: unknown, infer: boolean): any[];
@@ -0,0 +1,63 @@
1
+ function defaultsFor(dt) {
2
+ switch (dt) {
3
+ case "number":
4
+ return { editorType: "number" };
5
+ case "boolean":
6
+ return { editorType: "checkbox" };
7
+ case "date":
8
+ return { editorType: "date", format: { type: "date" } };
9
+ case "dateString":
10
+ // ISO date strings ('2026-06-27') - date editor, but no Date coercion.
11
+ return { editorType: "date" };
12
+ case "text":
13
+ default:
14
+ return { editorType: "text" };
15
+ }
16
+ }
17
+ /** Best-effort inference of a column's data type from a sample value. */
18
+ export function inferCellDataType(value) {
19
+ if (value == null)
20
+ return undefined;
21
+ if (typeof value === "number" && Number.isFinite(value))
22
+ return "number";
23
+ if (typeof value === "boolean")
24
+ return "boolean";
25
+ if (value instanceof Date)
26
+ return "date";
27
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}([T\s]|$)/.test(value)) {
28
+ return "dateString";
29
+ }
30
+ return "text";
31
+ }
32
+ /**
33
+ * Resolve `cellDataType` (explicit or, when `infer` is on, inferred from
34
+ * `sampleRow`) into concrete `editorType` / `format` defaults. Explicit fields
35
+ * on the ColumnDef are preserved. Columns that resolve to no type are returned
36
+ * untouched. Nested column groups are resolved recursively.
37
+ */
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ export function resolveColumnTypes(columns, sampleRow, infer) {
40
+ return columns.map((col) => {
41
+ // Recurse into header groups first.
42
+ if (col.columns && col.columns.length) {
43
+ return { ...col, columns: resolveColumnTypes(col.columns, sampleRow, infer) };
44
+ }
45
+ // An explicit editorType means the author already chose - don't override.
46
+ const hasExplicitEditor = col.editorType != null;
47
+ let dt = col.cellDataType;
48
+ if (!dt && infer && !hasExplicitEditor && sampleRow != null) {
49
+ const field = col.field;
50
+ const raw = col.accessorFn?.(sampleRow) ??
51
+ (typeof field === "string" ? sampleRow[field] : undefined);
52
+ dt = inferCellDataType(raw);
53
+ }
54
+ if (!dt)
55
+ return col;
56
+ const d = defaultsFor(dt);
57
+ return {
58
+ ...col,
59
+ editorType: col.editorType ?? d.editorType,
60
+ format: col.format ?? d.format,
61
+ };
62
+ });
63
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { inferCellDataType, resolveColumnTypes } from './column-types';
3
+ describe('inferCellDataType', () => {
4
+ it('classifies primitives', () => {
5
+ expect(inferCellDataType(42)).toBe('number');
6
+ expect(inferCellDataType(true)).toBe('boolean');
7
+ expect(inferCellDataType(new Date())).toBe('date');
8
+ expect(inferCellDataType('2026-06-27')).toBe('dateString');
9
+ expect(inferCellDataType('2026-06-27T10:00:00Z')).toBe('dateString');
10
+ expect(inferCellDataType('hello')).toBe('text');
11
+ });
12
+ it('returns undefined for null/undefined so nothing is forced', () => {
13
+ expect(inferCellDataType(null)).toBeUndefined();
14
+ expect(inferCellDataType(undefined)).toBeUndefined();
15
+ });
16
+ });
17
+ describe('resolveColumnTypes', () => {
18
+ it('maps explicit cellDataType to editorType + format', () => {
19
+ const cols = [
20
+ { field: 'name', cellDataType: 'text' },
21
+ { field: 'age', cellDataType: 'number' },
22
+ { field: 'active', cellDataType: 'boolean' },
23
+ { field: 'joined', cellDataType: 'date' },
24
+ { field: 'iso', cellDataType: 'dateString' },
25
+ ];
26
+ const out = resolveColumnTypes(cols, undefined, false);
27
+ expect(out[0].editorType).toBe('text');
28
+ expect(out[1].editorType).toBe('number');
29
+ expect(out[2].editorType).toBe('checkbox');
30
+ expect(out[3].editorType).toBe('date');
31
+ expect(out[3].format).toEqual({ type: 'date' });
32
+ expect(out[4].editorType).toBe('date');
33
+ expect(out[4].format).toBeUndefined();
34
+ });
35
+ it('never overrides an explicit editorType or format', () => {
36
+ const cols = [{ field: 'age', cellDataType: 'number', editorType: 'text', format: { type: 'currency' } }];
37
+ const out = resolveColumnTypes(cols, undefined, false);
38
+ expect(out[0].editorType).toBe('text');
39
+ expect(out[0].format).toEqual({ type: 'currency' });
40
+ });
41
+ it('infers from a sample row only when infer=true and nothing is declared', () => {
42
+ const cols = [
43
+ { field: 'age' },
44
+ { field: 'name' },
45
+ { field: 'when' },
46
+ { field: 'flag' },
47
+ ];
48
+ const sample = { age: 30, name: 'Ada', when: '2026-01-02', flag: false };
49
+ const off = resolveColumnTypes(cols, sample, false);
50
+ expect(off[0].editorType).toBeUndefined(); // inference off
51
+ const on = resolveColumnTypes(cols, sample, true);
52
+ expect(on[0].editorType).toBe('number');
53
+ expect(on[1].editorType).toBe('text');
54
+ expect(on[2].editorType).toBe('date');
55
+ expect(on[3].editorType).toBe('checkbox');
56
+ });
57
+ it('recurses into header groups', () => {
58
+ const cols = [{ header: 'Group', columns: [{ field: 'age', cellDataType: 'number' }] }];
59
+ const out = resolveColumnTypes(cols, undefined, false);
60
+ expect(out[0].columns[0].editorType).toBe('number');
61
+ });
62
+ });
@@ -0,0 +1 @@
1
+ export {};