@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.
- package/dist/GridMenus.svelte +205 -127
- package/dist/SvGrid.controller.svelte.d.ts +44 -1
- package/dist/SvGrid.controller.svelte.js +223 -38
- package/dist/SvGrid.css +2012 -1709
- package/dist/SvGrid.helpers.test.d.ts +1 -0
- package/dist/SvGrid.helpers.test.js +298 -0
- package/dist/SvGrid.svelte +2346 -2043
- package/dist/SvGrid.types.d.ts +91 -4
- package/dist/aligned-grids.d.ts +6 -0
- package/dist/aligned-grids.js +84 -0
- package/dist/aligned-grids.test.d.ts +1 -0
- package/dist/aligned-grids.test.js +75 -0
- package/dist/build-api.coverage.test.d.ts +20 -0
- package/dist/build-api.coverage.test.js +505 -0
- package/dist/build-api.js +59 -29
- package/dist/cell-render.test.d.ts +1 -0
- package/dist/cell-render.test.js +338 -0
- package/dist/chart-export.test.d.ts +1 -0
- package/dist/chart-export.test.js +302 -0
- package/dist/chart.coverage.test.d.ts +1 -0
- package/dist/chart.coverage.test.js +748 -0
- package/dist/clipboard.js +88 -24
- package/dist/clipboard.test.d.ts +1 -0
- package/dist/clipboard.test.js +700 -0
- package/dist/collaboration.coverage.test.d.ts +1 -0
- package/dist/collaboration.coverage.test.js +200 -0
- package/dist/column-groups.d.ts +19 -0
- package/dist/column-groups.js +62 -0
- package/dist/column-groups.test.d.ts +1 -0
- package/dist/column-groups.test.js +56 -0
- package/dist/column-types.d.ts +10 -0
- package/dist/column-types.js +63 -0
- package/dist/column-types.test.d.ts +1 -0
- package/dist/column-types.test.js +62 -0
- package/dist/columns.test.d.ts +1 -0
- package/dist/columns.test.js +625 -0
- package/dist/core.d.ts +85 -0
- package/dist/editing.d.ts +7 -0
- package/dist/editing.js +191 -5
- package/dist/editing.test.d.ts +1 -0
- package/dist/editing.test.js +732 -0
- package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
- package/dist/editors/cell-editors.coverage.test.js +139 -0
- package/dist/facet-buckets.test.d.ts +1 -0
- package/dist/facet-buckets.test.js +296 -0
- package/dist/filter-operators.test.d.ts +1 -0
- package/dist/filter-operators.test.js +135 -0
- package/dist/hyperformula-adapter.test.d.ts +1 -0
- package/dist/hyperformula-adapter.test.js +205 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
- package/dist/keyboard-handlers.coverage.test.js +495 -0
- package/dist/keyboard-handlers.js +7 -2
- package/dist/menus.js +1 -0
- package/dist/menus.test.d.ts +1 -0
- package/dist/menus.test.js +560 -0
- package/dist/named-views.coverage.test.d.ts +1 -0
- package/dist/named-views.coverage.test.js +180 -0
- package/dist/row-drag.d.ts +49 -0
- package/dist/row-drag.js +221 -0
- package/dist/row-drag.test.d.ts +1 -0
- package/dist/row-drag.test.js +142 -0
- package/dist/row-resize.test.d.ts +1 -0
- package/dist/row-resize.test.js +329 -0
- package/dist/scroll-sync.js +3 -0
- package/dist/scroll-sync.test.d.ts +1 -0
- package/dist/scroll-sync.test.js +290 -0
- package/dist/selection.d.ts +7 -1
- package/dist/selection.js +76 -36
- package/dist/selection.multi-range.test.d.ts +1 -0
- package/dist/selection.multi-range.test.js +55 -0
- package/dist/selection.test.d.ts +1 -0
- package/dist/selection.test.js +647 -0
- package/dist/server-data-source.coverage.test.d.ts +1 -0
- package/dist/server-data-source.coverage.test.js +154 -0
- package/dist/spreadsheet.d.ts +30 -0
- package/dist/spreadsheet.js +48 -0
- package/dist/spreadsheet.test.d.ts +1 -0
- package/dist/spreadsheet.test.js +446 -0
- package/dist/sv-grid-scrollbar.js +13 -1
- package/dist/svgrid-wrapper.types.d.ts +19 -0
- package/dist/svgrid.behavior.test.js +20 -0
- package/dist/svgrid.interaction.test.js +31 -0
- package/dist/svgrid.new-features.wrapper.test.js +34 -2
- package/dist/test-setup.js +9 -3
- package/dist/virtualization/scroll-scaling.d.ts +17 -0
- package/dist/virtualization/scroll-scaling.js +35 -0
- package/dist/virtualization/scroll-scaling.test.js +42 -1
- package/package.json +2 -1
- package/src/GridMenus.svelte +205 -127
- package/src/SvGrid.controller.svelte.ts +2352 -2195
- package/src/SvGrid.css +2012 -1747
- package/src/SvGrid.svelte +2346 -2047
- package/src/SvGrid.types.ts +537 -456
- package/src/aligned-grids.test.ts +80 -0
- package/src/aligned-grids.ts +87 -0
- package/src/build-api.ts +683 -663
- package/src/clipboard.test.ts +49 -0
- package/src/clipboard.ts +51 -23
- package/src/column-groups.test.ts +59 -0
- package/src/column-groups.ts +80 -0
- package/src/column-types.test.ts +68 -0
- package/src/column-types.ts +82 -0
- package/src/core.ts +78 -0
- package/src/editing.ts +669 -513
- package/src/index.ts +12 -0
- package/src/menus.ts +1 -0
- package/src/row-drag.test.ts +168 -0
- package/src/row-drag.ts +255 -0
- package/src/scroll-sync.ts +2 -0
- package/src/selection.multi-range.test.ts +61 -0
- package/src/selection.ts +71 -37
- package/src/spreadsheet.test.ts +489 -445
- package/src/spreadsheet.ts +304 -246
- package/src/svgrid-wrapper.types.ts +19 -0
- package/src/svgrid.new-features.wrapper.test.ts +2 -2
package/src/clipboard.test.ts
CHANGED
|
@@ -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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
437
|
-
const display = ctx.getCellDisplayValue(row.id, column.id, base);
|
|
438
|
-
cells.push(String(display ?? ""));
|
|
467
|
+
lines.push(cells.join("\t"));
|
|
439
468
|
}
|
|
440
|
-
|
|
469
|
+
blocks.push(lines.join("\n"));
|
|
441
470
|
}
|
|
442
|
-
|
|
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
|