@svgrid/grid 3.0.6 → 3.0.8

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 (77) hide show
  1. package/README.md +1 -1
  2. package/dist/SvGrid.controller.svelte.d.ts +2 -0
  3. package/dist/SvGrid.controller.svelte.js +46 -9
  4. package/dist/SvGrid.css +16 -5
  5. package/dist/SvGrid.helpers.js +2 -4
  6. package/dist/SvGrid.svelte +4 -1
  7. package/dist/cdn/{GridMenus-C-NtjlrJ.js → GridMenus-B8-Gbfq2.js} +2 -2
  8. package/dist/cdn/{GridMenus-TssHej3q.js → GridMenus-D15bhpX5.js} +2 -2
  9. package/dist/cdn/{SvGridCellEditor-BlyCSp0q.js → SvGridCellEditor-9yz-SbdZ.js} +1 -1
  10. package/dist/cdn/{SvGridCellEditor-ClRRA9lW.js → SvGridCellEditor-D7-ql1Hd.js} +1 -1
  11. package/dist/cdn/{SvGridChart-DsmwTyZF.js → SvGridChart-DMUOFxOe.js} +3 -3
  12. package/dist/cdn/{SvGridChart-B4EV8U6t.js → SvGridChart-N7ik0oWy.js} +3 -3
  13. package/dist/cdn/{SvGridChartBuilder-cHloHobN.js → SvGridChartBuilder-CB_Jnm2k.js} +3 -3
  14. package/dist/cdn/{SvGridChartBuilder-vOl7C0Za.js → SvGridChartBuilder-Ca7gF8ZK.js} +3 -3
  15. package/dist/cdn/{SvGridChartPanel-AcqF-Ll5.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
  16. package/dist/cdn/{SvGridChartPanel-biMtSBPA.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
  17. package/dist/cdn/{SvGridChartView-I3rDHXAq.js → SvGridChartView-B1LFgoPP.js} +2 -2
  18. package/dist/cdn/{SvGridChartView-Dx_GmZuL.js → SvGridChartView-By3n7DGZ.js} +2 -2
  19. package/dist/cdn/{cell-formatting-DVVKOSI9.js → cell-formatting-Mnv84zoE.js} +27 -5
  20. package/dist/cdn/{chart-DEZxwC0i.js → chart-By7h2Guv.js} +1 -1
  21. package/dist/cdn/{chart-panel-messages-D12XcTZJ.js → chart-panel-messages-BC-tOgFY.js} +1 -1
  22. package/dist/cdn/{chart-panel-messages-DguS7gC5.js → chart-panel-messages-DUaXr-sb.js} +1 -1
  23. package/dist/cdn/{chart-summary-CMAXBGY-.js → chart-summary-BDtrNEiA.js} +1 -1
  24. package/dist/cdn/{chart-trend-BkEp61Gj.js → chart-trend-C1aOAPBZ.js} +1 -1
  25. package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
  26. package/dist/cdn/{export-format-YzdwBww2.js → export-format-y1wdyCcv.js} +93 -97
  27. package/dist/cdn/{src-CgoeSM8k.js → src-CleI0g9l.js} +3256 -3198
  28. package/dist/cdn/{src-DfwxB1TH.js → src-Dz625Fhx.js} +3184 -3126
  29. package/dist/cdn/svgrid.js +11 -11
  30. package/dist/cdn/svgrid.svelte-external.js +11 -11
  31. package/dist/cdn/validate-GEs4ZPSW.js +80 -0
  32. package/dist/cell-formatting.d.ts +9 -0
  33. package/dist/cell-formatting.js +34 -0
  34. package/dist/core.js +124 -21
  35. package/dist/export-format.d.ts +8 -0
  36. package/dist/export-format.js +23 -16
  37. package/dist/headless.d.ts +1 -1
  38. package/dist/headless.js +1 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/keyboard-handlers.js +5 -1
  42. package/dist/selection.js +14 -4
  43. package/dist/{subscribe.js → subscribe.svelte.js} +5 -1
  44. package/dist/validate.js +10 -3
  45. package/dist/virtualization/column-virtualizer.d.ts +1 -0
  46. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
  47. package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
  48. package/dist/virtualization/virtualizer.d.ts +3 -0
  49. package/dist/virtualization/virtualizer.js +14 -1
  50. package/package.json +1 -1
  51. package/src/SvGrid.controller.svelte.ts +45 -13
  52. package/src/SvGrid.css +16 -5
  53. package/src/SvGrid.helpers.ts +2 -4
  54. package/src/SvGrid.svelte +4 -1
  55. package/src/cell-formatting.ts +31 -0
  56. package/src/core.sort.test.ts +86 -0
  57. package/src/core.ts +114 -21
  58. package/src/export-dates.test.ts +114 -0
  59. package/src/export-format.ts +26 -14
  60. package/src/headless.reactivity.svelte.test.ts +89 -0
  61. package/src/headless.ts +1 -1
  62. package/src/index.ts +1 -1
  63. package/src/keyboard-handlers.ts +5 -1
  64. package/src/selection.test.ts +37 -0
  65. package/src/selection.ts +16 -6
  66. package/src/{subscribe.test.ts → subscribe.svelte.test.ts} +1 -1
  67. package/src/{subscribe.ts → subscribe.svelte.ts} +5 -1
  68. package/src/svgrid.behavior.test.ts +29 -0
  69. package/src/svgrid.data-push.svelte.test.ts +94 -0
  70. package/src/svgrid.live-update-paths.svelte.test.ts +4 -2
  71. package/src/validate.test.ts +16 -0
  72. package/src/validate.ts +10 -2
  73. package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
  74. package/src/virtualization/virtualizer.test.ts +44 -0
  75. package/src/virtualization/virtualizer.ts +13 -1
  76. package/dist/cdn/validate-AZoD8BoR.js +0 -80
  77. /package/dist/{subscribe.d.ts → subscribe.svelte.d.ts} +0 -0
@@ -3,6 +3,7 @@
3
3
  // (plus DOM/Intl globals), so they live here to keep the component leaner.
4
4
  import type { CellEditorType, CellEditorOption } from "./index";
5
5
  import type { ResolvedCellFormat } from "./conditional-formatting";
6
+ import { parseDateValue } from "./cell-formatting";
6
7
 
7
8
  export function cfTextStyle(cf: ResolvedCellFormat): string {
8
9
  let s = "";
@@ -258,10 +259,7 @@ export function getEditorClass(editorType: CellEditorType) {
258
259
  }
259
260
 
260
261
  export function asDate(value: unknown) {
261
- if (value == null || value === "") return null;
262
- const parsed =
263
- value instanceof Date ? value : new Date(value as string | number);
264
- return Number.isNaN(parsed.getTime()) ? null : parsed;
262
+ return parseDateValue(value);
265
263
  }
266
264
 
267
265
  export function clampMenuX(x: number, width: number) {
package/src/SvGrid.svelte CHANGED
@@ -1212,7 +1212,10 @@
1212
1212
  column: Column<TData>,
1213
1213
  cellValue: unknown,
1214
1214
  )}
1215
- {#if column.columnDef.editorType === "checkbox" || typeof cellValue === "boolean"}
1215
+ <!-- A column's own `cell` renderer wins over every built-in display, the
1216
+ checkbox included: a boolean field drawn as a flag button or a status
1217
+ pill used to come out as a read-only checkbox, with `cell` never called. -->
1218
+ {#if (column.columnDef.editorType === "checkbox" || typeof cellValue === "boolean") && column.columnDef.cell == null}
1216
1219
  <div
1217
1220
  class="sv-grid-checkbox sv-grid-checkbox-readonly"
1218
1221
  role="checkbox"
@@ -127,6 +127,37 @@ export function getNumberFormatter(
127
127
  return fmt
128
128
  }
129
129
 
130
+ /**
131
+ * A cell value as a Date, or null when it is not one. Accepts a Date, epoch ms,
132
+ * or a parseable string.
133
+ *
134
+ * A bare calendar date ('2026-06-27') is read as LOCAL midnight of that day.
135
+ * `new Date('2026-06-27')` reads it as UTC midnight, which every zone west of
136
+ * UTC formats as the 26th. The day in the string is the day the user meant.
137
+ */
138
+ export function parseDateValue(value: unknown): Date | null {
139
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
140
+ if (typeof value === 'number') {
141
+ if (!Number.isFinite(value)) return null
142
+ const d = new Date(value)
143
+ return Number.isNaN(d.getTime()) ? null : d
144
+ }
145
+ if (typeof value !== 'string') return null
146
+ const s = value.trim()
147
+ if (s === '') return null
148
+ const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
149
+ if (ymd) {
150
+ const [y, m, day] = [Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3])]
151
+ const d = new Date(0)
152
+ d.setFullYear(y, m, day)
153
+ d.setHours(0, 0, 0, 0)
154
+ // Reject a rollover ('2026-02-30' is not March 2nd), as `new Date` does.
155
+ return d.getMonth() === m && d.getDate() === day ? d : null
156
+ }
157
+ const d = new Date(s)
158
+ return Number.isNaN(d.getTime()) ? null : d
159
+ }
160
+
130
161
  /** Cache of `Intl.DateTimeFormat` by `(locale, options)` signature. */
131
162
  const dateFormatterCache = new Map<string, Intl.DateTimeFormat>()
132
163
  /** A cached `Intl.DateTimeFormat` for the locale and options given. Cached because
@@ -12,6 +12,10 @@
12
12
  * snapshot the new output, `referenceSort` below is a literal transcription of
13
13
  * the ORIGINAL comparator, and every case asserts the two agree. If the rewrite
14
14
  * ever diverges on any input, including the randomised ones, this fails.
15
+ *
16
+ * The oracle calls the pairwise `sortFns`, so it tracks their semantics: since
17
+ * #104 those put blanks and unparseable values first instead of returning NaN,
18
+ * and `auto` compares numbers numerically.
15
19
  */
16
20
  import { describe, expect, it } from 'vitest'
17
21
  // From './core' rather than './index': `createSvGridCore` is the runes-free
@@ -233,6 +237,88 @@ describe('createSortedRowModel - equivalence with the original comparator', () =
233
237
  })
234
238
  })
