@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,42 @@
1
+ import { createSvGridCore } from './core'
2
+ import type {
3
+ RowData,
4
+ SvGrid,
5
+ SvGridOptions,
6
+ TableFeatures,
7
+ } from './core'
8
+
9
+ export type SvelteGrid<
10
+ TFeatures extends TableFeatures,
11
+ TData extends RowData,
12
+ TSelected = {},
13
+ > = SvGrid<TData> & {
14
+ readonly state: Readonly<TSelected>
15
+ }
16
+
17
+ export function createSvGrid<
18
+ TFeatures extends TableFeatures,
19
+ TData extends RowData,
20
+ TSelected = {},
21
+ >(
22
+ gridOptions: SvGridOptions<TFeatures, TData>,
23
+ selector: (state: Record<string, any>) => TSelected = () => ({}) as TSelected,
24
+ ): SvelteGrid<TFeatures, TData, TSelected> {
25
+ const grid = createSvGridCore(gridOptions) as SvelteGrid<TFeatures, TData, TSelected>
26
+ let selected = $state(selector(grid.store.state))
27
+ grid.store.subscribe(() => {
28
+ selected = selector(grid.store.state)
29
+ })
30
+
31
+ Object.defineProperty(grid, 'state', {
32
+ get() {
33
+ return selected
34
+ },
35
+ configurable: true,
36
+ enumerable: true,
37
+ })
38
+
39
+ return grid
40
+ }
41
+
42
+ export const createGrid = createSvGrid
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createGridState } from './index'
3
+
4
+ describe('createGrid', () => {
5
+ it('createGridState updates controlled values', () => {
6
+ const [sorting, setSorting] = createGridState([{ id: 'age', desc: false }])
7
+ setSorting((prev) => prev.map((entry) => ({ ...entry, desc: true })))
8
+ expect(sorting()[0]?.desc).toBe(true)
9
+ })
10
+ })
@@ -0,0 +1,17 @@
1
+ import type { Updater } from './core'
2
+
3
+ export function createGridState<TState>(
4
+ initialValue: TState,
5
+ ): [() => TState, (updater: Updater<TState>) => void] {
6
+ let value = $state(initialValue)
7
+
8
+ return [
9
+ () => value,
10
+ (updater: Updater<TState>) => {
11
+ if (updater instanceof Function) value = updater(value)
12
+ else value = updater
13
+ },
14
+ ]
15
+ }
16
+
17
+ export const createSvGridState = createGridState
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseEditorValue } from './cell-editors'
3
+
4
+ describe('parseEditorValue - number', () => {
5
+ it('parses a valid string', () => {
6
+ expect(parseEditorValue('number', '42')).toBe(42)
7
+ })
8
+ it('parses a numeric value as-is', () => {
9
+ expect(parseEditorValue('number', 42)).toBe(42)
10
+ })
11
+ it('returns null for non-numeric input', () => {
12
+ expect(parseEditorValue('number', 'bad')).toBe(null)
13
+ expect(parseEditorValue('number', NaN)).toBe(null)
14
+ })
15
+ it('returns null for Infinity', () => {
16
+ expect(parseEditorValue('number', Infinity)).toBe(null)
17
+ })
18
+ it('handles empty string as null', () => {
19
+ expect(parseEditorValue('number', '')).toBe(0)
20
+ })
21
+ })
22
+
23
+ describe('parseEditorValue - date', () => {
24
+ it('returns an ISO string for a valid date', () => {
25
+ const result = parseEditorValue('date', '2026-01-01')
26
+ expect(typeof result).toBe('string')
27
+ expect(String(result)).toContain('2026-01-01')
28
+ })
29
+ it('returns null for an unparseable string', () => {
30
+ expect(parseEditorValue('date', 'not-a-date')).toBe(null)
31
+ })
32
+ it('accepts a Date object via its string form', () => {
33
+ const result = parseEditorValue('date', new Date('2026-05-30T00:00:00Z'))
34
+ expect(String(result)).toContain('2026-05-30')
35
+ })
36
+ })
37
+
38
+ describe('parseEditorValue - datetime', () => {
39
+ it('returns an ISO string for a valid datetime input', () => {
40
+ const result = parseEditorValue('datetime', '2026-01-01T10:30')
41
+ expect(typeof result).toBe('string')
42
+ expect(String(result)).toContain('2026-01-01')
43
+ })
44
+ it('returns null for invalid datetime input', () => {
45
+ expect(parseEditorValue('datetime', 'bogus')).toBe(null)
46
+ })
47
+ })
48
+
49
+ describe('parseEditorValue - checkbox', () => {
50
+ it('returns booleans as-is', () => {
51
+ expect(parseEditorValue('checkbox', true)).toBe(true)
52
+ expect(parseEditorValue('checkbox', false)).toBe(false)
53
+ })
54
+ it('parses "true"/"false" strings case-insensitively', () => {
55
+ expect(parseEditorValue('checkbox', 'true')).toBe(true)
56
+ expect(parseEditorValue('checkbox', 'TRUE')).toBe(true)
57
+ expect(parseEditorValue('checkbox', 'false')).toBe(false)
58
+ expect(parseEditorValue('checkbox', 'False')).toBe(false)
59
+ })
60
+ it('treats other strings as false', () => {
61
+ expect(parseEditorValue('checkbox', 'yes')).toBe(false)
62
+ expect(parseEditorValue('checkbox', '1')).toBe(false)
63
+ expect(parseEditorValue('checkbox', '')).toBe(false)
64
+ })
65
+ it('coerces numbers via String() (non-"true" → false)', () => {
66
+ expect(parseEditorValue('checkbox', 1)).toBe(false)
67
+ expect(parseEditorValue('checkbox', 0)).toBe(false)
68
+ })
69
+ })
70
+
71
+ describe('parseEditorValue - text (default)', () => {
72
+ it('returns the string form of a value', () => {
73
+ expect(parseEditorValue('text', 'hello')).toBe('hello')
74
+ expect(parseEditorValue('text', 42)).toBe('42')
75
+ })
76
+ it('coerces null/undefined to empty string', () => {
77
+ expect(parseEditorValue('text', null)).toBe('')
78
+ expect(parseEditorValue('text', undefined)).toBe('')
79
+ })
80
+ })
@@ -0,0 +1,127 @@
1
+ export type CellEditorType =
2
+ | 'text'
3
+ | 'number'
4
+ | 'date'
5
+ | 'datetime'
6
+ | 'time'
7
+ | 'password'
8
+ | 'checkbox'
9
+ | 'list'
10
+ | 'chips'
11
+ | 'select'
12
+ | 'rich-select'
13
+ | 'textarea'
14
+ | 'color'
15
+ | 'rating'
16
+
17
+ /** Normalized option used by list/chips editors. The optional `color`
18
+ * paints the chip - both in the in-cell readonly chips render and in
19
+ * the dropdown trigger when `renderChipsInTrigger` is on. Any CSS
20
+ * color works (hex, rgb, hsl, oklch, named); the chip uses a soft
21
+ * tint built via `color-mix` so it sits well on both themes. */
22
+ export type CellEditorOption = { value: string | number; label: string; color?: string }
23
+
24
+ export type ParseEditorValueOptions = {
25
+ /** When true, list/chips return an array; otherwise a scalar. */
26
+ multiple?: boolean
27
+ }
28
+
29
+ /** Normalize the loose `editorOptions` ColumnDef prop into a uniform shape.
30
+ * Dynamic (function) editorOptions are resolved before reaching here. */
31
+ export function normalizeEditorOptions(
32
+ options:
33
+ | ReadonlyArray<
34
+ string | number | { value: string | number; label?: string; color?: string }
35
+ >
36
+ | undefined,
37
+ ): CellEditorOption[] {
38
+ if (!options || typeof options === 'function') return []
39
+ return options.map((opt) => {
40
+ if (typeof opt === 'string' || typeof opt === 'number') {
41
+ return { value: opt, label: String(opt) }
42
+ }
43
+ return {
44
+ value: opt.value,
45
+ label: opt.label ?? String(opt.value),
46
+ color: opt.color,
47
+ }
48
+ })
49
+ }
50
+
51
+ export function parseEditorValue(
52
+ type: CellEditorType,
53
+ value: unknown,
54
+ opts?: ParseEditorValueOptions,
55
+ ) {
56
+ if (type === 'number') {
57
+ const parsed = Number(value)
58
+ return Number.isFinite(parsed) ? parsed : null
59
+ }
60
+ if (type === 'rating') {
61
+ const parsed = Number(value)
62
+ if (!Number.isFinite(parsed)) return 0
63
+ return Math.max(0, Math.min(5, Math.round(parsed)))
64
+ }
65
+ if (type === 'color') {
66
+ const s = String(value ?? '').trim()
67
+ // Native <input type="color"> always returns "#rrggbb"; if anything
68
+ // else comes in (CSS name, rgb()), pass it through unchanged so the
69
+ // caller can keep whatever color string they had.
70
+ return s || '#000000'
71
+ }
72
+ if (type === 'date') {
73
+ const date = new Date(String(value))
74
+ return Number.isNaN(date.getTime()) ? null : date.toISOString()
75
+ }
76
+ if (type === 'datetime') {
77
+ const date = new Date(String(value))
78
+ return Number.isNaN(date.getTime()) ? null : date.toISOString()
79
+ }
80
+ if (type === 'time') {
81
+ // Native `<input type="time">` returns "HH:MM" (or "HH:MM:SS"). Accept
82
+ // anything that parses to that shape; reject malformed strings as
83
+ // `null` so consumers can distinguish "cleared" from "never set".
84
+ const s = String(value ?? '').trim()
85
+ if (!s) return null
86
+ const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
87
+ if (!m) return null
88
+ const h = Math.max(0, Math.min(23, parseInt(m[1]!, 10)))
89
+ const mm = Math.max(0, Math.min(59, parseInt(m[2]!, 10)))
90
+ const ss = m[3] ? Math.max(0, Math.min(59, parseInt(m[3]!, 10))) : null
91
+ const HH = String(h).padStart(2, '0')
92
+ const MM = String(mm).padStart(2, '0')
93
+ return ss === null ? `${HH}:${MM}` : `${HH}:${MM}:${String(ss).padStart(2, '0')}`
94
+ }
95
+ if (type === 'password') {
96
+ // Password is just a string with masked rendering. Empty string is a
97
+ // legitimate value (user cleared the field); keep it instead of nulling.
98
+ return String(value ?? '')
99
+ }
100
+ if (type === 'checkbox') {
101
+ if (typeof value === 'boolean') return value
102
+ return String(value).toLowerCase() === 'true'
103
+ }
104
+ if (type === 'select' || type === 'rich-select') {
105
+ // Single-select: trust the editor's reported value; coerce nullish
106
+ // to `null` so consumers can disambiguate "user cleared" from "user
107
+ // never picked".
108
+ if (Array.isArray(value)) return value[0] ?? null
109
+ return value ?? null
110
+ }
111
+ if (type === 'textarea') {
112
+ return String(value ?? '')
113
+ }
114
+ if (type === 'list' || type === 'chips') {
115
+ // The editor manages the value shape: a scalar for single-select, an
116
+ // array for multi-select. Trust what the editor handed us, just coerce
117
+ // the shape so consumers can rely on the contract.
118
+ if (opts?.multiple) {
119
+ if (Array.isArray(value)) return value.slice()
120
+ if (value == null || value === '') return []
121
+ return [value]
122
+ }
123
+ if (Array.isArray(value)) return value[0] ?? null
124
+ return value ?? null
125
+ }
126
+ return String(value ?? '')
127
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildFillPattern } from './fill-patterns'
3
+
4
+ describe('buildFillPattern - numeric', () => {
5
+ it('extrapolates an arithmetic progression', () => {
6
+ expect(buildFillPattern([1, 2, 3], 3)).toEqual([4, 5, 6])
7
+ expect(buildFillPattern([10, 20, 30], 2)).toEqual([40, 50])
8
+ expect(buildFillPattern([100, 90, 80], 3)).toEqual([70, 60, 50])
9
+ })
10
+
11
+ it('repeats a single numeric value (Excel default)', () => {
12
+ expect(buildFillPattern([42], 4)).toEqual([42, 42, 42, 42])
13
+ })
14
+ })
15
+
16
+ describe('buildFillPattern - known sequences', () => {
17
+ it('extends days of the week with wrap', () => {
18
+ expect(buildFillPattern(['Monday', 'Tuesday'], 3)).toEqual([
19
+ 'Wednesday',
20
+ 'Thursday',
21
+ 'Friday',
22
+ ])
23
+ })
24
+
25
+ it('preserves source casing', () => {
26
+ expect(buildFillPattern(['JAN', 'FEB'], 2)).toEqual(['MAR', 'APR'])
27
+ expect(buildFillPattern(['jan', 'feb'], 2)).toEqual(['mar', 'apr'])
28
+ })
29
+
30
+ it('extends quarters with wrap', () => {
31
+ expect(buildFillPattern(['Q3', 'Q4'], 3)).toEqual(['Q1', 'Q2', 'Q3'])
32
+ })
33
+ })
34
+
35
+ describe('buildFillPattern - chips / array values', () => {
36
+ it('cycles array values across target cells', () => {
37
+ const source = [['feature', 'editors']]
38
+ const result = buildFillPattern(source, 3)
39
+ expect(result).toEqual([
40
+ ['feature', 'editors'],
41
+ ['feature', 'editors'],
42
+ ['feature', 'editors'],
43
+ ])
44
+ })
45
+
46
+ it('returns INDEPENDENT copies of arrays so a later mutation to one filled cell does not bleed into siblings', () => {
47
+ const source = [['feature', 'editors']]
48
+ const result = buildFillPattern(source, 3) as string[][]
49
+ // Mutate one - siblings must not change.
50
+ result[0]!.push('bug')
51
+ expect(result[1]).toEqual(['feature', 'editors'])
52
+ expect(result[2]).toEqual(['feature', 'editors'])
53
+ })
54
+
55
+ it('cycles multi-cell array source', () => {
56
+ const source = [['a'], ['b'], ['c']]
57
+ const result = buildFillPattern(source, 4)
58
+ expect(result).toEqual([['a'], ['b'], ['c'], ['a']])
59
+ })
60
+ })
61
+
62
+ describe('buildFillPattern - prefix/number/suffix', () => {
63
+ it('extrapolates "Item N" series', () => {
64
+ expect(buildFillPattern(['Item 1', 'Item 2'], 3)).toEqual([
65
+ 'Item 3',
66
+ 'Item 4',
67
+ 'Item 5',
68
+ ])
69
+ })
70
+
71
+ it('preserves zero-padding width', () => {
72
+ expect(buildFillPattern(['Run-001', 'Run-002'], 2)).toEqual([
73
+ 'Run-003',
74
+ 'Run-004',
75
+ ])
76
+ })
77
+ })
78
+
79
+ describe('buildFillPattern - fallback cycle', () => {
80
+ it('cycles plain string list when no sequence/progression detected', () => {
81
+ expect(buildFillPattern(['red', 'green', 'blue'], 5)).toEqual([
82
+ 'red',
83
+ 'green',
84
+ 'blue',
85
+ 'red',
86
+ 'green',
87
+ ])
88
+ })
89
+
90
+ it('repeats a single non-numeric string', () => {
91
+ expect(buildFillPattern(['hello'], 3)).toEqual(['hello', 'hello', 'hello'])
92
+ })
93
+ })
@@ -0,0 +1,229 @@
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
+ /** Lookup table for the named sequences we know how to extrapolate.
27
+ * Add new ones (zodiac signs, planets, …) and the rest of the code
28
+ * handles them automatically. */
29
+ const KNOWN_SEQUENCES: ReadonlyArray<ReadonlyArray<string>> = [
30
+ // Days of the week, long
31
+ ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
32
+ // Days of the week, short
33
+ ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
34
+ // Months, long
35
+ ['January', 'February', 'March', 'April', 'May', 'June',
36
+ 'July', 'August', 'September', 'October', 'November', 'December'],
37
+ // Months, short
38
+ ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
39
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
40
+ // Quarters
41
+ ['Q1', 'Q2', 'Q3', 'Q4'],
42
+ ]
43
+
44
+ /** Determine the "case style" of a string and project it onto another. */
45
+ function matchCase(target: string, sample: string): string {
46
+ if (!sample) return target
47
+ if (sample === sample.toUpperCase() && sample !== sample.toLowerCase()) {
48
+ return target.toUpperCase()
49
+ }
50
+ if (sample === sample.toLowerCase() && sample !== sample.toUpperCase()) {
51
+ return target.toLowerCase()
52
+ }
53
+ // Title case: first letter upper, rest lower.
54
+ const first = sample[0]
55
+ if (first && first === first.toUpperCase() && sample.slice(1) === sample.slice(1).toLowerCase()) {
56
+ return target[0]!.toUpperCase() + target.slice(1).toLowerCase()
57
+ }
58
+ return target
59
+ }
60
+
61
+ /** Find a known sequence (case-insensitively) that contains every source
62
+ * value. Returns the sequence + each source's index inside it. */
63
+ function findSequenceMatch(values: ReadonlyArray<string>): {
64
+ sequence: ReadonlyArray<string>
65
+ indices: number[]
66
+ } | null {
67
+ const lowered = values.map((v) => v.toLowerCase())
68
+ outer: for (const sequence of KNOWN_SEQUENCES) {
69
+ const lowSeq = sequence.map((s) => s.toLowerCase())
70
+ const indices: number[] = []
71
+ for (const v of lowered) {
72
+ const idx = lowSeq.indexOf(v)
73
+ if (idx < 0) continue outer
74
+ indices.push(idx)
75
+ }
76
+ return { sequence, indices }
77
+ }
78
+ return null
79
+ }
80
+
81
+ /** Step a series of indices by `step` modulo the sequence length, then
82
+ * return the canonical sequence entries with the source's case
83
+ * projected onto them. */
84
+ function extrapolateSequence(
85
+ match: { sequence: ReadonlyArray<string>; indices: number[] },
86
+ sourceValues: ReadonlyArray<string>,
87
+ step: number,
88
+ targetCount: number,
89
+ ): string[] {
90
+ const seqLen = match.sequence.length
91
+ const lastIdx = match.indices[match.indices.length - 1]!
92
+ const caseSample = sourceValues[sourceValues.length - 1]!
93
+ const out: string[] = []
94
+ for (let i = 0; i < targetCount; i += 1) {
95
+ const nextIdx = ((lastIdx + step * (i + 1)) % seqLen + seqLen) % seqLen
96
+ out.push(matchCase(match.sequence[nextIdx]!, caseSample))
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** Split a value into a shared prefix, a numeric body, and a shared
102
+ * suffix. Returns null if not all values fit a `prefix + number +
103
+ * suffix` shape with the same prefix and suffix. */
104
+ function findPrefixNumberSuffix(values: ReadonlyArray<string>):
105
+ | { prefix: string; suffix: string; numbers: number[]; numberFormat: { width: number; padChar: '0' | ' ' | '' } }
106
+ | null {
107
+ // Capture leading non-digit run, the digit run, and trailing run.
108
+ // We do NOT match a leading minus inside the digit group - that turns
109
+ // strings like "Run-001" into prefix="Run", numStr="-001", which both
110
+ // breaks zero-pad detection AND extrapolates downward instead of up.
111
+ // True numeric negatives are already handled by the arithmetic-progression
112
+ // branch above; strings get a literal `-` as part of the prefix.
113
+ const parts: Array<{ prefix: string; numStr: string; suffix: string }> = []
114
+ const pattern = /^(.*?)(\d+)(.*)$/
115
+ for (const v of values) {
116
+ const m = v.match(pattern)
117
+ if (!m) return null
118
+ parts.push({ prefix: m[1] ?? '', numStr: m[2] ?? '', suffix: m[3] ?? '' })
119
+ }
120
+ const prefix = parts[0]!.prefix
121
+ const suffix = parts[0]!.suffix
122
+ for (const p of parts) {
123
+ if (p.prefix !== prefix || p.suffix !== suffix) return null
124
+ }
125
+ // Detect zero-padding from the first numeric chunk so generated
126
+ // values match (e.g. "001", "002" → "003").
127
+ const firstNum = parts[0]!.numStr
128
+ const width = firstNum.length
129
+ const padChar: '0' | ' ' | '' =
130
+ firstNum.length > 1 && firstNum[0] === '0' && firstNum !== '0' ? '0' : ''
131
+ return {
132
+ prefix,
133
+ suffix,
134
+ numbers: parts.map((p) => Number(p.numStr)),
135
+ numberFormat: { width, padChar },
136
+ }
137
+ }
138
+
139
+ function formatPaddedNumber(
140
+ n: number,
141
+ format: { width: number; padChar: '0' | ' ' | '' },
142
+ ): string {
143
+ const raw = String(n)
144
+ if (!format.padChar || raw.length >= format.width) return raw
145
+ const padded = raw.padStart(format.width, format.padChar)
146
+ // padStart can over-pad if the number's sign character was included
147
+ // in the width; that's fine in practice for fill scenarios.
148
+ return padded
149
+ }
150
+
151
+ /** Detect a constant arithmetic step across an array. Returns the step
152
+ * or null if values aren't equally spaced. */
153
+ function arithmeticStep(nums: ReadonlyArray<number>): number | null {
154
+ if (nums.length < 2) return null
155
+ const step = nums[1]! - nums[0]!
156
+ for (let i = 2; i < nums.length; i += 1) {
157
+ if (nums[i]! - nums[i - 1]! !== step) return null
158
+ }
159
+ return step
160
+ }
161
+
162
+ /**
163
+ * Compute the values to write into `targetCount` new cells given the
164
+ * `sourceValues` already in the selection. See module header for the
165
+ * pattern-detection rules.
166
+ */
167
+ export function buildFillPattern(
168
+ sourceValues: ReadonlyArray<unknown>,
169
+ targetCount: number,
170
+ ): unknown[] {
171
+ if (sourceValues.length === 0 || targetCount <= 0) return []
172
+
173
+ // -------- 1) Numeric arithmetic progression (2+ values) -----------
174
+ const nums = sourceValues.map((v) => Number(v))
175
+ const allNumericLike =
176
+ sourceValues.every((v) => v !== null && v !== '' && v !== undefined) &&
177
+ nums.every((n) => Number.isFinite(n))
178
+ if (allNumericLike && sourceValues.length >= 2) {
179
+ const step = arithmeticStep(nums)
180
+ if (step !== null) {
181
+ const last = nums[nums.length - 1]!
182
+ return Array.from({ length: targetCount }, (_, i) => last + step * (i + 1))
183
+ }
184
+ }
185
+
186
+ // -------- 2) Known string sequence (days / months / quarters) -----
187
+ const allStrings = sourceValues.every((v) => typeof v === 'string')
188
+ if (allStrings && sourceValues.length >= 1) {
189
+ const stringSources = sourceValues as string[]
190
+ const match = findSequenceMatch(stringSources)
191
+ if (match) {
192
+ const step = stringSources.length >= 2
193
+ ? arithmeticStep(match.indices) ?? 1
194
+ : 1
195
+ return extrapolateSequence(match, stringSources, step, targetCount)
196
+ }
197
+ }
198
+
199
+ // -------- 3) Prefix-number-suffix ("Item 1", "Item 2", …) ---------
200
+ if (allStrings && sourceValues.length >= 1) {
201
+ const stringSources = sourceValues as string[]
202
+ const split = findPrefixNumberSuffix(stringSources)
203
+ if (split) {
204
+ const step = split.numbers.length >= 2
205
+ ? arithmeticStep(split.numbers) ?? 1
206
+ : 1
207
+ const last = split.numbers[split.numbers.length - 1]!
208
+ return Array.from({ length: targetCount }, (_, i) => {
209
+ const next = last + step * (i + 1)
210
+ return split.prefix + formatPaddedNumber(next, split.numberFormat) + split.suffix
211
+ })
212
+ }
213
+ }
214
+
215
+ // -------- 4) Fallback: cycle the sources -------------------------
216
+ // Deep-clone arrays / plain objects so each filled cell gets an
217
+ // independent value - otherwise multi-value cells (chips columns
218
+ // store arrays) would all point at the SAME array, and editing one
219
+ // would silently mutate the others.
220
+ return Array.from({ length: targetCount }, (_, i) => cloneFillValue(sourceValues[i % sourceValues.length]))
221
+ }
222
+
223
+ function cloneFillValue(value: unknown): unknown {
224
+ if (Array.isArray(value)) return value.slice()
225
+ if (value && typeof value === 'object' && (value as object).constructor === Object) {
226
+ return { ...(value as Record<string, unknown>) }
227
+ }
228
+ return value
229
+ }