@svgrid/grid 2.6.3 → 2.6.5

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 (66) hide show
  1. package/dist/SvGrid.controller.svelte.d.ts +2 -1
  2. package/dist/SvGrid.controller.svelte.js +145 -3
  3. package/dist/SvGrid.css +32 -0
  4. package/dist/SvGrid.svelte +64 -6
  5. package/dist/SvGrid.types.d.ts +61 -0
  6. package/dist/a11y/grid-announcements.d.ts +58 -0
  7. package/dist/a11y/grid-announcements.js +70 -0
  8. package/dist/advanced-filter.svelte.d.ts +42 -0
  9. package/dist/advanced-filter.svelte.js +13 -0
  10. package/dist/build-api.js +44 -2
  11. package/dist/cdn/{GridMenus-BoVKgAv8.js → GridMenus-CBvjL5u4.js} +14 -14
  12. package/dist/cdn/{GridMenus-DL1zrDwf.js → GridMenus-DAzp6YXc.js} +14 -14
  13. package/dist/cdn/{src-M2NfKnjX.js → src-3WKN7L4G.js} +5932 -5743
  14. package/dist/cdn/{src-CEpw6Ato.js → src-Cl7h5-Ux.js} +3371 -3182
  15. package/dist/cdn/svgrid.js +8 -8
  16. package/dist/cdn/svgrid.svelte-external.js +8 -8
  17. package/dist/filter-operators.d.ts +2 -1
  18. package/dist/filter-operators.js +5 -6
  19. package/dist/filtering/excel-filters.d.ts +1 -0
  20. package/dist/filtering/excel-filters.js +5 -0
  21. package/dist/filtering/filter-operator-catalogue.d.ts +42 -0
  22. package/dist/filtering/filter-operator-catalogue.js +42 -0
  23. package/dist/filtering/predicate-expr.d.ts +78 -0
  24. package/dist/filtering/predicate-expr.js +1 -0
  25. package/dist/filtering/row-predicate.d.ts +17 -0
  26. package/dist/filtering/row-predicate.js +55 -0
  27. package/dist/grid-messages.d.ts +7 -0
  28. package/dist/grid-messages.js +7 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.js +5 -0
  31. package/dist/server-data-source.d.ts +34 -0
  32. package/dist/server-data-source.js +19 -0
  33. package/dist/svgrid-wrapper.types.d.ts +37 -5
  34. package/dist/themes/index.js +12 -4
  35. package/package.json +2 -1
  36. package/src/SvGrid.controller.svelte.ts +169 -2
  37. package/src/SvGrid.css +32 -0
  38. package/src/SvGrid.svelte +64 -6
  39. package/src/SvGrid.types.ts +61 -0
  40. package/src/a11y/grid-announcements.test.ts +78 -0
  41. package/src/a11y/grid-announcements.ts +94 -0
  42. package/src/a11y.announce.test.ts +250 -0
  43. package/src/a11y.axe.test.ts +194 -0
  44. package/src/advanced-filter.svelte.ts +59 -0
  45. package/src/build-api.ts +43 -2
  46. package/src/filter-operators.ts +9 -6
  47. package/src/filtering/excel-filters.ts +15 -0
  48. package/src/filtering/filter-operator-catalogue.ts +74 -0
  49. package/src/filtering/node-entry.test.ts +75 -0
  50. package/src/filtering/predicate-expr.ts +50 -0
  51. package/src/filtering/row-predicate.ts +57 -0
  52. package/src/grid-messages.ts +18 -0
  53. package/src/index.ts +28 -0
  54. package/src/server-data-source.test.ts +94 -0
  55. package/src/server-data-source.ts +56 -0
  56. package/src/svgrid-wrapper.types.ts +37 -5
  57. package/src/svgrid.advanced-filter.test.ts +481 -0
  58. package/src/svgrid.displayed-rows.test.ts +144 -0
  59. package/src/svgrid.group-props-reactive.svelte.test.ts +151 -0
  60. package/src/svgrid.group-props.test.ts +280 -0
  61. package/src/themes/contrast.test.ts +112 -0
  62. package/src/themes/index.ts +12 -4
  63. package/themes/catppuccin.css +3 -3
  64. package/themes/dracula.css +3 -3
  65. package/themes/nord.css +3 -3
  66. package/themes/notion.css +4 -4
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Apply a row predicate across a depth-ordered row list, keeping group banners
3
+ * consistent with what survives beneath them.
4
+ *
5
+ * The row list interleaves group banners with their descendants, banner first.
6
+ * A predicate written by a user is about DATA, so it is only tested against
7
+ * leaf rows; a banner then survives only if something under it did. Testing a
8
+ * banner directly would drop whole groups because its cells are aggregates, not
9
+ * row values.
10
+ *
11
+ * Note the existing column-filter and facet stages do test banners directly.
12
+ * That is a separate pre-existing inconsistency; this helper is written to be
13
+ * reusable if those are ever brought in line, but changing them is deliberately
14
+ * not bundled into the advanced filter.
15
+ */
16
+ /** Apply `keep` to leaf rows, dropping banners left with no surviving child. */
17
+ export function applyRowPredicate(rows, keep, isGroup) {
18
+ // Fast path: no grouping in play, so every row is a leaf.
19
+ let hasGroup = false;
20
+ for (const row of rows) {
21
+ if (isGroup(row)) {
22
+ hasGroup = true;
23
+ break;
24
+ }
25
+ }
26
+ if (!hasGroup)
27
+ return rows.filter(keep);
28
+ // One reverse pass. `survivorsBelow` counts leaves kept since we last closed
29
+ // out a banner, so a banner is kept when anything after it survived.
30
+ const keepFlags = new Array(rows.length).fill(false);
31
+ let survivorsSinceBanner = 0;
32
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
33
+ const row = rows[i];
34
+ if (isGroup(row)) {
35
+ if (survivorsSinceBanner > 0) {
36
+ keepFlags[i] = true;
37
+ // The banner itself counts as surviving content for any banner above
38
+ // it, so nested groups keep their ancestors.
39
+ survivorsSinceBanner = 1;
40
+ }
41
+ else {
42
+ keepFlags[i] = false;
43
+ }
44
+ }
45
+ else if (keep(row)) {
46
+ keepFlags[i] = true;
47
+ survivorsSinceBanner += 1;
48
+ }
49
+ }
50
+ const out = [];
51
+ for (let i = 0; i < rows.length; i += 1)
52
+ if (keepFlags[i])
53
+ out.push(rows[i]);
54
+ return out;
55
+ }
@@ -22,6 +22,13 @@ export type GridMessages = {
22
22
  total: string;
23
23
  rowsSuffix: string;
24
24
  rowSuffix: string;
25
+ announceFilterResults: string;
26
+ announceNoMatches: string;
27
+ announceFiltersCleared: string;
28
+ announceRowsSelected: string;
29
+ announceSelectionCleared: string;
30
+ advancedFilterActive: string;
31
+ advancedFilterClear: string;
25
32
  opContains: string;
26
33
  opNotContains: string;
27
34
  opEquals: string;
@@ -33,6 +33,13 @@ export const defaultGridMessages = {
33
33
  total: 'Total',
34
34
  rowsSuffix: 'rows',
35
35
  rowSuffix: 'row',
36
+ announceFilterResults: '{visible} of {total} rows match the current filters',
37
+ announceNoMatches: 'No rows match the current filters',
38
+ announceFiltersCleared: 'Filters cleared, showing all {total} rows',
39
+ announceRowsSelected: '{count} rows selected',
40
+ announceSelectionCleared: 'Selection cleared',
41
+ advancedFilterActive: 'Advanced filter',
42
+ advancedFilterClear: 'Clear advanced filter',
36
43
  menuCopy: 'Copy',
37
44
  menuCut: 'Cut',
38
45
  menuPaste: 'Paste',
package/dist/index.d.ts CHANGED
@@ -215,6 +215,10 @@ export type { VirtualItem, VirtualizerOptions, VirtualizerState } from './virtua
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
217
  export { applyExcelFilter, compileExcelFilter, normalizeForFilter, splitInTokens, joinInTokens, trailingInToken, type CompiledExcelFilter, type ExcelFilter, type ExcelFilterOperator, type ExcelFilterOptions, } from './filtering/excel-filters';
218
+ export { ALL_FILTER_OPERATORS, SET_OPERATOR_IDS, VALUELESS_OPERATOR_IDS, RANGE_OPERATOR_IDS, isSetOperator, isValuelessOperator, isRangeOperator, type FilterValueType, } from './filtering/filter-operator-catalogue';
219
+ export { applyRowPredicate } from './filtering/row-predicate';
220
+ export type { GridPredicateExpr, GridScalarExpr, GridComparisonOp, GridArithmeticOp, GridAggFn, } from './filtering/predicate-expr';
221
+ export { registerAdvancedFilterEngine, getAdvancedFilterEngine, hasAdvancedFilterEngine, type AdvancedFilterEngine, type AdvancedFilterCompileContext, type CompiledRowPredicate, } from './advanced-filter.svelte';
218
222
  export { getGridCellDomId, getGridCellA11yProps, getGridHeaderA11yProps, getGridRootA11yProps, getGridRowA11yProps, type GridCellA11yInput, type GridColumnA11yInput, type GridSortDirection, } from './a11y';
219
223
  /**
220
224
  * Compatibility alias for teams migrating from `createTable`.
package/dist/index.js CHANGED
@@ -250,6 +250,11 @@ export { createSvelteVirtualizer } from './virtualization/svelte-virtualizer.sve
250
250
  export { createColumnVirtualizer } from './virtualization/column-virtualizer';
251
251
  export { parseEditorValue, normalizeEditorOptions, } from './editors/cell-editors';
252
252
  export { applyExcelFilter, compileExcelFilter, normalizeForFilter, splitInTokens, joinInTokens, trailingInToken, } from './filtering/excel-filters';
253
+ export { ALL_FILTER_OPERATORS, SET_OPERATOR_IDS, VALUELESS_OPERATOR_IDS, RANGE_OPERATOR_IDS, isSetOperator, isValuelessOperator, isRangeOperator, } from './filtering/filter-operator-catalogue';
254
+ export { applyRowPredicate } from './filtering/row-predicate';
255
+ // Advanced-filter seam: enterprise registers the compiler, the grid owns the
256
+ // state and the pipeline slot.
257
+ export { registerAdvancedFilterEngine, getAdvancedFilterEngine, hasAdvancedFilterEngine, } from './advanced-filter.svelte';
253
258
  export { getGridCellDomId, getGridCellA11yProps, getGridHeaderA11yProps, getGridRootA11yProps, getGridRowA11yProps, } from './a11y';
254
259
  /**
255
260
  * Compatibility alias for teams migrating from `createTable`.
@@ -15,6 +15,7 @@
15
15
  * non-optimistic for now - the grid reflects a change only after the
16
16
  * follow-up re-fetch of the current page lands.
17
17
  */
18
+ import type { GridPredicateExpr } from './filtering/predicate-expr';
18
19
  export type ServerSortModel = Array<{
19
20
  id: string;
20
21
  desc: boolean;
@@ -33,6 +34,24 @@ export type ServerFilterModel = {
33
34
  valueTo?: string;
34
35
  selectedValues?: string[];
35
36
  }>;
37
+ /**
38
+ * Advanced-filter predicate (Pro), as a JSON AST. Expresses what `columns`
39
+ * cannot: OR across columns, nesting, negation, two conditions on one
40
+ * column, cross-column comparison, and aggregates.
41
+ *
42
+ * CONTRACT - all or nothing. A backend that receives this MUST either:
43
+ *
44
+ * (a) translate the WHOLE expression into its query, make `rowCount`
45
+ * reflect it, and set `appliedExpression: true` on the result; or
46
+ * (b) apply none of it and leave `appliedExpression` unset.
47
+ *
48
+ * Partial application is a contract violation, not a degraded mode: it
49
+ * returns a SUPERSET of the requested rows while the UI says the filter is
50
+ * on. That is strictly worse than not filtering, because nothing about the
51
+ * result looks wrong. When the ack is missing the grid says so rather than
52
+ * filtering the loaded page itself - see `ServerState.expressionUnapplied`.
53
+ */
54
+ expression?: GridPredicateExpr;
36
55
  };
37
56
  /** A value column to roll up per group. */
38
57
  export type ServerAggregation = {
@@ -137,6 +156,12 @@ export type ServerResult<TData> = {
137
156
  rows: ReadonlyArray<TData>;
138
157
  /** Total row count after filtering (for the pager). */
139
158
  rowCount: number;
159
+ /**
160
+ * Set `true` ONLY when `filterModel.expression` was applied in full. Leave it
161
+ * unset if you ignored the expression; the grid then warns rather than
162
+ * pretending the filter ran. See the contract on `ServerFilterModel.expression`.
163
+ */
164
+ appliedExpression?: boolean;
140
165
  };
141
166
  export type ServerDataSource<TData> = {
142
167
  getRows(request: ServerRequest): Promise<ServerResult<TData>>;
@@ -162,6 +187,15 @@ export type ServerState<TData> = {
162
187
  pageCount: number;
163
188
  sortModel: ServerSortModel;
164
189
  filterModel: ServerFilterModel;
190
+ /**
191
+ * True when an advanced-filter expression was sent but the source did not
192
+ * acknowledge applying it - so `rows` is unfiltered and the UI should say so.
193
+ * Surface this rather than hiding it: the rows look perfectly normal.
194
+ *
195
+ * Optional so existing code that builds a `ServerState` literal keeps
196
+ * compiling; the controller always sets it.
197
+ */
198
+ expressionUnapplied?: boolean;
165
199
  };
166
200
  export type ServerController<TData> = {
167
201
  /** Re-fetch the current page (e.g. after a mutation). */
@@ -10,11 +10,15 @@ export function createServerDataSource(source, options) {
10
10
  pageCount: 1,
11
11
  sortModel: [],
12
12
  filterModel: {},
13
+ expressionUnapplied: false,
13
14
  };
14
15
  // Monotonic request id: only the latest fetch is allowed to land, so a slow
15
16
  // response for an old sort/filter can't clobber a newer one.
16
17
  let requestSeq = 0;
17
18
  let disposed = false;
19
+ // Once per controller: a misconfigured backend would otherwise log on every
20
+ // page, scroll and filter change.
21
+ let warnedExpressionUnapplied = false;
18
22
  const emit = () => {
19
23
  state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize));
20
24
  options.onChange({ ...state });
@@ -44,6 +48,21 @@ export function createServerDataSource(source, options) {
44
48
  return; // stale
45
49
  state.rows = result.rows;
46
50
  state.total = result.rowCount;
51
+ // An expression was sent but the backend did not acknowledge applying it,
52
+ // so these rows are a SUPERSET of what was asked for. Say so instead of
53
+ // filtering the loaded page here: filtering one page would turn
54
+ // "3 of 1,000,000 match" into a confident lie and make paging incoherent,
55
+ // since page 2 would re-filter a different slice.
56
+ state.expressionUnapplied =
57
+ state.filterModel.expression != null && result.appliedExpression !== true;
58
+ if (state.expressionUnapplied && !warnedExpressionUnapplied) {
59
+ warnedExpressionUnapplied = true;
60
+ console.warn('[svgrid] The data source was sent filterModel.expression but did not ' +
61
+ 'return `appliedExpression: true`, so the advanced filter is NOT applied ' +
62
+ 'and the rows shown are unfiltered. Apply the whole expression and ' +
63
+ 'acknowledge it, or clear the advanced filter. ' +
64
+ 'See https://svgrid.com/docs/help/server/server-filtering');
65
+ }
47
66
  state.loading = false;
48
67
  emit();
49
68
  }
@@ -2,6 +2,7 @@ import type { CellFormatConfig, ColumnDef, RowData, SvGridOptions, TableFeatures
2
2
  import type { FilterOperator, Props } from './SvGrid.types';
3
3
  import type { GridExportOptions, GridClipboardOptions } from './export-format';
4
4
  import type { ChartSpec, ChartType } from './chart';
5
+ import type { GridPredicateExpr } from './filtering/predicate-expr';
5
6
  export type SvGridFilterOperator = FilterOperator;
6
7
  /**
7
8
  * A serializable snapshot of everything that makes up the current "view":
@@ -36,6 +37,13 @@ export type SvGridViewState = {
36
37
  }>;
37
38
  /** Facet (Excel-style value checklist) selections, keyed by column id. */
38
39
  facetFilters: Record<string, string[]>;
40
+ /**
41
+ * Advanced-filter expression (Pro). OPTIONAL, and omitted entirely when no
42
+ * advanced filter is set - so views saved before this feature existed, and
43
+ * views from grids that never use it, round-trip byte-identical. An explicit
44
+ * `null` clears the filter on `setState`.
45
+ */
46
+ advancedFilter?: GridPredicateExpr | null;
39
47
  /** Built-in chart panel state (present only when `charting` is on): the
40
48
  * ACTIVE chart, for back-compat + spread-and-tweak. */
41
49
  chart?: {
@@ -218,10 +226,25 @@ export type SvGridApi<TFeatures extends TableFeatures, TData extends RowData> =
218
226
  */
219
227
  refreshEditorOptions(columnId?: string): void;
220
228
  /**
221
- * Clear every active column filter (menu, filter-row, set-list, and global).
222
- * Resets the grid to "no filtering" in a single call.
229
+ * Clear every active column filter (menu, filter-row, set-list, global, and
230
+ * the advanced filter). Resets the grid to "no filtering" in a single call.
223
231
  */
224
232
  clearAllFilters(): void;
233
+ /**
234
+ * Set the advanced-filter expression (Pro). `null` clears it. Composed with
235
+ * AND after the global, column and facet filters.
236
+ *
237
+ * Rows are only removed once `@svgrid/enterprise`'s `enableAdvancedFilter()`
238
+ * has registered a compiler. Without it the expression is stored but nothing
239
+ * is filtered - use `isAdvancedFilterActive()` to tell the two apart.
240
+ */
241
+ setAdvancedFilter(expr: GridPredicateExpr | null): void;
242
+ /** The current advanced-filter expression, or `null`. */
243
+ getAdvancedFilter(): GridPredicateExpr | null;
244
+ /** Clear the advanced filter, leaving other filter surfaces untouched. */
245
+ clearAdvancedFilter(): void;
246
+ /** Whether an expression is set AND an engine is registered to run it. */
247
+ isAdvancedFilterActive(): boolean;
225
248
  /**
226
249
  * Read the active column-menu filters as a snapshot. Keyed by column id.
227
250
  * Returns an empty object when nothing is filtered. `valueTo` is only
@@ -233,9 +256,15 @@ export type SvGridApi<TFeatures extends TableFeatures, TData extends RowData> =
233
256
  valueTo?: string;
234
257
  }>;
235
258
  /**
236
- * Snapshot of the rows the grid is actually displaying right now -
237
- * after filtering, sorting, grouping, and pagination have been applied.
238
- * Use this when you need to export the visible result set (e.g. CSV).
259
+ * Snapshot of the DATA rows the grid is displaying right now - after
260
+ * filtering, sorting, grouping and pagination. Use this when you need the
261
+ * visible result set (e.g. to export it as CSV).
262
+ *
263
+ * Group banner rows are not included: the return type is `TData`, and a
264
+ * banner is not one of your rows. That matters while grouping is on, because
265
+ * with every group collapsed this returns an EMPTY array even though the
266
+ * grid visibly shows a banner per group. Count banners from `getState()`
267
+ * rather than from the length of this.
239
268
  */
240
269
  getDisplayedRows(): ReadonlyArray<TData>;
241
270
  /** Snapshot of the current data array (pre-pipeline). */
@@ -256,6 +285,9 @@ export type SvGridApi<TFeatures extends TableFeatures, TData extends RowData> =
256
285
  format?: CellFormatConfig;
257
286
  /** Effective horizontal alignment ('left' | 'center' | 'right'). */
258
287
  align?: 'left' | 'center' | 'right';
288
+ /** The column's declared `editorType`, when set. Lets a filter or
289
+ * expression UI offer type-appropriate operators. */
290
+ editorType?: string;
259
291
  }>;
260
292
  /**
261
293
  * Export the grid to a **CSV** file. Free in the community grid; the
@@ -81,16 +81,24 @@ export const themePresets = [
81
81
  light: { bg: '#ffffff', fg: '#282a30', muted: '#6b6f76', border: '#e9e9eb', headerBg: '#f7f8f8', headerFg: '#6b6f76', accent: '#5e6ad2', rowAlt: '#fbfbfc', rowHover: '#f4f4f5', selectionBg: '#e8eafd' },
82
82
  dark: { bg: '#08090a', fg: '#f7f8f8', muted: '#8a8f98', border: '#23252a', headerBg: '#0f1011', headerFg: '#8a8f98', accent: '#5e6ad2', rowAlt: '#0f1011', rowHover: '#1a1b1e', selectionBg: '#2a2d4a' } },
83
83
  { id: 'notion', name: 'Notion', radius: 4, font: 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
84
- light: { bg: '#ffffff', fg: '#37352f', muted: '#787774', border: '#e9e9e7', headerBg: '#f7f6f3', headerFg: '#787774', accent: '#2383e2', rowAlt: '#fbfbfa', rowHover: '#f1f1ef', selectionBg: '#d9eaf7' },
84
+ // muted + headerFg darkened from #787774, which sat at 4.48:1 on white and
85
+ // 4.14:1 on the header fill - both just under AA. #6b6a67 keeps the warm
86
+ // grey and clears it at 5.41:1 / 5:1.
87
+ light: { bg: '#ffffff', fg: '#37352f', muted: '#6b6a67', border: '#e9e9e7', headerBg: '#f7f6f3', headerFg: '#6b6a67', accent: '#2383e2', rowAlt: '#fbfbfa', rowHover: '#f1f1ef', selectionBg: '#d9eaf7' },
85
88
  dark: { bg: '#191919', fg: '#d4d4d4', muted: '#9b9b9b', border: '#2f2f2f', headerBg: '#202020', headerFg: '#9b9b9b', accent: '#529cca', rowAlt: '#1c1c1c', rowHover: '#252525', selectionBg: '#20415c' } },
86
89
  { id: 'nord', name: 'Nord', radius: 4, font: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
87
90
  light: { bg: '#eceff4', fg: '#2e3440', muted: '#4c566a', border: '#d8dee9', headerBg: '#e5e9f0', headerFg: '#2e3440', accent: '#5579a5', rowAlt: '#e5e9f0', rowHover: '#dde3ec', selectionBg: '#d8dee9' },
88
- dark: { bg: '#2e3440', fg: '#d8dee9', muted: '#4c566a', border: '#434c5e', headerBg: '#3b4252', headerFg: '#e5e9f0', accent: '#88c0d0', rowAlt: '#333b48', rowHover: '#3b4252', selectionBg: '#434c5e' } },
91
+ // muted lightened from Nord's #4c566a (nord3): on the nord0 background that
92
+ // is 1.69:1, effectively invisible. #97a0b0 keeps the Nord hue at 4.74:1.
93
+ dark: { bg: '#2e3440', fg: '#d8dee9', muted: '#97a0b0', border: '#434c5e', headerBg: '#3b4252', headerFg: '#e5e9f0', accent: '#88c0d0', rowAlt: '#333b48', rowHover: '#3b4252', selectionBg: '#434c5e' } },
89
94
  { id: 'dracula', name: 'Dracula', radius: 6, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
90
95
  light: { bg: '#fffbeb', fg: '#1f1f1f', muted: '#6c664b', border: '#dedccf', headerBg: '#ece9df', headerFg: '#1f1f1f', accent: '#644ac9', rowAlt: '#fffdf5', rowHover: '#f2efe3', selectionBg: '#cfcfde' },
91
- dark: { bg: '#282a36', fg: '#f8f8f2', muted: '#6272a4', border: '#44475a', headerBg: '#21222c', headerFg: '#f8f8f2', accent: '#bd93f9', rowAlt: '#2d2f3b', rowHover: '#343746', selectionBg: '#44475a' } },
96
+ // muted lightened from Dracula's comment colour #6272a4 (3.03:1) to 5.04:1.
97
+ dark: { bg: '#282a36', fg: '#f8f8f2', muted: '#8b98c9', border: '#44475a', headerBg: '#21222c', headerFg: '#f8f8f2', accent: '#bd93f9', rowAlt: '#2d2f3b', rowHover: '#343746', selectionBg: '#44475a' } },
92
98
  { id: 'catppuccin', name: 'Catppuccin', radius: 8, font: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
93
- light: { bg: '#eff1f5', fg: '#4c4f69', muted: '#6c6f85', border: '#ccd0da', headerBg: '#e6e9ef', headerFg: '#4c4f69', accent: '#8839ef', rowAlt: '#e6e9ef', rowHover: '#dce0e8', selectionBg: '#dce0e8' },
99
+ // muted moved from Latte subtext0 (#6c6f85, 4.37:1) to subtext1 (#5c5f77),
100
+ // still an official Catppuccin colour, at 5.53:1.
101
+ light: { bg: '#eff1f5', fg: '#4c4f69', muted: '#5c5f77', border: '#ccd0da', headerBg: '#e6e9ef', headerFg: '#4c4f69', accent: '#8839ef', rowAlt: '#e6e9ef', rowHover: '#dce0e8', selectionBg: '#dce0e8' },
94
102
  dark: { bg: '#1e1e2e', fg: '#cdd6f4', muted: '#a6adc8', border: '#313244', headerBg: '#181825', headerFg: '#cdd6f4', accent: '#cba6f7', rowAlt: '#1a1a28', rowHover: '#313244', selectionBg: '#45475a' } },
95
103
  ];
96
104
  /** The default preset (used when nothing is chosen). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svgrid/grid",
3
- "version": "2.6.3",
3
+ "version": "2.6.5",
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",
@@ -111,6 +111,7 @@
111
111
  "@sveltejs/package": "^2.5.7",
112
112
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
113
113
  "@vitest/coverage-v8": "^4.1.5",
114
+ "axe-core": "^4.13.0",
114
115
  "eslint-plugin-svelte": "^3.17.1",
115
116
  "jsdom": "^29.1.1",
116
117
  "publint": "^0.3.21",
@@ -1,7 +1,10 @@
1
1
  import {
2
2
  applyGroupAggregate,
3
+ applyRowPredicate,
3
4
  compileExcelFilter,
4
5
  type CompiledExcelFilter,
6
+ getAdvancedFilterEngine,
7
+ type GridPredicateExpr,
5
8
  normalizeForFilter,
6
9
  createColumnVirtualizer,
7
10
  createCoreRowModel,
@@ -94,6 +97,11 @@ import {
94
97
  createClipboard,
95
98
  } from "./clipboard";
96
99
  import { resolveGridMessages } from "./grid-messages";
100
+ import { announce } from "./a11y/live-region";
101
+ import {
102
+ filterAnnouncement,
103
+ selectionAnnouncement,
104
+ } from "./a11y/grid-announcements";
97
105
  import { getPivotEngine, hasPivotEngine } from "./pivot-view.svelte";
98
106
  import {
99
107
  filterOperatorOptions,
@@ -324,6 +332,13 @@ export function createSvGridController<
324
332
  let globalFilter = $state("");
325
333
  let scrollContainer: HTMLDivElement | null = $state(null);
326
334
  let gridRootEl: HTMLElement | null = $state(null);
335
+ // Advanced filter (Pro). Seeded once from `initialAdvancedFilter` and driven
336
+ // thereafter through the API - deliberately NOT a live prop, so it cannot
337
+ // repeat the `externalFilter` trap of looking controlled while being read
338
+ // exactly once.
339
+ let advancedFilter = $state<GridPredicateExpr | null>(
340
+ props.initialAdvancedFilter ?? null,
341
+ );
327
342
  let filterRowValues = $state<Record<string, string>>({});
328
343
  let filterMenuValues = $state<
329
344
  Record<
@@ -712,13 +727,15 @@ export function createSvGridController<
712
727
  },
713
728
  state: {
714
729
  columnFilters: [],
715
- grouping: [],
730
+ // svelte-ignore state_referenced_locally
731
+ grouping: treeDataConfig ? [] : [...(props.groupBy ?? [])],
716
732
  // svelte-ignore state_referenced_locally
717
733
  sorting: props.initialSorting ?? [],
718
734
  // svelte-ignore state_referenced_locally
719
735
  pagination: { pageIndex: 0, pageSize: props.pageSize ?? 10 },
720
736
  rowSelection: {},
721
- expanded: {},
737
+ // svelte-ignore state_referenced_locally
738
+ expanded: { ...(props.expanded ?? {}) },
722
739
  activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
723
740
  },
724
741
  });
@@ -781,6 +798,42 @@ export function createSvGridController<
781
798
  userColumnOrder = props.columnOrder ? [...props.columnOrder] : [];
782
799
  });
783
800
 
801
+ // `groupBy` prop -> engine grouping state. Same shape as the `columnOrder`
802
+ // re-seed above: we only write when the PROP itself changes, so a group-by
803
+ // set from the column menu or `api.setGroupBy()` is not clobbered on the next
804
+ // unrelated re-render.
805
+ let lastSeededGroupBy = (props.groupBy ?? []).join("|");
806
+ $effect(() => {
807
+ if (treeDataConfig) return;
808
+ const incoming = (props.groupBy ?? []).join("|");
809
+ if (incoming === lastSeededGroupBy) return;
810
+ lastSeededGroupBy = incoming;
811
+ grid.setGrouping(() => [...(props.groupBy ?? [])]);
812
+ });
813
+
814
+ // `expanded` prop -> engine expansion state, and the store -> the
815
+ // `onExpandedChange` callback. `lastSeededExpanded` is written from BOTH
816
+ // directions so a controlled parent that echoes the callback value straight
817
+ // back into the prop does not trigger a redundant re-seed (and so no
818
+ // prop -> state -> callback -> prop loop can form).
819
+ let lastSeededExpanded = JSON.stringify(props.expanded ?? {});
820
+ $effect(() => {
821
+ const incoming = JSON.stringify(props.expanded ?? {});
822
+ if (incoming === lastSeededExpanded) return;
823
+ lastSeededExpanded = incoming;
824
+ grid.setExpanded(() => ({ ...(props.expanded ?? {}) }));
825
+ });
826
+ $effect(() => {
827
+ let previous = grid.getState().expanded ?? {};
828
+ return grid.store.subscribe(() => {
829
+ const next = grid.getState().expanded ?? {};
830
+ if (next === previous) return;
831
+ previous = next;
832
+ lastSeededExpanded = JSON.stringify(next);
833
+ props.onExpandedChange?.({ ...next });
834
+ });
835
+ });
836
+
784
837
  // Declared here rather than beside the other pagination/state derivations:
785
838
  // the auto-group column pipeline below reads it, and a `const` used above its
786
839
  // declaration is a type error even though `$derived` is lazy enough at runtime.
@@ -1345,6 +1398,32 @@ export function createSvGridController<
1345
1398
  );
1346
1399
  }
1347
1400
 
1401
+ // --- Advanced filter (Pro) ---------------------------------------------
1402
+ // Runs LAST for two reasons: it evaluates over the smallest row set, and
1403
+ // its aggregates (`SUM(amount) > 1000`) fold over what the user is
1404
+ // currently looking at rather than the raw dataset. Composed with AND
1405
+ // against the three stages above.
1406
+ //
1407
+ // Fails OPEN throughout. No engine registered (enterprise absent), an
1408
+ // expression the engine cannot compile, or a throw from inside it all
1409
+ // leave `rows` untouched. A half-applied filter that quietly shows the
1410
+ // wrong rows is worse than one that visibly did nothing.
1411
+ if (advancedFilter) {
1412
+ const engine = getAdvancedFilterEngine();
1413
+ if (engine) {
1414
+ try {
1415
+ const predicate = engine(advancedFilter, {
1416
+ getValue: getRowColumnValue,
1417
+ locale: props.filterLocale ?? props.localization?.locale,
1418
+ rows,
1419
+ });
1420
+ if (predicate) rows = applyRowPredicate(rows, predicate, isGroupRow);
1421
+ } catch {
1422
+ // Malformed expression: leave the rows alone.
1423
+ }
1424
+ }
1425
+ }
1426
+
1348
1427
  return rows;
1349
1428
  });
1350
1429
 
@@ -1639,6 +1718,92 @@ export function createSvGridController<
1639
1718
  // map every consumer (SvGrid.svelte, GridFooter, menus, filter labels) reads.
1640
1719
  const gridMessages = $derived(resolveGridMessages(props.localization?.text));
1641
1720
 
1721
+ // ---- Screen-reader status announcements (WCAG 4.1.3) ----
1722
+ // Only genuine status messages belong here: information no focus change
1723
+ // reveals. `a11y/grid-announcements.ts` documents what is deliberately left
1724
+ // unsaid, and why saying it would make the grid talk over itself.
1725
+
1726
+ /** Data rows before any local filtering - the denominator in "12 of 250". */
1727
+ const unfilteredRowTotal = $derived.by(() => {
1728
+ dataStateVersion;
1729
+ void internalData;
1730
+ return grid.getRowModel().rows.length;
1731
+ });
1732
+
1733
+ /** Whether any of the five filter surfaces is currently narrowing the rows. */
1734
+ const anyFilterActive = $derived(
1735
+ globalFilter !== "" ||
1736
+ advancedFilter !== null ||
1737
+ Object.keys(filterRowValues).length > 0 ||
1738
+ Object.keys(filterMenuValues).length > 0 ||
1739
+ Object.keys(valueFilters).length > 0,
1740
+ );
1741
+
1742
+ let filtersWereActive = false;
1743
+ let filterAnnounceTimer: ReturnType<typeof setTimeout> | undefined;
1744
+
1745
+ $effect(() => {
1746
+ // In external-filter mode the server decides what matched, so the local
1747
+ // counts describe only the page in hand. Announcing them would misreport
1748
+ // the result set, which is worse than staying silent.
1749
+ if (externalFilterEnabled) return;
1750
+
1751
+ const visible = paginatedRowTotal;
1752
+ const total = unfilteredRowTotal;
1753
+ const active = anyFilterActive;
1754
+ const messages = gridMessages;
1755
+
1756
+ untrack(() => {
1757
+ const message = filterAnnouncement(
1758
+ visible,
1759
+ total,
1760
+ active,
1761
+ filtersWereActive,
1762
+ messages,
1763
+ );
1764
+ filtersWereActive = active;
1765
+ if (!message) return;
1766
+ // Typing in the global filter re-runs this per keystroke. A polite region
1767
+ // queues rather than replaces, so without a debounce the user hears the
1768
+ // count for every prefix they typed before the one they care about.
1769
+ clearTimeout(filterAnnounceTimer);
1770
+ filterAnnounceTimer = setTimeout(() => announce(message), 400);
1771
+ });
1772
+
1773
+ return () => clearTimeout(filterAnnounceTimer);
1774
+ });
1775
+
1776
+ // Forward advanced-filter changes to the consumer. Same dedupe-and-skip-first
1777
+ // pattern as the selection forwarder: the panel that authors the expression
1778
+ // lives outside the grid, so a change the grid makes itself (the toolbar's
1779
+ // clear, clearAllFilters) is invisible to it otherwise.
1780
+ let lastAdvancedFilterSerialized = JSON.stringify(
1781
+ untrack(() => advancedFilter) ?? null,
1782
+ );
1783
+ $effect(() => {
1784
+ const serialized = JSON.stringify(advancedFilter ?? null);
1785
+ if (serialized === untrack(() => lastAdvancedFilterSerialized)) return;
1786
+ untrack(() => {
1787
+ lastAdvancedFilterSerialized = serialized;
1788
+ props.onAdvancedFilterChange?.(advancedFilter ?? null);
1789
+ });
1790
+ });
1791
+
1792
+ let lastAnnouncedSelectionCount = 0;
1793
+ $effect(() => {
1794
+ const count = Object.values(rowSelectionState).filter(Boolean).length;
1795
+ const messages = gridMessages;
1796
+ untrack(() => {
1797
+ const message = selectionAnnouncement(
1798
+ lastAnnouncedSelectionCount,
1799
+ count,
1800
+ messages,
1801
+ );
1802
+ lastAnnouncedSelectionCount = count;
1803
+ if (message) announce(message);
1804
+ });
1805
+ });
1806
+
1642
1807
  // ---- In-grid pivot mode ----
1643
1808
  // The pivot ENGINE ships in @svgrid/enterprise (registered via the pivot-view
1644
1809
  // seam). When `pivot` is set and the engine is present, the grid runs it over
@@ -3089,6 +3254,8 @@ export function createSvGridController<
3089
3254
  set filterRowValues(v) { filterRowValues = v as never; },
3090
3255
  get filterMenuValues() { return filterMenuValues; },
3091
3256
  set filterMenuValues(v) { filterMenuValues = v as never; },
3257
+ get advancedFilter() { return advancedFilter; },
3258
+ set advancedFilter(v) { advancedFilter = v as never; },
3092
3259
  get verticalScrollbarEl() { return verticalScrollbarEl; },
3093
3260
  set verticalScrollbarEl(v) { verticalScrollbarEl = v as never; },
3094
3261
  get horizontalScrollbarEl() { return horizontalScrollbarEl; },
package/src/SvGrid.css CHANGED
@@ -122,6 +122,38 @@
122
122
  border-color: var(--sg-accent, #2563eb);
123
123
  color: #fff;
124
124
  }
125
+ /* Advanced-filter indicator. Reads as state rather than as another action, so
126
+ it uses the accent fill the toolbar buttons reserve for `is-active` instead
127
+ of the neutral button shell. */
128
+ .sv-grid-advf-chip {
129
+ display: inline-flex;
130
+ align-items: center;
131
+ gap: 6px;
132
+ padding: 5px 6px 5px 11px;
133
+ border-radius: 999px;
134
+ border: 1px solid color-mix(in srgb, var(--sg-accent, #2563eb) 35%, transparent);
135
+ background: color-mix(in srgb, var(--sg-accent, #2563eb) 12%, transparent);
136
+ color: var(--sg-accent, #2563eb);
137
+ font-size: 12.5px;
138
+ font-weight: 600;
139
+ }
140
+ .sv-grid-advf-clear {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ width: 18px;
145
+ height: 18px;
146
+ border: 0;
147
+ border-radius: 999px;
148
+ background: transparent;
149
+ color: inherit;
150
+ font-size: 11px;
151
+ cursor: pointer;
152
+ transition: background-color 120ms ease;
153
+ }
154
+ .sv-grid-advf-clear:hover {
155
+ background: color-mix(in srgb, var(--sg-accent, #2563eb) 25%, transparent);
156
+ }
125
157
  .sv-grid-tool-panel {
126
158
  position: absolute;
127
159
  top: 0;