@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.
- package/README.md +1 -1
- package/dist/SvGrid.controller.svelte.d.ts +2 -0
- package/dist/SvGrid.controller.svelte.js +46 -9
- package/dist/SvGrid.css +16 -5
- package/dist/SvGrid.helpers.js +2 -4
- package/dist/SvGrid.svelte +4 -1
- package/dist/cdn/{GridMenus-C-NtjlrJ.js → GridMenus-B8-Gbfq2.js} +2 -2
- package/dist/cdn/{GridMenus-TssHej3q.js → GridMenus-D15bhpX5.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-CleI0g9l.js} +3256 -3198
- package/dist/cdn/{src-DfwxB1TH.js → src-Dz625Fhx.js} +3184 -3126
- package/dist/cdn/svgrid.js +11 -11
- package/dist/cdn/svgrid.svelte-external.js +11 -11
- package/dist/cdn/validate-GEs4ZPSW.js +80 -0
- package/dist/cell-formatting.d.ts +9 -0
- package/dist/cell-formatting.js +34 -0
- package/dist/core.js +124 -21
- package/dist/export-format.d.ts +8 -0
- package/dist/export-format.js +23 -16
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/keyboard-handlers.js +5 -1
- package/dist/selection.js +14 -4
- package/dist/{subscribe.js → subscribe.svelte.js} +5 -1
- package/dist/validate.js +10 -3
- 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 +45 -13
- package/src/SvGrid.css +16 -5
- package/src/SvGrid.helpers.ts +2 -4
- package/src/SvGrid.svelte +4 -1
- package/src/cell-formatting.ts +31 -0
- package/src/core.sort.test.ts +86 -0
- package/src/core.ts +114 -21
- package/src/export-dates.test.ts +114 -0
- package/src/export-format.ts +26 -14
- package/src/headless.reactivity.svelte.test.ts +89 -0
- package/src/headless.ts +1 -1
- package/src/index.ts +1 -1
- package/src/keyboard-handlers.ts +5 -1
- package/src/selection.test.ts +37 -0
- package/src/selection.ts +16 -6
- package/src/{subscribe.test.ts → subscribe.svelte.test.ts} +1 -1
- package/src/{subscribe.ts → subscribe.svelte.ts} +5 -1
- package/src/svgrid.behavior.test.ts +29 -0
- package/src/svgrid.data-push.svelte.test.ts +94 -0
- package/src/svgrid.live-update-paths.svelte.test.ts +4 -2
- package/src/validate.test.ts +16 -0
- package/src/validate.ts +10 -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/dist/cdn/validate-AZoD8BoR.js +0 -80
- /package/dist/{subscribe.d.ts → subscribe.svelte.d.ts} +0 -0
package/src/SvGrid.helpers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"
|
package/src/cell-formatting.ts
CHANGED
|
@@ -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
|
package/src/core.sort.test.ts
CHANGED
|
@@ -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) =>
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
2130
|
-
|
|
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
|
+
})
|
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,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
|