@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.
@@ -118,60 +118,90 @@ function scanInTokens(value) {
118
118
  }
119
119
  return { tokens, trailing: take() };
120
120
  }
121
- export function applyExcelFilter(cellValue, filter, options) {
122
- const text = String(cellValue ?? '');
123
- const normalizedText = normalizeForFilter(text, options?.locale);
124
- const normalizedValue = normalizeForFilter(String(filter.value ?? ''), options?.locale);
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 normalizedText.includes(normalizedValue);
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
- return normalizedValue === '' || !normalizedText.includes(normalizedValue);
142
+ if (needle === '')
143
+ return () => true;
144
+ return (cellValue) => !fold(cellValue).includes(needle);
132
145
  case 'equals':
133
- return normalizedText === normalizedValue;
146
+ return (cellValue) => fold(cellValue) === needle;
134
147
  case 'notEquals':
135
- return normalizedValue === '' || normalizedText !== normalizedValue;
148
+ if (needle === '')
149
+ return () => true;
150
+ return (cellValue) => fold(cellValue) !== needle;
136
151
  case 'startsWith':
137
- return normalizedText.startsWith(normalizedValue);
152
+ return (cellValue) => fold(cellValue).startsWith(needle);
138
153
  case 'endsWith':
139
- return normalizedText.endsWith(normalizedValue);
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
- return new RegExp(pattern, 'i').test(text);
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
- const hit = tokens.some((t) => normalizeForFilter(t, options?.locale) === normalizedText);
160
- return filter.operator === 'in' ? hit : !hit;
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
- if (Number.isFinite(a) && Number.isFinite(b))
166
- return a > b;
167
- return String(cellValue ?? '') > String(filter.value ?? '');
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
- if (Number.isFinite(a) && Number.isFinite(b))
173
- return a < b;
174
- return String(cellValue ?? '') < String(filter.value ?? '');
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 a = Number(cellValue ?? 0);
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 s >= sl && s <= sh;
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 text.trim().length === 0;
229
+ return (cellValue) => String(cellValue ?? '').trim().length === 0;
198
230
  case 'isNotBlank':
199
- return text.trim().length > 0;
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.5.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
- const evalCond = (
1274
- cellValue: unknown,
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
- ): boolean =>
1280
- applyExcelFilter(
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
- menuFilters.every(([columnId, f]) => {
1308
+ compiledMenuFilters.every(({ columnId, join, a, b }) => {
1294
1309
  const cellValue = getRowColumnValue(row, columnId);
1295
- const aActive = condActive(f.operator, f.value, f.valueTo);
1296
- const bActive = !!f.operator2 && condActive(f.operator2, f.value2 ?? "", f.valueTo2);
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 f.join === "OR" ? ra || rb : ra && rb;
1314
+ return join === "OR" ? ra || rb : ra && rb;
1304
1315
  }),
1305
1316
  );
1306
1317
  }
@@ -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>
@@ -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>
@@ -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
- export function applyExcelFilter(
163
- cellValue: unknown,
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 text = String(cellValue ?? '')
168
- const normalizedText = normalizeForFilter(text, options?.locale)
169
- const normalizedValue = normalizeForFilter(String(filter.value ?? ''), options?.locale)
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 normalizedText.includes(normalizedValue)
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
- return normalizedValue === '' || !normalizedText.includes(normalizedValue)
190
+ if (needle === '') return () => true
191
+ return (cellValue) => !fold(cellValue).includes(needle)
177
192
  case 'equals':
178
- return normalizedText === normalizedValue
193
+ return (cellValue) => fold(cellValue) === needle
179
194
  case 'notEquals':
180
- return normalizedValue === '' || normalizedText !== normalizedValue
195
+ if (needle === '') return () => true
196
+ return (cellValue) => fold(cellValue) !== needle
181
197
  case 'startsWith':
182
- return normalizedText.startsWith(normalizedValue)
198
+ return (cellValue) => fold(cellValue).startsWith(needle)
183
199
  case 'endsWith':
184
- return normalizedText.endsWith(normalizedValue)
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
- return new RegExp(pattern, 'i').test(text)
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
- const hit = tokens.some(
202
- (t) => normalizeForFilter(t, options?.locale) === normalizedText,
203
- )
204
- return filter.operator === 'in' ? hit : !hit
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
- if (Number.isFinite(a) && Number.isFinite(b)) return a > b
210
- return String(cellValue ?? '') > String(filter.value ?? '')
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
- if (Number.isFinite(a) && Number.isFinite(b)) return a < b
216
- return String(cellValue ?? '') < String(filter.value ?? '')
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 a = Number(cellValue ?? 0)
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 s >= sl && s <= sh
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 text.trim().length === 0
268
+ return (cellValue) => String(cellValue ?? '').trim().length === 0
240
269
  case 'isNotBlank':
241
- return text.trim().length > 0
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
- expect(source).toContain('applyExcelFilter')
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
  })