@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.
- package/dist/SvGrid.controller.svelte.d.ts +2 -1
- package/dist/SvGrid.controller.svelte.js +145 -3
- package/dist/SvGrid.css +32 -0
- package/dist/SvGrid.svelte +64 -6
- package/dist/SvGrid.types.d.ts +61 -0
- package/dist/a11y/grid-announcements.d.ts +58 -0
- package/dist/a11y/grid-announcements.js +70 -0
- package/dist/advanced-filter.svelte.d.ts +42 -0
- package/dist/advanced-filter.svelte.js +13 -0
- package/dist/build-api.js +44 -2
- package/dist/cdn/{GridMenus-BoVKgAv8.js → GridMenus-CBvjL5u4.js} +14 -14
- package/dist/cdn/{GridMenus-DL1zrDwf.js → GridMenus-DAzp6YXc.js} +14 -14
- package/dist/cdn/{src-M2NfKnjX.js → src-3WKN7L4G.js} +5932 -5743
- package/dist/cdn/{src-CEpw6Ato.js → src-Cl7h5-Ux.js} +3371 -3182
- package/dist/cdn/svgrid.js +8 -8
- package/dist/cdn/svgrid.svelte-external.js +8 -8
- package/dist/filter-operators.d.ts +2 -1
- package/dist/filter-operators.js +5 -6
- package/dist/filtering/excel-filters.d.ts +1 -0
- package/dist/filtering/excel-filters.js +5 -0
- package/dist/filtering/filter-operator-catalogue.d.ts +42 -0
- package/dist/filtering/filter-operator-catalogue.js +42 -0
- package/dist/filtering/predicate-expr.d.ts +78 -0
- package/dist/filtering/predicate-expr.js +1 -0
- package/dist/filtering/row-predicate.d.ts +17 -0
- package/dist/filtering/row-predicate.js +55 -0
- package/dist/grid-messages.d.ts +7 -0
- package/dist/grid-messages.js +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/server-data-source.d.ts +34 -0
- package/dist/server-data-source.js +19 -0
- package/dist/svgrid-wrapper.types.d.ts +37 -5
- package/dist/themes/index.js +12 -4
- package/package.json +2 -1
- package/src/SvGrid.controller.svelte.ts +169 -2
- package/src/SvGrid.css +32 -0
- package/src/SvGrid.svelte +64 -6
- package/src/SvGrid.types.ts +61 -0
- package/src/a11y/grid-announcements.test.ts +78 -0
- package/src/a11y/grid-announcements.ts +94 -0
- package/src/a11y.announce.test.ts +250 -0
- package/src/a11y.axe.test.ts +194 -0
- package/src/advanced-filter.svelte.ts +59 -0
- package/src/build-api.ts +43 -2
- package/src/filter-operators.ts +9 -6
- package/src/filtering/excel-filters.ts +15 -0
- package/src/filtering/filter-operator-catalogue.ts +74 -0
- package/src/filtering/node-entry.test.ts +75 -0
- package/src/filtering/predicate-expr.ts +50 -0
- package/src/filtering/row-predicate.ts +57 -0
- package/src/grid-messages.ts +18 -0
- package/src/index.ts +28 -0
- package/src/server-data-source.test.ts +94 -0
- package/src/server-data-source.ts +56 -0
- package/src/svgrid-wrapper.types.ts +37 -5
- package/src/svgrid.advanced-filter.test.ts +481 -0
- package/src/svgrid.displayed-rows.test.ts +144 -0
- package/src/svgrid.group-props-reactive.svelte.test.ts +151 -0
- package/src/svgrid.group-props.test.ts +280 -0
- package/src/themes/contrast.test.ts +112 -0
- package/src/themes/index.ts +12 -4
- package/themes/catppuccin.css +3 -3
- package/themes/dracula.css +3 -3
- package/themes/nord.css +3 -3
- 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,
|
|
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
|
|
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);
|
package/src/filter-operators.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|