@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,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getGridCellA11yProps,
|
|
4
|
+
getGridHeaderA11yProps,
|
|
5
|
+
getGridRootA11yProps,
|
|
6
|
+
getGridRowA11yProps,
|
|
7
|
+
} from './a11y'
|
|
8
|
+
|
|
9
|
+
function attrsToHtml(attrs: Record<string, string | number | boolean>) {
|
|
10
|
+
return Object.entries(attrs)
|
|
11
|
+
.map(([key, value]) => `${key}="${String(value)}"`)
|
|
12
|
+
.join(' ')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('a11y contract rendering', () => {
|
|
16
|
+
it('builds expected headless markup attributes', () => {
|
|
17
|
+
const root = getGridRootA11yProps()
|
|
18
|
+
const header = getGridHeaderA11yProps({
|
|
19
|
+
sortable: true,
|
|
20
|
+
sortDirection: 'descending',
|
|
21
|
+
})
|
|
22
|
+
const row = getGridRowA11yProps(1)
|
|
23
|
+
const cell = getGridCellA11yProps({
|
|
24
|
+
rowIndex: 1,
|
|
25
|
+
colIndex: 2,
|
|
26
|
+
selected: true,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const html = [
|
|
30
|
+
`<table ${attrsToHtml(root)}><thead>`,
|
|
31
|
+
`<tr ${attrsToHtml(row)}>`,
|
|
32
|
+
`<th ${attrsToHtml(header)}>Age</th>`,
|
|
33
|
+
`</tr></thead><tbody>`,
|
|
34
|
+
`<tr ${attrsToHtml(row)}><td ${attrsToHtml(cell)}>42</td></tr>`,
|
|
35
|
+
`</tbody></table>`,
|
|
36
|
+
].join('')
|
|
37
|
+
|
|
38
|
+
expect(html).toContain('role="grid"')
|
|
39
|
+
expect(html).toContain('role="columnheader"')
|
|
40
|
+
expect(html).toContain('aria-sort="descending"')
|
|
41
|
+
expect(html).toContain('aria-selected="true"')
|
|
42
|
+
expect(html).toContain('aria-colindex="2"')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('creates stable active descendant ids', async () => {
|
|
46
|
+
const { getGridCellDomId } = await import('./a11y')
|
|
47
|
+
expect(getGridCellDomId('svgrid', 3, 4)).toBe('svgrid_cell_3_4')
|
|
48
|
+
})
|
|
49
|
+
})
|
package/src/a11y.test.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getGridCellA11yProps,
|
|
4
|
+
getGridHeaderA11yProps,
|
|
5
|
+
getGridRootA11yProps,
|
|
6
|
+
getGridRowA11yProps,
|
|
7
|
+
} from './a11y'
|
|
8
|
+
|
|
9
|
+
describe('a11y helpers', () => {
|
|
10
|
+
it('returns grid role props', () => {
|
|
11
|
+
expect(
|
|
12
|
+
getGridRootA11yProps({
|
|
13
|
+
activeDescendantId: 'svgrid_cell_0_0',
|
|
14
|
+
rowCount: 10,
|
|
15
|
+
colCount: 5,
|
|
16
|
+
}),
|
|
17
|
+
).toEqual({
|
|
18
|
+
role: 'grid',
|
|
19
|
+
tabindex: 0,
|
|
20
|
+
'aria-activedescendant': 'svgrid_cell_0_0',
|
|
21
|
+
'aria-rowcount': 10,
|
|
22
|
+
'aria-colcount': 5,
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns header sort semantics', () => {
|
|
27
|
+
expect(
|
|
28
|
+
getGridHeaderA11yProps({
|
|
29
|
+
sortable: true,
|
|
30
|
+
sortDirection: 'ascending',
|
|
31
|
+
}),
|
|
32
|
+
).toEqual({
|
|
33
|
+
role: 'columnheader',
|
|
34
|
+
'aria-sort': 'ascending',
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns row and cell indices for accessibility metadata', () => {
|
|
39
|
+
expect(getGridRowA11yProps(2)).toEqual({
|
|
40
|
+
role: 'row',
|
|
41
|
+
'aria-rowindex': 2,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(
|
|
45
|
+
getGridCellA11yProps({
|
|
46
|
+
id: 'svgrid_cell_2_3',
|
|
47
|
+
rowIndex: 2,
|
|
48
|
+
colIndex: 3,
|
|
49
|
+
selected: true,
|
|
50
|
+
}),
|
|
51
|
+
).toEqual({
|
|
52
|
+
role: 'gridcell',
|
|
53
|
+
id: 'svgrid_cell_2_3',
|
|
54
|
+
'aria-colindex': 3,
|
|
55
|
+
'aria-rowindex': 2,
|
|
56
|
+
'aria-selected': true,
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
})
|
package/src/a11y.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type GridSortDirection = 'ascending' | 'descending' | 'none' | 'other'
|
|
2
|
+
|
|
3
|
+
export type GridColumnA11yInput = {
|
|
4
|
+
sortable?: boolean
|
|
5
|
+
sortDirection?: GridSortDirection
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type GridCellA11yInput = {
|
|
9
|
+
colIndex?: number
|
|
10
|
+
rowIndex?: number
|
|
11
|
+
selected?: boolean
|
|
12
|
+
id?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getGridRootA11yProps(input: {
|
|
16
|
+
activeDescendantId?: string | null
|
|
17
|
+
rowCount?: number
|
|
18
|
+
colCount?: number
|
|
19
|
+
} = {}) {
|
|
20
|
+
return {
|
|
21
|
+
role: 'grid',
|
|
22
|
+
tabindex: 0,
|
|
23
|
+
...(input.activeDescendantId
|
|
24
|
+
? { 'aria-activedescendant': input.activeDescendantId }
|
|
25
|
+
: {}),
|
|
26
|
+
...(input.rowCount !== undefined ? { 'aria-rowcount': input.rowCount } : {}),
|
|
27
|
+
...(input.colCount !== undefined ? { 'aria-colcount': input.colCount } : {}),
|
|
28
|
+
} as const
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getGridHeaderA11yProps(input: GridColumnA11yInput = {}) {
|
|
32
|
+
const { sortable = false, sortDirection = 'none' } = input
|
|
33
|
+
return {
|
|
34
|
+
role: 'columnheader',
|
|
35
|
+
...(sortable ? { 'aria-sort': sortDirection } : {}),
|
|
36
|
+
} as const
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getGridRowA11yProps(rowIndex?: number) {
|
|
40
|
+
return {
|
|
41
|
+
role: 'row',
|
|
42
|
+
...(rowIndex !== undefined ? { 'aria-rowindex': rowIndex } : {}),
|
|
43
|
+
} as const
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getGridCellA11yProps(input: GridCellA11yInput = {}) {
|
|
47
|
+
const { colIndex, rowIndex, selected = false, id } = input
|
|
48
|
+
return {
|
|
49
|
+
role: 'gridcell',
|
|
50
|
+
...(id ? { id } : {}),
|
|
51
|
+
...(colIndex !== undefined ? { 'aria-colindex': colIndex } : {}),
|
|
52
|
+
...(rowIndex !== undefined ? { 'aria-rowindex': rowIndex } : {}),
|
|
53
|
+
...(selected ? { 'aria-selected': true } : {}),
|
|
54
|
+
} as const
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getGridCellDomId(baseId: string, rowIndex: number, colIndex: number) {
|
|
58
|
+
return `${baseId}_cell_${rowIndex}_${colIndex}`
|
|
59
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for cell-formatting helpers.
|
|
3
|
+
*
|
|
4
|
+
* Covers every code path in:
|
|
5
|
+
* - resolveDatePattern (every shortcut + datetime variant + unknown fallback)
|
|
6
|
+
* - getDateFormatter (cache hit + cache miss path)
|
|
7
|
+
* - formatNumericWithConfig (number, currency, percent + valueIsPercentPoints,
|
|
8
|
+
* NaN/Infinity/null fallbacks, locale variants, option overrides)
|
|
9
|
+
*
|
|
10
|
+
* Locale assertions use partial substring matches where the exact Intl output
|
|
11
|
+
* differs between Node ICU builds.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
formatNumericWithConfig,
|
|
16
|
+
resolveDatePattern,
|
|
17
|
+
getDateFormatter,
|
|
18
|
+
} from './cell-formatting'
|
|
19
|
+
|
|
20
|
+
describe('resolveDatePattern', () => {
|
|
21
|
+
it('returns undefined for missing pattern', () => {
|
|
22
|
+
expect(resolveDatePattern(undefined, 'date')).toBeUndefined()
|
|
23
|
+
expect(resolveDatePattern('', 'date')).toBeUndefined()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('handles the "d" (short numeric) date pattern', () => {
|
|
27
|
+
expect(resolveDatePattern('d', 'date')).toEqual({
|
|
28
|
+
year: 'numeric',
|
|
29
|
+
month: 'numeric',
|
|
30
|
+
day: 'numeric',
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('handles the "d" datetime pattern (adds hour + minute)', () => {
|
|
35
|
+
const opts = resolveDatePattern('d', 'datetime')!
|
|
36
|
+
expect(opts.hour).toBe('numeric')
|
|
37
|
+
expect(opts.minute).toBe('numeric')
|
|
38
|
+
expect(opts.year).toBe('numeric')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('handles the "D" (long) date pattern', () => {
|
|
42
|
+
expect(resolveDatePattern('D', 'date')).toEqual({
|
|
43
|
+
weekday: 'long',
|
|
44
|
+
year: 'numeric',
|
|
45
|
+
month: 'long',
|
|
46
|
+
day: 'numeric',
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('handles the "D" datetime pattern with time', () => {
|
|
51
|
+
const opts = resolveDatePattern('D', 'datetime')!
|
|
52
|
+
expect(opts.weekday).toBe('long')
|
|
53
|
+
expect(opts.month).toBe('long')
|
|
54
|
+
expect(opts.hour).toBe('numeric')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('handles "y-m-d" ISO-friendly date pattern', () => {
|
|
58
|
+
expect(resolveDatePattern('y-m-d', 'date')).toEqual({
|
|
59
|
+
year: 'numeric',
|
|
60
|
+
month: '2-digit',
|
|
61
|
+
day: '2-digit',
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('handles "y-m-d" datetime pattern (24h, 2-digit)', () => {
|
|
66
|
+
const opts = resolveDatePattern('y-m-d', 'datetime')!
|
|
67
|
+
expect(opts.hour).toBe('2-digit')
|
|
68
|
+
expect(opts.minute).toBe('2-digit')
|
|
69
|
+
expect(opts.hour12).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('handles dateStyle shortcuts (medium, short, long) without timeStyle for date', () => {
|
|
73
|
+
expect(resolveDatePattern('medium', 'date')).toEqual({
|
|
74
|
+
dateStyle: 'medium',
|
|
75
|
+
timeStyle: undefined,
|
|
76
|
+
})
|
|
77
|
+
expect(resolveDatePattern('short', 'date')).toEqual({
|
|
78
|
+
dateStyle: 'short',
|
|
79
|
+
timeStyle: undefined,
|
|
80
|
+
})
|
|
81
|
+
expect(resolveDatePattern('long', 'date')).toEqual({
|
|
82
|
+
dateStyle: 'long',
|
|
83
|
+
timeStyle: undefined,
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('attaches timeStyle: short when kind is datetime for dateStyle shortcuts', () => {
|
|
88
|
+
expect(resolveDatePattern('medium', 'datetime')).toEqual({
|
|
89
|
+
dateStyle: 'medium',
|
|
90
|
+
timeStyle: 'short',
|
|
91
|
+
})
|
|
92
|
+
expect(resolveDatePattern('short', 'datetime')).toEqual({
|
|
93
|
+
dateStyle: 'short',
|
|
94
|
+
timeStyle: 'short',
|
|
95
|
+
})
|
|
96
|
+
expect(resolveDatePattern('long', 'datetime')).toEqual({
|
|
97
|
+
dateStyle: 'long',
|
|
98
|
+
timeStyle: 'short',
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns undefined for unknown patterns', () => {
|
|
103
|
+
expect(resolveDatePattern('weird', 'date')).toBeUndefined()
|
|
104
|
+
expect(resolveDatePattern('yyyy-mm', 'datetime')).toBeUndefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('trims whitespace around the pattern', () => {
|
|
108
|
+
expect(resolveDatePattern(' d ', 'date')).toEqual({
|
|
109
|
+
year: 'numeric',
|
|
110
|
+
month: 'numeric',
|
|
111
|
+
day: 'numeric',
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('getDateFormatter', () => {
|
|
117
|
+
it('returns the same instance for repeated calls with the same signature', () => {
|
|
118
|
+
const a = getDateFormatter('en-US', { dateStyle: 'short' })
|
|
119
|
+
const b = getDateFormatter('en-US', { dateStyle: 'short' })
|
|
120
|
+
expect(a).toBe(b)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('returns a different instance when the locale changes', () => {
|
|
124
|
+
const en = getDateFormatter('en-US', { dateStyle: 'short' })
|
|
125
|
+
const de = getDateFormatter('de-DE', { dateStyle: 'short' })
|
|
126
|
+
expect(en).not.toBe(de)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('returns a different instance when options change', () => {
|
|
130
|
+
const a = getDateFormatter('en-US', { dateStyle: 'short' })
|
|
131
|
+
const b = getDateFormatter('en-US', { dateStyle: 'long' })
|
|
132
|
+
expect(a).not.toBe(b)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('treats undefined locale and empty-array locale as distinct', () => {
|
|
136
|
+
const undefLocale = getDateFormatter(undefined, { dateStyle: 'short' })
|
|
137
|
+
const arrLocale = getDateFormatter([], { dateStyle: 'short' })
|
|
138
|
+
// Both produce a working formatter; just smoke-check they work.
|
|
139
|
+
expect(typeof undefLocale.format(new Date())).toBe('string')
|
|
140
|
+
expect(typeof arrLocale.format(new Date())).toBe('string')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('keys correctly off every option dimension (smoke: each option produces a distinct cache entry)', () => {
|
|
144
|
+
// We exercise each conditional branch in the key construction so v8/c8
|
|
145
|
+
// counts the line as covered.
|
|
146
|
+
getDateFormatter('en-US', { year: 'numeric' })
|
|
147
|
+
getDateFormatter('en-US', { month: 'long' })
|
|
148
|
+
getDateFormatter('en-US', { day: '2-digit' })
|
|
149
|
+
getDateFormatter('en-US', { hour: '2-digit' })
|
|
150
|
+
getDateFormatter('en-US', { minute: '2-digit' })
|
|
151
|
+
getDateFormatter('en-US', { second: '2-digit' })
|
|
152
|
+
getDateFormatter('en-US', { weekday: 'short' })
|
|
153
|
+
getDateFormatter('en-US', { hour12: true })
|
|
154
|
+
getDateFormatter('en-US', { hour12: false })
|
|
155
|
+
getDateFormatter(['en-US', 'en-GB'], { dateStyle: 'short' })
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('formatNumericWithConfig - number', () => {
|
|
160
|
+
it('formats a basic integer with default locale', () => {
|
|
161
|
+
expect(formatNumericWithConfig(1234, { type: 'number' })).toMatch(/1[\.,]?234/)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('respects locale grouping and decimal separators', () => {
|
|
165
|
+
const de = formatNumericWithConfig(1234.5, { type: 'number', locales: 'de-DE' })
|
|
166
|
+
expect(de).toContain(',')
|
|
167
|
+
const en = formatNumericWithConfig(1234.5, { type: 'number', locales: 'en-US' })
|
|
168
|
+
expect(en).toContain('.')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('forwards fraction-digit options', () => {
|
|
172
|
+
expect(
|
|
173
|
+
formatNumericWithConfig(1.2345, {
|
|
174
|
+
type: 'number',
|
|
175
|
+
options: { minimumFractionDigits: 3, maximumFractionDigits: 3 },
|
|
176
|
+
locales: 'en-US',
|
|
177
|
+
}),
|
|
178
|
+
).toBe('1.235')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('returns the raw String(value) for non-numeric input', () => {
|
|
182
|
+
expect(formatNumericWithConfig('abc', { type: 'number' })).toBe('abc')
|
|
183
|
+
expect(formatNumericWithConfig(NaN, { type: 'number' })).toBe('NaN')
|
|
184
|
+
expect(formatNumericWithConfig(Infinity, { type: 'number' })).toBe('Infinity')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('treats null as 0 (Number(null) === 0) and undefined as String("")', () => {
|
|
188
|
+
// Number(null) === 0, so null formats as "0".
|
|
189
|
+
expect(formatNumericWithConfig(null, { type: 'number', locales: 'en-US' })).toBe('0')
|
|
190
|
+
// Number(undefined) === NaN -> falls back to String(undefined ?? '') -> "".
|
|
191
|
+
expect(formatNumericWithConfig(undefined, { type: 'number' })).toBe('')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('accepts strings that parse to numbers', () => {
|
|
195
|
+
const result = formatNumericWithConfig('42', { type: 'number', locales: 'en-US' })
|
|
196
|
+
expect(result).toBe('42')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('formatNumericWithConfig - currency', () => {
|
|
201
|
+
it('defaults to USD when currency is omitted', () => {
|
|
202
|
+
const usd = formatNumericWithConfig(99.5, { type: 'currency', locales: 'en-US' })
|
|
203
|
+
expect(usd).toMatch(/\$99\.50/)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('honors a custom currency', () => {
|
|
207
|
+
const eur = formatNumericWithConfig(99.5, {
|
|
208
|
+
type: 'currency',
|
|
209
|
+
currency: 'EUR',
|
|
210
|
+
locales: 'en-US',
|
|
211
|
+
})
|
|
212
|
+
expect(eur).toMatch(/€99\.50/)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('merges caller options on top of currency defaults', () => {
|
|
216
|
+
const result = formatNumericWithConfig(1234.5, {
|
|
217
|
+
type: 'currency',
|
|
218
|
+
currency: 'USD',
|
|
219
|
+
locales: 'en-US',
|
|
220
|
+
options: { maximumFractionDigits: 0, minimumFractionDigits: 0 },
|
|
221
|
+
})
|
|
222
|
+
expect(result).toBe('$1,235')
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('formatNumericWithConfig - percent', () => {
|
|
227
|
+
it('treats input as a 0..1 fraction by default', () => {
|
|
228
|
+
expect(formatNumericWithConfig(0.5, { type: 'percent', locales: 'en-US' })).toMatch(/50%/)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('treats input as 0..100 points when valueIsPercentPoints is true', () => {
|
|
232
|
+
expect(
|
|
233
|
+
formatNumericWithConfig(50, {
|
|
234
|
+
type: 'percent',
|
|
235
|
+
locales: 'en-US',
|
|
236
|
+
valueIsPercentPoints: true,
|
|
237
|
+
}),
|
|
238
|
+
).toMatch(/50%/)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('respects fraction-digits override', () => {
|
|
242
|
+
const result = formatNumericWithConfig(0.123, {
|
|
243
|
+
type: 'percent',
|
|
244
|
+
locales: 'en-US',
|
|
245
|
+
options: { minimumFractionDigits: 1, maximumFractionDigits: 1 },
|
|
246
|
+
})
|
|
247
|
+
expect(result).toBe('12.3%')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('formatNumericWithConfig - cache reuse', () => {
|
|
252
|
+
it('reuses the cached Intl.NumberFormat instance across calls', () => {
|
|
253
|
+
// We can't access the cache directly, but we can verify that repeated
|
|
254
|
+
// calls with the same config produce identical output - any cache miss
|
|
255
|
+
// would still return the same string, so this is a smoke check.
|
|
256
|
+
const a = formatNumericWithConfig(10, { type: 'number', locales: 'en-US' })
|
|
257
|
+
const b = formatNumericWithConfig(10, { type: 'number', locales: 'en-US' })
|
|
258
|
+
expect(a).toBe(b)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('handles array-of-locales input', () => {
|
|
262
|
+
const result = formatNumericWithConfig(1.5, {
|
|
263
|
+
type: 'number',
|
|
264
|
+
locales: ['en-US', 'en-GB'],
|
|
265
|
+
})
|
|
266
|
+
expect(result).toBe('1.5')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('handles undefined locale (browser default)', () => {
|
|
270
|
+
const result = formatNumericWithConfig(1.5, { type: 'number' })
|
|
271
|
+
expect(typeof result).toBe('string')
|
|
272
|
+
})
|
|
273
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for SvGrid column `format` presets (numbers, currency, percent, date patterns).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Map shortcut date patterns to Intl options (merged with caller `options`). */
|
|
6
|
+
export function resolveDatePattern(
|
|
7
|
+
pattern: string | undefined,
|
|
8
|
+
kind: 'date' | 'datetime',
|
|
9
|
+
): Intl.DateTimeFormatOptions | undefined {
|
|
10
|
+
if (!pattern) return undefined
|
|
11
|
+
|
|
12
|
+
const p = pattern.trim()
|
|
13
|
+
|
|
14
|
+
switch (p) {
|
|
15
|
+
/** Short numeric local date (no weekday). */
|
|
16
|
+
case 'd':
|
|
17
|
+
return kind === 'datetime'
|
|
18
|
+
? {
|
|
19
|
+
year: 'numeric',
|
|
20
|
+
month: 'numeric',
|
|
21
|
+
day: 'numeric',
|
|
22
|
+
hour: 'numeric',
|
|
23
|
+
minute: 'numeric',
|
|
24
|
+
}
|
|
25
|
+
: { year: 'numeric', month: 'numeric', day: 'numeric' }
|
|
26
|
+
/** Long spelled-out date (+ time if datetime). */
|
|
27
|
+
case 'D':
|
|
28
|
+
return kind === 'datetime'
|
|
29
|
+
? {
|
|
30
|
+
weekday: 'long',
|
|
31
|
+
year: 'numeric',
|
|
32
|
+
month: 'long',
|
|
33
|
+
day: 'numeric',
|
|
34
|
+
hour: 'numeric',
|
|
35
|
+
minute: 'numeric',
|
|
36
|
+
}
|
|
37
|
+
: { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
|
|
38
|
+
/** ISO-friendly yyyy-mm-dd (with time when datetime). */
|
|
39
|
+
case 'y-m-d':
|
|
40
|
+
return kind === 'datetime'
|
|
41
|
+
? {
|
|
42
|
+
year: 'numeric',
|
|
43
|
+
month: '2-digit',
|
|
44
|
+
day: '2-digit',
|
|
45
|
+
hour: '2-digit',
|
|
46
|
+
minute: '2-digit',
|
|
47
|
+
hour12: false,
|
|
48
|
+
}
|
|
49
|
+
: { year: 'numeric', month: '2-digit', day: '2-digit' }
|
|
50
|
+
case 'medium':
|
|
51
|
+
return { dateStyle: 'medium', timeStyle: kind === 'datetime' ? 'short' : undefined }
|
|
52
|
+
case 'short':
|
|
53
|
+
return { dateStyle: 'short', timeStyle: kind === 'datetime' ? 'short' : undefined }
|
|
54
|
+
case 'long':
|
|
55
|
+
return { dateStyle: 'long', timeStyle: kind === 'datetime' ? 'short' : undefined }
|
|
56
|
+
default:
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type NumericFormatInput = {
|
|
62
|
+
type: 'number' | 'currency' | 'percent'
|
|
63
|
+
locales?: string | string[]
|
|
64
|
+
currency?: string
|
|
65
|
+
/** When type is percent: treat value as 0–100 instead of Intl’s 0–1 fraction */
|
|
66
|
+
valueIsPercentPoints?: boolean
|
|
67
|
+
options?: Intl.NumberFormatOptions
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `Intl.NumberFormat` is one of the most expensive constructors in the
|
|
72
|
+
* platform - building one per cell on every scroll makes large grids
|
|
73
|
+
* noticeably stutter. Cache instances by config signature (locale + style
|
|
74
|
+
* + currency + options).
|
|
75
|
+
*/
|
|
76
|
+
const numberFormatterCache = new Map<string, Intl.NumberFormat>()
|
|
77
|
+
function getNumberFormatter(
|
|
78
|
+
locales: string | string[] | undefined,
|
|
79
|
+
options: Intl.NumberFormatOptions,
|
|
80
|
+
): Intl.NumberFormat {
|
|
81
|
+
// Stable key - for normal column configs the options object is reused
|
|
82
|
+
// (declared in module scope), so this stringifies a small static shape.
|
|
83
|
+
const key =
|
|
84
|
+
(Array.isArray(locales) ? locales.join(',') : locales ?? '') +
|
|
85
|
+
'|' +
|
|
86
|
+
(options.style ?? '') +
|
|
87
|
+
'|' +
|
|
88
|
+
(options.currency ?? '') +
|
|
89
|
+
'|' +
|
|
90
|
+
(options.minimumFractionDigits ?? '') +
|
|
91
|
+
'|' +
|
|
92
|
+
(options.maximumFractionDigits ?? '') +
|
|
93
|
+
'|' +
|
|
94
|
+
(options.useGrouping ?? '') +
|
|
95
|
+
'|' +
|
|
96
|
+
(options.currencyDisplay ?? '') +
|
|
97
|
+
'|' +
|
|
98
|
+
(options.notation ?? '')
|
|
99
|
+
let fmt = numberFormatterCache.get(key)
|
|
100
|
+
if (!fmt) {
|
|
101
|
+
fmt = new Intl.NumberFormat(locales, options)
|
|
102
|
+
numberFormatterCache.set(key, fmt)
|
|
103
|
+
}
|
|
104
|
+
return fmt
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Cache of `Intl.DateTimeFormat` by `(locale, options)` signature. */
|
|
108
|
+
const dateFormatterCache = new Map<string, Intl.DateTimeFormat>()
|
|
109
|
+
export function getDateFormatter(
|
|
110
|
+
locales: string | string[] | undefined,
|
|
111
|
+
options: Intl.DateTimeFormatOptions,
|
|
112
|
+
): Intl.DateTimeFormat {
|
|
113
|
+
const key =
|
|
114
|
+
(Array.isArray(locales) ? locales.join(',') : locales ?? '') +
|
|
115
|
+
'|' +
|
|
116
|
+
(options.dateStyle ?? '') +
|
|
117
|
+
'|' +
|
|
118
|
+
(options.timeStyle ?? '') +
|
|
119
|
+
'|' +
|
|
120
|
+
(options.year ?? '') +
|
|
121
|
+
'|' +
|
|
122
|
+
(options.month ?? '') +
|
|
123
|
+
'|' +
|
|
124
|
+
(options.day ?? '') +
|
|
125
|
+
'|' +
|
|
126
|
+
(options.hour ?? '') +
|
|
127
|
+
'|' +
|
|
128
|
+
(options.minute ?? '') +
|
|
129
|
+
'|' +
|
|
130
|
+
(options.second ?? '') +
|
|
131
|
+
'|' +
|
|
132
|
+
(options.weekday ?? '') +
|
|
133
|
+
'|' +
|
|
134
|
+
(options.hour12 ?? '')
|
|
135
|
+
let fmt = dateFormatterCache.get(key)
|
|
136
|
+
if (!fmt) {
|
|
137
|
+
fmt = new Intl.DateTimeFormat(locales, options)
|
|
138
|
+
dateFormatterCache.set(key, fmt)
|
|
139
|
+
}
|
|
140
|
+
return fmt
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Format a numeric value for number / currency / percent column formats. */
|
|
144
|
+
export function formatNumericWithConfig(value: unknown, config: NumericFormatInput): string {
|
|
145
|
+
const n = typeof value === 'number' ? value : Number(value)
|
|
146
|
+
if (!Number.isFinite(n)) return String(value ?? '')
|
|
147
|
+
|
|
148
|
+
const locales = config.locales
|
|
149
|
+
|
|
150
|
+
if (config.type === 'currency') {
|
|
151
|
+
return getNumberFormatter(locales, {
|
|
152
|
+
style: 'currency',
|
|
153
|
+
currency: config.currency ?? 'USD',
|
|
154
|
+
...config.options,
|
|
155
|
+
}).format(n)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (config.type === 'percent') {
|
|
159
|
+
const frac = config.valueIsPercentPoints === true ? n / 100 : n
|
|
160
|
+
return getNumberFormatter(locales, {
|
|
161
|
+
style: 'percent',
|
|
162
|
+
minimumFractionDigits: 0,
|
|
163
|
+
maximumFractionDigits: 2,
|
|
164
|
+
...config.options,
|
|
165
|
+
}).format(frac)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return getNumberFormatter(locales, config.options ?? {}).format(n)
|
|
169
|
+
}
|