@svgrid/grid 1.0.0
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/LICENSE +33 -0
- package/README.md +39 -0
- package/dist/FlexRender.svelte +96 -0
- package/dist/FlexRender.svelte.d.ts +49 -0
- package/dist/SvGrid.svelte +8742 -0
- package/dist/SvGrid.svelte.d.ts +381 -0
- package/dist/SvGridChart.svelte +653 -0
- package/dist/SvGridChart.svelte.d.ts +25 -0
- package/dist/SvGridChart.test.d.ts +1 -0
- package/dist/SvGridChart.test.js +62 -0
- package/dist/SvGridDropdown.svelte +675 -0
- package/dist/SvGridDropdown.svelte.d.ts +40 -0
- package/dist/a11y.contract.test.d.ts +1 -0
- package/dist/a11y.contract.test.js +39 -0
- package/dist/a11y.d.ts +38 -0
- package/dist/a11y.js +37 -0
- package/dist/a11y.test.d.ts +1 -0
- package/dist/a11y.test.js +44 -0
- package/dist/cell-formatting.d.ts +17 -0
- package/dist/cell-formatting.js +141 -0
- package/dist/cell-formatting.test.d.ts +1 -0
- package/dist/cell-formatting.test.js +234 -0
- package/dist/chart-export.d.ts +14 -0
- package/dist/chart-export.js +112 -0
- package/dist/chart.d.ts +226 -0
- package/dist/chart.js +732 -0
- package/dist/chart.test.d.ts +1 -0
- package/dist/chart.test.js +289 -0
- package/dist/collaboration.d.ts +74 -0
- package/dist/collaboration.js +98 -0
- package/dist/collaboration.test.d.ts +1 -0
- package/dist/collaboration.test.js +65 -0
- package/dist/conditional-formatting.d.ts +91 -0
- package/dist/conditional-formatting.js +170 -0
- package/dist/conditional-formatting.test.d.ts +1 -0
- package/dist/conditional-formatting.test.js +87 -0
- package/dist/core.coverage.test.d.ts +1 -0
- package/dist/core.coverage.test.js +186 -0
- package/dist/core.d.ts +370 -0
- package/dist/core.js +568 -0
- package/dist/core.performance.test.d.ts +1 -0
- package/dist/core.performance.test.js +29 -0
- package/dist/createGrid.svelte.d.ts +6 -0
- package/dist/createGrid.svelte.js +17 -0
- package/dist/createGrid.test.d.ts +1 -0
- package/dist/createGrid.test.js +9 -0
- package/dist/createGridState.svelte.d.ts +3 -0
- package/dist/createGridState.svelte.js +13 -0
- package/dist/editors/cell-editors.d.ts +23 -0
- package/dist/editors/cell-editors.js +97 -0
- package/dist/editors/cell-editors.test.d.ts +1 -0
- package/dist/editors/cell-editors.test.js +75 -0
- package/dist/fill-patterns.d.ts +30 -0
- package/dist/fill-patterns.js +207 -0
- package/dist/fill-patterns.test.d.ts +1 -0
- package/dist/fill-patterns.test.js +81 -0
- package/dist/filtering/excel-filters.d.ts +26 -0
- package/dist/filtering/excel-filters.js +64 -0
- package/dist/filtering/excel-filters.test.d.ts +1 -0
- package/dist/filtering/excel-filters.test.js +116 -0
- package/dist/filtering/locale-filter.test.d.ts +13 -0
- package/dist/filtering/locale-filter.test.js +189 -0
- package/dist/flex-render.d.ts +1 -0
- package/dist/flex-render.js +2 -0
- package/dist/flex-render.test.d.ts +1 -0
- package/dist/flex-render.test.js +142 -0
- package/dist/group-aggregate.test.d.ts +1 -0
- package/dist/group-aggregate.test.js +32 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +27 -0
- package/dist/keyboard.d.ts +8 -0
- package/dist/keyboard.js +91 -0
- package/dist/keyboard.test.d.ts +1 -0
- package/dist/keyboard.test.js +27 -0
- package/dist/merge-objects.d.ts +9 -0
- package/dist/merge-objects.js +28 -0
- package/dist/named-views.d.ts +42 -0
- package/dist/named-views.js +80 -0
- package/dist/named-views.test.d.ts +1 -0
- package/dist/named-views.test.js +57 -0
- package/dist/new-features.test.d.ts +1 -0
- package/dist/new-features.test.js +183 -0
- package/dist/render-component.d.ts +13 -0
- package/dist/render-component.js +14 -0
- package/dist/render-component.test.d.ts +1 -0
- package/dist/render-component.test.js +41 -0
- package/dist/server-data-source.d.ts +76 -0
- package/dist/server-data-source.js +83 -0
- package/dist/server-data-source.test.d.ts +1 -0
- package/dist/server-data-source.test.js +81 -0
- package/dist/sparkline.d.ts +56 -0
- package/dist/sparkline.js +97 -0
- package/dist/sparkline.test.d.ts +1 -0
- package/dist/sparkline.test.js +52 -0
- package/dist/static-functions.d.ts +1 -0
- package/dist/static-functions.js +1 -0
- package/dist/subscribe.d.ts +9 -0
- package/dist/subscribe.js +31 -0
- package/dist/subscribe.test.d.ts +1 -0
- package/dist/subscribe.test.js +93 -0
- package/dist/sv-grid-scrollbar.d.ts +1 -0
- package/dist/sv-grid-scrollbar.js +310 -0
- package/dist/svgrid-wrapper.types.d.ts +341 -0
- package/dist/svgrid-wrapper.types.js +1 -0
- package/dist/svgrid.api-extensions.test.d.ts +14 -0
- package/dist/svgrid.api-extensions.test.js +334 -0
- package/dist/svgrid.api.test.d.ts +15 -0
- package/dist/svgrid.api.test.js +169 -0
- package/dist/svgrid.behavior.test.d.ts +15 -0
- package/dist/svgrid.behavior.test.js +605 -0
- package/dist/svgrid.column-reorder.test.d.ts +15 -0
- package/dist/svgrid.column-reorder.test.js +224 -0
- package/dist/svgrid.features.test.d.ts +1 -0
- package/dist/svgrid.features.test.js +129 -0
- package/dist/svgrid.interaction.test.d.ts +9 -0
- package/dist/svgrid.interaction.test.js +318 -0
- package/dist/svgrid.locale-filtering.test.d.ts +15 -0
- package/dist/svgrid.locale-filtering.test.js +261 -0
- package/dist/svgrid.new-features.wrapper.test.d.ts +1 -0
- package/dist/svgrid.new-features.wrapper.test.js +136 -0
- package/dist/svgrid.row-pinning.test.d.ts +14 -0
- package/dist/svgrid.row-pinning.test.js +250 -0
- package/dist/svgrid.set-filter.test.d.ts +17 -0
- package/dist/svgrid.set-filter.test.js +268 -0
- package/dist/svgrid.wrapper.test.d.ts +1 -0
- package/dist/svgrid.wrapper.test.js +33 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +29 -0
- package/dist/transaction.test.d.ts +1 -0
- package/dist/transaction.test.js +89 -0
- package/dist/virtualization/column-virtualizer.d.ts +20 -0
- package/dist/virtualization/column-virtualizer.js +19 -0
- package/dist/virtualization/column-virtualizer.test.d.ts +1 -0
- package/dist/virtualization/column-virtualizer.test.js +25 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +11 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +20 -0
- package/dist/virtualization/types.d.ts +28 -0
- package/dist/virtualization/types.js +1 -0
- package/dist/virtualization/virtualizer.d.ts +13 -0
- package/dist/virtualization/virtualizer.js +232 -0
- package/dist/virtualization/virtualizer.test.d.ts +1 -0
- package/dist/virtualization/virtualizer.test.js +41 -0
- package/package.json +69 -0
- package/src/FlexRender.svelte +96 -0
- package/src/SvGrid.svelte +8742 -0
- package/src/SvGridChart.svelte +653 -0
- package/src/SvGridChart.test.ts +69 -0
- package/src/SvGridDropdown.svelte +675 -0
- package/src/a11y.contract.test.ts +49 -0
- package/src/a11y.test.ts +59 -0
- package/src/a11y.ts +59 -0
- package/src/cell-formatting.test.ts +273 -0
- package/src/cell-formatting.ts +169 -0
- package/src/chart-export.ts +144 -0
- package/src/chart.test.ts +311 -0
- package/src/chart.ts +934 -0
- package/src/collaboration.test.ts +74 -0
- package/src/collaboration.ts +161 -0
- package/src/conditional-formatting.test.ts +107 -0
- package/src/conditional-formatting.ts +239 -0
- package/src/core.coverage.test.ts +230 -0
- package/src/core.performance.test.ts +30 -0
- package/src/core.ts +991 -0
- package/src/createGrid.svelte.ts +42 -0
- package/src/createGrid.test.ts +10 -0
- package/src/createGridState.svelte.ts +17 -0
- package/src/editors/cell-editors.test.ts +80 -0
- package/src/editors/cell-editors.ts +127 -0
- package/src/fill-patterns.test.ts +93 -0
- package/src/fill-patterns.ts +229 -0
- package/src/filtering/excel-filters.test.ts +131 -0
- package/src/filtering/excel-filters.ts +101 -0
- package/src/filtering/locale-filter.test.ts +220 -0
- package/src/flex-render.test.ts +143 -0
- package/src/flex-render.ts +3 -0
- package/src/group-aggregate.test.ts +48 -0
- package/src/index.ts +159 -0
- package/src/keyboard.test.ts +59 -0
- package/src/keyboard.ts +97 -0
- package/src/merge-objects.ts +48 -0
- package/src/named-views.test.ts +66 -0
- package/src/named-views.ts +120 -0
- package/src/new-features.test.ts +217 -0
- package/src/render-component.test.ts +51 -0
- package/src/render-component.ts +28 -0
- package/src/server-data-source.test.ts +88 -0
- package/src/server-data-source.ts +163 -0
- package/src/sparkline.test.ts +59 -0
- package/src/sparkline.ts +164 -0
- package/src/static-functions.ts +11 -0
- package/src/subscribe.test.ts +103 -0
- package/src/subscribe.ts +38 -0
- package/src/sv-grid-scrollbar.ts +347 -0
- package/src/svgrid-wrapper.types.ts +382 -0
- package/src/svgrid.api-extensions.test.ts +362 -0
- package/src/svgrid.api.test.ts +192 -0
- package/src/svgrid.behavior.test.ts +657 -0
- package/src/svgrid.column-reorder.test.ts +234 -0
- package/src/svgrid.features.test.ts +157 -0
- package/src/svgrid.interaction.test.ts +355 -0
- package/src/svgrid.locale-filtering.test.ts +259 -0
- package/src/svgrid.new-features.wrapper.test.ts +164 -0
- package/src/svgrid.row-pinning.test.ts +266 -0
- package/src/svgrid.set-filter.test.ts +270 -0
- package/src/svgrid.wrapper.test.ts +35 -0
- package/src/test-setup.ts +37 -0
- package/src/transaction.test.ts +100 -0
- package/src/virtualization/column-virtualizer.test.ts +27 -0
- package/src/virtualization/column-virtualizer.ts +30 -0
- package/src/virtualization/svelte-virtualizer.svelte.ts +24 -0
- package/src/virtualization/types.ts +30 -0
- package/src/virtualization/virtualizer.test.ts +47 -0
- package/src/virtualization/virtualizer.ts +270 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Normalize the loose `editorOptions` ColumnDef prop into a uniform shape.
|
|
2
|
+
* Dynamic (function) editorOptions are resolved before reaching here. */
|
|
3
|
+
export function normalizeEditorOptions(options) {
|
|
4
|
+
if (!options || typeof options === 'function')
|
|
5
|
+
return [];
|
|
6
|
+
return options.map((opt) => {
|
|
7
|
+
if (typeof opt === 'string' || typeof opt === 'number') {
|
|
8
|
+
return { value: opt, label: String(opt) };
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
value: opt.value,
|
|
12
|
+
label: opt.label ?? String(opt.value),
|
|
13
|
+
color: opt.color,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function parseEditorValue(type, value, opts) {
|
|
18
|
+
if (type === 'number') {
|
|
19
|
+
const parsed = Number(value);
|
|
20
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
21
|
+
}
|
|
22
|
+
if (type === 'rating') {
|
|
23
|
+
const parsed = Number(value);
|
|
24
|
+
if (!Number.isFinite(parsed))
|
|
25
|
+
return 0;
|
|
26
|
+
return Math.max(0, Math.min(5, Math.round(parsed)));
|
|
27
|
+
}
|
|
28
|
+
if (type === 'color') {
|
|
29
|
+
const s = String(value ?? '').trim();
|
|
30
|
+
// Native <input type="color"> always returns "#rrggbb"; if anything
|
|
31
|
+
// else comes in (CSS name, rgb()), pass it through unchanged so the
|
|
32
|
+
// caller can keep whatever color string they had.
|
|
33
|
+
return s || '#000000';
|
|
34
|
+
}
|
|
35
|
+
if (type === 'date') {
|
|
36
|
+
const date = new Date(String(value));
|
|
37
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
38
|
+
}
|
|
39
|
+
if (type === 'datetime') {
|
|
40
|
+
const date = new Date(String(value));
|
|
41
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
42
|
+
}
|
|
43
|
+
if (type === 'time') {
|
|
44
|
+
// Native `<input type="time">` returns "HH:MM" (or "HH:MM:SS"). Accept
|
|
45
|
+
// anything that parses to that shape; reject malformed strings as
|
|
46
|
+
// `null` so consumers can distinguish "cleared" from "never set".
|
|
47
|
+
const s = String(value ?? '').trim();
|
|
48
|
+
if (!s)
|
|
49
|
+
return null;
|
|
50
|
+
const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
|
51
|
+
if (!m)
|
|
52
|
+
return null;
|
|
53
|
+
const h = Math.max(0, Math.min(23, parseInt(m[1], 10)));
|
|
54
|
+
const mm = Math.max(0, Math.min(59, parseInt(m[2], 10)));
|
|
55
|
+
const ss = m[3] ? Math.max(0, Math.min(59, parseInt(m[3], 10))) : null;
|
|
56
|
+
const HH = String(h).padStart(2, '0');
|
|
57
|
+
const MM = String(mm).padStart(2, '0');
|
|
58
|
+
return ss === null ? `${HH}:${MM}` : `${HH}:${MM}:${String(ss).padStart(2, '0')}`;
|
|
59
|
+
}
|
|
60
|
+
if (type === 'password') {
|
|
61
|
+
// Password is just a string with masked rendering. Empty string is a
|
|
62
|
+
// legitimate value (user cleared the field); keep it instead of nulling.
|
|
63
|
+
return String(value ?? '');
|
|
64
|
+
}
|
|
65
|
+
if (type === 'checkbox') {
|
|
66
|
+
if (typeof value === 'boolean')
|
|
67
|
+
return value;
|
|
68
|
+
return String(value).toLowerCase() === 'true';
|
|
69
|
+
}
|
|
70
|
+
if (type === 'select' || type === 'rich-select') {
|
|
71
|
+
// Single-select: trust the editor's reported value; coerce nullish
|
|
72
|
+
// to `null` so consumers can disambiguate "user cleared" from "user
|
|
73
|
+
// never picked".
|
|
74
|
+
if (Array.isArray(value))
|
|
75
|
+
return value[0] ?? null;
|
|
76
|
+
return value ?? null;
|
|
77
|
+
}
|
|
78
|
+
if (type === 'textarea') {
|
|
79
|
+
return String(value ?? '');
|
|
80
|
+
}
|
|
81
|
+
if (type === 'list' || type === 'chips') {
|
|
82
|
+
// The editor manages the value shape: a scalar for single-select, an
|
|
83
|
+
// array for multi-select. Trust what the editor handed us, just coerce
|
|
84
|
+
// the shape so consumers can rely on the contract.
|
|
85
|
+
if (opts?.multiple) {
|
|
86
|
+
if (Array.isArray(value))
|
|
87
|
+
return value.slice();
|
|
88
|
+
if (value == null || value === '')
|
|
89
|
+
return [];
|
|
90
|
+
return [value];
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(value))
|
|
93
|
+
return value[0] ?? null;
|
|
94
|
+
return value ?? null;
|
|
95
|
+
}
|
|
96
|
+
return String(value ?? '');
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseEditorValue } from './cell-editors';
|
|
3
|
+
describe('parseEditorValue - number', () => {
|
|
4
|
+
it('parses a valid string', () => {
|
|
5
|
+
expect(parseEditorValue('number', '42')).toBe(42);
|
|
6
|
+
});
|
|
7
|
+
it('parses a numeric value as-is', () => {
|
|
8
|
+
expect(parseEditorValue('number', 42)).toBe(42);
|
|
9
|
+
});
|
|
10
|
+
it('returns null for non-numeric input', () => {
|
|
11
|
+
expect(parseEditorValue('number', 'bad')).toBe(null);
|
|
12
|
+
expect(parseEditorValue('number', NaN)).toBe(null);
|
|
13
|
+
});
|
|
14
|
+
it('returns null for Infinity', () => {
|
|
15
|
+
expect(parseEditorValue('number', Infinity)).toBe(null);
|
|
16
|
+
});
|
|
17
|
+
it('handles empty string as null', () => {
|
|
18
|
+
expect(parseEditorValue('number', '')).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('parseEditorValue - date', () => {
|
|
22
|
+
it('returns an ISO string for a valid date', () => {
|
|
23
|
+
const result = parseEditorValue('date', '2026-01-01');
|
|
24
|
+
expect(typeof result).toBe('string');
|
|
25
|
+
expect(String(result)).toContain('2026-01-01');
|
|
26
|
+
});
|
|
27
|
+
it('returns null for an unparseable string', () => {
|
|
28
|
+
expect(parseEditorValue('date', 'not-a-date')).toBe(null);
|
|
29
|
+
});
|
|
30
|
+
it('accepts a Date object via its string form', () => {
|
|
31
|
+
const result = parseEditorValue('date', new Date('2026-05-30T00:00:00Z'));
|
|
32
|
+
expect(String(result)).toContain('2026-05-30');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('parseEditorValue - datetime', () => {
|
|
36
|
+
it('returns an ISO string for a valid datetime input', () => {
|
|
37
|
+
const result = parseEditorValue('datetime', '2026-01-01T10:30');
|
|
38
|
+
expect(typeof result).toBe('string');
|
|
39
|
+
expect(String(result)).toContain('2026-01-01');
|
|
40
|
+
});
|
|
41
|
+
it('returns null for invalid datetime input', () => {
|
|
42
|
+
expect(parseEditorValue('datetime', 'bogus')).toBe(null);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('parseEditorValue - checkbox', () => {
|
|
46
|
+
it('returns booleans as-is', () => {
|
|
47
|
+
expect(parseEditorValue('checkbox', true)).toBe(true);
|
|
48
|
+
expect(parseEditorValue('checkbox', false)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
it('parses "true"/"false" strings case-insensitively', () => {
|
|
51
|
+
expect(parseEditorValue('checkbox', 'true')).toBe(true);
|
|
52
|
+
expect(parseEditorValue('checkbox', 'TRUE')).toBe(true);
|
|
53
|
+
expect(parseEditorValue('checkbox', 'false')).toBe(false);
|
|
54
|
+
expect(parseEditorValue('checkbox', 'False')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
it('treats other strings as false', () => {
|
|
57
|
+
expect(parseEditorValue('checkbox', 'yes')).toBe(false);
|
|
58
|
+
expect(parseEditorValue('checkbox', '1')).toBe(false);
|
|
59
|
+
expect(parseEditorValue('checkbox', '')).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
it('coerces numbers via String() (non-"true" → false)', () => {
|
|
62
|
+
expect(parseEditorValue('checkbox', 1)).toBe(false);
|
|
63
|
+
expect(parseEditorValue('checkbox', 0)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('parseEditorValue - text (default)', () => {
|
|
67
|
+
it('returns the string form of a value', () => {
|
|
68
|
+
expect(parseEditorValue('text', 'hello')).toBe('hello');
|
|
69
|
+
expect(parseEditorValue('text', 42)).toBe('42');
|
|
70
|
+
});
|
|
71
|
+
it('coerces null/undefined to empty string', () => {
|
|
72
|
+
expect(parseEditorValue('text', null)).toBe('');
|
|
73
|
+
expect(parseEditorValue('text', undefined)).toBe('');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern detection + extrapolation for the cell-range fill handle.
|
|
3
|
+
*
|
|
4
|
+
* Recognized patterns, in priority order:
|
|
5
|
+
*
|
|
6
|
+
* 1. Known sequences (case-insensitive): days of the week (Monday /
|
|
7
|
+
* Mon), months (January / Jan), quarters (Q1..Q4). When source
|
|
8
|
+
* values match a sequence and form an arithmetic progression of
|
|
9
|
+
* indices into it, we extrapolate by stepping through the sequence
|
|
10
|
+
* with wrap-around. Output case is matched to the source - UPPER,
|
|
11
|
+
* lower, or Title.
|
|
12
|
+
*
|
|
13
|
+
* 2. Prefix-number-suffix patterns: "Item 1", "Item 2" → "Item 3";
|
|
14
|
+
* "2024-Q1", "2024-Q2" → "2024-Q3". The shared prefix + suffix is
|
|
15
|
+
* preserved and the number is extrapolated.
|
|
16
|
+
*
|
|
17
|
+
* 3. Pure numeric arithmetic progression: 1, 2, 3 → 4, 5, 6; or
|
|
18
|
+
* 100, 200, 300 → 400, 500, 600.
|
|
19
|
+
*
|
|
20
|
+
* 4. Fallback: cycle the source values across the new cells.
|
|
21
|
+
*
|
|
22
|
+
* A single source value falls through to the cycle case (repeats), which
|
|
23
|
+
* matches Excel's default fill behavior.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Compute the values to write into `targetCount` new cells given the
|
|
27
|
+
* `sourceValues` already in the selection. See module header for the
|
|
28
|
+
* pattern-detection rules.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildFillPattern(sourceValues: ReadonlyArray<unknown>, targetCount: number): unknown[];
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern detection + extrapolation for the cell-range fill handle.
|
|
3
|
+
*
|
|
4
|
+
* Recognized patterns, in priority order:
|
|
5
|
+
*
|
|
6
|
+
* 1. Known sequences (case-insensitive): days of the week (Monday /
|
|
7
|
+
* Mon), months (January / Jan), quarters (Q1..Q4). When source
|
|
8
|
+
* values match a sequence and form an arithmetic progression of
|
|
9
|
+
* indices into it, we extrapolate by stepping through the sequence
|
|
10
|
+
* with wrap-around. Output case is matched to the source - UPPER,
|
|
11
|
+
* lower, or Title.
|
|
12
|
+
*
|
|
13
|
+
* 2. Prefix-number-suffix patterns: "Item 1", "Item 2" → "Item 3";
|
|
14
|
+
* "2024-Q1", "2024-Q2" → "2024-Q3". The shared prefix + suffix is
|
|
15
|
+
* preserved and the number is extrapolated.
|
|
16
|
+
*
|
|
17
|
+
* 3. Pure numeric arithmetic progression: 1, 2, 3 → 4, 5, 6; or
|
|
18
|
+
* 100, 200, 300 → 400, 500, 600.
|
|
19
|
+
*
|
|
20
|
+
* 4. Fallback: cycle the source values across the new cells.
|
|
21
|
+
*
|
|
22
|
+
* A single source value falls through to the cycle case (repeats), which
|
|
23
|
+
* matches Excel's default fill behavior.
|
|
24
|
+
*/
|
|
25
|
+
/** Lookup table for the named sequences we know how to extrapolate.
|
|
26
|
+
* Add new ones (zodiac signs, planets, …) and the rest of the code
|
|
27
|
+
* handles them automatically. */
|
|
28
|
+
const KNOWN_SEQUENCES = [
|
|
29
|
+
// Days of the week, long
|
|
30
|
+
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
|
31
|
+
// Days of the week, short
|
|
32
|
+
['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
|
33
|
+
// Months, long
|
|
34
|
+
['January', 'February', 'March', 'April', 'May', 'June',
|
|
35
|
+
'July', 'August', 'September', 'October', 'November', 'December'],
|
|
36
|
+
// Months, short
|
|
37
|
+
['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
38
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
|
39
|
+
// Quarters
|
|
40
|
+
['Q1', 'Q2', 'Q3', 'Q4'],
|
|
41
|
+
];
|
|
42
|
+
/** Determine the "case style" of a string and project it onto another. */
|
|
43
|
+
function matchCase(target, sample) {
|
|
44
|
+
if (!sample)
|
|
45
|
+
return target;
|
|
46
|
+
if (sample === sample.toUpperCase() && sample !== sample.toLowerCase()) {
|
|
47
|
+
return target.toUpperCase();
|
|
48
|
+
}
|
|
49
|
+
if (sample === sample.toLowerCase() && sample !== sample.toUpperCase()) {
|
|
50
|
+
return target.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
// Title case: first letter upper, rest lower.
|
|
53
|
+
const first = sample[0];
|
|
54
|
+
if (first && first === first.toUpperCase() && sample.slice(1) === sample.slice(1).toLowerCase()) {
|
|
55
|
+
return target[0].toUpperCase() + target.slice(1).toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
return target;
|
|
58
|
+
}
|
|
59
|
+
/** Find a known sequence (case-insensitively) that contains every source
|
|
60
|
+
* value. Returns the sequence + each source's index inside it. */
|
|
61
|
+
function findSequenceMatch(values) {
|
|
62
|
+
const lowered = values.map((v) => v.toLowerCase());
|
|
63
|
+
outer: for (const sequence of KNOWN_SEQUENCES) {
|
|
64
|
+
const lowSeq = sequence.map((s) => s.toLowerCase());
|
|
65
|
+
const indices = [];
|
|
66
|
+
for (const v of lowered) {
|
|
67
|
+
const idx = lowSeq.indexOf(v);
|
|
68
|
+
if (idx < 0)
|
|
69
|
+
continue outer;
|
|
70
|
+
indices.push(idx);
|
|
71
|
+
}
|
|
72
|
+
return { sequence, indices };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/** Step a series of indices by `step` modulo the sequence length, then
|
|
77
|
+
* return the canonical sequence entries with the source's case
|
|
78
|
+
* projected onto them. */
|
|
79
|
+
function extrapolateSequence(match, sourceValues, step, targetCount) {
|
|
80
|
+
const seqLen = match.sequence.length;
|
|
81
|
+
const lastIdx = match.indices[match.indices.length - 1];
|
|
82
|
+
const caseSample = sourceValues[sourceValues.length - 1];
|
|
83
|
+
const out = [];
|
|
84
|
+
for (let i = 0; i < targetCount; i += 1) {
|
|
85
|
+
const nextIdx = ((lastIdx + step * (i + 1)) % seqLen + seqLen) % seqLen;
|
|
86
|
+
out.push(matchCase(match.sequence[nextIdx], caseSample));
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** Split a value into a shared prefix, a numeric body, and a shared
|
|
91
|
+
* suffix. Returns null if not all values fit a `prefix + number +
|
|
92
|
+
* suffix` shape with the same prefix and suffix. */
|
|
93
|
+
function findPrefixNumberSuffix(values) {
|
|
94
|
+
// Capture leading non-digit run, the digit run, and trailing run.
|
|
95
|
+
// We do NOT match a leading minus inside the digit group - that turns
|
|
96
|
+
// strings like "Run-001" into prefix="Run", numStr="-001", which both
|
|
97
|
+
// breaks zero-pad detection AND extrapolates downward instead of up.
|
|
98
|
+
// True numeric negatives are already handled by the arithmetic-progression
|
|
99
|
+
// branch above; strings get a literal `-` as part of the prefix.
|
|
100
|
+
const parts = [];
|
|
101
|
+
const pattern = /^(.*?)(\d+)(.*)$/;
|
|
102
|
+
for (const v of values) {
|
|
103
|
+
const m = v.match(pattern);
|
|
104
|
+
if (!m)
|
|
105
|
+
return null;
|
|
106
|
+
parts.push({ prefix: m[1] ?? '', numStr: m[2] ?? '', suffix: m[3] ?? '' });
|
|
107
|
+
}
|
|
108
|
+
const prefix = parts[0].prefix;
|
|
109
|
+
const suffix = parts[0].suffix;
|
|
110
|
+
for (const p of parts) {
|
|
111
|
+
if (p.prefix !== prefix || p.suffix !== suffix)
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
// Detect zero-padding from the first numeric chunk so generated
|
|
115
|
+
// values match (e.g. "001", "002" → "003").
|
|
116
|
+
const firstNum = parts[0].numStr;
|
|
117
|
+
const width = firstNum.length;
|
|
118
|
+
const padChar = firstNum.length > 1 && firstNum[0] === '0' && firstNum !== '0' ? '0' : '';
|
|
119
|
+
return {
|
|
120
|
+
prefix,
|
|
121
|
+
suffix,
|
|
122
|
+
numbers: parts.map((p) => Number(p.numStr)),
|
|
123
|
+
numberFormat: { width, padChar },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function formatPaddedNumber(n, format) {
|
|
127
|
+
const raw = String(n);
|
|
128
|
+
if (!format.padChar || raw.length >= format.width)
|
|
129
|
+
return raw;
|
|
130
|
+
const padded = raw.padStart(format.width, format.padChar);
|
|
131
|
+
// padStart can over-pad if the number's sign character was included
|
|
132
|
+
// in the width; that's fine in practice for fill scenarios.
|
|
133
|
+
return padded;
|
|
134
|
+
}
|
|
135
|
+
/** Detect a constant arithmetic step across an array. Returns the step
|
|
136
|
+
* or null if values aren't equally spaced. */
|
|
137
|
+
function arithmeticStep(nums) {
|
|
138
|
+
if (nums.length < 2)
|
|
139
|
+
return null;
|
|
140
|
+
const step = nums[1] - nums[0];
|
|
141
|
+
for (let i = 2; i < nums.length; i += 1) {
|
|
142
|
+
if (nums[i] - nums[i - 1] !== step)
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return step;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Compute the values to write into `targetCount` new cells given the
|
|
149
|
+
* `sourceValues` already in the selection. See module header for the
|
|
150
|
+
* pattern-detection rules.
|
|
151
|
+
*/
|
|
152
|
+
export function buildFillPattern(sourceValues, targetCount) {
|
|
153
|
+
if (sourceValues.length === 0 || targetCount <= 0)
|
|
154
|
+
return [];
|
|
155
|
+
// -------- 1) Numeric arithmetic progression (2+ values) -----------
|
|
156
|
+
const nums = sourceValues.map((v) => Number(v));
|
|
157
|
+
const allNumericLike = sourceValues.every((v) => v !== null && v !== '' && v !== undefined) &&
|
|
158
|
+
nums.every((n) => Number.isFinite(n));
|
|
159
|
+
if (allNumericLike && sourceValues.length >= 2) {
|
|
160
|
+
const step = arithmeticStep(nums);
|
|
161
|
+
if (step !== null) {
|
|
162
|
+
const last = nums[nums.length - 1];
|
|
163
|
+
return Array.from({ length: targetCount }, (_, i) => last + step * (i + 1));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// -------- 2) Known string sequence (days / months / quarters) -----
|
|
167
|
+
const allStrings = sourceValues.every((v) => typeof v === 'string');
|
|
168
|
+
if (allStrings && sourceValues.length >= 1) {
|
|
169
|
+
const stringSources = sourceValues;
|
|
170
|
+
const match = findSequenceMatch(stringSources);
|
|
171
|
+
if (match) {
|
|
172
|
+
const step = stringSources.length >= 2
|
|
173
|
+
? arithmeticStep(match.indices) ?? 1
|
|
174
|
+
: 1;
|
|
175
|
+
return extrapolateSequence(match, stringSources, step, targetCount);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// -------- 3) Prefix-number-suffix ("Item 1", "Item 2", …) ---------
|
|
179
|
+
if (allStrings && sourceValues.length >= 1) {
|
|
180
|
+
const stringSources = sourceValues;
|
|
181
|
+
const split = findPrefixNumberSuffix(stringSources);
|
|
182
|
+
if (split) {
|
|
183
|
+
const step = split.numbers.length >= 2
|
|
184
|
+
? arithmeticStep(split.numbers) ?? 1
|
|
185
|
+
: 1;
|
|
186
|
+
const last = split.numbers[split.numbers.length - 1];
|
|
187
|
+
return Array.from({ length: targetCount }, (_, i) => {
|
|
188
|
+
const next = last + step * (i + 1);
|
|
189
|
+
return split.prefix + formatPaddedNumber(next, split.numberFormat) + split.suffix;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// -------- 4) Fallback: cycle the sources -------------------------
|
|
194
|
+
// Deep-clone arrays / plain objects so each filled cell gets an
|
|
195
|
+
// independent value - otherwise multi-value cells (chips columns
|
|
196
|
+
// store arrays) would all point at the SAME array, and editing one
|
|
197
|
+
// would silently mutate the others.
|
|
198
|
+
return Array.from({ length: targetCount }, (_, i) => cloneFillValue(sourceValues[i % sourceValues.length]));
|
|
199
|
+
}
|
|
200
|
+
function cloneFillValue(value) {
|
|
201
|
+
if (Array.isArray(value))
|
|
202
|
+
return value.slice();
|
|
203
|
+
if (value && typeof value === 'object' && value.constructor === Object) {
|
|
204
|
+
return { ...value };
|
|
205
|
+
}
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildFillPattern } from './fill-patterns';
|
|
3
|
+
describe('buildFillPattern - numeric', () => {
|
|
4
|
+
it('extrapolates an arithmetic progression', () => {
|
|
5
|
+
expect(buildFillPattern([1, 2, 3], 3)).toEqual([4, 5, 6]);
|
|
6
|
+
expect(buildFillPattern([10, 20, 30], 2)).toEqual([40, 50]);
|
|
7
|
+
expect(buildFillPattern([100, 90, 80], 3)).toEqual([70, 60, 50]);
|
|
8
|
+
});
|
|
9
|
+
it('repeats a single numeric value (Excel default)', () => {
|
|
10
|
+
expect(buildFillPattern([42], 4)).toEqual([42, 42, 42, 42]);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
describe('buildFillPattern - known sequences', () => {
|
|
14
|
+
it('extends days of the week with wrap', () => {
|
|
15
|
+
expect(buildFillPattern(['Monday', 'Tuesday'], 3)).toEqual([
|
|
16
|
+
'Wednesday',
|
|
17
|
+
'Thursday',
|
|
18
|
+
'Friday',
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
it('preserves source casing', () => {
|
|
22
|
+
expect(buildFillPattern(['JAN', 'FEB'], 2)).toEqual(['MAR', 'APR']);
|
|
23
|
+
expect(buildFillPattern(['jan', 'feb'], 2)).toEqual(['mar', 'apr']);
|
|
24
|
+
});
|
|
25
|
+
it('extends quarters with wrap', () => {
|
|
26
|
+
expect(buildFillPattern(['Q3', 'Q4'], 3)).toEqual(['Q1', 'Q2', 'Q3']);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('buildFillPattern - chips / array values', () => {
|
|
30
|
+
it('cycles array values across target cells', () => {
|
|
31
|
+
const source = [['feature', 'editors']];
|
|
32
|
+
const result = buildFillPattern(source, 3);
|
|
33
|
+
expect(result).toEqual([
|
|
34
|
+
['feature', 'editors'],
|
|
35
|
+
['feature', 'editors'],
|
|
36
|
+
['feature', 'editors'],
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
it('returns INDEPENDENT copies of arrays so a later mutation to one filled cell does not bleed into siblings', () => {
|
|
40
|
+
const source = [['feature', 'editors']];
|
|
41
|
+
const result = buildFillPattern(source, 3);
|
|
42
|
+
// Mutate one - siblings must not change.
|
|
43
|
+
result[0].push('bug');
|
|
44
|
+
expect(result[1]).toEqual(['feature', 'editors']);
|
|
45
|
+
expect(result[2]).toEqual(['feature', 'editors']);
|
|
46
|
+
});
|
|
47
|
+
it('cycles multi-cell array source', () => {
|
|
48
|
+
const source = [['a'], ['b'], ['c']];
|
|
49
|
+
const result = buildFillPattern(source, 4);
|
|
50
|
+
expect(result).toEqual([['a'], ['b'], ['c'], ['a']]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('buildFillPattern - prefix/number/suffix', () => {
|
|
54
|
+
it('extrapolates "Item N" series', () => {
|
|
55
|
+
expect(buildFillPattern(['Item 1', 'Item 2'], 3)).toEqual([
|
|
56
|
+
'Item 3',
|
|
57
|
+
'Item 4',
|
|
58
|
+
'Item 5',
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
it('preserves zero-padding width', () => {
|
|
62
|
+
expect(buildFillPattern(['Run-001', 'Run-002'], 2)).toEqual([
|
|
63
|
+
'Run-003',
|
|
64
|
+
'Run-004',
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('buildFillPattern - fallback cycle', () => {
|
|
69
|
+
it('cycles plain string list when no sequence/progression detected', () => {
|
|
70
|
+
expect(buildFillPattern(['red', 'green', 'blue'], 5)).toEqual([
|
|
71
|
+
'red',
|
|
72
|
+
'green',
|
|
73
|
+
'blue',
|
|
74
|
+
'red',
|
|
75
|
+
'green',
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
it('repeats a single non-numeric string', () => {
|
|
79
|
+
expect(buildFillPattern(['hello'], 3)).toEqual(['hello', 'hello', 'hello']);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type ExcelFilterOperator = 'contains' | 'equals' | 'startsWith' | 'greaterThan' | 'lessThan' | 'between' | 'isBlank';
|
|
2
|
+
export type ExcelFilter = {
|
|
3
|
+
id: string;
|
|
4
|
+
operator: ExcelFilterOperator;
|
|
5
|
+
value?: unknown;
|
|
6
|
+
valueTo?: unknown;
|
|
7
|
+
};
|
|
8
|
+
export type ExcelFilterOptions = {
|
|
9
|
+
/**
|
|
10
|
+
* BCP-47 locale tag (or fallback list) for accent- and case-
|
|
11
|
+
* insensitive text comparison. When set, "café", "Café" and "CAFÉ"
|
|
12
|
+
* all match "cafe" without strain.
|
|
13
|
+
*
|
|
14
|
+
* Implementation: NFD-decompose both sides, strip combining marks
|
|
15
|
+
* (diacritics), then locale-aware lowercase. This is the standard
|
|
16
|
+
* "lowercase + asciifolding" pipeline used by Elasticsearch, Postgres
|
|
17
|
+
* (with `unaccent`), and Lucene. Cheap, deterministic, no Collator
|
|
18
|
+
* round-trip.
|
|
19
|
+
*/
|
|
20
|
+
locale?: string | ReadonlyArray<string>;
|
|
21
|
+
};
|
|
22
|
+
/** NFD-decompose, strip combining marks (diacritics), then locale-aware
|
|
23
|
+
* lowercase. The locale-aware lowercasing handles Turkish dotted-I /
|
|
24
|
+
* dotless-i correctly when the consumer threads `"tr"` through. */
|
|
25
|
+
export declare function normalizeForFilter(s: string, locale?: string | ReadonlyArray<string>): string;
|
|
26
|
+
export declare function applyExcelFilter(cellValue: unknown, filter: ExcelFilter, options?: ExcelFilterOptions): boolean;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// U+0300..U+036F is the Combining Diacritical Marks block. After NFD
|
|
2
|
+
// decomposition accented characters become "base char + combining mark";
|
|
3
|
+
// stripping the marks leaves the bare ASCII base.
|
|
4
|
+
const DIACRITIC_RE = /[̀-ͯ]/g;
|
|
5
|
+
/** NFD-decompose, strip combining marks (diacritics), then locale-aware
|
|
6
|
+
* lowercase. The locale-aware lowercasing handles Turkish dotted-I /
|
|
7
|
+
* dotless-i correctly when the consumer threads `"tr"` through. */
|
|
8
|
+
export function normalizeForFilter(s, locale) {
|
|
9
|
+
if (!s)
|
|
10
|
+
return '';
|
|
11
|
+
const stripped = s.normalize('NFD').replace(DIACRITIC_RE, '');
|
|
12
|
+
return locale
|
|
13
|
+
? stripped.toLocaleLowerCase(locale)
|
|
14
|
+
: stripped.toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
export function applyExcelFilter(cellValue, filter, options) {
|
|
17
|
+
const text = String(cellValue ?? '');
|
|
18
|
+
const normalizedText = normalizeForFilter(text, options?.locale);
|
|
19
|
+
const normalizedValue = normalizeForFilter(String(filter.value ?? ''), options?.locale);
|
|
20
|
+
switch (filter.operator) {
|
|
21
|
+
case 'contains':
|
|
22
|
+
return normalizedText.includes(normalizedValue);
|
|
23
|
+
case 'equals':
|
|
24
|
+
return normalizedText === normalizedValue;
|
|
25
|
+
case 'startsWith':
|
|
26
|
+
return normalizedText.startsWith(normalizedValue);
|
|
27
|
+
case 'greaterThan': {
|
|
28
|
+
const a = Number(cellValue);
|
|
29
|
+
const b = Number(filter.value);
|
|
30
|
+
if (Number.isFinite(a) && Number.isFinite(b))
|
|
31
|
+
return a > b;
|
|
32
|
+
return String(cellValue ?? '') > String(filter.value ?? '');
|
|
33
|
+
}
|
|
34
|
+
case 'lessThan': {
|
|
35
|
+
const a = Number(cellValue);
|
|
36
|
+
const b = Number(filter.value);
|
|
37
|
+
if (Number.isFinite(a) && Number.isFinite(b))
|
|
38
|
+
return a < b;
|
|
39
|
+
return String(cellValue ?? '') < String(filter.value ?? '');
|
|
40
|
+
}
|
|
41
|
+
case 'between': {
|
|
42
|
+
// Two paths: numeric (coerce both endpoints with the historical
|
|
43
|
+
// `?? 0` fallback) and string (used when EITHER endpoint is a
|
|
44
|
+
// non-numeric string like an ISO date). Numeric wins when its
|
|
45
|
+
// operands are finite so callers passing numeric ages, prices, etc.
|
|
46
|
+
// see the historical inclusive-range behaviour.
|
|
47
|
+
const lo = filter.value == null ? 0 : Number(filter.value);
|
|
48
|
+
const hi = filter.valueTo == null ? 0 : Number(filter.valueTo);
|
|
49
|
+
const a = Number(cellValue ?? 0);
|
|
50
|
+
if (Number.isFinite(a) && Number.isFinite(lo) && Number.isFinite(hi)) {
|
|
51
|
+
return a >= lo && a <= hi;
|
|
52
|
+
}
|
|
53
|
+
// Either endpoint is non-numeric (ISO date string, etc). Compare
|
|
54
|
+
// lexicographically - YYYY-MM-DD orders chronologically so the
|
|
55
|
+
// result matches user intent.
|
|
56
|
+
const s = String(cellValue ?? '');
|
|
57
|
+
const sl = String(filter.value ?? '');
|
|
58
|
+
const sh = String(filter.valueTo ?? '');
|
|
59
|
+
return s >= sl && s <= sh;
|
|
60
|
+
}
|
|
61
|
+
case 'isBlank':
|
|
62
|
+
return text.trim().length === 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|