@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,194 @@
1
+ /**
2
+ * Accessibility tests that run axe-core against a REAL rendered grid.
3
+ *
4
+ * The other a11y tests in this package (`a11y.test.ts`, `a11y.contract.test.ts`)
5
+ * check the ARIA prop builders in isolation, against strings. Useful, but they
6
+ * cannot catch a violation that only exists once the component is assembled:
7
+ * a duplicated id, a control with no accessible name, a role nested somewhere
8
+ * it is not allowed. This file mounts `<SvGrid>` and audits the resulting DOM.
9
+ *
10
+ * It runs in the normal vitest suite - which means it runs in CI. The Playwright
11
+ * e2e suite cannot, because it depends on the private `website/` submodule.
12
+ *
13
+ * jsdom limitation, stated rather than hidden: jsdom performs no layout, so
14
+ * every geometry-dependent rule is meaningless here and is disabled below. The
15
+ * important one is `color-contrast`, which needs real painting. Contrast is a
16
+ * property of a theme, not of grid markup, and the guidance for verifying it
17
+ * against your own theme lives in docs/help/accessibility.md. What this file
18
+ * does cover is the structural half: roles, names, relationships, and ids.
19
+ */
20
+ import { describe, expect, it } from 'vitest'
21
+ import { mount, unmount } from 'svelte'
22
+ import axe from 'axe-core'
23
+ import SvGrid from './SvGrid.svelte'
24
+ import {
25
+ tableFeatures,
26
+ columnFilteringFeature,
27
+ rowSortingFeature,
28
+ rowSelectionFeature,
29
+ createCoreRowModel,
30
+ createFilteredRowModel,
31
+ createSortedRowModel,
32
+ } from './index'
33
+ import type { ColumnDef } from './index'
34
+
35
+ type Row = { id: string; name: string; team: string; score: number }
36
+
37
+ const features = tableFeatures({
38
+ columnFilteringFeature,
39
+ rowSortingFeature,
40
+ rowSelectionFeature,
41
+ })
42
+
43
+ const sortFns = {} as never
44
+
45
+ const rows: Row[] = [
46
+ { id: 'r1', name: 'Ada Lovelace', team: 'Platform', score: 91 },
47
+ { id: 'r2', name: 'Grace Hopper', team: 'Compilers', score: 88 },
48
+ { id: 'r3', name: 'Karen Sparck Jones', team: 'Search', score: 95 },
49
+ { id: 'r4', name: 'Barbara Liskov', team: 'Platform', score: 93 },
50
+ ]
51
+
52
+ const cols: ColumnDef<typeof features, Row>[] = [
53
+ { field: 'name', header: 'Name', width: 200 },
54
+ { field: 'team', header: 'Team', width: 140 },
55
+ { field: 'score', header: 'Score', width: 100, align: 'right' },
56
+ ]
57
+
58
+ /**
59
+ * Rules that depend on layout or painting. jsdom gives every element zero size
60
+ * and no computed colour, so these produce noise rather than signal.
61
+ */
62
+ const LAYOUT_DEPENDENT_RULES = [
63
+ 'color-contrast',
64
+ 'scrollable-region-focusable',
65
+ 'target-size',
66
+ ]
67
+
68
+ function mountGrid(extraProps: Record<string, unknown> = {}) {
69
+ const target = document.createElement('div')
70
+ document.body.appendChild(target)
71
+ const app = mount(SvGrid, {
72
+ target,
73
+ props: {
74
+ data: rows,
75
+ columns: cols,
76
+ features,
77
+ _rowModels: {
78
+ coreRowModel: createCoreRowModel(),
79
+ filteredRowModel: createFilteredRowModel(),
80
+ sortedRowModel: createSortedRowModel(sortFns),
81
+ },
82
+ containerHeight: 300,
83
+ virtualization: false,
84
+ columnVirtualization: false,
85
+ ...extraProps,
86
+ } as never,
87
+ })
88
+ return {
89
+ target,
90
+ destroy: () => {
91
+ unmount(app)
92
+ target.remove()
93
+ },
94
+ }
95
+ }
96
+
97
+ async function auditGrid(props: Record<string, unknown> = {}) {
98
+ const { target, destroy } = mountGrid(props)
99
+ try {
100
+ // Let the grid settle (row model + effects) before auditing.
101
+ await new Promise((r) => setTimeout(r, 0))
102
+ const results = await axe.run(target, {
103
+ rules: Object.fromEntries(LAYOUT_DEPENDENT_RULES.map((id) => [id, { enabled: false }])),
104
+ })
105
+ return results.violations.map((v) => ({
106
+ id: v.id,
107
+ impact: v.impact,
108
+ help: v.help,
109
+ nodes: v.nodes.map((n) => n.html).slice(0, 3),
110
+ }))
111
+ } finally {
112
+ destroy()
113
+ }
114
+ }
115
+
116
+ describe('axe audit of a rendered grid', () => {
117
+ it('reports no violations for a plain grid', async () => {
118
+ expect(await auditGrid()).toEqual([])
119
+ })
120
+
121
+ it('reports no violations with the filter row and global filter shown', async () => {
122
+ expect(
123
+ await auditGrid({ showColumnFilters: true, showFilterRow: true, showGlobalFilter: true }),
124
+ ).toEqual([])
125
+ })
126
+
127
+ it('reports no violations with row selection enabled', async () => {
128
+ expect(await auditGrid({ showRowSelection: true })).toEqual([])
129
+ })
130
+
131
+ it('reports no violations with pagination shown', async () => {
132
+ expect(await auditGrid({ showPagination: true, pageSize: 2 })).toEqual([])
133
+ })
134
+
135
+ it('actually audits something - the grid role is present in the tree', async () => {
136
+ // Guards against the audit silently passing because nothing rendered.
137
+ const { target, destroy } = mountGrid()
138
+ try {
139
+ await new Promise((r) => setTimeout(r, 0))
140
+ expect(target.querySelector('[role="grid"], table')).not.toBeNull()
141
+ } finally {
142
+ destroy()
143
+ }
144
+ })
145
+ })
146
+
147
+ describe('validation is exposed to assistive technology', () => {
148
+ // `validate` used to mark a bad cell with a red class and a `title` only.
149
+ // Both are visual: the class carries no semantics at all, and `title` is
150
+ // unreliably surfaced and unreachable by keyboard. A screen-reader user had
151
+ // no way to know the cell was rejected, which is WCAG 3.3.1.
152
+ const validatedCols: ColumnDef<typeof features, Row>[] = [
153
+ { field: 'name', header: 'Name', width: 200 },
154
+ {
155
+ field: 'score',
156
+ header: 'Score',
157
+ width: 100,
158
+ validate: ({ value }) => (Number(value) < 90 ? 'Score must be at least 90' : null),
159
+ },
160
+ ]
161
+
162
+ it('marks an invalid cell with aria-invalid and keeps the message', async () => {
163
+ const { target, destroy } = mountGrid({ columns: validatedCols })
164
+ try {
165
+ await new Promise((r) => setTimeout(r, 0))
166
+ const invalid = [...target.querySelectorAll('td[aria-invalid="true"]')]
167
+ // Grace 88 fails, Ada 91 / Karen 95 / Barbara 93 pass.
168
+ expect(invalid).toHaveLength(1)
169
+ // The reason must reach assistive tech, not just the hover tooltip:
170
+ // either as the accessible description (`title`) or as visually-hidden
171
+ // text read with the cell. Which one depends on the render path.
172
+ const reason =
173
+ invalid[0]?.getAttribute('title') ??
174
+ invalid[0]?.querySelector('.sv-grid-sr-only')?.textContent
175
+ expect(reason).toBe('Score must be at least 90')
176
+ } finally {
177
+ destroy()
178
+ }
179
+ })
180
+
181
+ it('leaves valid cells unmarked', async () => {
182
+ const { target, destroy } = mountGrid({ columns: validatedCols })
183
+ try {
184
+ await new Promise((r) => setTimeout(r, 0))
185
+ expect(target.querySelectorAll('td[aria-invalid]')).toHaveLength(1)
186
+ } finally {
187
+ destroy()
188
+ }
189
+ })
190
+
191
+ it('passes axe with an invalid cell rendered', async () => {
192
+ expect(await auditGrid({ columns: validatedCols })).toEqual([])
193
+ })
194
+ })
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Registration seam for the advanced filter (a `@svgrid/enterprise` feature).
3
+ *
4
+ * The grid owns the state and the pipeline slot; enterprise owns the compiler.
5
+ * Same shape as `pivot-view.svelte.ts`: a module-level `$state` holder with a
6
+ * register / get / has triple, so the free package carries no commercial code.
7
+ */
8
+ import type { GridPredicateExpr } from './filtering/predicate-expr'
9
+
10
+ /** What the engine needs in order to compile an expression into a predicate. */
11
+ export type AdvancedFilterCompileContext<TRow> = {
12
+ /**
13
+ * Resolve a column id to its cell value for a row. The grid passes the same
14
+ * accessor the column-menu filter stage uses, so a `cmp` on a `fieldFn` or
15
+ * value-getter column resolves identically in both.
16
+ */
17
+ getValue: (row: TRow, columnId: string) => unknown
18
+ /** Locale for text folding, matching the column filters. */
19
+ locale?: string | ReadonlyArray<string>
20
+ /**
21
+ * The rows entering this stage. Aggregates (`SUM(amount) > 1000`) are folded
22
+ * over exactly these, once, so they see the rows the user is currently
23
+ * looking at rather than the raw dataset.
24
+ */
25
+ rows: ReadonlyArray<TRow>
26
+ }
27
+
28
+ /** A compiled expression: cheap to call once per row. */
29
+ export type CompiledRowPredicate<TRow> = (row: TRow) => boolean
30
+
31
+ /**
32
+ * Compile an expression to a row predicate.
33
+ *
34
+ * Returns `null` when the expression cannot be compiled (an unknown node kind,
35
+ * an unresolvable column). Must never throw: the grid treats both `null` and a
36
+ * thrown error as "do not filter", because a half-applied filter silently
37
+ * showing the wrong rows is worse than an unapplied one.
38
+ */
39
+ export type AdvancedFilterEngine = <TRow>(
40
+ expr: GridPredicateExpr,
41
+ ctx: AdvancedFilterCompileContext<TRow>,
42
+ ) => CompiledRowPredicate<TRow> | null
43
+
44
+ let engine = $state<AdvancedFilterEngine | null>(null)
45
+
46
+ /** Register the engine. Enterprise calls this from `enableAdvancedFilter()`. */
47
+ export function registerAdvancedFilterEngine(fn: AdvancedFilterEngine | null): void {
48
+ engine = fn
49
+ }
50
+
51
+ /** The registered engine, or null when enterprise is not installed. */
52
+ export function getAdvancedFilterEngine(): AdvancedFilterEngine | null {
53
+ return engine
54
+ }
55
+
56
+ /** Whether an advanced-filter engine has been registered. */
57
+ export function hasAdvancedFilterEngine(): boolean {
58
+ return engine != null
59
+ }
package/src/build-api.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  getColumnAlign,
26
26
  columnDefMatchesId,
