@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.
Files changed (52) hide show
  1. package/dist/SvGrid.controller.svelte.d.ts +2 -0
  2. package/dist/SvGrid.controller.svelte.js +30 -4
  3. package/dist/SvGrid.css +16 -5
  4. package/dist/SvGrid.helpers.js +2 -4
  5. package/dist/cdn/{GridMenus-C-NtjlrJ.js → GridMenus-BgUS-W3o.js} +2 -2
  6. package/dist/cdn/{GridMenus-TssHej3q.js → GridMenus-CcwU8I4t.js} +2 -2
  7. package/dist/cdn/{SvGridCellEditor-BlyCSp0q.js → SvGridCellEditor-9yz-SbdZ.js} +1 -1
  8. package/dist/cdn/{SvGridCellEditor-ClRRA9lW.js → SvGridCellEditor-D7-ql1Hd.js} +1 -1
  9. package/dist/cdn/{SvGridChart-DsmwTyZF.js → SvGridChart-DMUOFxOe.js} +3 -3
  10. package/dist/cdn/{SvGridChart-B4EV8U6t.js → SvGridChart-N7ik0oWy.js} +3 -3
  11. package/dist/cdn/{SvGridChartBuilder-cHloHobN.js → SvGridChartBuilder-CB_Jnm2k.js} +3 -3
  12. package/dist/cdn/{SvGridChartBuilder-vOl7C0Za.js → SvGridChartBuilder-Ca7gF8ZK.js} +3 -3
  13. package/dist/cdn/{SvGridChartPanel-AcqF-Ll5.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
  14. package/dist/cdn/{SvGridChartPanel-biMtSBPA.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
  15. package/dist/cdn/{SvGridChartView-I3rDHXAq.js → SvGridChartView-B1LFgoPP.js} +2 -2
  16. package/dist/cdn/{SvGridChartView-Dx_GmZuL.js → SvGridChartView-By3n7DGZ.js} +2 -2
  17. package/dist/cdn/{cell-formatting-DVVKOSI9.js → cell-formatting-Mnv84zoE.js} +27 -5
  18. package/dist/cdn/{chart-DEZxwC0i.js → chart-By7h2Guv.js} +1 -1
  19. package/dist/cdn/{chart-panel-messages-D12XcTZJ.js → chart-panel-messages-BC-tOgFY.js} +1 -1
  20. package/dist/cdn/{chart-panel-messages-DguS7gC5.js → chart-panel-messages-DUaXr-sb.js} +1 -1
  21. package/dist/cdn/{chart-summary-CMAXBGY-.js → chart-summary-BDtrNEiA.js} +1 -1
  22. package/dist/cdn/{chart-trend-BkEp61Gj.js → chart-trend-C1aOAPBZ.js} +1 -1
  23. package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
  24. package/dist/cdn/{export-format-YzdwBww2.js → export-format-y1wdyCcv.js} +93 -97
  25. package/dist/cdn/{src-CgoeSM8k.js → src-25JmJ_7g.js} +3244 -3191
  26. package/dist/cdn/{src-DfwxB1TH.js → src-kE9B98r6.js} +3172 -3119
  27. package/dist/cdn/svgrid.js +11 -11
  28. package/dist/cdn/svgrid.svelte-external.js +11 -11
  29. package/dist/cell-formatting.d.ts +9 -0
  30. package/dist/cell-formatting.js +34 -0
  31. package/dist/core.js +117 -19
  32. package/dist/export-format.d.ts +8 -0
  33. package/dist/export-format.js +23 -16
  34. package/dist/virtualization/column-virtualizer.d.ts +1 -0
  35. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
  36. package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
  37. package/dist/virtualization/virtualizer.d.ts +3 -0
  38. package/dist/virtualization/virtualizer.js +14 -1
  39. package/package.json +1 -1
  40. package/src/SvGrid.controller.svelte.ts +30 -4
  41. package/src/SvGrid.css +16 -5
  42. package/src/SvGrid.helpers.ts +2 -4
  43. package/src/cell-formatting.ts +31 -0
  44. package/src/core.sort.test.ts +86 -0
  45. package/src/core.ts +109 -19
  46. package/src/export-dates.test.ts +114 -0
  47. package/src/export-format.ts +26 -14
  48. package/src/svgrid.data-push.svelte.test.ts +94 -0
  49. package/src/svgrid.live-update-paths.svelte.test.ts +4 -2
  50. package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
  51. package/src/virtualization/virtualizer.test.ts +44 -0
  52. 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) => String(a).localeCompare(String(b)),
749
- number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),
750
- date: (a: unknown, b: unknown) => {
751
- const aa = new Date(a as any).getTime()
752
- const bb = new Date(b as any).getTime()
753
- return aa - bb
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) => Number(row.getCellValueByColumnId(columnId) ?? 0)
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) => new Date(row.getCellValueByColumnId(columnId) as any).getTime()
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(String(rows[i]!.getCellValueByColumnId(columnId)))
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 = String(rows[i]!.getCellValueByColumnId(columnId))
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) => rankOf.get(String(row.getCellValueByColumnId(columnId))) ?? MISSING_KEY
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) => String(row.getCellValueByColumnId(columnId))
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` and `date` comparators.
1739
- * Subtraction rather than `<`/`>` on purpose: it reproduces the originals
1740
- * exactly, NaN included. An unparseable date or a non-numeric value yields NaN,
1741
- * and the sort spec turns a NaN comparison result into 0 (SortCompare coerces
1742
- * it), which is the behaviour callers already depend on.
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
+ })
@@ -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
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
43
- if (typeof value === 'number' && Number.isFinite(value)) {
44
- const d = new Date(value)
45
- return Number.isNaN(d.getTime()) ? null : d
46
- }
47
- if (typeof value === 'string' && value.trim() !== '') {
48
- const d = new Date(value)
49
- return Number.isNaN(d.getTime()) ? null : d
50
- }
51
- return null
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.toISOString()
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.toISOString() : String(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.toISOString() : String(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
  "'": '&apos;',
521
533
  }
522
534
  function xmlText(value: unknown): string {
523
- const s = value == null ? '' : value instanceof Date ? value.toISOString() : String(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 and they are not
5
- * interchangeable, which is the whole design problem of a ticking grid:
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
- if (sameOptions(options, merged)) return
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) {