@svgrid/grid 3.0.6 → 3.0.7
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/dist/SvGrid.controller.svelte.d.ts +2 -0
- package/dist/SvGrid.controller.svelte.js +30 -4
- package/dist/SvGrid.css +16 -5
- package/dist/SvGrid.helpers.js +2 -4
- package/dist/cdn/{GridMenus-C-NtjlrJ.js → GridMenus-BgUS-W3o.js} +2 -2
- package/dist/cdn/{GridMenus-TssHej3q.js → GridMenus-CcwU8I4t.js} +2 -2
- package/dist/cdn/{SvGridCellEditor-BlyCSp0q.js → SvGridCellEditor-9yz-SbdZ.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-ClRRA9lW.js → SvGridCellEditor-D7-ql1Hd.js} +1 -1
- package/dist/cdn/{SvGridChart-DsmwTyZF.js → SvGridChart-DMUOFxOe.js} +3 -3
- package/dist/cdn/{SvGridChart-B4EV8U6t.js → SvGridChart-N7ik0oWy.js} +3 -3
- package/dist/cdn/{SvGridChartBuilder-cHloHobN.js → SvGridChartBuilder-CB_Jnm2k.js} +3 -3
- package/dist/cdn/{SvGridChartBuilder-vOl7C0Za.js → SvGridChartBuilder-Ca7gF8ZK.js} +3 -3
- package/dist/cdn/{SvGridChartPanel-AcqF-Ll5.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
- package/dist/cdn/{SvGridChartPanel-biMtSBPA.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
- package/dist/cdn/{SvGridChartView-I3rDHXAq.js → SvGridChartView-B1LFgoPP.js} +2 -2
- package/dist/cdn/{SvGridChartView-Dx_GmZuL.js → SvGridChartView-By3n7DGZ.js} +2 -2
- package/dist/cdn/{cell-formatting-DVVKOSI9.js → cell-formatting-Mnv84zoE.js} +27 -5
- package/dist/cdn/{chart-DEZxwC0i.js → chart-By7h2Guv.js} +1 -1
- package/dist/cdn/{chart-panel-messages-D12XcTZJ.js → chart-panel-messages-BC-tOgFY.js} +1 -1
- package/dist/cdn/{chart-panel-messages-DguS7gC5.js → chart-panel-messages-DUaXr-sb.js} +1 -1
- package/dist/cdn/{chart-summary-CMAXBGY-.js → chart-summary-BDtrNEiA.js} +1 -1
- package/dist/cdn/{chart-trend-BkEp61Gj.js → chart-trend-C1aOAPBZ.js} +1 -1
- package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
- package/dist/cdn/{export-format-YzdwBww2.js → export-format-y1wdyCcv.js} +93 -97
- package/dist/cdn/{src-CgoeSM8k.js → src-25JmJ_7g.js} +3244 -3191
- package/dist/cdn/{src-DfwxB1TH.js → src-kE9B98r6.js} +3172 -3119
- package/dist/cdn/svgrid.js +11 -11
- package/dist/cdn/svgrid.svelte-external.js +11 -11
- package/dist/cell-formatting.d.ts +9 -0
- package/dist/cell-formatting.js +34 -0
- package/dist/core.js +117 -19
- package/dist/export-format.d.ts +8 -0
- package/dist/export-format.js +23 -16
- package/dist/virtualization/column-virtualizer.d.ts +1 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
- package/dist/virtualization/virtualizer.d.ts +3 -0
- package/dist/virtualization/virtualizer.js +14 -1
- package/package.json +1 -1
- package/src/SvGrid.controller.svelte.ts +30 -4
- package/src/SvGrid.css +16 -5
- package/src/SvGrid.helpers.ts +2 -4
- package/src/cell-formatting.ts +31 -0
- package/src/core.sort.test.ts +86 -0
- package/src/core.ts +109 -19
- package/src/export-dates.test.ts +114 -0
- package/src/export-format.ts +26 -14
- package/src/svgrid.data-push.svelte.test.ts +94 -0
- package/src/svgrid.live-update-paths.svelte.test.ts +4 -2
- package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
- package/src/virtualization/virtualizer.test.ts +44 -0
- package/src/virtualization/virtualizer.ts +13 -1
package/src/core.ts
CHANGED
|
@@ -745,13 +745,52 @@ export function tableFeatures<T extends TableFeatures>(features: T): T {
|
|
|
745
745
|
* text; set a column's type or supply your own comparator to override.
|
|
746
746
|
*/
|
|
747
747
|
export const sortFns = {
|
|
748
|
-
auto: (a: unknown, b: unknown) =>
|
|
749
|
-
number: (a: unknown, b: unknown) =>
|
|
750
|
-
date: (a: unknown, b: unknown) =>
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
748
|
+
auto: (a: unknown, b: unknown) => compareAutoValues(a, b),
|
|
749
|
+
number: (a: unknown, b: unknown) => compareNumericKeys(numberSortKey(a), numberSortKey(b)),
|
|
750
|
+
date: (a: unknown, b: unknown) => compareNumericKeys(dateSortKey(a), dateSortKey(b)),
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Null, undefined and the empty string: the values every built-in sort puts first. */
|
|
754
|
+
function isBlankSortValue(value: unknown): boolean {
|
|
755
|
+
return value == null || value === ''
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Sort key for the `number` comparator. Blanks and anything that does not
|
|
760
|
+
* coerce to a number ('n/a', a placeholder) become -Infinity, so they sort
|
|
761
|
+
* together ahead of every number instead of producing NaN. A NaN key made the
|
|
762
|
+
* comparator inconsistent - NaN compared equal to everything while the numbers
|
|
763
|
+
* around it did not - and `Array.prototype.sort` then returned a visibly
|
|
764
|
+
* unsorted column (#104).
|
|
765
|
+
*/
|
|
766
|
+
function numberSortKey(value: unknown): number {
|
|
767
|
+
if (isBlankSortValue(value)) return -Infinity
|
|
768
|
+
const n = Number(value)
|
|
769
|
+
return Number.isNaN(n) ? -Infinity : n
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Sort key for the `date` comparator; blanks and invalid dates sort first. */
|
|
773
|
+
function dateSortKey(value: unknown): number {
|
|
774
|
+
if (isBlankSortValue(value)) return -Infinity
|
|
775
|
+
const t = new Date(value as any).getTime()
|
|
776
|
+
return Number.isNaN(t) ? -Infinity : t
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The `auto` comparator: blanks first, then numbers in numeric order, then
|
|
781
|
+
* everything else as collated text. Ordering by kind before value keeps it
|
|
782
|
+
* transitive on mixed columns - comparing a number to a string as text
|
|
783
|
+
* instead would give 2 < 10, 10 < '1a' and '1a' < 2, a cycle.
|
|
784
|
+
*/
|
|
785
|
+
function compareAutoValues(a: unknown, b: unknown): number {
|
|
786
|
+
const aBlank = isBlankSortValue(a)
|
|
787
|
+
const bBlank = isBlankSortValue(b)
|
|
788
|
+
if (aBlank || bBlank) return aBlank === bBlank ? 0 : aBlank ? -1 : 1
|
|
789
|
+
const aNum = typeof a === 'number'
|
|
790
|
+
const bNum = typeof b === 'number'
|
|
791
|
+
if (aNum && bNum) return compareNumericKeys(numberSortKey(a), numberSortKey(b))
|
|
792
|
+
if (aNum !== bNum) return aNum ? -1 : 1
|
|
793
|
+
return compareCollatedKeys(String(a), String(b))
|
|
755
794
|
}
|
|
756
795
|
|
|
757
796
|
/**
|
|
@@ -1446,15 +1485,35 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1446
1485
|
let keys: Array<any> = new Array(n)
|
|
1447
1486
|
let compare: (a: any, b: any) => number
|
|
1448
1487
|
let keyOf: (row: Row<TData>) => any
|
|
1488
|
+
let autoMode: 'number' | 'mixed' | 'text' | undefined
|
|
1449
1489
|
|
|
1450
1490
|
if (comparator === sortFns.number) {
|
|
1451
|
-
keyOf = (row) =>
|
|
1491
|
+
keyOf = (row) => numberSortKey(row.getCellValueByColumnId(columnId))
|
|
1452
1492
|
for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
|
|
1453
1493
|
compare = compareNumericKeys
|
|
1454
1494
|
} else if (comparator === sortFns.date) {
|
|
1455
|
-
keyOf = (row) =>
|
|
1495
|
+
keyOf = (row) => dateSortKey(row.getCellValueByColumnId(columnId))
|
|
1456
1496
|
for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
|
|
1457
1497
|
compare = compareNumericKeys
|
|
1498
|
+
} else if (comparator === sortFns.auto && (autoMode = autoSortMode(rows, columnId)) === 'number') {
|
|
1499
|
+
// A column with no declared type whose values are all numbers. Sorting
|
|
1500
|
+
// it as text put 10 before 2 (#104).
|
|
1501
|
+
keyOf = (row) => {
|
|
1502
|
+
const value = row.getCellValueByColumnId(columnId)
|
|
1503
|
+
// A text value arriving in a tick changes the mode; only a full sort
|
|
1504
|
+
// can place it.
|
|
1505
|
+
return typeof value === 'number' || isBlankSortValue(value)
|
|
1506
|
+
? numberSortKey(value)
|
|
1507
|
+
: MISSING_KEY
|
|
1508
|
+
}
|
|
1509
|
+
for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
|
|
1510
|
+
compare = compareNumericKeys
|
|
1511
|
+
} else if (autoMode === 'mixed') {
|
|
1512
|
+
// Numbers and text in one column. Rare, so no key tricks: the raw
|
|
1513
|
+
// values through the pairwise comparator, which orders by kind first.
|
|
1514
|
+
keyOf = (row) => row.getCellValueByColumnId(columnId)
|
|
1515
|
+
for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
|
|
1516
|
+
compare = compareAutoValues
|
|
1458
1517
|
} else if (comparator === sortFns.auto) {
|
|
1459
1518
|
const strings: string[] = new Array(n)
|
|
1460
1519
|
// Decide whether ranking is worth attempting BEFORE paying for it.
|
|
@@ -1477,7 +1536,7 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1477
1536
|
const sample = new Set<string>()
|
|
1478
1537
|
let sampled = 0
|
|
1479
1538
|
for (let i = 0; i < n; i += stride) {
|
|
1480
|
-
sample.add(
|
|
1539
|
+
sample.add(autoTextKey(rows[i]!.getCellValueByColumnId(columnId)))
|
|
1481
1540
|
sampled++
|
|
1482
1541
|
}
|
|
1483
1542
|
// Only attempt ranking when the sample suggests real repetition.
|
|
@@ -1485,7 +1544,7 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1485
1544
|
}
|
|
1486
1545
|
|
|
1487
1546
|
for (let i = 0; i < n; i++) {
|
|
1488
|
-
const s =
|
|
1547
|
+
const s = autoTextKey(rows[i]!.getCellValueByColumnId(columnId))
|
|
1489
1548
|
strings[i] = s
|
|
1490
1549
|
if (distinct) {
|
|
1491
1550
|
distinct.add(s)
|
|
@@ -1514,11 +1573,19 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1514
1573
|
compare = compareNumericKeys
|
|
1515
1574
|
// A replacement whose text is not among the ranked values has no
|
|
1516
1575
|
// rank; the repair path treats that as "sort everything again".
|
|
1517
|
-
keyOf = (row) =>
|
|
1576
|
+
keyOf = (row) => {
|
|
1577
|
+
const value = row.getCellValueByColumnId(columnId)
|
|
1578
|
+
// A number arriving in a tick makes the column mixed; resort fully.
|
|
1579
|
+
if (typeof value === 'number') return MISSING_KEY
|
|
1580
|
+
return rankOf.get(autoTextKey(value)) ?? MISSING_KEY
|
|
1581
|
+
}
|
|
1518
1582
|
} else {
|
|
1519
1583
|
keys = strings
|
|
1520
1584
|
compare = compareCollatedKeys
|
|
1521
|
-
keyOf = (row) =>
|
|
1585
|
+
keyOf = (row) => {
|
|
1586
|
+
const value = row.getCellValueByColumnId(columnId)
|
|
1587
|
+
return typeof value === 'number' ? MISSING_KEY : autoTextKey(value)
|
|
1588
|
+
}
|
|
1522
1589
|
}
|
|
1523
1590
|
} else {
|
|
1524
1591
|
keyOf = (row) => row.getCellValueByColumnId(columnId)
|
|
@@ -1735,14 +1802,37 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1735
1802
|
}
|
|
1736
1803
|
|
|
1737
1804
|
/**
|
|
1738
|
-
* Numeric key comparison for the built-in `number
|
|
1739
|
-
*
|
|
1740
|
-
*
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
1805
|
+
* Numeric key comparison for the built-in `number`, `date` and numeric
|
|
1806
|
+
* `auto` comparators. Keys are never NaN (blanks and unparseable values map to
|
|
1807
|
+
* -Infinity), but two -Infinity keys subtract to NaN, so equal keys return 0
|
|
1808
|
+
* explicitly: the multi-clause sort reads any non-zero result, NaN included,
|
|
1809
|
+
* as decided and would skip the next clause.
|
|
1743
1810
|
*/
|
|
1744
1811
|
function compareNumericKeys(a: number, b: number): number {
|
|
1745
|
-
return a - b
|
|
1812
|
+
return a === b ? 0 : a - b
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/** Text key for the `auto` comparator: blanks become '' so they collate first. */
|
|
1816
|
+
function autoTextKey(value: unknown): string {
|
|
1817
|
+
return isBlankSortValue(value) ? '' : String(value)
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/**
|
|
1821
|
+
* How the `auto` comparator should treat a column: 'number' when every
|
|
1822
|
+
* non-blank value is a number, 'mixed' when numbers share it with anything
|
|
1823
|
+
* else, 'text' otherwise. One pass, stopping as soon as the answer is known.
|
|
1824
|
+
*/
|
|
1825
|
+
function autoSortMode(rows: ReadonlyArray<Row<any>>, columnId: string): 'number' | 'mixed' | 'text' {
|
|
1826
|
+
let numbers = false
|
|
1827
|
+
let other = false
|
|
1828
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1829
|
+
const value = rows[i]!.getCellValueByColumnId(columnId)
|
|
1830
|
+
if (isBlankSortValue(value)) continue
|
|
1831
|
+
if (typeof value === 'number') numbers = true
|
|
1832
|
+
else other = true
|
|
1833
|
+
if (numbers && other) return 'mixed'
|
|
1834
|
+
}
|
|
1835
|
+
return numbers ? 'number' : 'text'
|
|
1746
1836
|
}
|
|
1747
1837
|
|
|
1748
1838
|
/**
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates must come out of an export as the day the grid showed, in every
|
|
3
|
+
* timezone (#103). Text exports used `toISOString()`, which is UTC, so local
|
|
4
|
+
* midnight on the 15th came out as the 14th east of UTC. A bare 'YYYY-MM-DD'
|
|
5
|
+
* was parsed as UTC midnight, so west of UTC it formatted as the day before.
|
|
6
|
+
*
|
|
7
|
+
* These pin `process.env.TZ` per case: a suite run on a UTC machine (CI) would
|
|
8
|
+
* pass the old code without it.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
import { parseDateValue } from './cell-formatting'
|
|
12
|
+
import { asDate } from './SvGrid.helpers'
|
|
13
|
+
import {
|
|
14
|
+
coerceExportDate,
|
|
15
|
+
formatValueForExport,
|
|
16
|
+
serializeDelimited,
|
|
17
|
+
serializeHtml,
|
|
18
|
+
serializeMarkdown,
|
|
19
|
+
toLocalIsoString,
|
|
20
|
+
} from './export-format'
|
|
21
|
+
|
|
22
|
+
async function inZone<T>(tz: string, fn: () => T | Promise<T>): Promise<T> {
|
|
23
|
+
const prev = process.env.TZ
|
|
24
|
+
process.env.TZ = tz
|
|
25
|
+
try {
|
|
26
|
+
return await fn()
|
|
27
|
+
} finally {
|
|
28
|
+
// Assigning undefined would set the string "undefined".
|
|
29
|
+
if (prev === undefined) delete process.env.TZ
|
|
30
|
+
else process.env.TZ = prev
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const EAST = 'Europe/Sofia' // UTC+2 in January
|
|
35
|
+
const FAR_EAST = 'Pacific/Auckland' // UTC+13 in January
|
|
36
|
+
const WEST = 'America/New_York' // UTC-5 in January
|
|
37
|
+
|
|
38
|
+
describe('parseDateValue', () => {
|
|
39
|
+
it('reads a bare calendar date as local midnight of that day', async () => {
|
|
40
|
+
for (const tz of [EAST, FAR_EAST, WEST, 'UTC']) {
|
|
41
|
+
await inZone(tz, () => {
|
|
42
|
+
const d = parseDateValue('2026-06-27')!
|
|
43
|
+
expect([d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()], tz).toEqual([2026, 5, 27, 0])
|
|
44
|
+
// The grid's display path uses the same parser.
|
|
45
|
+
expect(asDate('2026-06-27')!.getTime(), tz).toBe(d.getTime())
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects an impossible calendar date instead of rolling it over', () => {
|
|
51
|
+
expect(parseDateValue('2026-02-30')).toBeNull()
|
|
52
|
+
expect(parseDateValue('2026-13-01')).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('leaves full timestamps, Dates and epoch numbers alone', () => {
|
|
56
|
+
expect(parseDateValue('2024-01-15T10:00:00Z')!.toISOString()).toBe('2024-01-15T10:00:00.000Z')
|
|
57
|
+
const d = new Date(2024, 0, 15)
|
|
58
|
+
expect(parseDateValue(d)).toBe(d)
|
|
59
|
+
expect(parseDateValue(0)!.getTime()).toBe(0)
|
|
60
|
+
expect(parseDateValue(null)).toBeNull()
|
|
61
|
+
expect(parseDateValue('')).toBeNull()
|
|
62
|
+
expect(parseDateValue('nope')).toBeNull()
|
|
63
|
+
expect(parseDateValue(true)).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('coerceExportDate follows it', async () => {
|
|
67
|
+
await inZone(WEST, () => {
|
|
68
|
+
expect(coerceExportDate('2024-01-15')!.getDate()).toBe(15)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('formatted date export west of UTC', () => {
|
|
74
|
+
it('shows the calendar day of a date-only string', async () => {
|
|
75
|
+
await inZone(WEST, () => {
|
|
76
|
+
expect(formatValueForExport('2024-01-15', { type: 'date', locales: 'en-US' })).toBe('01/15/2024')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('toLocalIsoString', () => {
|
|
82
|
+
it('writes the local wall clock with its offset', async () => {
|
|
83
|
+
await inZone(EAST, () => {
|
|
84
|
+
expect(toLocalIsoString(new Date(2024, 0, 15))).toBe('2024-01-15T00:00:00.000+02:00')
|
|
85
|
+
})
|
|
86
|
+
await inZone(WEST, () => {
|
|
87
|
+
expect(toLocalIsoString(new Date(2024, 0, 15, 9, 5, 7, 42))).toBe('2024-01-15T09:05:07.042-05:00')
|
|
88
|
+
})
|
|
89
|
+
await inZone('Asia/Kolkata', () => {
|
|
90
|
+
expect(toLocalIsoString(new Date(2024, 0, 15))).toBe('2024-01-15T00:00:00.000+05:30')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('is the same instant as the Date', async () => {
|
|
95
|
+
await inZone(FAR_EAST, () => {
|
|
96
|
+
const d = new Date(2024, 0, 15, 3, 4, 5)
|
|
97
|
+
expect(new Date(toLocalIsoString(d)).getTime()).toBe(d.getTime())
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('text exports keep the local day (#103)', () => {
|
|
103
|
+
it('CSV, HTML and Markdown write 2024-01-15 east of UTC', async () => {
|
|
104
|
+
for (const tz of [EAST, FAR_EAST]) {
|
|
105
|
+
await inZone(tz, async () => {
|
|
106
|
+
// Build the Date inside the zone: local midnight there.
|
|
107
|
+
const local = [{ when: 'When' }, { when: new Date(2024, 0, 15) }]
|
|
108
|
+
expect(await serializeDelimited(local, ['when']), tz).toContain('2024-01-15T00:00:00.000+')
|
|
109
|
+
expect(await serializeHtml(local, ['when']), tz).toContain('2024-01-15T00:00:00.000+')
|
|
110
|
+
expect(await serializeMarkdown(local, ['when']), tz).toContain('2024-01-15T00:00:00.000+')
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
})
|
package/src/export-format.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { CellFormatConfig } from './core'
|
|
|
20
20
|
import {
|
|
21
21
|
formatNumericWithConfig,
|
|
22
22
|
getDateFormatter,
|
|
23
|
+
parseDateValue,
|
|
23
24
|
resolveDatePattern,
|
|
24
25
|
} from './cell-formatting'
|
|
25
26
|
|
|
@@ -39,16 +40,27 @@ export {
|
|
|
39
40
|
/** Best-effort coercion of a cell value to a Date. Accepts Date, epoch ms
|
|
40
41
|
* numbers, and parseable date strings. Returns null when it isn't a date. */
|
|
41
42
|
export function coerceExportDate(value: unknown): Date | null {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
return parseDateValue(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A Date as ISO 8601 in LOCAL time with its offset, e.g.
|
|
48
|
+
* `2024-01-15T00:00:00.000+02:00`. Text exports used `toISOString()`, which
|
|
49
|
+
* is UTC: local midnight on the 15th came out as `2024-01-14T22:00:00.000Z`
|
|
50
|
+
* east of UTC, and anything reading the date part got the wrong day (#103).
|
|
51
|
+
* Same instant, but the date part is the one the grid showed.
|
|
52
|
+
*/
|
|
53
|
+
export function toLocalIsoString(d: Date): string {
|
|
54
|
+
const pad = (n: number, w = 2) => String(Math.abs(n)).padStart(w, '0')
|
|
55
|
+
const offset = -d.getTimezoneOffset()
|
|
56
|
+
const sign = offset < 0 ? '-' : '+'
|
|
57
|
+
const year = d.getFullYear()
|
|
58
|
+
return (
|
|
59
|
+
(year < 0 ? '-' + pad(year, 6) : pad(year, 4)) +
|
|
60
|
+
`-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
61
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` +
|
|
62
|
+
`${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`
|
|
63
|
+
)
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
/**
|
|
@@ -325,7 +337,7 @@ function csvCell(value: unknown, delimiter: string): string {
|
|
|
325
337
|
value == null
|
|
326
338
|
? ''
|
|
327
339
|
: value instanceof Date
|
|
328
|
-
? value
|
|
340
|
+
? toLocalIsoString(value)
|
|
329
341
|
: String(value)
|
|
330
342
|
if (s === '') return s
|
|
331
343
|
if (s.includes(delimiter) || s.includes('"') || s.includes('\n') || s.includes('\r')) {
|
|
@@ -373,7 +385,7 @@ const HTML_ESCAPE: Record<string, string> = {
|
|
|
373
385
|
}
|
|
374
386
|
function htmlCell(value: unknown): string {
|
|
375
387
|
const s =
|
|
376
|
-
value == null ? '' : value instanceof Date ? value
|
|
388
|
+
value == null ? '' : value instanceof Date ? toLocalIsoString(value) : String(value)
|
|
377
389
|
return s.replace(/[&<>"']/g, (c) => HTML_ESCAPE[c]!)
|
|
378
390
|
}
|
|
379
391
|
|
|
@@ -472,7 +484,7 @@ export async function serializeJson(
|
|
|
472
484
|
|
|
473
485
|
/** Escape a Markdown table cell: pipes and newlines would break the row. */
|
|
474
486
|
function mdCell(value: unknown): string {
|
|
475
|
-
const s = value == null ? '' : value instanceof Date ? value
|
|
487
|
+
const s = value == null ? '' : value instanceof Date ? toLocalIsoString(value) : String(value)
|
|
476
488
|
return s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>')
|
|
477
489
|
}
|
|
478
490
|
|
|
@@ -520,7 +532,7 @@ const XML_ESCAPE: Record<string, string> = {
|
|
|
520
532
|
"'": ''',
|
|
521
533
|
}
|
|
522
534
|
function xmlText(value: unknown): string {
|
|
523
|
-
const s = value == null ? '' : value instanceof Date ? value
|
|
535
|
+
const s = value == null ? '' : value instanceof Date ? toLocalIsoString(value) : String(value)
|
|
524
536
|
// Stripping control characters is the point: XML 1.0 forbids them outright,
|
|
525
537
|
// so a stray \x00 from a data source would produce an unopenable file.
|
|
526
538
|
// eslint-disable-next-line no-control-regex
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pushing onto a `$state` array passed as `data`.
|
|
3
|
+
*
|
|
4
|
+
* docs/getting-started/3-data-and-columns.md teaches exactly this: declare
|
|
5
|
+
* `let rows = $state([...])`, hand it to `data`, and an "Add row" button that
|
|
6
|
+
* does nothing but `rows.push(...)`. The grid is supposed to follow.
|
|
7
|
+
*
|
|
8
|
+
* `svgrid.live-update-paths.svelte.test.ts` covers the two MUTATION paths
|
|
9
|
+
* (field write, element replacement) against a sorted grid. This file covers
|
|
10
|
+
* the LENGTH path on the plainest possible grid, because a row appearing is a
|
|
11
|
+
* different question from a cell changing: nothing in the template is reading
|
|
12
|
+
* the new row yet, so the row model has to notice the array grew.
|
|
13
|
+
*
|
|
14
|
+
* Lives in a `.svelte.test.ts` so `$state` is available.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
18
|
+
import SvGrid from './SvGrid.svelte'
|
|
19
|
+
|
|
20
|
+
type Staff = { id: string; firstName: string; age: number }
|
|
21
|
+
|
|
22
|
+
const staffColumns = [
|
|
23
|
+
{ field: 'firstName', header: 'First name', width: 180 },
|
|
24
|
+
{ field: 'age', header: 'Age', width: 90 },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** The props object is the `$state` proxy itself; a spread would snapshot it. */
|
|
28
|
+
function pushProps(rows: Staff[]) {
|
|
29
|
+
const props = $state({
|
|
30
|
+
data: rows,
|
|
31
|
+
columns: staffColumns,
|
|
32
|
+
containerHeight: 400,
|
|
33
|
+
virtualization: false,
|
|
34
|
+
})
|
|
35
|
+
return props
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mountGrid(props: ReturnType<typeof pushProps>) {
|
|
39
|
+
const target = document.createElement('div')
|
|
40
|
+
document.body.appendChild(target)
|
|
41
|
+
const app = mount(SvGrid, { target, props: props as never })
|
|
42
|
+
flushSync()
|
|
43
|
+
const names = () =>
|
|
44
|
+
Array.from(
|
|
45
|
+
target.querySelectorAll('tbody.sv-grid-body tr.sv-grid-row:not(.sv-grid-row-spacer)'),
|
|
46
|
+
).map((tr) => tr.querySelector('.sv-grid-cell:not(.sv-grid-selection-cell)')?.textContent?.trim())
|
|
47
|
+
return {
|
|
48
|
+
names,
|
|
49
|
+
done() {
|
|
50
|
+
unmount(app)
|
|
51
|
+
target.remove()
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('data array length changes', () => {
|
|
57
|
+
it('renders the rows it was given', () => {
|
|
58
|
+
const rows = $state<Staff[]>([
|
|
59
|
+
{ id: '1', firstName: 'Ada', age: 36 },
|
|
60
|
+
{ id: '2', firstName: 'Linus', age: 54 },
|
|
61
|
+
])
|
|
62
|
+
const g = mountGrid(pushProps(rows))
|
|
63
|
+
expect(g.names()).toEqual(['Ada', 'Linus'])
|
|
64
|
+
g.done()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('push onto the $state array adds the row (the docs "Add row" button)', () => {
|
|
68
|
+
const rows = $state<Staff[]>([
|
|
69
|
+
{ id: '1', firstName: 'Ada', age: 36 },
|
|
70
|
+
{ id: '2', firstName: 'Linus', age: 54 },
|
|
71
|
+
])
|
|
72
|
+
const g = mountGrid(pushProps(rows))
|
|
73
|
+
|
|
74
|
+
rows.push({ id: '3', firstName: 'New', age: 0 })
|
|
75
|
+
flushSync()
|
|
76
|
+
|
|
77
|
+
expect(g.names()).toEqual(['Ada', 'Linus', 'New'])
|
|
78
|
+
g.done()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('splice off the $state array removes the row', () => {
|
|
82
|
+
const rows = $state<Staff[]>([
|
|
83
|
+
{ id: '1', firstName: 'Ada', age: 36 },
|
|
84
|
+
{ id: '2', firstName: 'Linus', age: 54 },
|
|
85
|
+
])
|
|
86
|
+
const g = mountGrid(pushProps(rows))
|
|
87
|
+
|
|
88
|
+
rows.splice(0, 1)
|
|
89
|
+
flushSync()
|
|
90
|
+
|
|
91
|
+
expect(g.names()).toEqual(['Linus'])
|
|
92
|
+
g.done()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The two ways a live feed can change a row, and what each one reaches.
|
|
3
3
|
*
|
|
4
|
-
* A `$state` array passed as `data` has two update paths
|
|
5
|
-
* interchangeable, which is the whole design problem of a
|
|
4
|
+
* A `$state` array passed as `data` has two update paths for a row's VALUES,
|
|
5
|
+
* and they are not interchangeable, which is the whole design problem of a
|
|
6
|
+
* ticking grid. (Changing the row COUNT is a third path, tracked on its own;
|
|
7
|
+
* it lives in `svgrid.data-push.svelte.test.ts`.)
|
|
6
8
|
*
|
|
7
9
|
* 1. Mutate a field through the proxy (`rows[i].price = x`). The visible
|
|
8
10
|
* cell re-renders because its template reads the field through the
|
|
@@ -14,6 +14,7 @@ export function createSvelteVirtualizer(options: VirtualizerOptions) {
|
|
|
14
14
|
return version
|
|
15
15
|
},
|
|
16
16
|
setOptions: virtualizer.setOptions,
|
|
17
|
+
measure: virtualizer.measure,
|
|
17
18
|
setScrollOffset: virtualizer.setScrollOffset,
|
|
18
19
|
setViewportHeight: virtualizer.setViewportHeight,
|
|
19
20
|
scrollToIndex: virtualizer.scrollToIndex,
|
|
@@ -44,4 +44,48 @@ describe('virtualizer', () => {
|
|
|
44
44
|
v.scrollToIndex(9_999)
|
|
45
45
|
expect(v.getState().scrollOffset).toBe(2_700)
|
|
46
46
|
})
|
|
47
|
+
|
|
48
|
+
it('re-measures a stable size function passed back through setOptions (#102)', () => {
|
|
49
|
+
const heights = new Map<number, number>()
|
|
50
|
+
const estimateSize = (i: number) => heights.get(i) ?? 20
|
|
51
|
+
const v = createVirtualizer({ count: 10, estimateSize, viewportHeight: 100 })
|
|
52
|
+
expect(v.getTotalSize()).toBe(200)
|
|
53
|
+
|
|
54
|
+
heights.set(0, 120)
|
|
55
|
+
v.setOptions({ estimateSize })
|
|
56
|
+
expect(v.getSizeForIndex(0)).toBe(120)
|
|
57
|
+
expect(v.getTotalSize()).toBe(300) // 9 x 20 + 120
|
|
58
|
+
expect(v.getOffsetForIndex(5)).toBe(200) // 120 + 4 x 20
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('measure() re-reads sizes and notifies subscribers', () => {
|
|
62
|
+
const heights = new Map<number, number>()
|
|
63
|
+
const v = createVirtualizer({
|
|
64
|
+
count: 10,
|
|
65
|
+
estimateSize: (i: number) => heights.get(i) ?? 20,
|
|
66
|
+
viewportHeight: 100,
|
|
67
|
+
})
|
|
68
|
+
let notified = 0
|
|
69
|
+
v.subscribe(() => (notified += 1))
|
|
70
|
+
|
|
71
|
+
heights.set(3, 50)
|
|
72
|
+
v.measure()
|
|
73
|
+
expect(v.getTotalSize()).toBe(230)
|
|
74
|
+
expect(v.getOffsetForIndex(4)).toBe(110)
|
|
75
|
+
expect(notified).toBe(1)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps the offset cache across scrolls', () => {
|
|
79
|
+
let calls = 0
|
|
80
|
+
const v = createVirtualizer({
|
|
81
|
+
count: 1000,
|
|
82
|
+
estimateSize: () => (calls++, 20),
|
|
83
|
+
viewportHeight: 100,
|
|
84
|
+
})
|
|
85
|
+
const afterBuild = calls
|
|
86
|
+
v.setScrollOffset(500)
|
|
87
|
+
v.setScrollOffset(900)
|
|
88
|
+
v.setViewportHeight(200)
|
|
89
|
+
expect(calls).toBe(afterBuild)
|
|
90
|
+
})
|
|
47
91
|
})
|
|
@@ -261,8 +261,20 @@ export function createVirtualizer(initial: VirtualizerOptions) {
|
|
|
261
261
|
return {
|
|
262
262
|
setOptions(next: Partial<VirtualizerOptions>) {
|
|
263
263
|
const merged = { ...options, ...next }
|
|
264
|
-
|
|
264
|
+
// Passing a size function means "sizes may have changed", even when it is
|
|
265
|
+
// the same reference: the documented variable-height pattern is a stable
|
|
266
|
+
// function reading a mutable map, and keying the cache on identity alone
|
|
267
|
+
// left the offsets and total stale after a row grew (#102).
|
|
268
|
+
const remeasure = typeof next.estimateSize === 'function'
|
|
269
|
+
if (!remeasure && sameOptions(options, merged)) return
|
|
265
270
|
options = merged
|
|
271
|
+
if (remeasure) offsetCache = null
|
|
272
|
+
recalc()
|
|
273
|
+
},
|
|
274
|
+
/** Drop the cached offsets and re-read every size from `estimateSize`.
|
|
275
|
+
* Call after the sizes a stable size function reports have changed. */
|
|
276
|
+
measure() {
|
|
277
|
+
offsetCache = null
|
|
266
278
|
recalc()
|
|
267
279
|
},
|
|
268
280
|
setScrollOffset(scrollOffset: number) {
|