27
27
  } from "./cell-values";
28
+ import { hasAdvancedFilterEngine } from "./advanced-filter.svelte";
28
29
 
29
30
  export function createGridApi<
30
31
  TFeatures extends TableFeatures = TableFeatures,
@@ -369,13 +370,33 @@ export function createGridApi<
369
370
  // See `setFilter` for why the read-modify-write is untracked.
370
371
  untrack(() => {
371
372
  // Wipe every filter surface in one go: column-menu, filter-row, set
372
- // filters, and the global search box.
373
+ // filters, the global search box, and the advanced filter. This
374
+ // promises "every filter surface", so leaving the advanced one set
375
+ // would strand rows nobody can see a cause for.
373
376
  ctx.filterMenuValues = {};
374
377
  ctx.filterRowValues = {};
375
378
  ctx.valueFilters = {};
376
379
  if (ctx.globalFilter !== "") ctx.globalFilter = "";
380
+ if (ctx.advancedFilter !== null) ctx.advancedFilter = null;
377
381
  });
378
382
  },
383
+ setAdvancedFilter(expr) {
384
+ // See `setFilter` for why the write is untracked.
385
+ untrack(() => {
386
+ ctx.advancedFilter = expr ?? null;
387
+ });
388
+ },
389
+ getAdvancedFilter() {
390
+ return ctx.advancedFilter ?? null;
391
+ },
392
+ clearAdvancedFilter() {
393
+ untrack(() => {
394
+ ctx.advancedFilter = null;
395
+ });
396
+ },
397
+ isAdvancedFilterActive() {
398
+ return ctx.advancedFilter != null && hasAdvancedFilterEngine();
399
+ },
379
400
  getFilters() {
380
401
  // Return a defensive copy so callers can't mutate internal state.
381
402
  const out: Record<
@@ -397,9 +418,14 @@ export function createGridApi<
397
418
  // `allRows` is the final, post-filter, post-sort, post-group,
398
419
  // post-pagination list - exactly what the body renders. Skip group
399
420
  // header rows (they wrap an aggregate, not a TData row).
421
+ //
422
+ // `isGroupRow` rather than a `subRows.length` test: a TREE parent also
423
+ // has subRows, but it is a real data row that renders its own cells, so
424
+ // dropping it here silently truncated every tree read - including the
425
+ // enterprise export, whose default row source is this method.
400
426
  const out: TData[] = [];
401
427
  for (const row of ctx.allRows) {
402
- if (row.subRows && row.subRows.length > 0) continue;
428
+ if (isGroupRow(row)) continue;
403
429
  out.push(row.original as TData);
404
430
  }
405
431
  return out;
@@ -428,6 +454,10 @@ export function createGridApi<
428
454
  // editorType-based default the body uses.
429
455
  format: c.columnDef.format,
430
456
  align: getColumnAlign(c),
457
+ // The column's declared value type. Lets a filter or expression UI
458
+ // offer the right operators (numeric ranges vs text matching) without
459
+ // re-walking the columnDef tree.
460
+ editorType: (c.columnDef as { editorType?: string }).editorType,
431
461
  });
