@svgrid/grid 3.0.6 → 3.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SvGrid.controller.svelte.d.ts +2 -0
- package/dist/SvGrid.controller.svelte.js +30 -4
- package/dist/SvGrid.css +16 -5
- package/dist/SvGrid.helpers.js +2 -4
- package/dist/cdn/{GridMenus-C-NtjlrJ.js → GridMenus-BgUS-W3o.js} +2 -2
- package/dist/cdn/{GridMenus-TssHej3q.js → GridMenus-CcwU8I4t.js} +2 -2
- package/dist/cdn/{SvGridCellEditor-BlyCSp0q.js → SvGridCellEditor-9yz-SbdZ.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-ClRRA9lW.js → SvGridCellEditor-D7-ql1Hd.js} +1 -1
- package/dist/cdn/{SvGridChart-DsmwTyZF.js → SvGridChart-DMUOFxOe.js} +3 -3
- package/dist/cdn/{SvGridChart-B4EV8U6t.js → SvGridChart-N7ik0oWy.js} +3 -3
- package/dist/cdn/{SvGridChartBuilder-cHloHobN.js → SvGridChartBuilder-CB_Jnm2k.js} +3 -3
- package/dist/cdn/{SvGridChartBuilder-vOl7C0Za.js → SvGridChartBuilder-Ca7gF8ZK.js} +3 -3
- package/dist/cdn/{SvGridChartPanel-AcqF-Ll5.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
- package/dist/cdn/{SvGridChartPanel-biMtSBPA.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
- package/dist/cdn/{SvGridChartView-I3rDHXAq.js → SvGridChartView-B1LFgoPP.js} +2 -2
- package/dist/cdn/{SvGridChartView-Dx_GmZuL.js → SvGridChartView-By3n7DGZ.js} +2 -2
- package/dist/cdn/{cell-formatting-DVVKOSI9.js → cell-formatting-Mnv84zoE.js} +27 -5
- package/dist/cdn/{chart-DEZxwC0i.js → chart-By7h2Guv.js} +1 -1
- package/dist/cdn/{chart-panel-messages-D12XcTZJ.js → chart-panel-messages-BC-tOgFY.js} +1 -1
- package/dist/cdn/{chart-panel-messages-DguS7gC5.js → chart-panel-messages-DUaXr-sb.js} +1 -1
- package/dist/cdn/{chart-summary-CMAXBGY-.js → chart-summary-BDtrNEiA.js} +1 -1
- package/dist/cdn/{chart-trend-BkEp61Gj.js → chart-trend-C1aOAPBZ.js} +1 -1
- package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
- package/dist/cdn/{export-format-YzdwBww2.js → export-format-y1wdyCcv.js} +93 -97
- package/dist/cdn/{src-CgoeSM8k.js → src-25JmJ_7g.js} +3244 -3191
- package/dist/cdn/{src-DfwxB1TH.js → src-kE9B98r6.js} +3172 -3119
- package/dist/cdn/svgrid.js +11 -11
- package/dist/cdn/svgrid.svelte-external.js +11 -11
- package/dist/cell-formatting.d.ts +9 -0
- package/dist/cell-formatting.js +34 -0
- package/dist/core.js +117 -19
- package/dist/export-format.d.ts +8 -0
- package/dist/export-format.js +23 -16
- package/dist/virtualization/column-virtualizer.d.ts +1 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
- package/dist/virtualization/virtualizer.d.ts +3 -0
- package/dist/virtualization/virtualizer.js +14 -1
- package/package.json +1 -1
- package/src/SvGrid.controller.svelte.ts +30 -4
- package/src/SvGrid.css +16 -5
- package/src/SvGrid.helpers.ts +2 -4
- package/src/cell-formatting.ts +31 -0
- package/src/core.sort.test.ts +86 -0
- package/src/core.ts +109 -19
- package/src/export-dates.test.ts +114 -0
- package/src/export-format.ts +26 -14
- package/src/svgrid.data-push.svelte.test.ts +94 -0
- package/src/svgrid.live-update-paths.svelte.test.ts +4 -2
- package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
- package/src/virtualization/virtualizer.test.ts +44 -0
- package/src/virtualization/virtualizer.ts +13 -1
package/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) =>
|
|
134
|
-
number: (a, b) =>
|
|
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) =>
|
|
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) =>
|
|
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(
|
|
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 =
|
|
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) =>
|
|
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) =>
|
|
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
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
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.
|
package/dist/export-format.d.ts
CHANGED
|
@@ -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
|
package/dist/export-format.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
200
|
+
? toLocalIsoString(value)
|
|
194
201
|
: String(value);
|
|
195
202
|
if (s === '')
|
|
196
203
|
return s;
|
|
@@ -231,7 +238,7 @@ const HTML_ESCAPE = {
|
|
|
231
238
|
"'": ''',
|
|
232
239
|
};
|
|
233
240
|
function htmlCell(value) {
|
|
234
|
-
const s = value == null ? '' : value instanceof Date ? 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
|
|
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
|
"'": ''',
|
|
352
359
|
};
|
|
353
360
|
function xmlText(value) {
|
|
354
|
-
const s = value == null ? '' : value instanceof Date ? 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1403
|
-
|
|
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
|
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/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 {
|