@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.
Files changed (213) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +39 -0
  3. package/dist/FlexRender.svelte +96 -0
  4. package/dist/FlexRender.svelte.d.ts +49 -0
  5. package/dist/SvGrid.svelte +8742 -0
  6. package/dist/SvGrid.svelte.d.ts +381 -0
  7. package/dist/SvGridChart.svelte +653 -0
  8. package/dist/SvGridChart.svelte.d.ts +25 -0
  9. package/dist/SvGridChart.test.d.ts +1 -0
  10. package/dist/SvGridChart.test.js +62 -0
  11. package/dist/SvGridDropdown.svelte +675 -0
  12. package/dist/SvGridDropdown.svelte.d.ts +40 -0
  13. package/dist/a11y.contract.test.d.ts +1 -0
  14. package/dist/a11y.contract.test.js +39 -0
  15. package/dist/a11y.d.ts +38 -0
  16. package/dist/a11y.js +37 -0
  17. package/dist/a11y.test.d.ts +1 -0
  18. package/dist/a11y.test.js +44 -0
  19. package/dist/cell-formatting.d.ts +17 -0
  20. package/dist/cell-formatting.js +141 -0
  21. package/dist/cell-formatting.test.d.ts +1 -0
  22. package/dist/cell-formatting.test.js +234 -0
  23. package/dist/chart-export.d.ts +14 -0
  24. package/dist/chart-export.js +112 -0
  25. package/dist/chart.d.ts +226 -0
  26. package/dist/chart.js +732 -0
  27. package/dist/chart.test.d.ts +1 -0
  28. package/dist/chart.test.js +289 -0
  29. package/dist/collaboration.d.ts +74 -0
  30. package/dist/collaboration.js +98 -0
  31. package/dist/collaboration.test.d.ts +1 -0
  32. package/dist/collaboration.test.js +65 -0
  33. package/dist/conditional-formatting.d.ts +91 -0
  34. package/dist/conditional-formatting.js +170 -0
  35. package/dist/conditional-formatting.test.d.ts +1 -0
  36. package/dist/conditional-formatting.test.js +87 -0
  37. package/dist/core.coverage.test.d.ts +1 -0
  38. package/dist/core.coverage.test.js +186 -0
  39. package/dist/core.d.ts +370 -0
  40. package/dist/core.js +568 -0
  41. package/dist/core.performance.test.d.ts +1 -0
  42. package/dist/core.performance.test.js +29 -0
  43. package/dist/createGrid.svelte.d.ts +6 -0
  44. package/dist/createGrid.svelte.js +17 -0
  45. package/dist/createGrid.test.d.ts +1 -0
  46. package/dist/createGrid.test.js +9 -0
  47. package/dist/createGridState.svelte.d.ts +3 -0
  48. package/dist/createGridState.svelte.js +13 -0
  49. package/dist/editors/cell-editors.d.ts +23 -0
  50. package/dist/editors/cell-editors.js +97 -0
  51. package/dist/editors/cell-editors.test.d.ts +1 -0
  52. package/dist/editors/cell-editors.test.js +75 -0
  53. package/dist/fill-patterns.d.ts +30 -0
  54. package/dist/fill-patterns.js +207 -0
  55. package/dist/fill-patterns.test.d.ts +1 -0
  56. package/dist/fill-patterns.test.js +81 -0
  57. package/dist/filtering/excel-filters.d.ts +26 -0
  58. package/dist/filtering/excel-filters.js +64 -0
  59. package/dist/filtering/excel-filters.test.d.ts +1 -0
  60. package/dist/filtering/excel-filters.test.js +116 -0
  61. package/dist/filtering/locale-filter.test.d.ts +13 -0
  62. package/dist/filtering/locale-filter.test.js +189 -0
  63. package/dist/flex-render.d.ts +1 -0
  64. package/dist/flex-render.js +2 -0
  65. package/dist/flex-render.test.d.ts +1 -0
  66. package/dist/flex-render.test.js +142 -0
  67. package/dist/group-aggregate.test.d.ts +1 -0
  68. package/dist/group-aggregate.test.js +32 -0
  69. package/dist/index.d.ts +29 -0
  70. package/dist/index.js +27 -0
  71. package/dist/keyboard.d.ts +8 -0
  72. package/dist/keyboard.js +91 -0
  73. package/dist/keyboard.test.d.ts +1 -0
  74. package/dist/keyboard.test.js +27 -0
  75. package/dist/merge-objects.d.ts +9 -0
  76. package/dist/merge-objects.js +28 -0
  77. package/dist/named-views.d.ts +42 -0
  78. package/dist/named-views.js +80 -0
  79. package/dist/named-views.test.d.ts +1 -0
  80. package/dist/named-views.test.js +57 -0
  81. package/dist/new-features.test.d.ts +1 -0
  82. package/dist/new-features.test.js +183 -0
  83. package/dist/render-component.d.ts +13 -0
  84. package/dist/render-component.js +14 -0
  85. package/dist/render-component.test.d.ts +1 -0
  86. package/dist/render-component.test.js +41 -0
  87. package/dist/server-data-source.d.ts +76 -0
  88. package/dist/server-data-source.js +83 -0
  89. package/dist/server-data-source.test.d.ts +1 -0
  90. package/dist/server-data-source.test.js +81 -0
  91. package/dist/sparkline.d.ts +56 -0
  92. package/dist/sparkline.js +97 -0
  93. package/dist/sparkline.test.d.ts +1 -0
  94. package/dist/sparkline.test.js +52 -0
  95. package/dist/static-functions.d.ts +1 -0
  96. package/dist/static-functions.js +1 -0
  97. package/dist/subscribe.d.ts +9 -0
  98. package/dist/subscribe.js +31 -0
  99. package/dist/subscribe.test.d.ts +1 -0
  100. package/dist/subscribe.test.js +93 -0
  101. package/dist/sv-grid-scrollbar.d.ts +1 -0
  102. package/dist/sv-grid-scrollbar.js +310 -0
  103. package/dist/svgrid-wrapper.types.d.ts +341 -0
  104. package/dist/svgrid-wrapper.types.js +1 -0
  105. package/dist/svgrid.api-extensions.test.d.ts +14 -0
  106. package/dist/svgrid.api-extensions.test.js +334 -0
  107. package/dist/svgrid.api.test.d.ts +15 -0
  108. package/dist/svgrid.api.test.js +169 -0
  109. package/dist/svgrid.behavior.test.d.ts +15 -0
  110. package/dist/svgrid.behavior.test.js +605 -0
  111. package/dist/svgrid.column-reorder.test.d.ts +15 -0
  112. package/dist/svgrid.column-reorder.test.js +224 -0
  113. package/dist/svgrid.features.test.d.ts +1 -0
  114. package/dist/svgrid.features.test.js +129 -0
  115. package/dist/svgrid.interaction.test.d.ts +9 -0
  116. package/dist/svgrid.interaction.test.js +318 -0
  117. package/dist/svgrid.locale-filtering.test.d.ts +15 -0
  118. package/dist/svgrid.locale-filtering.test.js +261 -0
  119. package/dist/svgrid.new-features.wrapper.test.d.ts +1 -0
  120. package/dist/svgrid.new-features.wrapper.test.js +136 -0
  121. package/dist/svgrid.row-pinning.test.d.ts +14 -0
  122. package/dist/svgrid.row-pinning.test.js +250 -0
  123. package/dist/svgrid.set-filter.test.d.ts +17 -0
  124. package/dist/svgrid.set-filter.test.js +268 -0
  125. package/dist/svgrid.wrapper.test.d.ts +1 -0
  126. package/dist/svgrid.wrapper.test.js +33 -0
  127. package/dist/test-setup.d.ts +1 -0
  128. package/dist/test-setup.js +29 -0
  129. package/dist/transaction.test.d.ts +1 -0
  130. package/dist/transaction.test.js +89 -0
  131. package/dist/virtualization/column-virtualizer.d.ts +20 -0
  132. package/dist/virtualization/column-virtualizer.js +19 -0
  133. package/dist/virtualization/column-virtualizer.test.d.ts +1 -0
  134. package/dist/virtualization/column-virtualizer.test.js +25 -0
  135. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +11 -0
  136. package/dist/virtualization/svelte-virtualizer.svelte.js +20 -0
  137. package/dist/virtualization/types.d.ts +28 -0
  138. package/dist/virtualization/types.js +1 -0
  139. package/dist/virtualization/virtualizer.d.ts +13 -0
  140. package/dist/virtualization/virtualizer.js +232 -0
  141. package/dist/virtualization/virtualizer.test.d.ts +1 -0
  142. package/dist/virtualization/virtualizer.test.js +41 -0
  143. package/package.json +69 -0
  144. package/src/FlexRender.svelte +96 -0
  145. package/src/SvGrid.svelte +8742 -0
  146. package/src/SvGridChart.svelte +653 -0
  147. package/src/SvGridChart.test.ts +69 -0
  148. package/src/SvGridDropdown.svelte +675 -0
  149. package/src/a11y.contract.test.ts +49 -0
  150. package/src/a11y.test.ts +59 -0
  151. package/src/a11y.ts +59 -0
  152. package/src/cell-formatting.test.ts +273 -0
  153. package/src/cell-formatting.ts +169 -0
  154. package/src/chart-export.ts +144 -0
  155. package/src/chart.test.ts +311 -0
  156. package/src/chart.ts +934 -0
  157. package/src/collaboration.test.ts +74 -0
  158. package/src/collaboration.ts +161 -0
  159. package/src/conditional-formatting.test.ts +107 -0
  160. package/src/conditional-formatting.ts +239 -0
  161. package/src/core.coverage.test.ts +230 -0
  162. package/src/core.performance.test.ts +30 -0
  163. package/src/core.ts +991 -0
  164. package/src/createGrid.svelte.ts +42 -0
  165. package/src/createGrid.test.ts +10 -0
  166. package/src/createGridState.svelte.ts +17 -0
  167. package/src/editors/cell-editors.test.ts +80 -0
  168. package/src/editors/cell-editors.ts +127 -0
  169. package/src/fill-patterns.test.ts +93 -0
  170. package/src/fill-patterns.ts +229 -0
  171. package/src/filtering/excel-filters.test.ts +131 -0
  172. package/src/filtering/excel-filters.ts +101 -0
  173. package/src/filtering/locale-filter.test.ts +220 -0
  174. package/src/flex-render.test.ts +143 -0
  175. package/src/flex-render.ts +3 -0
  176. package/src/group-aggregate.test.ts +48 -0
  177. package/src/index.ts +159 -0
  178. package/src/keyboard.test.ts +59 -0
  179. package/src/keyboard.ts +97 -0
  180. package/src/merge-objects.ts +48 -0
  181. package/src/named-views.test.ts +66 -0
  182. package/src/named-views.ts +120 -0
  183. package/src/new-features.test.ts +217 -0
  184. package/src/render-component.test.ts +51 -0
  185. package/src/render-component.ts +28 -0
  186. package/src/server-data-source.test.ts +88 -0
  187. package/src/server-data-source.ts +163 -0
  188. package/src/sparkline.test.ts +59 -0
  189. package/src/sparkline.ts +164 -0
  190. package/src/static-functions.ts +11 -0
  191. package/src/subscribe.test.ts +103 -0
  192. package/src/subscribe.ts +38 -0
  193. package/src/sv-grid-scrollbar.ts +347 -0
  194. package/src/svgrid-wrapper.types.ts +382 -0
  195. package/src/svgrid.api-extensions.test.ts +362 -0
  196. package/src/svgrid.api.test.ts +192 -0
  197. package/src/svgrid.behavior.test.ts +657 -0
  198. package/src/svgrid.column-reorder.test.ts +234 -0
  199. package/src/svgrid.features.test.ts +157 -0
  200. package/src/svgrid.interaction.test.ts +355 -0
  201. package/src/svgrid.locale-filtering.test.ts +259 -0
  202. package/src/svgrid.new-features.wrapper.test.ts +164 -0
  203. package/src/svgrid.row-pinning.test.ts +266 -0
  204. package/src/svgrid.set-filter.test.ts +270 -0
  205. package/src/svgrid.wrapper.test.ts +35 -0
  206. package/src/test-setup.ts +37 -0
  207. package/src/transaction.test.ts +100 -0
  208. package/src/virtualization/column-virtualizer.test.ts +27 -0
  209. package/src/virtualization/column-virtualizer.ts +30 -0
  210. package/src/virtualization/svelte-virtualizer.svelte.ts +24 -0
  211. package/src/virtualization/types.ts +30 -0
  212. package/src/virtualization/virtualizer.test.ts +47 -0
  213. 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 {};