432
462
  // Visible columns first, in their current visual order...
433
463
  const out = ctx.allColumns.map((c: any) => describe(c, true));
@@ -721,6 +751,11 @@ export function createGridApi<
721
751
  Array.from(set),
722
752
  ]),
723
753
  ),
754
+ // Emitted only when set. `attachAutoSavedView` diffs
755
+ // JSON.stringify(getState()) on a timer, so adding an always-present
756
+ // key would change every existing snapshot and force one spurious
757
+ // save in every app on upgrade.
758
+ ...(ctx.advancedFilter ? { advancedFilter: ctx.advancedFilter } : {}),
724
759
  ...(ctx.chartingEnabled
725
760
  ? {
726
761
  chart: {
@@ -777,6 +812,12 @@ export function createGridApi<
777
812
  next[k] = new Set(arr);
778
813
  ctx.valueFilters = next;
779
814
  }
815
+ // `in` rather than a truthiness check, so an explicit null clears while
816
+ // an absent key leaves the current filter alone (the documented
817
+ // "only the keys present are applied" contract).
818
+ if ("advancedFilter" in state) {
819
+ ctx.advancedFilter = state.advancedFilter ?? null;
820
+ }
780
821
  if ((state.chart || state.charts) && ctx.chartingEnabled) {
781
822
  if (Array.isArray(state.charts) && state.charts.length) {
782
823
  ctx.applyChartsState(state.charts, state.chartActive);
@@ -3,6 +3,10 @@
3
3
  // so it lives outside the controller.
4
4
  import type { Column, RowData } from "./index";
5
5
  import type { FilterOperator, FilterOption } from "./SvGrid.types";
6
+ import {
7
+ SET_OPERATOR_IDS,
8
+ VALUELESS_OPERATOR_IDS,
9
+ } from "./filtering/filter-operator-catalogue";
6
10
 
7
11
  export const filterOperatorOptions: Array<FilterOption> = [
8
12
  { value: "contains", label: "Contains", iconName: "op-contains" },
@@ -22,13 +26,12 @@ export const filterOperatorOptions: Array<FilterOption> = [
22
26
  ];
23
27
  /** Operators whose predicate is a set-membership test over a token list.
24
28
  * The filter row renders a multi-value chip input for these instead of a
25
- * single text box. */
26
- export const SET_OPERATORS: ReadonlyArray<FilterOperator> = ["in", "notIn"];
29
+ * single text box. Sourced from the shared catalogue so the grid and the
30
+ * expression editor cannot disagree about which control to render. */
31
+ export const SET_OPERATORS: ReadonlyArray<FilterOperator> = SET_OPERATOR_IDS;
27
32
  /** Operators that need no value input - they act on emptiness alone. */
28
- export const VALUELESS_OPERATORS: ReadonlyArray<FilterOperator> = [
29
- "isBlank",
30
- "isNotBlank",
31
- ];
33
+ export const VALUELESS_OPERATORS: ReadonlyArray<FilterOperator> =
34
+ VALUELESS_OPERATOR_IDS;
32
35
  /** Which operators make sense for each column editor type. */
33
36
  export const TEXT_OPERATORS: Array<FilterOperator> = [
34
37
  "contains",
@@ -159,6 +159,21 @@ function scanInTokens(value: unknown): { tokens: string[]; trailing: string } {
159
159
  return { tokens, trailing: take() }
160
160
  }
161
161
 
162
+ // Re-exported here so consumers can reach the catalogue through the
163
+ // `@svgrid/grid/filtering` subpath. Importing it from the package root would
164
+ // pull in SvGrid.svelte, which is a runtime cost (and a bundler hazard) for
165
+ // anything that only wants operator semantics.
166
+ export {
167
+ ALL_FILTER_OPERATORS,
168
+ SET_OPERATOR_IDS,
169
+ VALUELESS_OPERATOR_IDS,
170
+ RANGE_OPERATOR_IDS,
171
+ isSetOperator,
172
+ isValuelessOperator,
173
+ isRangeOperator,
174
+ type FilterValueType,
175
+ } from './filter-operator-catalogue.js'
176
+
162
177
  /** A filter with its needle-side work already done. Call per row. */
163
178
  export type CompiledExcelFilter = (cellValue: unknown) => boolean
164
179
 
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The canonical filter-operator catalogue: which operators exist, and the
3
+ * semantics every surface must agree on.
4
+ *
5
+ * Deliberately narrow. Two things live here:
6
+ *
7
+ * 1. `ALL_FILTER_OPERATORS` - the operator identity list, so a new operator
8
+ * cannot be added to one surface and silently missed by another.
9
+ * 2. The input-shape predicates - whether an operator takes no value, a
10
+ * token list, or a second value. Get these wrong and a filter UI renders
11
+ * the wrong control, so they cannot be allowed to diverge.
12
+ *
13
+ * What deliberately does NOT live here: labels, ordering, and which operators
14
+ * a given column type offers. Those are presentation choices that legitimately
15
+ * differ per surface - the grid's filter menu leads with "Contains" for text
16
+ * while the expression editor leads with "Equals", and both are right for
17
+ * their context.
18
+ *
19
+ * Zero imports beyond the operator union, so any package can consume this
20
+ * without pulling in the grid's type graph.
21
+ */
22
+ import type { ExcelFilterOperator } from './excel-filters.js'
23
+
24
+ /** Coarse value type used to decide which operators a column offers. */
25
+ export type FilterValueType = 'text' | 'number' | 'date' | 'datetime' | 'boolean'
26
+
27
+ /**
28
+ * Every filter operator, in the grid's filter-menu order. Any surface that
29
+ * presents operators should be able to account for all of these.
30
+ */
31
+ export const ALL_FILTER_OPERATORS: ReadonlyArray<ExcelFilterOperator> = [
32
+ 'contains',
33
+ 'notContains',
34
+ 'equals',
35
+ 'notEquals',
36
+ 'startsWith',
37
+ 'endsWith',
38
+ 'regex',
39
+ 'in',
40
+ 'notIn',
41
+ 'greaterThan',
42
+ 'lessThan',
43
+ 'between',
44
+ 'isBlank',
45
+ 'isNotBlank',
46
+ ]
47
+
48
+ /** Operators whose predicate is a set-membership test over a token list.
49
+ * A filter UI renders a multi-value chip input for these. */
50
+ export const SET_OPERATOR_IDS: ReadonlyArray<ExcelFilterOperator> = ['in', 'notIn']
51
+
52
+ /** Operators that need no value input - they act on emptiness alone. */
53
+ export const VALUELESS_OPERATOR_IDS: ReadonlyArray<ExcelFilterOperator> = [
54
+ 'isBlank',
55
+ 'isNotBlank',
56
+ ]
57
+
58
+ /** Operators that need a second value (`valueTo`). */
59
+ export const RANGE_OPERATOR_IDS: ReadonlyArray<ExcelFilterOperator> = ['between']
60
+
61
+ /** Whether an operator takes a multi-value token list. */
62
+ export function isSetOperator(op: ExcelFilterOperator): boolean {
63
+ return SET_OPERATOR_IDS.includes(op)
64
+ }
65
+
66
+ /** Whether an operator needs no value input. */
67
+ export function isValuelessOperator(op: ExcelFilterOperator): boolean {
68
+ return VALUELESS_OPERATOR_IDS.includes(op)
69
+ }
70
+
71
+ /** Whether an operator needs a second (`valueTo`) value. */
72
+ export function isRangeOperator(op: ExcelFilterOperator): boolean {
73
+ return RANGE_OPERATOR_IDS.includes(op)
74
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readdirSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * `@svgrid/grid/filtering` is one of the few entry points consumed by RAW NODE
7
+ * rather than by a bundler: @svgrid/enterprise's Studio bundle keeps
8
+ * `@svgrid/grid` external, so the published dist is imported by Node's own ESM
9
+ * resolver at runtime.
10
+ *
11
+ * Node ESM does not guess extensions. The rest of the package can use
12
+ * extensionless relative imports because a bundler resolves them; anything
13
+ * reachable from this entry cannot. `import './x'` works in Vite and throws
14
+ * ERR_MODULE_NOT_FOUND under Node.
15
+ *
16
+ * This was not hypothetical. Splitting the operator catalogue out of
17
+ * `excel-filters.ts` gave that file its first relative import and broke every
18
+ * generated Studio app at startup. Only `pnpm verify:app` caught it, after the
19
+ * change was committed.
20
+ *
21
+ * The rule enforced here is deliberately blunt - EVERY relative specifier in
22
+ * this folder ends in `.js`, type-only ones included. Type imports are erased
23
+ * and would be safe either way, but exempting them means parsing TypeScript to
24
+ * decide which is which, and a subtle parser in a guard test is how the guard
25
+ * silently stops guarding. TypeScript resolves `./excel-filters.js` to
26
+ * `excel-filters.ts` regardless, so the strict rule costs nothing.
27
+ */
28
+
29
+ const DIR = import.meta.dirname
30
+
31
+ /** Every relative module specifier in the file, however it is imported. */
32
+ function relativeSpecifiers(source: string): string[] {
33
+ const out: string[] = []
34
+ // Covers `from './x'`, bare `import './x'`, and dynamic `import('./x')`.
35
+ const re = /(?:from|import)\s*\(?\s*['"](\.[^'"]*)['"]/g
36
+ let m: RegExpExecArray | null
37
+ while ((m = re.exec(source))) out.push(m[1]!)
38
+ return out
39
+ }
40
+
41
+ const files = readdirSync(DIR).filter(
42
+ (f) => f.endsWith('.ts') && !f.endsWith('.test.ts'),
43
+ )
44
+
45
+ describe('the /filtering entry stays resolvable by raw Node', () => {
46
+ it('finds the source files to check', () => {
47
+ // Guards against the directory read silently matching nothing, which would
48
+ // turn every case below into a vacuous pass.
49
+ expect(files.length).toBeGreaterThan(0)
50
+ expect(files).toContain('excel-filters.ts')
51
+ })
52
+
53
+ it.each(files)('%s gives every relative import a .js extension', (file) => {
54
+ const offenders = relativeSpecifiers(readFileSync(join(DIR, file), 'utf8')).filter(
55
+ (spec) => !spec.endsWith('.js'),
56
+ )
57
+ expect(
58
+ offenders,
59
+ `${file} imports ${offenders.join(', ')} without a .js extension. ` +
60
+ 'Node cannot resolve that in the published dist, which breaks every ' +
61
+ 'generated Studio app at startup. Add .js to the specifier.',
62
+ ).toEqual([])
63
+ })
64
+
65
+ it('detects a missing extension', () => {
66
+ // The guard has to be able to fail. Without this the regex could stop
67
+ // matching and every case above would pass for the wrong reason.
68
+ expect(relativeSpecifiers(`export { a } from './some-module'`)).toEqual([
69
+ './some-module',
70
+ ])
71
+ expect(relativeSpecifiers(`import './side-effect'`)).toEqual(['./side-effect'])
72
+ expect(relativeSpecifiers(`const m = await import('./lazy')`)).toEqual(['./lazy'])
73
+ expect(relativeSpecifiers(`import { a } from './ok.js'`)).toEqual(['./ok.js'])
74
+ })
75
+ })
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The advanced-filter expression shape, as the free grid sees it.
3
+ *
4
+ * This is a structural mirror of `@svgrid/enterprise`'s `PredicateExpr`. It is
5
+ * written here, in MIT code, against the grid's own operator union - the same
6
+ * arrangement `GridPivotConfig` uses so the free package can type a commercial
7
+ * feature's payload without depending on the commercial package.
8
+ *
9
+ * The two definitions must stay assignable in both directions. A bidirectional
10
+ * assignability test in `@svgrid/enterprise` fails `pnpm test:types` the moment
11
+ * either side drifts, so this is a checked mirror rather than a hopeful one.
12
+ *
13
+ * The grid never interprets these nodes. It hands the whole expression to the
14
+ * engine registered via `registerAdvancedFilterEngine`, which compiles it. That
15
+ * keeps the evaluator, the parser and the editor commercial.
16
+ */
17
+ import type { ExcelFilterOperator } from './excel-filters.js'
18
+
19
+ /** Comparison operators available to a cross-column (`scalarCmp`) node. */
20
+ export type GridComparisonOp = '=' | '!=' | '>' | '<' | '>=' | '<='
21
+
22
+ /** Arithmetic operators available inside a scalar expression. */
23
+ export type GridArithmeticOp = '+' | '-' | '*' | '/' | '%'
24
+
25
+ /** Aggregate functions available inside a scalar expression. */
26
+ export type GridAggFn = 'sum' | 'avg' | 'min' | 'max' | 'count'
27
+
28
+ /** A value-producing expression: a column, a literal, or maths over them. */
29
+ export type GridScalarExpr =
30
+ | { kind: 'col'; id: string }
31
+ | { kind: 'lit'; value: string | number | boolean | null }
32
+ | { kind: 'neg'; expr: GridScalarExpr }
33
+ | { kind: 'bin'; op: GridArithmeticOp; left: GridScalarExpr; right: GridScalarExpr }
34
+ | { kind: 'agg'; fn: GridAggFn; column: string }
35
+ | { kind: 'func'; name: string; args: GridScalarExpr[] }
36
+
37
+ /**
38
+ * A boolean expression over a row.
39
+ *
40
+ * `cmp` is deliberately isomorphic to one column-menu filter condition: its
41
+ * `op` is the same `ExcelFilterOperator` the filter row uses, so a simple
42
+ * advanced filter and a column filter mean exactly the same thing.
43
+ */
44
+ export type GridPredicateExpr =
45
+ | { kind: 'and'; parts: GridPredicateExpr[] }
46
+ | { kind: 'or'; parts: GridPredicateExpr[] }
47
+ | { kind: 'not'; expr: GridPredicateExpr }
48
+ | { kind: 'cmp'; column: string; op: ExcelFilterOperator; value?: unknown; valueTo?: unknown }
49
+ | { kind: 'scalarCmp'; left: GridScalarExpr; op: GridComparisonOp; right: GridScalarExpr }
50
+ | { kind: 'const'; value: boolean }
@@ -0,0 +1,57 @@
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
+
17
+ /** Apply `keep` to leaf rows, dropping banners left with no surviving child. */
18
+ export function applyRowPredicate<TRow>(
19
+ rows: ReadonlyArray<TRow>,
20
+ keep: (row: TRow) => boolean,
21
+ isGroup: (row: TRow) => boolean,
22
+ ): TRow[] {
23
+ // Fast path: no grouping in play, so every row is a leaf.
24
+ let hasGroup = false
25
+ for (const row of rows) {
26
+ if (isGroup(row)) {
27
+ hasGroup = true
28
+ break
29
+ }
30
+ }
31
+ if (!hasGroup) return rows.filter(keep)
32
+
33
+ // One reverse pass. `survivorsBelow` counts leaves kept since we last closed
34
+ // out a banner, so a banner is kept when anything after it survived.
35
+ const keepFlags = new Array<boolean>(rows.length).fill(false)
36
+ let survivorsSinceBanner = 0
37
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
38
+ const row = rows[i]!
39
+ if (isGroup(row)) {
40
+ if (survivorsSinceBanner > 0) {
41
+ keepFlags[i] = true
42
+ // The banner itself counts as surviving content for any banner above
43
+ // it, so nested groups keep their ancestors.
44
+ survivorsSinceBanner = 1
45
+ } else {
46
+ keepFlags[i] = false
47
+ }
48
+ } else if (keep(row)) {
49
+ keepFlags[i] = true
50
+ survivorsSinceBanner += 1
51
+ }
52
+ }
53
+
54
+ const out: TRow[] = []
55
+ for (let i = 0; i < rows.length; i += 1) if (keepFlags[i]) out.push(rows[i]!)
56
+ return out
57
+ }