@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,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
|
+
}
|