235
239
 
240
+ describe('built-in comparators keep the Array.sort contract (#104)', () => {
241
+ it('number: non-numeric values sort together ahead of the numbers', () => {
242
+ expect([5, 'n/a', 3, 1, 'n/a', 4, 2].sort(sortFns.number)).toEqual(['n/a', 'n/a', 1, 2, 3, 4, 5])
243
+ expect(sortFns.number('n/a', 'x')).toBe(0)
244
+ expect(Number.isNaN(sortFns.number('n/a', 3))).toBe(false)
245
+ })
246
+
247
+ it('date: blanks and invalid dates sort first and never return NaN', () => {
248
+ // null, not undefined: Array.prototype.sort moves undefined to the end
249
+ // without calling the comparator.
250
+ const sorted = ['2021-01-01', null, 'junk', '2020-01-01'].sort(sortFns.date)
251
+ expect(sorted).toEqual([null, 'junk', '2020-01-01', '2021-01-01'])
252
+ expect(sortFns.date(undefined, 'junk')).toBe(0)
253
+ })
254
+
255
+ it('auto: numbers compare numerically, blanks come first', () => {
256
+ expect([2, 10, 1, 20, 100, 3].sort(sortFns.auto)).toEqual([1, 2, 3, 10, 20, 100])
257
+ expect(['oak', null, 'nuclear', '', 'apple'].sort(sortFns.auto)).toEqual([
258
+ null, '', 'apple', 'nuclear', 'oak',
259
+ ])
260
+ })
261
+
262
+ it('every built-in comparator is antisymmetric and transitive on mixed values', () => {
263
+ const pool: unknown[] = [
264
+ null, undefined, '', 0, -1, 2, 10, NaN, 1e21, '1a', '2', '10', 'n/a', 'apple', 'Apple',
265
+ true, '2020-01-01', 'junk', 1600000000000,
266
+ ]
267
+ for (const [name, cmp] of Object.entries(sortFns)) {
268
+ const sign = (a: unknown, b: unknown) => Math.sign(cmp(a, b))
269
+ for (const a of pool) {
270
+ for (const b of pool) {
271
+ const ab = sign(a, b)
272
+ expect(Number.isNaN(ab), `${name}(${String(a)}, ${String(b)}) is NaN`).toBe(false)
273
+ expect(ab, `${name} antisymmetry on ${String(a)}, ${String(b)}`).toBe(-sign(b, a) || 0)
274
+ for (const c of pool) {
275
+ if (ab <= 0 && sign(b, c) <= 0) {
276
+ expect(sign(a, c), `${name}: ${String(a)} <= ${String(b)} <= ${String(c)}`).toBeLessThanOrEqual(0)
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+ })
283
+
284
+ it('sorts an untyped numeric column numerically in the row model', () => {
285
+ const rows: Row[] = [2, 10, null, 1, 20, 100, 3].map((qty) => ({ qty }))
286
+ const cols = [{ field: 'qty' }]
287
+ expect(actualSort(rows, cols, [{ id: 'qty', desc: false }]).map((r) => r.qty)).toEqual([
288
+ null, 1, 2, 3, 10, 20, 100,
289
+ ])
290
+ expect(actualSort(rows, cols, [{ id: 'qty', desc: true }]).map((r) => r.qty)).toEqual([
291
+ 100, 20, 10, 3, 2, 1, null,
292
+ ])
293
+ })
294
+
295
+ it('sorts a number column with placeholder text in order', () => {
296
+ const rows: Row[] = [5, 'n/a', 3, 1, 'n/a', 4, 2].map((v) => ({ v }))
297
+ const sorted = actualSort(rows, [{ field: 'v', editorType: 'number' }], [{ id: 'v', desc: false }])
298
+ expect(sorted.map((r) => r.v)).toEqual(['n/a', 'n/a', 1, 2, 3, 4, 5])
299
+ })
300
+
301
+ it('orders a mixed number and text column by kind, then value', () => {
302
+ const rows: Row[] = [10, 'b', 2, null, 'a', 1].map((v) => ({ v }))
303
+ const sorted = actualSort(rows, [{ field: 'v' }], [{ id: 'v', desc: false }])
304
+ expect(sorted.map((r) => r.v)).toEqual([null, 1, 2, 10, 'a', 'b'])
305
+ })
306
+
307
+ it('lets the next clause decide when two blanks tie on a numeric clause', () => {
308
+ // Two -Infinity keys subtract to NaN; the multi-clause loop must see 0.
309
+ const rows: Row[] = [
310
+ { num: null, text: 'b' },
311
+ { num: 'n/a', text: 'a' },
312
+ { num: 1, text: 'c' },
313
+ ]
314
+ const sorted = actualSort(rows, COLUMNS, [
315
+ { id: 'num', desc: false },
316
+ { id: 'text', desc: false },
317
+ ])
318
+ expect(sorted.map((r) => r.text)).toEqual(['a', 'b', 'c'])
319
+ })
320
+ })
321
+
236
322
  describe('createSortedRowModel - work budget', () => {
237
323
  /** Count `getAllColumns` calls the way tools/bench does, but in-process. */
238
324
  function countColumnLookups(rowCount: number, sorting: SortingState): number {
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
  /**
@@ -2126,8 +2216,11 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
2126
2216
  const original = data[index]!
2127
2217
  const kept = previous?.[index] as BaseRowState<TData> | undefined
2128
2218
  if (kept && kept.original === original) {
2129
- kept[ROW_VALUES] = null
2130
- kept[ROW_CELLS] = null
2219
+ // Read before writing: a row nobody rendered (a million off-screen
2220
+ // placeholders under a row model) has nothing memoised, and a
2221
+ // store per row was a measurable share of each block landing.
2222
+ if (kept[ROW_VALUES] !== null) kept[ROW_VALUES] = null
2223
+ if (kept[ROW_CELLS] !== null) kept[ROW_CELLS] = null
2131
2224
  cachedBaseRows[index] = kept
2132
2225
  continue
2133
2226
  }
@@ -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,89 @@
1
+ /**
2
+ * The headless docs tell you to write:
3
+ *
4
+ * const table = createSvGrid({ ..., state: { sorting }, onSortingChange })
5
+ * const rows = $derived(table.getRowModel().rows)
6
+ *
7
+ * `createStore` (core.ts) is a plain closure + listener set, NOT runes, and
8
+ * `options.state` is spread into the initial store once at creation. So that
9
+ * `$derived` has no reactive dependency on the engine's state: clicking a
10
+ * header updates the store and the component's own `sorting` rune, the sort
11
+ * indicator moves, and the ROWS never recompute.
12
+ *
13
+ * Caught while recording the headless video: the arrow cleared and the order
14
+ * did not change. Lives in a `.svelte.test.ts` so `$state` is available.
15
+ */
16
+ import { describe, expect, it } from 'vitest'
17
+ import { flushSync } from 'svelte'
18
+ import {
19
+ createSvGrid,
20
+ createCoreRowModel,
21
+ createSortedRowModel,
22
+ tableFeatures,
23
+ rowSortingFeature,
24
+ } from './index'
25
+
26
+ type Repo = { name: string; stars: number }
27
+
28
+ // Deliberately NOT in stars order: clearing the sort has to produce a
29
+ // visibly different list, or the assertions below prove nothing.
30
+ const data: Repo[] = [
31
+ { name: 'sv-grid', stars: 172 },
32
+ { name: 'svelte', stars: 78000 },
33
+ { name: 'vite', stars: 68000 },
34
+ ]
35
+
36
+ const features = tableFeatures({ rowSortingFeature })
37
+ const columns = [
38
+ { field: 'name', header: 'Repo' },
39
+ { field: 'stars', header: 'Stars' },
40
+ ] as never
41
+
42
+ function build(onSortingChange: (next: unknown) => void, seed: Array<{ id: string; desc: boolean }>) {
43
+ return createSvGrid({
44
+ _features: features,
45
+ _rowModels: {
46
+ coreRowModel: createCoreRowModel<Repo>(),
47
+ sortedRowModel: createSortedRowModel<Repo>(),
48
+ },
49
+ data,
50
+ columns,
51
+ state: { sorting: seed },
52
+ onSortingChange,
53
+ } as never)
54
+ }
55
+
56
+ describe('headless state reactivity', () => {
57
+ it('seeds the sort from options.state', () => {
58
+ const table = build(() => {}, [{ id: 'stars', desc: true }])
59
+ const names = table.getRowModel().rows.map((r) => (r.original as Repo).name)
60
+ expect(names).toEqual(['svelte', 'vite', 'sv-grid'])
61
+ })
62
+
63
+ it('re-sorts when the toggle handler runs', () => {
64
+ let sorting = $state([{ id: 'stars', desc: true }])
65
+ const table = build((next) => (sorting = next as typeof sorting), sorting)
66
+
67
+ // The pattern the headless docs now teach: touch the state rune the
68
+ // component owns so the derived re-runs. A plain
69
+ // `$derived(table.getRowModel().rows)` reads only the framework-free
70
+ // engine and never recomputes, which is the bug this guards.
71
+ const rows = $derived.by(() => {
72
+ sorting
73
+ return table.getRowModel().rows
74
+ })
75
+ expect(rows.map((r) => (r.original as Repo).name)).toEqual(['svelte', 'vite', 'sv-grid'])
76
+
77
+ const stars = table.getAllColumns().find((c) => c.id === 'stars')!
78
+ stars.getToggleSortingHandler()()
79
+ flushSync()
80
+
81
+ // The engine's own state really did change...
82
+ expect(table.getState().sorting).toEqual([])
83
+ // ...and the component's rune did too, which is why the arrow clears.
84
+ expect(sorting).toEqual([])
85
+
86
+ // The rows must follow the cleared sort, i.e. fall back to data order.
87
+ expect(rows.map((r) => (r.original as Repo).name)).toEqual(['sv-grid', 'svelte', 'vite'])
88
+ })
89
+ })
package/src/headless.ts CHANGED
@@ -84,7 +84,7 @@ export {
84
84
 
85
85
  export { createGrid, createSvGrid, type SvelteGrid } from './createGrid.svelte'
86
86
  export { createGridState, createSvGridState } from './createGridState.svelte'
87
- export { subscribeGrid, subscribeSvGrid } from './subscribe'
87
+ export { subscribeGrid, subscribeSvGrid } from './subscribe.svelte'
88
88
 
89
89
  // The virtualizers are pure window arithmetic (scroll offset in, visible
90
90
  // range out) with no DOM of their own, and a custom renderer over 50k rows