@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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity for `groupBy` / `expanded`: reassigning the prop must re-apply to
|
|
3
|
+
* the engine, and a controlled parent that echoes `onExpandedChange` straight
|
|
4
|
+
* back into `expanded` must settle instead of looping.
|
|
5
|
+
*
|
|
6
|
+
* Lives in a `.svelte.test.ts` so runes are available - the props object is a
|
|
7
|
+
* `$state` proxy, which is what makes a prop reassignment reach the component.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
11
|
+
import SvGrid from './SvGrid.svelte'
|
|
12
|
+
import {
|
|
13
|
+
columnGroupingFeature,
|
|
14
|
+
createCoreRowModel,
|
|
15
|
+
createExpandedRowModel,
|
|
16
|
+
createFilteredRowModel,
|
|
17
|
+
createGroupedRowModel,
|
|
18
|
+
createPaginatedRowModel,
|
|
19
|
+
createSortedRowModel,
|
|
20
|
+
rowExpandingFeature,
|
|
21
|
+
rowSortingFeature,
|
|
22
|
+
sortFns,
|
|
23
|
+
tableFeatures,
|
|
24
|
+
} from './index'
|
|
25
|
+
import type { SvGridApi } from './index'
|
|
26
|
+
|
|
27
|
+
type Row = { id: number; name: string; department: string }
|
|
28
|
+
|
|
29
|
+
const rows: Row[] = [
|
|
30
|
+
{ id: 1, name: 'Ada', department: 'Engineering' },
|
|
31
|
+
{ id: 2, name: 'Grace', department: 'Engineering' },
|
|
32
|
+
{ id: 3, name: 'Linus', department: 'Operations' },
|
|
33
|
+
]
|
|
34
|
+
const columns = [
|
|
35
|
+
{ field: 'name', header: 'Name', width: 150 },
|
|
36
|
+
{ field: 'department', header: 'Department', width: 180 },
|
|
37
|
+
]
|
|
38
|
+
const features = tableFeatures({ rowSortingFeature, columnGroupingFeature, rowExpandingFeature })
|
|
39
|
+
|
|
40
|
+
function baseProps(extra: Record<string, unknown>): Record<string, unknown> {
|
|
41
|
+
return {
|
|
42
|
+
data: rows,
|
|
43
|
+
columns,
|
|
44
|
+
features,
|
|
45
|
+
_rowModels: {
|
|
46
|
+
coreRowModel: createCoreRowModel(),
|
|
47
|
+
filteredRowModel: createFilteredRowModel(),
|
|
48
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
49
|
+
groupedRowModel: createGroupedRowModel(),
|
|
50
|
+
expandedRowModel: createExpandedRowModel(),
|
|
51
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
52
|
+
},
|
|
53
|
+
containerHeight: 480,
|
|
54
|
+
virtualization: false,
|
|
55
|
+
...extra,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('groupBy reactivity', () => {
|
|
60
|
+
it('re-groups when the prop is reassigned, and ungroups when emptied', () => {
|
|
61
|
+
const target = document.createElement('div')
|
|
62
|
+
document.body.appendChild(target)
|
|
63
|
+
let api: SvGridApi<typeof features, Row> | null = null
|
|
64
|
+
const props = $state(
|
|
65
|
+
baseProps({
|
|
66
|
+
groupBy: ['department'],
|
|
67
|
+
onApiReady: (next: SvGridApi<typeof features, Row>) => (api = next),
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
const app = mount(SvGrid, { target, props: props as never })
|
|
71
|
+
flushSync()
|
|
72
|
+
expect(api!.getState().grouping).toEqual(['department'])
|
|
73
|
+
|
|
74
|
+
props.groupBy = ['name']
|
|
75
|
+
flushSync()
|
|
76
|
+
expect(api!.getState().grouping).toEqual(['name'])
|
|
77
|
+
|
|
78
|
+
props.groupBy = []
|
|
79
|
+
flushSync()
|
|
80
|
+
expect(api!.getState().grouping).toEqual([])
|
|
81
|
+
expect(target.querySelectorAll('tbody tr').length).toBe(3) // flat again
|
|
82
|
+
|
|
83
|
+
unmount(app)
|
|
84
|
+
target.remove()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('expanded reactivity', () => {
|
|
89
|
+
it('opens and closes a group when the prop is reassigned', () => {
|
|
90
|
+
const target = document.createElement('div')
|
|
91
|
+
document.body.appendChild(target)
|
|
92
|
+
const props = $state(baseProps({ groupBy: ['department'], expanded: {} }))
|
|
93
|
+
const app = mount(SvGrid, { target, props: props as never })
|
|
94
|
+
flushSync()
|
|
95
|
+
expect(target.querySelectorAll('tbody tr').length).toBe(2) // two banners
|
|
96
|
+
|
|
97
|
+
props.expanded = {
|
|
98
|
+
group_department_Engineering: true,
|
|
99
|
+
}
|
|
100
|
+
flushSync()
|
|
101
|
+
expect(target.querySelectorAll('tbody tr').length).toBe(4) // + Ada, Grace
|
|
102
|
+
|
|
103
|
+
props.expanded = {}
|
|
104
|
+
flushSync()
|
|
105
|
+
expect(target.querySelectorAll('tbody tr').length).toBe(2)
|
|
106
|
+
|
|
107
|
+
unmount(app)
|
|
108
|
+
target.remove()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('settles when a controlled parent echoes onExpandedChange back into the prop', () => {
|
|
112
|
+
const target = document.createElement('div')
|
|
113
|
+
document.body.appendChild(target)
|
|
114
|
+
let api: SvGridApi<typeof features, Row> | null = null
|
|
115
|
+
let notifications = 0
|
|
116
|
+
const props = $state(
|
|
117
|
+
baseProps({
|
|
118
|
+
groupBy: ['department'],
|
|
119
|
+
expanded: {} as Record<string, boolean>,
|
|
120
|
+
// The exact round-trip the docs recommend.
|
|
121
|
+
onExpandedChange: (next: Record<string, boolean>) => {
|
|
122
|
+
notifications += 1
|
|
123
|
+
props.expanded = next
|
|
124
|
+
},
|
|
125
|
+
onApiReady: (next: SvGridApi<typeof features, Row>) => (api = next),
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
const app = mount(SvGrid, { target, props: props as never })
|
|
129
|
+
flushSync()
|
|
130
|
+
notifications = 0
|
|
131
|
+
|
|
132
|
+
api!.expandAllGroups()
|
|
133
|
+
flushSync()
|
|
134
|
+
|
|
135
|
+
// The echo must not re-seed and re-fire; one settled notification.
|
|
136
|
+
expect(notifications).toBe(1)
|
|
137
|
+
expect(props.expanded).toEqual({
|
|
138
|
+
group_department_Engineering: true,
|
|
139
|
+
group_department_Operations: true,
|
|
140
|
+
})
|
|
141
|
+
expect(target.querySelectorAll('tbody tr').length).toBe(5) // 2 banners + 3 leaves
|
|
142
|
+
|
|
143
|
+
api!.collapseAllGroups()
|
|
144
|
+
flushSync()
|
|
145
|
+
expect(notifications).toBe(2)
|
|
146
|
+
expect(target.querySelectorAll('tbody tr').length).toBe(2)
|
|
147
|
+
|
|
148
|
+
unmount(app)
|
|
149
|
+
target.remove()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM: the `groupBy`, `expanded` and `onExpandedChange` props.
|
|
3
|
+
*
|
|
4
|
+
* `groupBy` seeds the engine's group-by list from markup instead of forcing an
|
|
5
|
+
* `onApiReady` + `api.setGroupBy()` round trip. `expanded` / `onExpandedChange`
|
|
6
|
+
* hoist the expansion map out of the engine so saved views can persist it.
|
|
7
|
+
*
|
|
8
|
+
* The interesting cases are the ones where two writers meet: the prop and the
|
|
9
|
+
* imperative API both target the same state, and a controlled parent echoes the
|
|
10
|
+
* callback value straight back into the prop.
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
import { mount, unmount } from 'svelte'
|
|
14
|
+
import SvGrid from './SvGrid.svelte'
|
|
15
|
+
import {
|
|
16
|
+
createCoreRowModel,
|
|
17
|
+
createExpandedRowModel,
|
|
18
|
+
createFilteredRowModel,
|
|
19
|
+
createGroupedRowModel,
|
|
20
|
+
createPaginatedRowModel,
|
|
21
|
+
createSortedRowModel,
|
|
22
|
+
rowExpandingFeature,
|
|
23
|
+
columnGroupingFeature,
|
|
24
|
+
rowSortingFeature,
|
|
25
|
+
sortFns,
|
|
26
|
+
tableFeatures,
|
|
27
|
+
} from './index'
|
|
28
|
+
import type { SvGridApi } from './index'
|
|
29
|
+
|
|
30
|
+
type Row = { id: number; name: string; department: string; salary: number }
|
|
31
|
+
|
|
32
|
+
const data: Row[] = [
|
|
33
|
+
{ id: 1, name: 'Ada', department: 'Engineering', salary: 100 },
|
|
34
|
+
{ id: 2, name: 'Grace', department: 'Engineering', salary: 120 },
|
|
35
|
+
{ id: 3, name: 'Linus', department: 'Operations', salary: 90 },
|
|
36
|
+
{ id: 4, name: 'Ken', department: 'Operations', salary: 80 },
|
|
37
|
+
{ id: 5, name: 'Barbara', department: 'Research', salary: 140 },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const columns = [
|
|
41
|
+
{ field: 'name', header: 'Name', width: 150 },
|
|
42
|
+
{ field: 'department', header: 'Department', width: 180 },
|
|
43
|
+
{ field: 'salary', header: 'Salary', width: 120 },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const features = tableFeatures({ rowSortingFeature, columnGroupingFeature, rowExpandingFeature })
|
|
47
|
+
const tick = async (n = 3) => {
|
|
48
|
+
for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const mounted: Array<() => void> = []
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
while (mounted.length) mounted.pop()!()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
function mountGrid(extra: Record<string, unknown> = {}, rows: unknown[] = data, cols: unknown = columns) {
|
|
57
|
+
const target = document.createElement('div')
|
|
58
|
+
document.body.appendChild(target)
|
|
59
|
+
let api: SvGridApi<never, Row> | null = null
|
|
60
|
+
const app = mount(SvGrid, {
|
|
61
|
+
target,
|
|
62
|
+
props: {
|
|
63
|
+
data: rows,
|
|
64
|
+
columns: cols,
|
|
65
|
+
features,
|
|
66
|
+
_rowModels: {
|
|
67
|
+
coreRowModel: createCoreRowModel(),
|
|
68
|
+
filteredRowModel: createFilteredRowModel(),
|
|
69
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
70
|
+
groupedRowModel: createGroupedRowModel(),
|
|
71
|
+
expandedRowModel: createExpandedRowModel(),
|
|
72
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
73
|
+
},
|
|
74
|
+
containerHeight: 600,
|
|
75
|
+
virtualization: false,
|
|
76
|
+
onApiReady: (next: SvGridApi<never, Row>) => (api = next),
|
|
77
|
+
...extra,
|
|
78
|
+
} as never,
|
|
79
|
+
})
|
|
80
|
+
mounted.push(() => unmount(app))
|
|
81
|
+
return { target, get api() { return api! } }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const bodyRows = (t: HTMLElement) => t.querySelectorAll('tbody tr').length
|
|
85
|
+
|
|
86
|
+
describe('groupBy prop', () => {
|
|
87
|
+
it('groups from markup with no api round trip', async () => {
|
|
88
|
+
const g = mountGrid({ groupBy: ['department'] })
|
|
89
|
+
await tick()
|
|
90
|
+
expect(g.api.getState().grouping).toEqual(['department'])
|
|
91
|
+
// One banner per distinct department, all collapsed.
|
|
92
|
+
expect(bodyRows(g.target)).toBe(3)
|
|
93
|
+
expect(g.target.querySelector('tbody')?.textContent).toContain('Engineering')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('nests in the order given', async () => {
|
|
97
|
+
const g = mountGrid({ groupBy: ['department', 'name'] })
|
|
98
|
+
await tick()
|
|
99
|
+
expect(g.api.getState().grouping).toEqual(['department', 'name'])
|
|
100
|
+
g.api.expandAllGroups()
|
|
101
|
+
await tick()
|
|
102
|
+
// Outer banners still bucket by department, so the leaves stay under them.
|
|
103
|
+
expect(g.api.getDisplayedRows()).toHaveLength(5)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('defaults to no grouping when omitted', async () => {
|
|
107
|
+
const g = mountGrid()
|
|
108
|
+
await tick()
|
|
109
|
+
expect(g.api.getState().grouping).toEqual([])
|
|
110
|
+
expect(bodyRows(g.target)).toBe(5)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('re-applies when the prop changes', async () => {
|
|
114
|
+
// Re-mounting with a different prop value is the component-level
|
|
115
|
+
// equivalent of the parent reassigning it.
|
|
116
|
+
const g = mountGrid({ groupBy: ['name'] })
|
|
117
|
+
await tick()
|
|
118
|
+
expect(g.api.getState().grouping).toEqual(['name'])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does not clobber a group-by set through the api', async () => {
|
|
122
|
+
const g = mountGrid({ groupBy: ['department'] })
|
|
123
|
+
await tick()
|
|
124
|
+
g.api.setGroupBy(['name'])
|
|
125
|
+
await tick(5)
|
|
126
|
+
// The prop has not changed, so the seeding effect must stay out of the way.
|
|
127
|
+
expect(g.api.getState().grouping).toEqual(['name'])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('is ignored when treeData is set', async () => {
|
|
131
|
+
const tree = [
|
|
132
|
+
{ id: 1, managerId: null, name: 'Ada', department: 'Engineering', salary: 1 },
|
|
133
|
+
{ id: 2, managerId: 1, name: 'Grace', department: 'Engineering', salary: 2 },
|
|
134
|
+
]
|
|
135
|
+
const g = mountGrid(
|
|
136
|
+
{ groupBy: ['department'], treeData: { parentField: 'managerId', column: 'name' } },
|
|
137
|
+
tree,
|
|
138
|
+
)
|
|
139
|
+
await tick()
|
|
140
|
+
// A row cannot be both a hierarchy node and bucketed under a banner.
|
|
141
|
+
expect(g.api.getState().grouping).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('expanded prop', () => {
|
|
146
|
+
it('seeds the expansion map so a group starts open', async () => {
|
|
147
|
+
const g = mountGrid({ groupBy: ['department'], expanded: { group_department_Engineering: true } })
|
|
148
|
+
await tick()
|
|
149
|
+
// 3 banners + Engineering's 2 leaves.
|
|
150
|
+
expect(bodyRows(g.target)).toBe(5)
|
|
151
|
+
expect(g.api.getDisplayedRows().map((r) => r.name)).toEqual(['Ada', 'Grace'])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('starts fully collapsed when omitted', async () => {
|
|
155
|
+
const g = mountGrid({ groupBy: ['department'] })
|
|
156
|
+
await tick()
|
|
157
|
+
expect(bodyRows(g.target)).toBe(3)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('re-applies when the prop object changes', async () => {
|
|
161
|
+
const g = mountGrid({ groupBy: ['department'], expanded: {} })
|
|
162
|
+
await tick()
|
|
163
|
+
expect(bodyRows(g.target)).toBe(3)
|
|
164
|
+
g.api.setRowExpanded('group_department_Operations', true)
|
|
165
|
+
await tick()
|
|
166
|
+
expect(bodyRows(g.target)).toBe(5)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('onExpandedChange prop', () => {
|
|
171
|
+
it('fires with the full next map when a group expands', async () => {
|
|
172
|
+
const onExpandedChange = vi.fn()
|
|
173
|
+
const g = mountGrid({ groupBy: ['department'], onExpandedChange })
|
|
174
|
+
await tick()
|
|
175
|
+
onExpandedChange.mockClear()
|
|
176
|
+
g.api.setRowExpanded('group_department_Engineering', true)
|
|
177
|
+
await tick()
|
|
178
|
+
expect(onExpandedChange).toHaveBeenCalled()
|
|
179
|
+
expect(onExpandedChange.mock.calls.at(-1)![0]).toEqual({ group_department_Engineering: true })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('fires for expandAllGroups and collapseAllGroups', async () => {
|
|
183
|
+
const onExpandedChange = vi.fn()
|
|
184
|
+
const g = mountGrid({ groupBy: ['department'], onExpandedChange })
|
|
185
|
+
await tick()
|
|
186
|
+
onExpandedChange.mockClear()
|
|
187
|
+
g.api.expandAllGroups()
|
|
188
|
+
await tick()
|
|
189
|
+
const afterExpand = onExpandedChange.mock.calls.at(-1)![0] as Record<string, boolean>
|
|
190
|
+
expect(Object.values(afterExpand).filter(Boolean)).toHaveLength(3)
|
|
191
|
+
g.api.collapseAllGroups()
|
|
192
|
+
await tick()
|
|
193
|
+
expect(onExpandedChange.mock.calls.at(-1)![0]).toEqual({})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('fires when the user clicks a group banner', async () => {
|
|
197
|
+
const onExpandedChange = vi.fn()
|
|
198
|
+
const g = mountGrid({ groupBy: ['department'], onExpandedChange })
|
|
199
|
+
await tick()
|
|
200
|
+
onExpandedChange.mockClear()
|
|
201
|
+
// The toggle handler lives on the banner cell; the row carries aria-expanded.
|
|
202
|
+
const banner = g.target.querySelector('tbody .sv-grid-group-cell') as HTMLElement | null
|
|
203
|
+
expect(banner).toBeTruthy()
|
|
204
|
+
banner!.click()
|
|
205
|
+
await tick()
|
|
206
|
+
expect(onExpandedChange).toHaveBeenCalled()
|
|
207
|
+
expect(bodyRows(g.target)).toBe(5) // 3 banners + the opened group's 2 leaves
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('hands back a copy, not the engine\'s own object', async () => {
|
|
211
|
+
const onExpandedChange = vi.fn()
|
|
212
|
+
const g = mountGrid({ groupBy: ['department'], onExpandedChange })
|
|
213
|
+
await tick()
|
|
214
|
+
g.api.setRowExpanded('group_department_Engineering', true)
|
|
215
|
+
await tick()
|
|
216
|
+
const handed = onExpandedChange.mock.calls.at(-1)![0] as Record<string, boolean>
|
|
217
|
+
handed.mutated = true
|
|
218
|
+
// A later notification must not carry the caller's scribble.
|
|
219
|
+
g.api.setRowExpanded('group_department_Operations', true)
|
|
220
|
+
await tick()
|
|
221
|
+
expect(onExpandedChange.mock.calls.at(-1)![0]).not.toHaveProperty('mutated')
|
|
222
|
+
expect(bodyRows(g.target)).toBe(7) // 3 banners + 2 + 2 leaves
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('does not loop when a controlled parent echoes the value back', async () => {
|
|
226
|
+
// The shape the docs recommend: the callback value is written straight back
|
|
227
|
+
// into the prop. If the seeding effect treated that echo as a fresh prop
|
|
228
|
+
// value it would re-seed, re-fire, and spin.
|
|
229
|
+
const onExpandedChange = vi.fn()
|
|
230
|
+
const g = mountGrid({ groupBy: ['department'], expanded: {}, onExpandedChange })
|
|
231
|
+
await tick()
|
|
232
|
+
onExpandedChange.mockClear()
|
|
233
|
+
g.api.expandAllGroups()
|
|
234
|
+
await tick(10)
|
|
235
|
+
// One settled notification, not an escalating cascade.
|
|
236
|
+
expect(onExpandedChange.mock.calls.length).toBeLessThanOrEqual(2)
|
|
237
|
+
expect(bodyRows(g.target)).toBe(8) // 3 banners + 5 leaves
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('tree rows use the same expansion props', () => {
|
|
242
|
+
const tree = [
|
|
243
|
+
{ id: 1, managerId: null, name: 'Ada' },
|
|
244
|
+
{ id: 2, managerId: 1, name: 'Grace' },
|
|
245
|
+
{ id: 3, managerId: 2, name: 'Alan' },
|
|
246
|
+
{ id: 4, managerId: 1, name: 'Linus' },
|
|
247
|
+
]
|
|
248
|
+
const treeCols = [{ field: 'name', header: 'Name', width: 200 }]
|
|
249
|
+
|
|
250
|
+
it('onExpandedChange fires for tree expansion too', async () => {
|
|
251
|
+
const onExpandedChange = vi.fn()
|
|
252
|
+
const g = mountGrid(
|
|
253
|
+
{ treeData: { parentField: 'managerId', column: 'name' }, onExpandedChange },
|
|
254
|
+
tree,
|
|
255
|
+
treeCols,
|
|
256
|
+
)
|
|
257
|
+
await tick()
|
|
258
|
+
onExpandedChange.mockClear()
|
|
259
|
+
g.api.expandAllGroups()
|
|
260
|
+
await tick()
|
|
261
|
+
expect(onExpandedChange).toHaveBeenCalled()
|
|
262
|
+
expect(bodyRows(g.target)).toBe(4)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('expanded seeds an open tree branch on first paint', async () => {
|
|
266
|
+
// Tree expansion keys off the ENGINE row id, so pin it to the data id.
|
|
267
|
+
const g = mountGrid(
|
|
268
|
+
{
|
|
269
|
+
treeData: { parentField: 'managerId', column: 'name' },
|
|
270
|
+
getRowId: (r: { id: number }) => String(r.id),
|
|
271
|
+
expanded: { '1': true },
|
|
272
|
+
},
|
|
273
|
+
tree,
|
|
274
|
+
treeCols,
|
|
275
|
+
)
|
|
276
|
+
await tick()
|
|
277
|
+
// Ada plus her two direct reports; Alan stays hidden under Grace.
|
|
278
|
+
expect(bodyRows(g.target)).toBe(3)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { themePresets, resolveThemeTokens, type ThemeMode } from './index'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WCAG contrast for every built-in theme, in both light and dark.
|
|
6
|
+
*
|
|
7
|
+
* The axe suite (`a11y.axe.test.ts`) runs in jsdom, which does no layout and no
|
|
8
|
+
* painting, so its `color-contrast` rule is disabled there. That leaves a real
|
|
9
|
+
* gap: a theme could ship text nobody can read and nothing would complain.
|
|
10
|
+
* Our tokens are plain hex, so the ratios are computable directly - no browser
|
|
11
|
+
* needed, and every preset is covered rather than whichever one a demo happens
|
|
12
|
+
* to use.
|
|
13
|
+
*
|
|
14
|
+
* Thresholds are the WCAG 2.1 AA ones:
|
|
15
|
+
* - 4.5:1 for normal-size text
|
|
16
|
+
* - 3:1 for large text and for non-text UI boundaries (SC 1.4.11)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const AA_TEXT = 4.5
|
|
20
|
+
const AA_UI = 3
|
|
21
|
+
|
|
22
|
+
/** sRGB hex -> relative luminance, per WCAG 2.x. */
|
|
23
|
+
function luminance(hex: string): number {
|
|
24
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
|
|
25
|
+
if (!m) throw new Error(`not a plain hex colour: ${hex}`)
|
|
26
|
+
let h = m[1]!
|
|
27
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
|
28
|
+
const [r, g, b] = [0, 2, 4].map((i) => {
|
|
29
|
+
const c = parseInt(h.slice(i, i + 2), 16) / 255
|
|
30
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
31
|
+
}) as [number, number, number]
|
|
32
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function contrast(fg: string, bg: string): number {
|
|
36
|
+
const a = luminance(fg)
|
|
37
|
+
const b = luminance(bg)
|
|
38
|
+
const [hi, lo] = a > b ? [a, b] : [b, a]
|
|
39
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const round = (n: number) => Math.round(n * 100) / 100
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pairs that actually meet a user's eye. Each is [foreground, background,
|
|
46
|
+
* threshold, what it is] - named so a failure says which surface is unreadable
|
|
47
|
+
* rather than just which token.
|
|
48
|
+
*/
|
|
49
|
+
const PAIRS: Array<[string, string, number, string]> = [
|
|
50
|
+
['--sg-fg', '--sg-bg', AA_TEXT, 'body text on the grid background'],
|
|
51
|
+
['--sg-fg', '--sg-row-alt-bg', AA_TEXT, 'body text on zebra-striped rows'],
|
|
52
|
+
['--sg-fg', '--sg-row-hover-bg', AA_TEXT, 'body text on a hovered row'],
|
|
53
|
+
['--sg-fg', '--sg-selection-bg', AA_TEXT, 'body text on a selected row'],
|
|
54
|
+
['--sg-muted', '--sg-bg', AA_TEXT, 'secondary text on the grid background'],
|
|
55
|
+
['--sg-header-fg', '--sg-header-bg', AA_TEXT, 'header text'],
|
|
56
|
+
['--sg-on-accent', '--sg-accent', AA_TEXT, 'text on an accent-filled control'],
|
|
57
|
+
['--sg-accent', '--sg-bg', AA_UI, 'accent used as a focus/selection indicator'],
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Deliberately NOT checked: `--sg-border` against `--sg-bg`.
|
|
62
|
+
*
|
|
63
|
+
* WCAG 1.4.11 covers UI components whose STATE must be perceivable, and
|
|
64
|
+
* graphics required to understand content. A table gridline is neither: the
|
|
65
|
+
* grid's structure is carried by the accessibility tree (role=grid, rows,
|
|
66
|
+
* columnheaders), so a screen reader never depends on the line being visible,
|
|
67
|
+
* and a sighted user reads the table from its alignment and spacing. Holding
|
|
68
|
+
* gridlines to 3:1 would force every preset to draw near-black rules, which is
|
|
69
|
+
* why no mainstream design system does it. Focus rings and selection states DO
|
|
70
|
+
* have to clear 3:1, which is what the `--sg-accent` row above checks.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
const MODES: ThemeMode[] = ['light', 'dark']
|
|
74
|
+
|
|
75
|
+
describe('built-in theme contrast (WCAG 2.1 AA)', () => {
|
|
76
|
+
const cases = themePresets.flatMap((preset) =>
|
|
77
|
+
MODES.map((mode) => [preset.id, mode, preset] as const),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
it.each(cases)('%s / %s meets AA on every text surface', (id, mode, preset) => {
|
|
81
|
+
const tokens = resolveThemeTokens(preset, mode)
|
|
82
|
+
const failures: string[] = []
|
|
83
|
+
|
|
84
|
+
for (const [fgToken, bgToken, threshold, label] of PAIRS) {
|
|
85
|
+
const fg = tokens[fgToken]
|
|
86
|
+
const bg = tokens[bgToken]
|
|
87
|
+
// Skip anything a preset expresses as a gradient/color-mix rather than a
|
|
88
|
+
// flat hex - those cannot be judged without painting, and are called out
|
|
89
|
+
// in docs as the consumer's responsibility.
|
|
90
|
+
if (!fg || !bg || !/^#/.test(fg) || !/^#/.test(bg)) continue
|
|
91
|
+
const ratio = contrast(fg, bg)
|
|
92
|
+
if (ratio < threshold) {
|
|
93
|
+
failures.push(
|
|
94
|
+
`${label}: ${fgToken} ${fg} on ${bgToken} ${bg} = ${round(ratio)}:1 (needs ${threshold}:1)`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
expect(failures, `${id} (${mode}) contrast failures:\n ${failures.join('\n ')}`).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('checks a meaningful number of presets', () => {
|
|
103
|
+
// Guards against the suite silently passing because the preset list moved.
|
|
104
|
+
expect(themePresets.length).toBeGreaterThanOrEqual(15)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('computes known ratios correctly', () => {
|
|
108
|
+
// Sanity-check the maths itself against WCAG's canonical extremes.
|
|
109
|
+
expect(round(contrast('#000000', '#ffffff'))).toBe(21)
|
|
110
|
+
expect(round(contrast('#ffffff', '#ffffff'))).toBe(1)
|
|
111
|
+
})
|
|
112
|
+
})
|
package/src/themes/index.ts
CHANGED
|
@@ -141,16 +141,24 @@ export const themePresets: ThemePreset[] = [
|
|
|
141
141
|
light: { bg: '#ffffff', fg: '#282a30', muted: '#6b6f76', border: '#e9e9eb', headerBg: '#f7f8f8', headerFg: '#6b6f76', accent: '#5e6ad2', rowAlt: '#fbfbfc', rowHover: '#f4f4f5', selectionBg: '#e8eafd' },
|
|
142
142
|
dark: { bg: '#08090a', fg: '#f7f8f8', muted: '#8a8f98', border: '#23252a', headerBg: '#0f1011', headerFg: '#8a8f98', accent: '#5e6ad2', rowAlt: '#0f1011', rowHover: '#1a1b1e', selectionBg: '#2a2d4a' } },
|
|
143
143
|
{ id: 'notion', name: 'Notion', radius: 4, font: 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
144
|
-
|
|
144
|
+
// muted + headerFg darkened from #787774, which sat at 4.48:1 on white and
|
|
145
|
+
// 4.14:1 on the header fill - both just under AA. #6b6a67 keeps the warm
|
|
146
|
+
// grey and clears it at 5.41:1 / 5:1.
|
|
147
|
+
light: { bg: '#ffffff', fg: '#37352f', muted: '#6b6a67', border: '#e9e9e7', headerBg: '#f7f6f3', headerFg: '#6b6a67', accent: '#2383e2', rowAlt: '#fbfbfa', rowHover: '#f1f1ef', selectionBg: '#d9eaf7' },
|
|
145
148
|
dark: { bg: '#191919', fg: '#d4d4d4', muted: '#9b9b9b', border: '#2f2f2f', headerBg: '#202020', headerFg: '#9b9b9b', accent: '#529cca', rowAlt: '#1c1c1c', rowHover: '#252525', selectionBg: '#20415c' } },
|
|
146
149
|
{ id: 'nord', name: 'Nord', radius: 4, font: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
147
150
|
light: { bg: '#eceff4', fg: '#2e3440', muted: '#4c566a', border: '#d8dee9', headerBg: '#e5e9f0', headerFg: '#2e3440', accent: '#5579a5', rowAlt: '#e5e9f0', rowHover: '#dde3ec', selectionBg: '#d8dee9' },
|
|
148
|
-
|
|
151
|
+
// muted lightened from Nord's #4c566a (nord3): on the nord0 background that
|
|
152
|
+
// is 1.69:1, effectively invisible. #97a0b0 keeps the Nord hue at 4.74:1.
|
|
153
|
+
dark: { bg: '#2e3440', fg: '#d8dee9', muted: '#97a0b0', border: '#434c5e', headerBg: '#3b4252', headerFg: '#e5e9f0', accent: '#88c0d0', rowAlt: '#333b48', rowHover: '#3b4252', selectionBg: '#434c5e' } },
|
|
149
154
|
{ id: 'dracula', name: 'Dracula', radius: 6, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
150
155
|
light: { bg: '#fffbeb', fg: '#1f1f1f', muted: '#6c664b', border: '#dedccf', headerBg: '#ece9df', headerFg: '#1f1f1f', accent: '#644ac9', rowAlt: '#fffdf5', rowHover: '#f2efe3', selectionBg: '#cfcfde' },
|
|
151
|
-
|
|
156
|
+
// muted lightened from Dracula's comment colour #6272a4 (3.03:1) to 5.04:1.
|
|
157
|
+
dark: { bg: '#282a36', fg: '#f8f8f2', muted: '#8b98c9', border: '#44475a', headerBg: '#21222c', headerFg: '#f8f8f2', accent: '#bd93f9', rowAlt: '#2d2f3b', rowHover: '#343746', selectionBg: '#44475a' } },
|
|
152
158
|
{ id: 'catppuccin', name: 'Catppuccin', radius: 8, font: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
153
|
-
|
|
159
|
+
// muted moved from Latte subtext0 (#6c6f85, 4.37:1) to subtext1 (#5c5f77),
|
|
160
|
+
// still an official Catppuccin colour, at 5.53:1.
|
|
161
|
+
light: { bg: '#eff1f5', fg: '#4c4f69', muted: '#5c5f77', border: '#ccd0da', headerBg: '#e6e9ef', headerFg: '#4c4f69', accent: '#8839ef', rowAlt: '#e6e9ef', rowHover: '#dce0e8', selectionBg: '#dce0e8' },
|
|
154
162
|
dark: { bg: '#1e1e2e', fg: '#cdd6f4', muted: '#a6adc8', border: '#313244', headerBg: '#181825', headerFg: '#cdd6f4', accent: '#cba6f7', rowAlt: '#1a1a28', rowHover: '#313244', selectionBg: '#45475a' } },
|
|
155
163
|
]
|
|
156
164
|
|
package/themes/catppuccin.css
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
:root {
|
|
3
3
|
--sg-bg: #eff1f5;
|
|
4
4
|
--sg-fg: #4c4f69;
|
|
5
|
-
--sg-muted: #
|
|
5
|
+
--sg-muted: #5c5f77;
|
|
6
6
|
--sg-border: #ccd0da;
|
|
7
7
|
--sg-header-bg: #e6e9ef;
|
|
8
8
|
--sg-header-fg: #4c4f69;
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
--sg-invalid-fg: #dc2626;
|
|
30
30
|
--sg-scrollbar-bg: #e6e9ef;
|
|
31
31
|
--sg-scrollbar-border: #ccd0da;
|
|
32
|
-
--sg-scrollbar-thumb: #
|
|
32
|
+
--sg-scrollbar-thumb: #5c5f77;
|
|
33
33
|
--sg-scrollbar-thumb-hover: #4c4f69;
|
|
34
34
|
--sg-scrollbar-thumb-active: #4c4f69;
|
|
35
|
-
--sg-scrollbar-arrow: #
|
|
35
|
+
--sg-scrollbar-arrow: #5c5f77;
|
|
36
36
|
--sg-scrollbar-arrow-hover: #4c4f69;
|
|
37
37
|
--sg-scrollbar-arrow-hover-bg: #dce0e8;
|
|
38
38
|
--sg-scrollbar-arrow-active: #4c4f69;
|
package/themes/dracula.css
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
:root[data-theme='dark'] {
|
|
43
43
|
--sg-bg: #282a36;
|
|
44
44
|
--sg-fg: #f8f8f2;
|
|
45
|
-
--sg-muted: #
|
|
45
|
+
--sg-muted: #8b98c9;
|
|
46
46
|
--sg-border: #44475a;
|
|
47
47
|
--sg-header-bg: #21222c;
|
|
48
48
|
--sg-header-fg: #f8f8f2;
|
|
@@ -69,10 +69,10 @@
|
|
|
69
69
|
--sg-invalid-fg: #f87171;
|
|
70
70
|
--sg-scrollbar-bg: #21222c;
|
|
71
71
|
--sg-scrollbar-border: #44475a;
|
|
72
|
-
--sg-scrollbar-thumb: #
|
|
72
|
+
--sg-scrollbar-thumb: #8b98c9;
|
|
73
73
|
--sg-scrollbar-thumb-hover: #f8f8f2;
|
|
74
74
|
--sg-scrollbar-thumb-active: #f8f8f2;
|
|
75
|
-
--sg-scrollbar-arrow: #
|
|
75
|
+
--sg-scrollbar-arrow: #8b98c9;
|
|
76
76
|
--sg-scrollbar-arrow-hover: #f8f8f2;
|
|
77
77
|
--sg-scrollbar-arrow-hover-bg: #343746;
|
|
78
78
|
--sg-scrollbar-arrow-active: #f8f8f2;
|
package/themes/nord.css
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
:root[data-theme='dark'] {
|
|
43
43
|
--sg-bg: #2e3440;
|
|
44
44
|
--sg-fg: #d8dee9;
|
|
45
|
-
--sg-muted: #
|
|
45
|
+
--sg-muted: #97a0b0;
|
|
46
46
|
--sg-border: #434c5e;
|
|
47
47
|
--sg-header-bg: #3b4252;
|
|
48
48
|
--sg-header-fg: #e5e9f0;
|
|
@@ -69,10 +69,10 @@
|
|
|
69
69
|
--sg-invalid-fg: #f87171;
|
|
70
70
|
--sg-scrollbar-bg: #3b4252;
|
|
71
71
|
--sg-scrollbar-border: #434c5e;
|
|
72
|
-
--sg-scrollbar-thumb: #
|
|
72
|
+
--sg-scrollbar-thumb: #97a0b0;
|
|
73
73
|
--sg-scrollbar-thumb-hover: #d8dee9;
|
|
74
74
|
--sg-scrollbar-thumb-active: #d8dee9;
|
|
75
|
-
--sg-scrollbar-arrow: #
|
|
75
|
+
--sg-scrollbar-arrow: #97a0b0;
|
|
76
76
|
--sg-scrollbar-arrow-hover: #d8dee9;
|
|
77
77
|
--sg-scrollbar-arrow-hover-bg: #3b4252;
|
|
78
78
|
--sg-scrollbar-arrow-active: #d8dee9;
|
package/themes/notion.css
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
:root {
|
|
3
3
|
--sg-bg: #ffffff;
|
|
4
4
|
--sg-fg: #37352f;
|
|
5
|
-
--sg-muted: #
|
|
5
|
+
--sg-muted: #6b6a67;
|
|
6
6
|
--sg-border: #e9e9e7;
|
|
7
7
|
--sg-header-bg: #f7f6f3;
|
|
8
|
-
--sg-header-fg: #
|
|
8
|
+
--sg-header-fg: #6b6a67;
|
|
9
9
|
--sg-bg-subtle: #f7f6f3;
|
|
10
10
|
--sg-row-alt-bg: #fbfbfa;
|
|
11
11
|
--sg-row-hover-bg: #f1f1ef;
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
--sg-invalid-fg: #dc2626;
|
|
30
30
|
--sg-scrollbar-bg: #f7f6f3;
|
|
31
31
|
--sg-scrollbar-border: #e9e9e7;
|
|
32
|
-
--sg-scrollbar-thumb: #
|
|
32
|
+
--sg-scrollbar-thumb: #6b6a67;
|
|
33
33
|
--sg-scrollbar-thumb-hover: #37352f;
|
|
34
34
|
--sg-scrollbar-thumb-active: #37352f;
|
|
35
|
-
--sg-scrollbar-arrow: #
|
|
35
|
+
--sg-scrollbar-arrow: #6b6a67;
|
|
36
36
|
--sg-scrollbar-arrow-hover: #37352f;
|
|
37
37
|
--sg-scrollbar-arrow-hover-bg: #f1f1ef;
|
|
38
38
|
--sg-scrollbar-arrow-active: #37352f;
|