@svgrid/grid 2.5.2 → 2.6.2
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/SvCountryInput.svelte +4 -1
- package/dist/SvGrid.controller.svelte.js +22 -10
- package/dist/SvSlider.svelte +4 -1
- package/dist/SvTagsInput.svelte +4 -1
- package/dist/cdn/{GridMenus-oQYElQll.js → GridMenus-BoVKgAv8.js} +8 -8
- package/dist/cdn/{GridMenus-T-bZNprc.js → GridMenus-DL1zrDwf.js} +8 -8
- package/dist/cdn/{src-72QTH8lh.js → src-CEpw6Ato.js} +2977 -2952
- package/dist/cdn/{src-Cm7C_WOZ.js → src-M2NfKnjX.js} +5667 -5642
- package/dist/cdn/svgrid.js +7 -7
- package/dist/cdn/svgrid.svelte-external.js +7 -7
- package/dist/editor-contract.d.ts +6 -0
- package/dist/filtering/excel-filters.d.ts +14 -0
- package/dist/filtering/excel-filters.js +67 -32
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/SvCountryInput.svelte +4 -1
- package/src/SvGrid.controller.svelte.ts +25 -14
- package/src/SvSlider.svelte +4 -1
- package/src/SvTagsInput.svelte +4 -1
- package/src/editor-contract.ts +6 -0
- package/src/filtering/excel-filters.ts +71 -34
- package/src/index.ts +2 -0
- package/src/svgrid.wrapper.test.ts +3 -1
|
@@ -118,60 +118,90 @@ function scanInTokens(value) {
|
|
|
118
118
|
}
|
|
119
119
|
return { tokens, trailing: take() };
|
|
120
120
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Compile a filter once, then test many rows against it.
|
|
123
|
+
*
|
|
124
|
+
* Everything that depends only on the FILTER - folding the needle, splitting
|
|
125
|
+
* `in` tokens, building the regex, coercing range endpoints - happens here,
|
|
126
|
+
* so the per-row closure does the minimum. Filtering 100k rows used to redo
|
|
127
|
+
* all of it 100k times.
|
|
128
|
+
*
|
|
129
|
+
* `applyExcelFilter` is defined in terms of this, so there is exactly one
|
|
130
|
+
* copy of the operator semantics.
|
|
131
|
+
*/
|
|
132
|
+
export function compileExcelFilter(filter, options) {
|
|
133
|
+
const locale = options?.locale;
|
|
134
|
+
const fold = (v) => normalizeForFilter(String(v ?? ''), locale);
|
|
135
|
+
const needle = fold(filter.value);
|
|
125
136
|
switch (filter.operator) {
|
|
126
137
|
case 'contains':
|
|
127
|
-
return
|
|
138
|
+
return (cellValue) => fold(cellValue).includes(needle);
|
|
128
139
|
case 'notContains':
|
|
129
140
|
// An empty needle is no constraint (mirrors `contains` returning true),
|
|
130
141
|
// so nothing is excluded until the user types something.
|
|
131
|
-
|
|
142
|
+
if (needle === '')
|
|
143
|
+
return () => true;
|
|
144
|
+
return (cellValue) => !fold(cellValue).includes(needle);
|
|
132
145
|
case 'equals':
|
|
133
|
-
return
|
|
146
|
+
return (cellValue) => fold(cellValue) === needle;
|
|
134
147
|
case 'notEquals':
|
|
135
|
-
|
|
148
|
+
if (needle === '')
|
|
149
|
+
return () => true;
|
|
150
|
+
return (cellValue) => fold(cellValue) !== needle;
|
|
136
151
|
case 'startsWith':
|
|
137
|
-
return
|
|
152
|
+
return (cellValue) => fold(cellValue).startsWith(needle);
|
|
138
153
|
case 'endsWith':
|
|
139
|
-
return
|
|
154
|
+
return (cellValue) => fold(cellValue).endsWith(needle);
|
|
140
155
|
case 'regex': {
|
|
141
156
|
// Case-insensitive by default (matches the accent/case-folded feel of
|
|
142
157
|
// the other text operators). An invalid pattern matches nothing rather
|
|
143
158
|
// than throwing, so a half-typed regex never crashes the row model.
|
|
159
|
+
// Note this tests the RAW text, not the folded text.
|
|
144
160
|
const pattern = String(filter.value ?? '');
|
|
145
161
|
if (!pattern)
|
|
146
|
-
return true;
|
|
162
|
+
return () => true;
|
|
163
|
+
let re;
|
|
147
164
|
try {
|
|
148
|
-
|
|
165
|
+
re = new RegExp(pattern, 'i');
|
|
149
166
|
}
|
|
150
167
|
catch {
|
|
151
|
-
return false;
|
|
168
|
+
return () => false;
|
|
152
169
|
}
|
|
170
|
+
return (cellValue) => re.test(String(cellValue ?? ''));
|
|
153
171
|
}
|
|
154
172
|
case 'in':
|
|
155
173
|
case 'notIn': {
|
|
156
174
|
const tokens = splitInTokens(filter.value);
|
|
157
175
|
if (tokens.length === 0)
|
|
158
|
-
return true;
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
return () => true;
|
|
177
|
+
// A Set of pre-folded tokens turns the old per-row `tokens.some(...)`
|
|
178
|
+
// (which re-folded every token for every row) into one lookup.
|
|
179
|
+
const wanted = new Set(tokens.map((t) => normalizeForFilter(t, locale)));
|
|
180
|
+
if (filter.operator === 'in')
|
|
181
|
+
return (cellValue) => wanted.has(fold(cellValue));
|
|
182
|
+
return (cellValue) => !wanted.has(fold(cellValue));
|
|
161
183
|
}
|
|
162
184
|
case 'greaterThan': {
|
|
163
|
-
const a = Number(cellValue);
|
|
164
185
|
const b = Number(filter.value);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return
|
|
186
|
+
const bFinite = Number.isFinite(b);
|
|
187
|
+
const bText = String(filter.value ?? '');
|
|
188
|
+
return (cellValue) => {
|
|
189
|
+
const a = Number(cellValue);
|
|
190
|
+
if (bFinite && Number.isFinite(a))
|
|
191
|
+
return a > b;
|
|
192
|
+
return String(cellValue ?? '') > bText;
|
|
193
|
+
};
|
|
168
194
|
}
|
|
169
195
|
case 'lessThan': {
|
|
170
|
-
const a = Number(cellValue);
|
|
171
196
|
const b = Number(filter.value);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return
|
|
197
|
+
const bFinite = Number.isFinite(b);
|
|
198
|
+
const bText = String(filter.value ?? '');
|
|
199
|
+
return (cellValue) => {
|
|
200
|
+
const a = Number(cellValue);
|
|
201
|
+
if (bFinite && Number.isFinite(a))
|
|
202
|
+
return a < b;
|
|
203
|
+
return String(cellValue ?? '') < bText;
|
|
204
|
+
};
|
|
175
205
|
}
|
|
176
206
|
case 'between': {
|
|
177
207
|
// Two paths: numeric (coerce both endpoints with the historical
|
|
@@ -181,21 +211,26 @@ export function applyExcelFilter(cellValue, filter, options) {
|
|
|
181
211
|
// see the historical inclusive-range behaviour.
|
|
182
212
|
const lo = filter.value == null ? 0 : Number(filter.value);
|
|
183
213
|
const hi = filter.valueTo == null ? 0 : Number(filter.valueTo);
|
|
184
|
-
const
|
|
185
|
-
if (Number.isFinite(a) && Number.isFinite(lo) && Number.isFinite(hi)) {
|
|
186
|
-
return a >= lo && a <= hi;
|
|
187
|
-
}
|
|
214
|
+
const rangeFinite = Number.isFinite(lo) && Number.isFinite(hi);
|
|
188
215
|
// Either endpoint is non-numeric (ISO date string, etc). Compare
|
|
189
216
|
// lexicographically - YYYY-MM-DD orders chronologically so the
|
|
190
217
|
// result matches user intent.
|
|
191
|
-
const s = String(cellValue ?? '');
|
|
192
218
|
const sl = String(filter.value ?? '');
|
|
193
219
|
const sh = String(filter.valueTo ?? '');
|
|
194
|
-
return
|
|
220
|
+
return (cellValue) => {
|
|
221
|
+
const a = Number(cellValue ?? 0);
|
|
222
|
+
if (rangeFinite && Number.isFinite(a))
|
|
223
|
+
return a >= lo && a <= hi;
|
|
224
|
+
const s = String(cellValue ?? '');
|
|
225
|
+
return s >= sl && s <= sh;
|
|
226
|
+
};
|
|
195
227
|
}
|
|
196
228
|
case 'isBlank':
|
|
197
|
-
return
|
|
229
|
+
return (cellValue) => String(cellValue ?? '').trim().length === 0;
|
|
198
230
|
case 'isNotBlank':
|
|
199
|
-
return
|
|
231
|
+
return (cellValue) => String(cellValue ?? '').trim().length > 0;
|
|
200
232
|
}
|
|
201
233
|
}
|
|
234
|
+
export function applyExcelFilter(cellValue, filter, options) {
|
|
235
|
+
return compileExcelFilter(filter, options)(cellValue);
|
|
236
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -214,7 +214,7 @@ export { createColumnVirtualizer } from './virtualization/column-virtualizer';
|
|
|
214
214
|
export type { VirtualItem, VirtualizerOptions, VirtualizerState } from './virtualization/types';
|
|
215
215
|
export type { SvGridApi, SvGridFilterOperator, SvGridWrapperProps } from './svgrid-wrapper.types';
|
|
216
216
|
export { parseEditorValue, normalizeEditorOptions, type CellEditorType, type CellEditorOption, } from './editors/cell-editors';
|
|
217
|
-
export { applyExcelFilter, normalizeForFilter, splitInTokens, joinInTokens, trailingInToken, type ExcelFilter, type ExcelFilterOperator, type ExcelFilterOptions, } from './filtering/excel-filters';
|
|
217
|
+
export { applyExcelFilter, compileExcelFilter, normalizeForFilter, splitInTokens, joinInTokens, trailingInToken, type CompiledExcelFilter, type ExcelFilter, type ExcelFilterOperator, type ExcelFilterOptions, } from './filtering/excel-filters';
|
|
218
218
|
export { getGridCellDomId, getGridCellA11yProps, getGridHeaderA11yProps, getGridRootA11yProps, getGridRowA11yProps, type GridCellA11yInput, type GridColumnA11yInput, type GridSortDirection, } from './a11y';
|
|
219
219
|
/**
|
|
220
220
|
* Compatibility alias for teams migrating from `createTable`.
|
package/dist/index.js
CHANGED
|
@@ -249,7 +249,7 @@ export { createVirtualizer } from './virtualization/virtualizer';
|
|
|
249
249
|
export { createSvelteVirtualizer } from './virtualization/svelte-virtualizer.svelte';
|
|
250
250
|
export { createColumnVirtualizer } from './virtualization/column-virtualizer';
|
|
251
251
|
export { parseEditorValue, normalizeEditorOptions, } from './editors/cell-editors';
|
|
252
|
-
export { applyExcelFilter, normalizeForFilter, splitInTokens, joinInTokens, trailingInToken, } from './filtering/excel-filters';
|
|
252
|
+
export { applyExcelFilter, compileExcelFilter, normalizeForFilter, splitInTokens, joinInTokens, trailingInToken, } from './filtering/excel-filters';
|
|
253
253
|
export { getGridCellDomId, getGridCellA11yProps, getGridHeaderA11yProps, getGridRootA11yProps, getGridRowA11yProps, } from './a11y';
|
|
254
254
|
/**
|
|
255
255
|
* Compatibility alias for teams migrating from `createTable`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svgrid/grid",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.2",
|
|
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",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
name,
|
|
38
38
|
size = 'md',
|
|
39
39
|
ariaLabel,
|
|
40
|
+
block = false,
|
|
40
41
|
invalid = false,
|
|
41
42
|
required = false,
|
|
42
43
|
error,
|
|
@@ -93,7 +94,7 @@
|
|
|
93
94
|
</script>
|
|
94
95
|
|
|
95
96
|
<SvField id={uid} {label} {hint} {error} {required} {dir}>
|
|
96
|
-
<button bind:this={triggerEl} class="sv-country sv-country--{size}" class:is-open={ci.open} class:is-disabled={disabled} class:is-invalid={invalid} {...ci.triggerProps()}>
|
|
97
|
+
<button bind:this={triggerEl} class="sv-country sv-country--{size}" class:is-block={block} class:is-open={ci.open} class:is-disabled={disabled} class:is-invalid={invalid} {...ci.triggerProps()}>
|
|
97
98
|
{#if selected}
|
|
98
99
|
<span class="sv-country__flag" aria-hidden="true">{flagEmoji(selected.code)}</span>
|
|
99
100
|
<span class="sv-country__name">{selected.name}</span>
|
|
@@ -157,4 +158,6 @@
|
|
|
157
158
|
:global(.sv-country__opt) { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
|
158
159
|
:global(.sv-country__opt.is-active) { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
159
160
|
:global(.sv-country__opt.is-selected) { color: var(--sg-accent, #2563eb); font-weight: 600; }
|
|
161
|
+
/* Fill the container - see `block` in SvEditorProps. */
|
|
162
|
+
.sv-country.is-block { width: 100%; max-width: 100%; }
|
|
160
163
|
</style>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
applyExcelFilter,
|
|
3
2
|
applyGroupAggregate,
|
|
3
|
+
compileExcelFilter,
|
|
4
|
+
type CompiledExcelFilter,
|
|
4
5
|
normalizeForFilter,
|
|
5
6
|
createColumnVirtualizer,
|
|
6
7
|
createCoreRowModel,
|
|
@@ -1270,15 +1271,17 @@ export function createSvGridController<
|
|
|
1270
1271
|
if (op === "between") return value.trim().length > 0 && (valueTo ?? "").trim().length > 0;
|
|
1271
1272
|
return value.trim().length > 0;
|
|
1272
1273
|
};
|
|
1273
|
-
|
|
1274
|
-
|
|
1274
|
+
// Compile a condition ONCE per filter change rather than once per row.
|
|
1275
|
+
// Folding the needle, splitting `in` tokens and building a regex all
|
|
1276
|
+
// depend only on the filter, so over 100k rows this used to be 100k
|
|
1277
|
+
// redundant passes.
|
|
1278
|
+
const compileCond = (
|
|
1275
1279
|
columnId: string,
|
|
1276
1280
|
op: FilterOperator,
|
|
1277
1281
|
value: string,
|
|
1278
1282
|
valueTo?: string,
|
|
1279
|
-
):
|
|
1280
|
-
|
|
1281
|
-
cellValue,
|
|
1283
|
+
): CompiledExcelFilter =>
|
|
1284
|
+
compileExcelFilter(
|
|
1282
1285
|
{ id: columnId, operator: op, value, valueTo: op === "between" ? valueTo : undefined },
|
|
1283
1286
|
{ locale: (props.filterLocale ?? props.localization?.locale) },
|
|
1284
1287
|
);
|
|
@@ -1289,18 +1292,26 @@ export function createSvGridController<
|
|
|
1289
1292
|
return a || b;
|
|
1290
1293
|
});
|
|
1291
1294
|
if (menuFilters.length) {
|
|
1295
|
+
// Hoisted out of the row loop: each column's (up to two) conditions
|
|
1296
|
+
// become compiled predicates before a single row is tested.
|
|
1297
|
+
const compiledMenuFilters = menuFilters.map(([columnId, f]) => ({
|
|
1298
|
+
columnId,
|
|
1299
|
+
join: f.join,
|
|
1300
|
+
a: condActive(f.operator, f.value, f.valueTo)
|
|
1301
|
+
? compileCond(columnId, f.operator, f.value, f.valueTo)
|
|
1302
|
+
: null,
|
|
1303
|
+
b: !!f.operator2 && condActive(f.operator2, f.value2 ?? "", f.valueTo2)
|
|
1304
|
+
? compileCond(columnId, f.operator2 as FilterOperator, f.value2 ?? "", f.valueTo2)
|
|
1305
|
+
: null,
|
|
1306
|
+
}));
|
|
1292
1307
|
rows = rows.filter((row) =>
|
|
1293
|
-
|
|
1308
|
+
compiledMenuFilters.every(({ columnId, join, a, b }) => {
|
|
1294
1309
|
const cellValue = getRowColumnValue(row, columnId);
|
|
1295
|
-
const
|
|
1296
|
-
const
|
|
1297
|
-
const ra = aActive ? evalCond(cellValue, columnId, f.operator, f.value, f.valueTo) : null;
|
|
1298
|
-
const rb = bActive
|
|
1299
|
-
? evalCond(cellValue, columnId, f.operator2 as FilterOperator, f.value2 ?? "", f.valueTo2)
|
|
1300
|
-
: null;
|
|
1310
|
+
const ra = a ? a(cellValue) : null;
|
|
1311
|
+
const rb = b ? b(cellValue) : null;
|
|
1301
1312
|
if (ra === null) return rb ?? true;
|
|
1302
1313
|
if (rb === null) return ra;
|
|
1303
|
-
return
|
|
1314
|
+
return join === "OR" ? ra || rb : ra && rb;
|
|
1304
1315
|
}),
|
|
1305
1316
|
);
|
|
1306
1317
|
}
|
package/src/SvSlider.svelte
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
orientation = 'horizontal',
|
|
45
45
|
name,
|
|
46
46
|
ariaLabel,
|
|
47
|
+
block = false,
|
|
47
48
|
invalid = false,
|
|
48
49
|
required = false,
|
|
49
50
|
error,
|
|
@@ -109,7 +110,7 @@
|
|
|
109
110
|
|
|
110
111
|
<SvField id={uid} {label} {hint} {error} {required} {dir}>
|
|
111
112
|
<div
|
|
112
|
-
class="sv-slider sv-slider--{orientation} sv-slider--{size}"
|
|
113
|
+
class="sv-slider sv-slider--{orientation} sv-slider--{size}" class:is-block={block}
|
|
113
114
|
class:is-disabled={disabled}
|
|
114
115
|
class:is-readonly={readonly}
|
|
115
116
|
dir={resolvedDir}
|
|
@@ -197,4 +198,6 @@
|
|
|
197
198
|
.sv-slider__tick { position: absolute; width: 2px; height: 2px; border-radius: 50%; background: var(--sg-muted, #94a3b8); }
|
|
198
199
|
.sv-slider--horizontal .sv-slider__tick { inset-inline-start: var(--p); top: 50%; transform: translate(-50%, -50%); }
|
|
199
200
|
.sv-slider--vertical .sv-slider__tick { bottom: var(--p); left: 50%; transform: translate(-50%, 50%); }
|
|
201
|
+
/* Fill the container - see `block` in SvEditorProps. */
|
|
202
|
+
.sv-slider--horizontal.is-block { width: 100%; max-width: 100%; }
|
|
200
203
|
</style>
|
package/src/SvTagsInput.svelte
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
size = 'md',
|
|
37
37
|
name,
|
|
38
38
|
ariaLabel,
|
|
39
|
+
block = false,
|
|
39
40
|
invalid = false,
|
|
40
41
|
required = false,
|
|
41
42
|
error,
|
|
@@ -69,7 +70,7 @@
|
|
|
69
70
|
</script>
|
|
70
71
|
|
|
71
72
|
<SvField id={uid} {label} {hint} {error} {required} {dir}>
|
|
72
|
-
<div class="sv-tags sv-tags--{size}" class:is-disabled={disabled} class:is-invalid={invalid} {...ti.rootProps()}>
|
|
73
|
+
<div class="sv-tags sv-tags--{size}" class:is-block={block} class:is-disabled={disabled} class:is-invalid={invalid} {...ti.rootProps()}>
|
|
73
74
|
{#each value as tag, i (tag + i)}
|
|
74
75
|
<span class="sv-tags__chip" {...ti.tagProps(i)}>
|
|
75
76
|
<span class="sv-tags__label">{tag}</span>
|
|
@@ -109,4 +110,6 @@
|
|
|
109
110
|
.sv-tags__x { background: none; border: 0; color: inherit; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 2px; opacity: 0.7; }
|
|
110
111
|
.sv-tags__x:hover { opacity: 1; }
|
|
111
112
|
.sv-tags__input { flex: 1; min-width: 80px; border: 0; background: none; outline: none; color: inherit; font: inherit; font-size: var(--_fs); height: 24px; }
|
|
113
|
+
/* Fill the container - see `block` in SvEditorProps. */
|
|
114
|
+
.sv-tags.is-block { width: 100%; max-width: 100%; }
|
|
112
115
|
</style>
|
package/src/editor-contract.ts
CHANGED
|
@@ -41,6 +41,12 @@ export type SvEditorProps = {
|
|
|
41
41
|
required?: boolean
|
|
42
42
|
/** Marks the control invalid (`aria-invalid` + error styling). */
|
|
43
43
|
invalid?: boolean
|
|
44
|
+
/**
|
|
45
|
+
* Fill the container instead of the control's own default width. A default
|
|
46
|
+
* width is right for a control sitting on its own; in a form grid a row of
|
|
47
|
+
* inputs each stopping at a different width reads as broken.
|
|
48
|
+
*/
|
|
49
|
+
block?: boolean
|
|
44
50
|
/** Error message; when set it is announced via `aria-describedby` and shown. */
|
|
45
51
|
error?: string
|
|
46
52
|
/** Visible field label, rendered above the control and wired via `for`/`id`. */
|
|
@@ -159,61 +159,89 @@ function scanInTokens(value: unknown): { tokens: string[]; trailing: string } {
|
|
|
159
159
|
return { tokens, trailing: take() }
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
/** A filter with its needle-side work already done. Call per row. */
|
|
163
|
+
export type CompiledExcelFilter = (cellValue: unknown) => boolean
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Compile a filter once, then test many rows against it.
|
|
167
|
+
*
|
|
168
|
+
* Everything that depends only on the FILTER - folding the needle, splitting
|
|
169
|
+
* `in` tokens, building the regex, coercing range endpoints - happens here,
|
|
170
|
+
* so the per-row closure does the minimum. Filtering 100k rows used to redo
|
|
171
|
+
* all of it 100k times.
|
|
172
|
+
*
|
|
173
|
+
* `applyExcelFilter` is defined in terms of this, so there is exactly one
|
|
174
|
+
* copy of the operator semantics.
|
|
175
|
+
*/
|
|
176
|
+
export function compileExcelFilter(
|
|
164
177
|
filter: ExcelFilter,
|
|
165
178
|
options?: ExcelFilterOptions,
|
|
166
|
-
) {
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
const
|
|
179
|
+
): CompiledExcelFilter {
|
|
180
|
+
const locale = options?.locale
|
|
181
|
+
const fold = (v: unknown) => normalizeForFilter(String(v ?? ''), locale)
|
|
182
|
+
const needle = fold(filter.value)
|
|
183
|
+
|
|
170
184
|
switch (filter.operator) {
|
|
171
185
|
case 'contains':
|
|
172
|
-
return
|
|
186
|
+
return (cellValue) => fold(cellValue).includes(needle)
|
|
173
187
|
case 'notContains':
|
|
174
188
|
// An empty needle is no constraint (mirrors `contains` returning true),
|
|
175
189
|
// so nothing is excluded until the user types something.
|
|
176
|
-
|
|
190
|
+
if (needle === '') return () => true
|
|
191
|
+
return (cellValue) => !fold(cellValue).includes(needle)
|
|
177
192
|
case 'equals':
|
|
178
|
-
return
|
|
193
|
+
return (cellValue) => fold(cellValue) === needle
|
|
179
194
|
case 'notEquals':
|
|
180
|
-
|
|
195
|
+
if (needle === '') return () => true
|
|
196
|
+
return (cellValue) => fold(cellValue) !== needle
|
|
181
197
|
case 'startsWith':
|
|
182
|
-
return
|
|
198
|
+
return (cellValue) => fold(cellValue).startsWith(needle)
|
|
183
199
|
case 'endsWith':
|
|
184
|
-
return
|
|
200
|
+
return (cellValue) => fold(cellValue).endsWith(needle)
|
|
185
201
|
case 'regex': {
|
|
186
202
|
// Case-insensitive by default (matches the accent/case-folded feel of
|
|
187
203
|
// the other text operators). An invalid pattern matches nothing rather
|
|
188
204
|
// than throwing, so a half-typed regex never crashes the row model.
|
|
205
|
+
// Note this tests the RAW text, not the folded text.
|
|
189
206
|
const pattern = String(filter.value ?? '')
|
|
190
|
-
if (!pattern) return true
|
|
207
|
+
if (!pattern) return () => true
|
|
208
|
+
let re: RegExp
|
|
191
209
|
try {
|
|
192
|
-
|
|
210
|
+
re = new RegExp(pattern, 'i')
|
|
193
211
|
} catch {
|
|
194
|
-
return false
|
|
212
|
+
return () => false
|
|
195
213
|
}
|
|
214
|
+
return (cellValue) => re.test(String(cellValue ?? ''))
|
|
196
215
|
}
|
|
197
216
|
case 'in':
|
|
198
217
|
case 'notIn': {
|
|
199
218
|
const tokens = splitInTokens(filter.value)
|
|
200
|
-
if (tokens.length === 0) return true
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
)
|
|
204
|
-
|
|
219
|
+
if (tokens.length === 0) return () => true
|
|
220
|
+
// A Set of pre-folded tokens turns the old per-row `tokens.some(...)`
|
|
221
|
+
// (which re-folded every token for every row) into one lookup.
|
|
222
|
+
const wanted = new Set(tokens.map((t) => normalizeForFilter(t, locale)))
|
|
223
|
+
if (filter.operator === 'in') return (cellValue) => wanted.has(fold(cellValue))
|
|
224
|
+
return (cellValue) => !wanted.has(fold(cellValue))
|
|
205
225
|
}
|
|
206
226
|
case 'greaterThan': {
|
|
207
|
-
const a = Number(cellValue)
|
|
208
227
|
const b = Number(filter.value)
|
|
209
|
-
|
|
210
|
-
|
|
228
|
+
const bFinite = Number.isFinite(b)
|
|
229
|
+
const bText = String(filter.value ?? '')
|
|
230
|
+
return (cellValue) => {
|
|
231
|
+
const a = Number(cellValue)
|
|
232
|
+
if (bFinite && Number.isFinite(a)) return a > b
|
|
233
|
+
return String(cellValue ?? '') > bText
|
|
234
|
+
}
|
|
211
235
|
}
|
|
212
236
|
case 'lessThan': {
|
|
213
|
-
const a = Number(cellValue)
|
|
214
237
|
const b = Number(filter.value)
|
|
215
|
-
|
|
216
|
-
|
|
238
|
+
const bFinite = Number.isFinite(b)
|
|
239
|
+
const bText = String(filter.value ?? '')
|
|
240
|
+
return (cellValue) => {
|
|
241
|
+
const a = Number(cellValue)
|
|
242
|
+
if (bFinite && Number.isFinite(a)) return a < b
|
|
243
|
+
return String(cellValue ?? '') < bText
|
|
244
|
+
}
|
|
217
245
|
}
|
|
218
246
|
case 'between': {
|
|
219
247
|
// Two paths: numeric (coerce both endpoints with the historical
|
|
@@ -223,21 +251,30 @@ export function applyExcelFilter(
|
|
|
223
251
|
// see the historical inclusive-range behaviour.
|
|
224
252
|
const lo = filter.value == null ? 0 : Number(filter.value)
|
|
225
253
|
const hi = filter.valueTo == null ? 0 : Number(filter.valueTo)
|
|
226
|
-
const
|
|
227
|
-
if (Number.isFinite(a) && Number.isFinite(lo) && Number.isFinite(hi)) {
|
|
228
|
-
return a >= lo && a <= hi
|
|
229
|
-
}
|
|
254
|
+
const rangeFinite = Number.isFinite(lo) && Number.isFinite(hi)
|
|
230
255
|
// Either endpoint is non-numeric (ISO date string, etc). Compare
|
|
231
256
|
// lexicographically - YYYY-MM-DD orders chronologically so the
|
|
232
257
|
// result matches user intent.
|
|
233
|
-
const s = String(cellValue ?? '')
|
|
234
258
|
const sl = String(filter.value ?? '')
|
|
235
259
|
const sh = String(filter.valueTo ?? '')
|
|
236
|
-
return
|
|
260
|
+
return (cellValue) => {
|
|
261
|
+
const a = Number(cellValue ?? 0)
|
|
262
|
+
if (rangeFinite && Number.isFinite(a)) return a >= lo && a <= hi
|
|
263
|
+
const s = String(cellValue ?? '')
|
|
264
|
+
return s >= sl && s <= sh
|
|
265
|
+
}
|
|
237
266
|
}
|
|
238
267
|
case 'isBlank':
|
|
239
|
-
return
|
|
268
|
+
return (cellValue) => String(cellValue ?? '').trim().length === 0
|
|
240
269
|
case 'isNotBlank':
|
|
241
|
-
return
|
|
270
|
+
return (cellValue) => String(cellValue ?? '').trim().length > 0
|
|
242
271
|
}
|
|
243
272
|
}
|
|
273
|
+
|
|
274
|
+
export function applyExcelFilter(
|
|
275
|
+
cellValue: unknown,
|
|
276
|
+
filter: ExcelFilter,
|
|
277
|
+
options?: ExcelFilterOptions,
|
|
278
|
+
) {
|
|
279
|
+
return compileExcelFilter(filter, options)(cellValue)
|
|
280
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -699,10 +699,12 @@ export {
|
|
|
699
699
|
} from './editors/cell-editors'
|
|
700
700
|
export {
|
|
701
701
|
applyExcelFilter,
|
|
702
|
+
compileExcelFilter,
|
|
702
703
|
normalizeForFilter,
|
|
703
704
|
splitInTokens,
|
|
704
705
|
joinInTokens,
|
|
705
706
|
trailingInToken,
|
|
707
|
+
type CompiledExcelFilter,
|
|
706
708
|
type ExcelFilter,
|
|
707
709
|
type ExcelFilterOperator,
|
|
708
710
|
type ExcelFilterOptions,
|
|
@@ -55,7 +55,9 @@ describe('SvGrid wrapper', () => {
|
|
|
55
55
|
expect(source).toContain('editorType === "number"')
|
|
56
56
|
expect(source).toContain('editorType === "date"')
|
|
57
57
|
expect(source).toContain('editorType === "checkbox"')
|
|
58
|
-
|
|
58
|
+
// The controller compiles each condition once per filter change rather
|
|
59
|
+
// than calling applyExcelFilter per row; both share one switch body.
|
|
60
|
+
expect(source).toContain('compileExcelFilter')
|
|
59
61
|
expect(source).toContain('showFilterRow')
|
|
60
62
|
})
|
|
61
63
|
})
|