@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/dist/core.js CHANGED
@@ -130,14 +130,54 @@ export function tableFeatures(features) {
130
130
  * text; set a column's type or supply your own comparator to override.
131
131
  */
132
132
  export const sortFns = {
133
- auto: (a, b) => String(a).localeCompare(String(b)),
134
- number: (a, b) => Number(a ?? 0) - Number(b ?? 0),
135
- date: (a, b) => {
136
- const aa = new Date(a).getTime();
137
- const bb = new Date(b).getTime();
138
- return aa - bb;
139
- },
133
+ auto: (a, b) => compareAutoValues(a, b),
134
+ number: (a, b) => compareNumericKeys(numberSortKey(a), numberSortKey(b)),
135
+ date: (a, b) => compareNumericKeys(dateSortKey(a), dateSortKey(b)),
140
136
  };
137
+ /** Null, undefined and the empty string: the values every built-in sort puts first. */
138
+ function isBlankSortValue(value) {
139
+ return value == null || value === '';
140
+ }
141
+ /**
142
+ * Sort key for the `number` comparator. Blanks and anything that does not
143
+ * coerce to a number ('n/a', a placeholder) become -Infinity, so they sort
144
+ * together ahead of every number instead of producing NaN. A NaN key made the
145
+ * comparator inconsistent - NaN compared equal to everything while the numbers
146
+ * around it did not - and `Array.prototype.sort` then returned a visibly
147
+ * unsorted column (#104).
148
+ */
149
+ function numberSortKey(value) {
150
+ if (isBlankSortValue(value))
151
+ return -Infinity;
152
+ const n = Number(value);
153
+ return Number.isNaN(n) ? -Infinity : n;
154
+ }
155
+ /** Sort key for the `date` comparator; blanks and invalid dates sort first. */
156
+ function dateSortKey(value) {
157
+ if (isBlankSortValue(value))
158
+ return -Infinity;
159
+ const t = new Date(value).getTime();
160
+ return Number.isNaN(t) ? -Infinity : t;
161
+ }
162
+ /**
163
+ * The `auto` comparator: blanks first, then numbers in numeric order, then
164
+ * everything else as collated text. Ordering by kind before value keeps it
165
+ * transitive on mixed columns - comparing a number to a string as text
166
+ * instead would give 2 < 10, 10 < '1a' and '1a' < 2, a cycle.
167
+ */
168
+ function compareAutoValues(a, b) {
169
+ const aBlank = isBlankSortValue(a);
170
+ const bBlank = isBlankSortValue(b);
171
+ if (aBlank || bBlank)
172
+ return aBlank === bBlank ? 0 : aBlank ? -1 : 1;
173
+ const aNum = typeof a === 'number';
174
+ const bNum = typeof b === 'number';
175
+ if (aNum && bNum)
176
+ return compareNumericKeys(numberSortKey(a), numberSortKey(b));
177
+ if (aNum !== bNum)
178
+ return aNum ? -1 : 1;
179
+ return compareCollatedKeys(String(a), String(b));
180
+ }
141
181
  /**
142
182
  * Built-in match functions, named by {@link ColumnFilter}'s `fn`.
143
183
  * `includesString` is case-insensitive substring; `equals` is strict identity.
@@ -736,18 +776,42 @@ export function createSortedRowModel(localSortFns = sortFns) {
736
776
  let keys = new Array(n);
737
777
  let compare;
738
778
  let keyOf;
779
+ let autoMode;
739
780
  if (comparator === sortFns.number) {
740
- keyOf = (row) => Number(row.getCellValueByColumnId(columnId) ?? 0);
781
+ keyOf = (row) => numberSortKey(row.getCellValueByColumnId(columnId));
741
782
  for (let i = 0; i < n; i++)
742
783
  keys[i] = keyOf(rows[i]);
743
784
  compare = compareNumericKeys;
744
785
  }
745
786
  else if (comparator === sortFns.date) {
746
- keyOf = (row) => new Date(row.getCellValueByColumnId(columnId)).getTime();
787
+ keyOf = (row) => dateSortKey(row.getCellValueByColumnId(columnId));
788
+ for (let i = 0; i < n; i++)
789
+ keys[i] = keyOf(rows[i]);
790
+ compare = compareNumericKeys;
791
+ }
792
+ else if (comparator === sortFns.auto && (autoMode = autoSortMode(rows, columnId)) === 'number') {
793
+ // A column with no declared type whose values are all numbers. Sorting
794
+ // it as text put 10 before 2 (#104).
795
+ keyOf = (row) => {
796
+ const value = row.getCellValueByColumnId(columnId);
797
+ // A text value arriving in a tick changes the mode; only a full sort
798
+ // can place it.
799
+ return typeof value === 'number' || isBlankSortValue(value)
800
+ ? numberSortKey(value)
801
+ : MISSING_KEY;
802
+ };
747
803
  for (let i = 0; i < n; i++)
748
804
  keys[i] = keyOf(rows[i]);
749
805
  compare = compareNumericKeys;
750
806
  }
807
+ else if (autoMode === 'mixed') {
808
+ // Numbers and text in one column. Rare, so no key tricks: the raw
809
+ // values through the pairwise comparator, which orders by kind first.
810
+ keyOf = (row) => row.getCellValueByColumnId(columnId);
811
+ for (let i = 0; i < n; i++)
812
+ keys[i] = keyOf(rows[i]);
813
+ compare = compareAutoValues;
814
+ }
751
815
  else if (comparator === sortFns.auto) {
752
816
  const strings = new Array(n);
753
817
  // Decide whether ranking is worth attempting BEFORE paying for it.
@@ -770,7 +834,7 @@ export function createSortedRowModel(localSortFns = sortFns) {
770
834
  const sample = new Set();
771
835
  let sampled = 0;
772
836
  for (let i = 0; i < n; i += stride) {
773
- sample.add(String(rows[i].getCellValueByColumnId(columnId)));
837
+ sample.add(autoTextKey(rows[i].getCellValueByColumnId(columnId)));
774
838
  sampled++;
775
839
  }
776
840
  // Only attempt ranking when the sample suggests real repetition.
@@ -778,7 +842,7 @@ export function createSortedRowModel(localSortFns = sortFns) {
778
842
  distinct = new Set();
779
843
  }
780
844
  for (let i = 0; i < n; i++) {
781
- const s = String(rows[i].getCellValueByColumnId(columnId));
845
+ const s = autoTextKey(rows[i].getCellValueByColumnId(columnId));
782
846
  strings[i] = s;
783
847
  if (distinct) {
784
848
  distinct.add(s);
@@ -809,12 +873,21 @@ export function createSortedRowModel(localSortFns = sortFns) {
809
873
  compare = compareNumericKeys;
810
874
  // A replacement whose text is not among the ranked values has no
811
875
  // rank; the repair path treats that as "sort everything again".
812
- keyOf = (row) => rankOf.get(String(row.getCellValueByColumnId(columnId))) ?? MISSING_KEY;
876
+ keyOf = (row) => {
877
+ const value = row.getCellValueByColumnId(columnId);
878
+ // A number arriving in a tick makes the column mixed; resort fully.
879
+ if (typeof value === 'number')
880
+ return MISSING_KEY;
881
+ return rankOf.get(autoTextKey(value)) ?? MISSING_KEY;
882
+ };
813
883
  }
814
884
  else {
815
885
  keys = strings;
816
886
  compare = compareCollatedKeys;
817
- keyOf = (row) => String(row.getCellValueByColumnId(columnId));
887
+ keyOf = (row) => {
888
+ const value = row.getCellValueByColumnId(columnId);
889
+ return typeof value === 'number' ? MISSING_KEY : autoTextKey(value);
890
+ };
818
891
  }
819
892
  }
820
893
  else {
@@ -1037,14 +1110,39 @@ export function createSortedRowModel(localSortFns = sortFns) {
1037
1110
  };
1038
1111
  }
1039
1112
  /**
1040
- * Numeric key comparison for the built-in `number` and `date` comparators.
1041
- * Subtraction rather than `<`/`>` on purpose: it reproduces the originals
1042
- * exactly, NaN included. An unparseable date or a non-numeric value yields NaN,
1043
- * and the sort spec turns a NaN comparison result into 0 (SortCompare coerces
1044
- * it), which is the behaviour callers already depend on.
1113
+ * Numeric key comparison for the built-in `number`, `date` and numeric
1114
+ * `auto` comparators. Keys are never NaN (blanks and unparseable values map to
1115
+ * -Infinity), but two -Infinity keys subtract to NaN, so equal keys return 0
1116
+ * explicitly: the multi-clause sort reads any non-zero result, NaN included,
1117
+ * as decided and would skip the next clause.
1045
1118
  */
1046
1119
  function compareNumericKeys(a, b) {
1047
- return a - b;
1120
+ return a === b ? 0 : a - b;
1121
+ }
1122
+ /** Text key for the `auto` comparator: blanks become '' so they collate first. */
1123
+ function autoTextKey(value) {
1124
+ return isBlankSortValue(value) ? '' : String(value);
1125
+ }
1126
+ /**
1127
+ * How the `auto` comparator should treat a column: 'number' when every
1128
+ * non-blank value is a number, 'mixed' when numbers share it with anything
1129
+ * else, 'text' otherwise. One pass, stopping as soon as the answer is known.
1130
+ */
1131
+ function autoSortMode(rows, columnId) {
1132
+ let numbers = false;
1133
+ let other = false;
1134
+ for (let i = 0; i < rows.length; i++) {
1135
+ const value = rows[i].getCellValueByColumnId(columnId);
1136
+ if (isBlankSortValue(value))
1137
+ continue;
1138
+ if (typeof value === 'number')
1139
+ numbers = true;
1140
+ else
1141
+ other = true;
1142
+ if (numbers && other)
1143
+ return 'mixed';
1144
+ }
1145
+ return numbers ? 'number' : 'text';
1048
1146
  }
1049
1147
  /**
1050
1148
  * Text key comparison for the built-in `auto` comparator.
@@ -21,6 +21,14 @@ export { computeColumnStat, contrastText, formatsNeedingStats, resolveCellFormat
21
21
  /** Best-effort coercion of a cell value to a Date. Accepts Date, epoch ms
22
22
  * numbers, and parseable date strings. Returns null when it isn't a date. */
23
23
  export declare function coerceExportDate(value: unknown): Date | null;
24
+ /**
25
+ * A Date as ISO 8601 in LOCAL time with its offset, e.g.
26
+ * `2024-01-15T00:00:00.000+02:00`. Text exports used `toISOString()`, which
27
+ * is UTC: local midnight on the 15th came out as `2024-01-14T22:00:00.000Z`
28
+ * east of UTC, and anything reading the date part got the wrong day (#103).
29
+ * Same instant, but the date part is the one the grid showed.
30
+ */
31
+ export declare function toLocalIsoString(d: Date): string;
24
32
  /**
25
33
  * Format a raw cell value to its on-screen display string using the column's
26
34
  * `format` config. Mirrors the grid's own numeric / currency / percent / date
@@ -1,4 +1,4 @@
1
- import { formatNumericWithConfig, getDateFormatter, resolveDatePattern, } from './cell-formatting.js';
1
+ import { formatNumericWithConfig, getDateFormatter, parseDateValue, resolveDatePattern, } from './cell-formatting.js';
2
2
  // Re-export the pure conditional-formatting engine here too, so the
3
3
  // Svelte-free `@svgrid/grid/format` subpath carries it for @svgrid/enterprise's
4
4
  // export code (which must not pull in SvGrid.svelte).
@@ -6,17 +6,24 @@ export { computeColumnStat, contrastText, formatsNeedingStats, resolveCellFormat
6
6
  /** Best-effort coercion of a cell value to a Date. Accepts Date, epoch ms
7
7
  * numbers, and parseable date strings. Returns null when it isn't a date. */
8
8
  export function coerceExportDate(value) {
9
- if (value instanceof Date)
10
- return Number.isNaN(value.getTime()) ? null : value;
11
- if (typeof value === 'number' && Number.isFinite(value)) {
12
- const d = new Date(value);
13
- return Number.isNaN(d.getTime()) ? null : d;
14
- }
15
- if (typeof value === 'string' && value.trim() !== '') {
16
- const d = new Date(value);
17
- return Number.isNaN(d.getTime()) ? null : d;
18
- }
19
- return null;
9
+ return parseDateValue(value);
10
+ }
11
+ /**
12
+ * A Date as ISO 8601 in LOCAL time with its offset, e.g.
13
+ * `2024-01-15T00:00:00.000+02:00`. Text exports used `toISOString()`, which
14
+ * is UTC: local midnight on the 15th came out as `2024-01-14T22:00:00.000Z`
15
+ * east of UTC, and anything reading the date part got the wrong day (#103).
16
+ * Same instant, but the date part is the one the grid showed.
17
+ */
18
+ export function toLocalIsoString(d) {
19
+ const pad = (n, w = 2) => String(Math.abs(n)).padStart(w, '0');
20
+ const offset = -d.getTimezoneOffset();
21
+ const sign = offset < 0 ? '-' : '+';
22
+ const year = d.getFullYear();
23
+ return ((year < 0 ? '-' + pad(year, 6) : pad(year, 4)) +
24
+ `-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
25
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` +
26
+ `${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`);
20
27
  }
21
28
  /**
22
29
  * Format a raw cell value to its on-screen display string using the column's
@@ -190,7 +197,7 @@ function csvCell(value, delimiter) {
190
197
  const s = value == null
191
198
  ? ''
192
199
  : value instanceof Date
193
- ? value.toISOString()
200
+ ? toLocalIsoString(value)
194
201
  : String(value);
195
202
  if (s === '')
196
203
  return s;
@@ -231,7 +238,7 @@ const HTML_ESCAPE = {
231
238
  "'": '&#39;',
232
239
  };
233
240
  function htmlCell(value) {
234
- const s = value == null ? '' : value instanceof Date ? value.toISOString() : String(value);
241
+ const s = value == null ? '' : value instanceof Date ? toLocalIsoString(value) : String(value);
235
242
  return s.replace(/[&<>"']/g, (c) => HTML_ESCAPE[c]);
236
243
  }
237
244
  /**
@@ -311,7 +318,7 @@ export async function serializeJson(rows, fields, opts = {}) {
311
318
  }
312
319
  /** Escape a Markdown table cell: pipes and newlines would break the row. */
313
320
  function mdCell(value) {
314
- const s = value == null ? '' : value instanceof Date ? value.toISOString() : String(value);
321
+ const s = value == null ? '' : value instanceof Date ? toLocalIsoString(value) : String(value);
315
322
  return s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
316
323
  }
317
324
  /**
@@ -351,7 +358,7 @@ const XML_ESCAPE = {
351
358
  "'": '&apos;',
352
359
  };
353
360
  function xmlText(value) {
354
- const s = value == null ? '' : value instanceof Date ? value.toISOString() : String(value);
361
+ const s = value == null ? '' : value instanceof Date ? toLocalIsoString(value) : String(value);
355
362
  // Stripping control characters is the point: XML 1.0 forbids them outright,
356
363
  // so a stray \x00 from a data source would produce an unopenable file.
357
364
  // eslint-disable-next-line no-control-regex
@@ -10,6 +10,7 @@ export declare function createColumnVirtualizer(input: {
10
10
  setViewportWidth(viewportWidth: number): void;
11
11
  setHorizontalOffset(scrollOffset: number): void;
12
12
  setOptions(next: Partial<import("./types.js").VirtualizerOptions>): void;
13
+ measure(): void;
13
14
  setScrollOffset(scrollOffset: number): void;
14
15
  setViewportHeight(viewportHeight: number): void;
15
16
  scrollToIndex(index: number): void;
@@ -2,6 +2,7 @@ import type { VirtualizerOptions } from './types.js';
2
2
  export declare function createSvelteVirtualizer(options: VirtualizerOptions): {
3
3
  readonly version: number;
4
4
  setOptions: (next: Partial<VirtualizerOptions>) => void;
5
+ measure: () => void;
5
6
  setScrollOffset: (scrollOffset: number) => void;
6
7
  setViewportHeight: (viewportHeight: number) => void;
7
8
  scrollToIndex: (index: number) => void;
@@ -10,6 +10,7 @@ export function createSvelteVirtualizer(options) {
10
10
  return version;
11
11
  },
12
12
  setOptions: virtualizer.setOptions,
13
+ measure: virtualizer.measure,
13
14
  setScrollOffset: virtualizer.setScrollOffset,
14
15
  setViewportHeight: virtualizer.setViewportHeight,
15
16
  scrollToIndex: virtualizer.scrollToIndex,
@@ -16,6 +16,9 @@ type VirtualizerListener = () => void;
16
16
  export declare function buildPreMeasureItems(count: number, estimateSize: number, viewportHeight: number, overscan: number): Array<VirtualItem>;
17
17
  export declare function createVirtualizer(initial: VirtualizerOptions): {
18
18
  setOptions(next: Partial<VirtualizerOptions>): void;
19
+ /** Drop the cached offsets and re-read every size from `estimateSize`.
20
+ * Call after the sizes a stable size function reports have changed. */
21
+ measure(): void;
19
22
  setScrollOffset(scrollOffset: number): void;
20
23
  setViewportHeight(viewportHeight: number): void;
21
24
  scrollToIndex(index: number): void;
@@ -210,9 +210,22 @@ export function createVirtualizer(initial) {
210
210
  return {
211
211
  setOptions(next) {
212
212
  const merged = { ...options, ...next };
213
- if (sameOptions(options, merged))
213
+ // Passing a size function means "sizes may have changed", even when it is
214
+ // the same reference: the documented variable-height pattern is a stable
215
+ // function reading a mutable map, and keying the cache on identity alone
216
+ // left the offsets and total stale after a row grew (#102).
217
+ const remeasure = typeof next.estimateSize === 'function';
218
+ if (!remeasure && sameOptions(options, merged))
214
219
  return;
215
220
  options = merged;
221
+ if (remeasure)
222
+ offsetCache = null;
223
+ recalc();
224
+ },
225
+ /** Drop the cached offsets and re-read every size from `estimateSize`.
226
+ * Call after the sizes a stable size function reports have changed. */
227
+ measure() {
228
+ offsetCache = null;
216
229
  recalc();
217
230
  },
218
231
  setScrollOffset(scrollOffset) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svgrid/grid",
3
- "version": "3.0.6",
3
+ "version": "3.0.7",
4
4
  "description": "Svelte 5-native data grid and data table. Headless-first engine with virtual scrolling for 100k+ rows, Excel-style filtering, inline editing, grouping, tree data, pivot, and server-side data. Drop-in <SvGrid> component. TypeScript, SSR-ready.",
5
5
  "author": "jQWidgets <boikom@jqwidgets.com>",
6
6
  "license": "MIT",
@@ -845,7 +845,18 @@ export function createSvGridController<
845
845
  // `data` is optional on the type (a `rowModel` may supply the rows
846
846
  // instead), so every read falls back to empty rather than to undefined.
847
847
  const EMPTY_DATA: ReadonlyArray<TData> = [];
848
- let internalData = $state.raw<ReadonlyArray<TData>>(props.data ?? EMPTY_DATA);
848
+ // Seeded as a copy, for the same reason the sync effect below makes one: the
849
+ // row model tells data apart by array identity, so the grid has to own an
850
+ // array the consumer cannot grow behind its back.
851
+ let internalData = $state.raw<ReadonlyArray<TData>>(
852
+ props.data?.length ? props.data.slice() : EMPTY_DATA,
853
+ );
854
+ // What the last sync saw, so the effect can tell a replacement (drop the
855
+ // cell edits) from a push or splice (keep them) and skip a no-op run.
856
+ // svelte-ignore state_referenced_locally
857
+ let lastData: ReadonlyArray<TData> = props.data ?? EMPTY_DATA;
858
+ // svelte-ignore state_referenced_locally
859
+ let lastDataLength = props.data?.length ?? 0;
849
860
  // Resolve `cellDataType` / `inferColumnTypes` into concrete editorType +
850
861
  // format defaults once, up front, so every downstream reader sees a normal
851
862
  // column. Explicit fields on the ColumnDef always win.
@@ -893,12 +904,27 @@ export function createSvGridController<
893
904
  }
894
905
 
895
906
  $effect(() => {
907
+ const next = props.data ?? EMPTY_DATA;
908
+ // Reading `length` is what subscribes this effect to a `$state` array's own
909
+ // length signal, so `rows.push(...)` and `rows.splice(...)` re-run it.
910
+ // Without this read nothing in the grid tracks the row count at all:
911
+ // `getRowModel` reads `.length` only inside its cache miss, and the miss is
912
+ // keyed on the array reference, which a mutation does not change.
913
+ const length = next.length;
914
+ const replaced = next !== lastData;
915
+ if (!replaced && length === lastDataLength) return;
916
+ lastData = next;
917
+ lastDataLength = length;
918
+ // A fresh array object, because `getRowModel` memoises on identity. The
919
+ // elements are the same objects, so a row that did not move keeps its row
920
+ // object and a field write still reaches its cell through the proxy.
921
+ internalData = length === 0 ? EMPTY_DATA : next.slice();
896
922
  // When the consumer replaces `data` (e.g. a "Reset" button), drop any
897
923
  // accumulated cell-edit overrides - otherwise `getCellDisplayValue`
898
924
  // would keep returning the old edited values from `editedCellValues`
899
- // even though the underlying data has been replaced.
900
- internalData = props.data ?? EMPTY_DATA;
901
- editedCellValues = {};
925
+ // even though the underlying data has been replaced. Growing or shrinking
926
+ // the array is not a reset, so it keeps them.
927
+ if (replaced) editedCellValues = {};
902
928
  });
903
929
  $effect(() => {
904
930
  internalColumns = resolveCols(props.columns);
package/src/SvGrid.css CHANGED
@@ -1387,9 +1387,6 @@ select.sv-grid-fr-editor {
1387
1387
  .sv-grid-cell-active,
1388
1388
  .sv-grid-cell-editing {
1389
1389
  position: relative;
1390
- /* High enough to sit above pinned-column stacking contexts (z=4) so the
1391
- list/chips popover overlays adjacent cells instead of being clipped. */
1392
- z-index: 20;
1393
1390
  /* `box-shadow: inset` is bound to the cell's own box, so the ring
1394
1391
  clips cleanly with the cell when it's partially scrolled out of
1395
1392
  view (and shows up consistently at the right edge of a virtualized
@@ -1399,10 +1396,24 @@ select.sv-grid-fr-editor {
1399
1396
  inconsistent at scroll boundaries. */
1400
1397
  box-shadow: inset 0 0 0 2px var(--sg-accent, #0b63f3);
1401
1398
  outline: none;
1402
- /* Let list/chips popouts extend past the cell box (default cells have
1403
- overflow: hidden so the chips picker would otherwise be clipped). */
1399
+ }
1400
+ /* Only the EDITING cell escapes its own box and outranks the pinned-column
1401
+ stacking contexts (z=4), so its list/chips popout overlays the neighbours
1402
+ instead of being clipped. The merely-ACTIVE cell used to get the same
1403
+ treatment, and on a grid scrolled sideways that made it paint straight over
1404
+ the sticky selection / row-number column: the checkbox disappeared and the
1405
+ cell's text - unclipped, because overflow was visible - spilled across it.
1406
+ A grid seeds its active cell at (0,0), so this hit the FIRST row of any
1407
+ horizontally scrolled grid with `showRowSelection`, untouched by the user. */
1408
+ .sv-grid-cell-editing {
1409
+ z-index: 20;
1404
1410
  overflow: visible;
1405
1411
  }
1412
+ /* Above ordinary cells so the ring is not overdrawn by a neighbour, below the
1413
+ sticky columns (z=4) so those keep covering it as they should. */
1414
+ .sv-grid-cell-active:not(.sv-grid-cell-editing) {
1415
+ z-index: 3;
1416
+ }
1406
1417
  /* The active cell a grid mounts with is seeded at (0,0), not chosen: no
1407
1418
  ring until a click, an arrow key or the api activates a cell (the same
1408
1419
  gate the fill handle has always had). Once activated it stays through a
@@ -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) {
@@ -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 {