@svgrid/grid 3.0.0 → 3.0.1

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 (259) hide show
  1. package/CHANGELOG.md +116 -88
  2. package/README.md +199 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +181 -181
  5. package/dist/SvAutoComplete.svelte +169 -169
  6. package/dist/SvAvatar.svelte +75 -75
  7. package/dist/SvCalendar.svelte +503 -503
  8. package/dist/SvCarousel.svelte +141 -141
  9. package/dist/SvCheckBox.svelte +102 -102
  10. package/dist/SvCircularProgress.svelte +109 -109
  11. package/dist/SvColorInput.svelte +181 -181
  12. package/dist/SvComboBox.svelte +279 -279
  13. package/dist/SvContextMenu.svelte +116 -116
  14. package/dist/SvCountryInput.svelte +163 -163
  15. package/dist/SvDrawer.svelte +254 -254
  16. package/dist/SvDropDownList.svelte +378 -378
  17. package/dist/SvDurationInput.svelte +126 -126
  18. package/dist/SvField.svelte +293 -293
  19. package/dist/SvForm.svelte +437 -437
  20. package/dist/SvGridCellEditor.svelte +748 -744
  21. package/dist/SvGridChart.svelte +1716 -1716
  22. package/dist/SvGridChartPanel.svelte +485 -485
  23. package/dist/SvGridChartView.svelte +70 -70
  24. package/dist/SvGridDropdown.svelte +728 -728
  25. package/dist/SvGridSelect.svelte +270 -270
  26. package/dist/SvGroupCell.svelte +116 -116
  27. package/dist/SvListBox.svelte +334 -334
  28. package/dist/SvMaskedInput.svelte +122 -122
  29. package/dist/SvMenu.svelte +124 -124
  30. package/dist/SvMenuList.svelte +146 -146
  31. package/dist/SvMultiSelect.svelte +293 -293
  32. package/dist/SvNumberInput.svelte +172 -172
  33. package/dist/SvOtpInput.svelte +158 -158
  34. package/dist/SvPasswordInput.svelte +151 -151
  35. package/dist/SvPhoneInput.svelte +133 -133
  36. package/dist/SvPopover.svelte +197 -197
  37. package/dist/SvProgress.svelte +116 -116
  38. package/dist/SvRadioGroup.svelte +107 -107
  39. package/dist/SvRating.svelte +112 -112
  40. package/dist/SvResult.svelte +73 -73
  41. package/dist/SvRichText.svelte +211 -211
  42. package/dist/SvRowGroupPanel.svelte +170 -170
  43. package/dist/SvScrollArea.svelte +61 -61
  44. package/dist/SvSlider.svelte +203 -203
  45. package/dist/SvSwitchButton.svelte +108 -108
  46. package/dist/SvTagsInput.svelte +115 -115
  47. package/dist/SvTextInput.svelte +147 -147
  48. package/dist/SvTimePicker.svelte +245 -245
  49. package/dist/SvToaster.svelte +159 -159
  50. package/dist/SvToggleButton.svelte +85 -85
  51. package/dist/SvTooltip.svelte +161 -161
  52. package/dist/SvTour.svelte +208 -208
  53. package/dist/SvTree.svelte +444 -444
  54. package/dist/SvTreeSelect.svelte +239 -239
  55. package/dist/cdn/{GridMenus-DMihUtma.js → GridMenus-CYzOvmNQ.js} +3 -3
  56. package/dist/cdn/{GridMenus-nmNDj1a3.js → GridMenus-DFB6iwF8.js} +3 -3
  57. package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
  58. package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
  59. package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
  60. package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
  61. package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
  62. package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
  63. package/dist/cdn/{SvGridChart-CwhFz7GV.js → SvGridChart-BEJmNNx9.js} +1 -1
  64. package/dist/cdn/{SvGridChart-Bs2GIR2Q.js → SvGridChart-BwVos976.js} +1 -1
  65. package/dist/cdn/{SvGridChartPanel-1WXuStzM.js → SvGridChartPanel-BOknOkrP.js} +1 -1
  66. package/dist/cdn/{SvGridChartPanel-e5_nqNP9.js → SvGridChartPanel-D2PjII4O.js} +1 -1
  67. package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-00LPUHL1.js} +1 -1
  68. package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-BhUEJ5ki.js} +1 -1
  69. package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
  70. package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
  71. package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
  72. package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
  73. package/dist/cdn/{src-B_YS5AOc.js → src-C8QjSaq-.js} +42 -44
  74. package/dist/cdn/{src-DYXXpuSk.js → src-D0hO2eYN.js} +41 -43
  75. package/dist/cdn/svgrid.js +7 -7
  76. package/dist/cdn/svgrid.svelte-external.js +7 -7
  77. package/dist/chart-export.js +8 -8
  78. package/dist/editing.js +22 -13
  79. package/package.json +11 -11
  80. package/src/FlexRender.svelte +96 -96
  81. package/src/GridFooter.svelte +181 -181
  82. package/src/SvAutoComplete.svelte +169 -169
  83. package/src/SvAvatar.svelte +75 -75
  84. package/src/SvCalendar.svelte +503 -503
  85. package/src/SvCalendar.test.ts +226 -226
  86. package/src/SvCarousel.svelte +141 -141
  87. package/src/SvCheckBox.svelte +102 -102
  88. package/src/SvCircularProgress.svelte +109 -109
  89. package/src/SvColorInput.svelte +181 -181
  90. package/src/SvComboBox.svelte +279 -279
  91. package/src/SvContextMenu.svelte +116 -116
  92. package/src/SvCountryInput.svelte +163 -163
  93. package/src/SvDrawer.svelte +254 -254
  94. package/src/SvDropDownList.svelte +378 -378
  95. package/src/SvDurationInput.svelte +126 -126
  96. package/src/SvField.svelte +293 -293
  97. package/src/SvForm.svelte +437 -437
  98. package/src/SvForm.test.ts +411 -411
  99. package/src/SvGrid.types.ts +2092 -2092
  100. package/src/SvGridCellEditor.svelte +748 -744
  101. package/src/SvGridChart.svelte +1716 -1716
  102. package/src/SvGridChartPanel.svelte +485 -485
  103. package/src/SvGridChartView.svelte +70 -70
  104. package/src/SvGridDropdown.svelte +728 -728
  105. package/src/SvGridSelect.svelte +270 -270
  106. package/src/SvGroupCell.svelte +116 -116
  107. package/src/SvListBox.svelte +334 -334
  108. package/src/SvMaskedInput.svelte +122 -122
  109. package/src/SvMenu.svelte +124 -124
  110. package/src/SvMenu.test.ts +97 -97
  111. package/src/SvMenuList.svelte +146 -146
  112. package/src/SvMultiSelect.svelte +293 -293
  113. package/src/SvNumberInput.svelte +172 -172
  114. package/src/SvOtpInput.svelte +158 -158
  115. package/src/SvPasswordInput.svelte +151 -151
  116. package/src/SvPhoneInput.svelte +133 -133
  117. package/src/SvPopover.svelte +197 -197
  118. package/src/SvProgress.svelte +116 -116
  119. package/src/SvRadioGroup.svelte +107 -107
  120. package/src/SvRating.svelte +112 -112
  121. package/src/SvResult.svelte +73 -73
  122. package/src/SvRichText.svelte +211 -211
  123. package/src/SvRowGroupPanel.svelte +170 -170
  124. package/src/SvScrollArea.svelte +61 -61
  125. package/src/SvSlider.svelte +203 -203
  126. package/src/SvSwitchButton.svelte +108 -108
  127. package/src/SvTagsInput.svelte +115 -115
  128. package/src/SvTextInput.svelte +147 -147
  129. package/src/SvTimePicker.svelte +245 -245
  130. package/src/SvToaster.svelte +159 -159
  131. package/src/SvToaster.test.ts +95 -95
  132. package/src/SvToggleButton.svelte +85 -85
  133. package/src/SvTooltip.svelte +161 -161
  134. package/src/SvTour.svelte +208 -208
  135. package/src/SvTree.svelte +444 -444
  136. package/src/SvTreeSelect.svelte +239 -239
  137. package/src/a11y/dismissable.test.ts +119 -119
  138. package/src/a11y/dismissable.ts +114 -114
  139. package/src/a11y.contract.test.ts +49 -49
  140. package/src/a11y.test.ts +59 -59
  141. package/src/a11y.ts +61 -61
  142. package/src/ai.test.ts +502 -502
  143. package/src/ai.ts +1419 -1419
  144. package/src/build-api.coverage.test.ts +633 -633
  145. package/src/build-api.ts +846 -846
  146. package/src/builtin-editors.grid.test.ts +83 -83
  147. package/src/cell-formatting.ts +171 -171
  148. package/src/cell-render.test.ts +513 -513
  149. package/src/cell-render.ts +496 -496
  150. package/src/cell-values.ts +148 -148
  151. package/src/chart-export.ts +202 -202
  152. package/src/chart-view.svelte.ts +36 -36
  153. package/src/chart.ts +2321 -2321
  154. package/src/collaboration.test.ts +104 -104
  155. package/src/collaboration.ts +167 -167
  156. package/src/column-groups.ts +78 -78
  157. package/src/core.performance.test.ts +30 -30
  158. package/src/core.ts +1865 -1865
  159. package/src/createAutocomplete.svelte.ts +132 -132
  160. package/src/createCombobox.svelte.ts +191 -191
  161. package/src/createCountryInput.svelte.ts +157 -157
  162. package/src/createDropdownList.svelte.ts +168 -168
  163. package/src/createForm.svelte.ts +386 -386
  164. package/src/createGrid.svelte.ts +42 -42
  165. package/src/createGrid.test.ts +10 -10
  166. package/src/createGridState.svelte.ts +17 -17
  167. package/src/createListbox.svelte.ts +250 -250
  168. package/src/createMenu.svelte.ts +224 -224
  169. package/src/createPopoverSelect.svelte.ts +213 -213
  170. package/src/createSlider.svelte.ts +191 -191
  171. package/src/createTooltip.svelte.ts +144 -144
  172. package/src/createTree.svelte.ts +322 -322
  173. package/src/datetime/date-core.ts +206 -206
  174. package/src/datetime/date-restrict.ts +61 -61
  175. package/src/datetime/timezone.ts +135 -135
  176. package/src/dock-manager-model.ts +596 -596
  177. package/src/dock-model.ts +374 -374
  178. package/src/editing.test.ts +974 -974
  179. package/src/editing.ts +618 -609
  180. package/src/editor-contract.ts +171 -171
  181. package/src/editor-registry.grid.test.ts +144 -144
  182. package/src/editor-registry.ts +122 -122
  183. package/src/export-data-api.test.ts +126 -126
  184. package/src/export-format.test.ts +107 -107
  185. package/src/export-format.ts +601 -601
  186. package/src/filter-operators.ts +160 -160
  187. package/src/filtering/excel-filters.ts +325 -325
  188. package/src/flex-render.ts +3 -3
  189. package/src/form-field.ts +127 -127
  190. package/src/group-display.test.ts +167 -167
  191. package/src/group-display.ts +200 -200
  192. package/src/headless.ts +87 -87
  193. package/src/js-scroller.svelte.ts +173 -173
  194. package/src/keyboard-handlers.ts +270 -270
  195. package/src/keyboard.test.ts +59 -59
  196. package/src/keyboard.ts +97 -97
  197. package/src/list-nav.test.ts +49 -49
  198. package/src/list-nav.ts +29 -29
  199. package/src/list-option.test.ts +56 -56
  200. package/src/list-option.ts +179 -179
  201. package/src/menus.ts +597 -597
  202. package/src/merge-objects.ts +48 -48
  203. package/src/overlays.test.ts +90 -90
  204. package/src/positioning.ts +268 -268
  205. package/src/render-component.ts +28 -28
  206. package/src/row-drag.test.ts +401 -401
  207. package/src/row-drag.ts +419 -419
  208. package/src/row-resize.test.ts +524 -524
  209. package/src/row-resize.ts +228 -228
  210. package/src/scheduler-ical.ts +181 -181
  211. package/src/scheduler-model.test.ts +562 -562
  212. package/src/scheduler-model.ts +873 -873
  213. package/src/selection.test.ts +885 -885
  214. package/src/server-data-source.test.ts +383 -383
  215. package/src/server-data-source.ts +469 -469
  216. package/src/sparkline.test.ts +68 -68
  217. package/src/sparkline.ts +169 -169
  218. package/src/spreadsheet.test.ts +488 -488
  219. package/src/spreadsheet.ts +312 -312
  220. package/src/static-functions.ts +11 -11
  221. package/src/subscribe.ts +38 -38
  222. package/src/summaries.ts +113 -113
  223. package/src/svgrid-wrapper.types.ts +563 -563
  224. package/src/svgrid.async-editor-options.test.ts +273 -273
  225. package/src/svgrid.auto-row-height.test.ts +204 -204
  226. package/src/svgrid.behavior.test.ts +910 -910
  227. package/src/svgrid.charting.test.ts +534 -534
  228. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  229. package/src/svgrid.context-menu.test.ts +147 -147
  230. package/src/svgrid.features.test.ts +157 -157
  231. package/src/svgrid.filter-depth.test.ts +163 -163
  232. package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
  233. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  234. package/src/svgrid.grand-total.test.ts +188 -188
  235. package/src/svgrid.group-display-mode.test.ts +171 -171
  236. package/src/svgrid.group-footers.test.ts +121 -121
  237. package/src/svgrid.group-pagination.test.ts +153 -153
  238. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  239. package/src/svgrid.tree-data.test.ts +186 -186
  240. package/src/svgrid.wrapper.test.ts +63 -63
  241. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  242. package/src/test-setup.ts +62 -62
  243. package/src/themes/index.ts +288 -288
  244. package/src/toast-store.svelte.ts +250 -250
  245. package/src/toast-store.test.ts +147 -147
  246. package/src/tree-row-model.test.ts +168 -168
  247. package/src/ui-buttons.test.ts +144 -144
  248. package/src/ui-inputs.test.ts +118 -118
  249. package/src/ui-localization.test.ts +113 -113
  250. package/src/ui-range.test.ts +70 -70
  251. package/src/ui-selection.test.ts +155 -155
  252. package/src/ui-tier1.test.ts +142 -142
  253. package/src/virtual.test.ts +88 -88
  254. package/src/virtualization/column-virtualizer.test.ts +27 -27
  255. package/src/virtualization/column-virtualizer.ts +30 -30
  256. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  257. package/src/virtualization/types.ts +30 -30
  258. package/src/virtualization/virtualizer.test.ts +47 -47
  259. package/src/virtualization/virtualizer.ts +322 -322
@@ -1,377 +1,382 @@
1
- /**
2
- * The Excel-style filter menu's value checklist is an SvListBox, not a div of
3
- * checkboxes. Two things that used to bite on a high-cardinality live column
4
- * (10k distinct symbols on a ticking feed):
5
- *
6
- * 1. every distinct value mounted a label + focusable checkbox, so the
7
- * popover held ~30k nodes and 10k tab stops;
8
- * 2. the offered values were derived live, so each data tick rebuilt the
9
- * distinct-value set, re-sorted it and re-keyed the whole list.
10
- *
11
- * The list now windows its rows and snapshots its values when the menu opens.
12
- */
13
- import { describe, expect, it, vi } from 'vitest'
14
- import { mount, unmount } from 'svelte'
15
- import SvGrid from './SvGrid.svelte'
16
- import {
17
- columnFilteringFeature,
18
- createCoreRowModel,
19
- createFilteredRowModel,
20
- createSortedRowModel,
21
- rowSortingFeature,
22
- sortFns,
23
- splitInTokens,
24
- tableFeatures,
25
- } from './index'
26
- import type { ColumnDef, SvGridApi } from './index'
27
-
28
- type Row = { id: number; symbol: string; team: string }
29
- const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
30
-
31
- const makeRows = (count: number, prefix = 'SYM'): Row[] =>
32
- Array.from({ length: count }, (_, i) => ({
33
- id: i + 1,
34
- symbol: `${prefix}${String(i).padStart(5, '0')}`,
35
- team: ['A', 'B', 'C'][i % 3]!,
36
- }))
37
-
38
- const cols: ColumnDef<typeof features, Row>[] = [
39
- { field: 'symbol', header: 'Symbol', width: 200 },
40
- { field: 'team', header: 'Team', width: 160 },
41
- ]
42
- const tick = () => new Promise<void>((r) => setTimeout(r))
43
-
44
- function mountGrid(data: Row[]) {
45
- return new Promise<{
46
- target: HTMLElement
47
- api: SvGridApi<typeof features, Row>
48
- setData: (next: Row[]) => void
49
- destroy: () => void
50
- }>((res, rej) => {
51
- const target = document.createElement('div')
52
- document.body.appendChild(target)
53
- const props = $state({
54
- data,
55
- columns: cols,
56
- features,
57
- _rowModels: {
58
- coreRowModel: createCoreRowModel(),
59
- filteredRowModel: createFilteredRowModel(),
60
- sortedRowModel: createSortedRowModel(sortFns),
61
- },
62
- rowHeight: 32,
63
- containerHeight: 400,
64
- virtualization: true,
65
- filterMode: 'menu' as const,
66
- showColumnFilters: true,
67
- onApiReady(api: SvGridApi<typeof features, Row>) {
68
- res({
69
- target,
70
- api,
71
- setData: (next: Row[]) => (props.data = next),
72
- destroy: () => { unmount(app); target.remove() },
73
- })
74
- },
75
- })
76
- const app = mount(SvGrid, { target, props: props as any })
77
- queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
78
- })
79
- }
80
-
81
- /** Open the funnel popover on the first column and return the menu element. */
82
- async function openFilterMenu(target: HTMLElement, columnIndex = 0) {
83
- const btn = target.querySelectorAll('.sv-grid-col-filter-btn')[
84
- columnIndex
85
- ] as HTMLButtonElement
86
- btn.click()
87
- // GridMenus is a lazy chunk, so poll until the popover mounts AND its value
88
- // checklist has rendered.
89
- //
90
- // Waiting only for the popover element returns while the listbox is still
91
- // empty: the chunk arrives, the menu element appears, and the values are
92
- // snapshotted and windowed in a later flush. Callers then read option counts
93
- // from a list that has not populated yet. It survives locally because those
94
- // flushes land in the same task, but under parallel load the gap widens - it
95
- // failed once that way in a full-suite run. Every caller here opens a menu on
96
- // a column that has values, so a populated list is the settled state to wait
97
- // for, and each test's own assertions are unchanged.
98
- //
99
- // The 1s default is not enough for the lazy chunk when `test:lib` runs the
100
- // whole suite with `--coverage`: instrumentation plus parallel load pushes
101
- // the import past a second and the menu is still null when the clock runs
102
- // out. The assertions are unchanged - this only waits longer for a dynamic
103
- // import, so a slow machine reports a real failure rather than a timeout.
104
- await vi.waitFor(
105
- () => {
106
- const menu = target.querySelector('.sv-grid-filter-menu')
107
- expect(menu).not.toBeNull()
108
- expect(menu!.querySelectorAll('[role="option"]').length).toBeGreaterThan(0)
109
- },
110
- { timeout: 5000 },
111
- )
112
- return target.querySelector('.sv-grid-filter-menu') as HTMLElement
113
- }
114
-
115
- // The LABEL only. The row also carries a trailing match count, which these
116
- // tests are not about - reading the whole row's text would couple them to it.
117
- const labelOf = (el: Element) =>
118
- (el.querySelector('.sv-listbox__label') ?? el).textContent?.trim() ?? ''
119
-
120
- const optionLabels = (menu: HTMLElement) =>
121
- Array.from(menu.querySelectorAll('[role="option"]')).map(labelOf)
122
-
123
- const findOption = (menu: HTMLElement, label: string) => {
124
- const hit = Array.from(menu.querySelectorAll('[role="option"]')).find(
125
- (el) => labelOf(el) === label,
126
- )
127
- if (!hit) throw new Error(`no option labelled "${label}" in [${optionLabels(menu)}]`)
128
- return hit as HTMLElement
129
- }
130
-
131
- describe('filter menu value checklist', () => {
132
- it('windows a high-cardinality column instead of mounting every value', async () => {
133
- const rows = makeRows(120)
134
- const { target, destroy } = await mountGrid(rows)
135
- await tick()
136
- const menu = await openFilterMenu(target)
137
-
138
- const list = menu.querySelector('.sv-listbox')
139
- expect(list).not.toBeNull()
140
- expect(list!.classList.contains('is-virtual')).toBe(true)
141
-
142
- const rendered = menu.querySelectorAll('[role="option"]').length
143
- expect(rendered).toBeGreaterThan(0)
144
- expect(rendered).toBeLessThan(60)
145
-
146
- destroy()
147
- })
148
-
149
- it('is a single tab stop, not one per value', async () => {
150
- const { target, destroy } = await mountGrid(makeRows(120))
151
- await tick()
152
- const menu = await openFilterMenu(target)
153
-
154
- // Roving tabindex: the listbox root is focusable, its options are not.
155
- expect(menu.querySelectorAll('.sv-listbox[tabindex="0"]').length).toBe(1)
156
- expect(menu.querySelectorAll('[role="option"][tabindex]').length).toBe(0)
157
- // No focusable checkbox per row any more.
158
- expect(menu.querySelectorAll('.sv-listbox input').length).toBe(0)
159
-
160
- destroy()
161
- })
162
-
163
- it('keeps a short list fully rendered (no fixed-height box)', async () => {
164
- const { target, destroy } = await mountGrid(makeRows(9))
165
- await tick()
166
- const menu = await openFilterMenu(target)
167
-
168
- const list = menu.querySelector('.sv-listbox')!
169
- expect(list.classList.contains('is-virtual')).toBe(false)
170
- expect(menu.querySelectorAll('[role="option"]').length).toBe(9)
171
-
172
- destroy()
173
- })
174
-
175
- it('snapshots the offered values, so a live data tick does not rebuild them', async () => {
176
- const { target, setData, destroy } = await mountGrid(makeRows(6, 'OLD'))
177
- await tick()
178
- const menu = await openFilterMenu(target)
179
- const before = optionLabels(menu)
180
- expect(before[0]).toBe('OLD00000')
181
-
182
- // A streaming grid replaces its row array; the open menu must not re-scan.
183
- setData(makeRows(6, 'NEW'))
184
- await tick()
185
- expect(optionLabels(menu)).toEqual(before)
186
-
187
- destroy()
188
- })
189
-
190
- it('re-reads the values on the next open', async () => {
191
- const { target, setData, destroy } = await mountGrid(makeRows(6, 'OLD'))
192
- await tick()
193
- await openFilterMenu(target)
194
- setData(makeRows(6, 'NEW'))
195
- await tick()
196
-
197
- // Close, then reopen: the snapshot is taken fresh.
198
- ;(target.querySelector('.sv-grid-menu-backdrop') as HTMLElement).click()
199
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-filter-menu')).toBeNull())
200
- const reopened = await openFilterMenu(target)
201
- expect(optionLabels(reopened)[0]).toBe('NEW00000')
202
-
203
- destroy()
204
- })
205
-
206
- it('unchecking a value filters the rows out', async () => {
207
- const { target, api, destroy } = await mountGrid(makeRows(9))
208
- await tick()
209
- const menu = await openFilterMenu(target)
210
-
211
- const first = menu.querySelector('[role="option"]') as HTMLElement
212
- expect(first.getAttribute('aria-selected')).toBe('true')
213
- first.click()
214
- await tick()
215
-
216
- expect(first.getAttribute('aria-selected')).toBe('false')
217
- expect(api.getDisplayedRows().length).toBe(8)
218
-
219
- destroy()
220
- })
221
-
222
- it('an in/notIn box offers a windowed checklist, not a capped datalist', async () => {
223
- const { target, destroy } = await mountGrid(makeRows(300))
224
- await tick()
225
- const menu = await openFilterMenu(target)
226
-
227
- const op = menu.querySelector('.sv-grid-menu-operator-select') as HTMLSelectElement
228
- op.value = 'in'
229
- op.dispatchEvent(new Event('change', { bubbles: true }))
230
- await tick()
231
-
232
- expect(menu.querySelector('datalist')).toBeNull()
233
- const box = menu.querySelector('.sv-grid-menu-condition-value') as HTMLInputElement
234
- expect(box.getAttribute('list')).toBeNull()
235
-
236
- box.dispatchEvent(new FocusEvent('focus'))
237
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
238
- const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
239
-
240
- // Uncapped source list, windowed render.
241
- expect(suggest.querySelector('.sv-listbox')!.classList.contains('is-virtual')).toBe(true)
242
- expect(suggest.querySelectorAll('[role="option"]').length).toBeLessThan(60)
243
-
244
- destroy()
245
- })
246
-
247
- it('picking a suggestion replaces the fragment being typed', async () => {
248
- const { target, destroy } = await mountGrid(makeRows(9))
249
- await tick()
250
- const menu = await openFilterMenu(target)
251
-
252
- const op = menu.querySelector('.sv-grid-menu-operator-select') as HTMLSelectElement
253
- op.value = 'in'
254
- op.dispatchEvent(new Event('change', { bubbles: true }))
255
- await tick()
256
-
257
- const box = menu.querySelector('.sv-grid-menu-condition-value') as HTMLInputElement
258
- box.dispatchEvent(new FocusEvent('focus'))
259
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
260
-
261
- // Commit one token, then start typing a second.
262
- box.value = 'SYM00003, SYM0000'
263
- box.dispatchEvent(new Event('input', { bubbles: true }))
264
- await tick()
265
-
266
- const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
267
- expect(optionLabels(suggest).length).toBeGreaterThan(1)
268
-
269
- // Pick another: the fragment is consumed, not left beside it.
270
- const pick = findOption(suggest, 'SYM00005')
271
- pick.click()
272
- await tick()
273
-
274
- expect(splitInTokens(box.value).sort()).toEqual(['SYM00003', 'SYM00005'])
275
- // ...and both read as selected straight away. A value written by the
276
- // dropdown has no trailing separator, so anything that mistook the last
277
- // token for a half-typed fragment would show it unchecked.
278
- const checked = suggest.querySelectorAll('[role="option"][aria-selected="true"]')
279
- expect(Array.from(checked).map(labelOf).sort()).toEqual([
280
- 'SYM00003',
281
- 'SYM00005',
282
- ])
283
-
284
- destroy()
285
- })
286
-
287
- it('a value whose label contains a comma survives being picked', async () => {
288
- const rows: Row[] = [
289
- { id: 1, symbol: 'Aug 17, 2026', team: 'A' },
290
- { id: 2, symbol: '1,234 - 5,678', team: 'B' },
291
- { id: 3, symbol: 'plain', team: 'C' },
292
- ]
293
- const { target, api, destroy } = await mountGrid(rows)
294
- await tick()
295
- const menu = await openFilterMenu(target)
296
-
297
- const op = menu.querySelector('.sv-grid-menu-operator-select') as HTMLSelectElement
298
- op.value = 'in'
299
- op.dispatchEvent(new Event('change', { bubbles: true }))
300
- await tick()
301
-
302
- const box = menu.querySelector('.sv-grid-menu-condition-value') as HTMLInputElement
303
- box.dispatchEvent(new FocusEvent('focus'))
304
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
305
- const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
306
-
307
- const pick = findOption(suggest, 'Aug 17, 2026')
308
- pick.click()
309
- await tick()
310
-
311
- // The comma inside the value must not split it into two filter tokens.
312
- expect(splitInTokens(box.value)).toEqual(['Aug 17, 2026'])
313
- expect(api.getDisplayedRows().length).toBe(1)
314
-
315
- destroy()
316
- })
317
-
318
- it('a search-hidden value stays checked when another is toggled', async () => {
319
- const { target, api, destroy } = await mountGrid(makeRows(9))
320
- await tick()
321
- const menu = await openFilterMenu(target)
322
-
323
- // Uncheck SYM00000 while everything is visible.
324
- ;(menu.querySelector('[role="option"]') as HTMLElement).click()
325
- await tick()
326
- expect(api.getDisplayedRows().length).toBe(8)
327
-
328
- // Narrow to a single value, uncheck it too. The first exclusion must
329
- // survive - it is only hidden by the search box, not deselected.
330
- const search = menu.querySelector('.sv-grid-menu-search') as HTMLInputElement
331
- search.value = 'SYM00005'
332
- search.dispatchEvent(new Event('input', { bubbles: true }))
333
- await tick()
334
- expect(menu.querySelectorAll('[role="option"]').length).toBe(1)
335
- ;(menu.querySelector('[role="option"]') as HTMLElement).click()
336
- await tick()
337
-
338
- expect(api.getDisplayedRows().length).toBe(7)
339
-
340
- destroy()
341
- })
342
-
343
- it('shows how many rows each value matches', async () => {
344
- // 9 rows over teams A/B/C, so three each.
345
- const { target, destroy } = await mountGrid(makeRows(9))
346
- await tick()
347
- const menu = await openFilterMenu(target, 1)
348
- const rows = Array.from(menu.querySelectorAll('[role="option"]')).map((el) => ({
349
- label: el.querySelector('.sv-listbox__label')?.textContent?.trim(),
350
- hint: el.querySelector('.sv-listbox__hint')?.textContent?.trim(),
351
- }))
352
- expect(rows).toEqual([
353
- { label: 'A', hint: '3' },
354
- { label: 'B', hint: '3' },
355
- { label: 'C', hint: '3' },
356
- ])
357
- destroy()
358
- })
359
-
360
- it('offers only the values still reachable under the other columns\' filters', async () => {
361
- // Symbols SYM00000..2 are teams A, B, C in turn. Filtering Symbol down to
362
- // one row must leave the Team list showing that row's team alone - the
363
- // list used to be built from the whole dataset, so it offered every team
364
- // and two of the three were a one-click route to an empty grid.
365
- const { target, api, destroy } = await mountGrid(makeRows(9))
366
- await tick()
367
- api.setFilter('symbol', { operator: 'equals', value: 'SYM00001' })
368
- await tick()
369
- expect(api.getDisplayedRows().length).toBe(1)
370
-
371
- const menu = await openFilterMenu(target, 1)
372
- const labels = optionLabels(menu)
373
- expect(labels).toEqual(['B'])
374
-
375
- destroy()
376
- })
377
- })
1
+ /**
2
+ * The Excel-style filter menu's value checklist is an SvListBox, not a div of
3
+ * checkboxes. Two things that used to bite on a high-cardinality live column
4
+ * (10k distinct symbols on a ticking feed):
5
+ *
6
+ * 1. every distinct value mounted a label + focusable checkbox, so the
7
+ * popover held ~30k nodes and 10k tab stops;
8
+ * 2. the offered values were derived live, so each data tick rebuilt the
9
+ * distinct-value set, re-sorted it and re-keyed the whole list.
10
+ *
11
+ * The list now windows its rows and snapshots its values when the menu opens.
12
+ */
13
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
14
+ import { mount, unmount } from 'svelte'
15
+ import SvGrid from './SvGrid.svelte'
16
+ import {
17
+ columnFilteringFeature,
18
+ createCoreRowModel,
19
+ createFilteredRowModel,
20
+ createSortedRowModel,
21
+ rowSortingFeature,
22
+ sortFns,
23
+ splitInTokens,
24
+ tableFeatures,
25
+ } from './index'
26
+ import type { ColumnDef, SvGridApi } from './index'
27
+
28
+ type Row = { id: number; symbol: string; team: string }
29
+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
30
+
31
+ const makeRows = (count: number, prefix = 'SYM'): Row[] =>
32
+ Array.from({ length: count }, (_, i) => ({
33
+ id: i + 1,
34
+ symbol: `${prefix}${String(i).padStart(5, '0')}`,
35
+ team: ['A', 'B', 'C'][i % 3]!,
36
+ }))
37
+
38
+ const cols: ColumnDef<typeof features, Row>[] = [
39
+ { field: 'symbol', header: 'Symbol', width: 200 },
40
+ { field: 'team', header: 'Team', width: 160 },
41
+ ]
42
+ const tick = () => new Promise<void>((r) => setTimeout(r))
43
+
44
+ function mountGrid(data: Row[]) {
45
+ return new Promise<{
46
+ target: HTMLElement
47
+ api: SvGridApi<typeof features, Row>
48
+ setData: (next: Row[]) => void
49
+ destroy: () => void
50
+ }>((res, rej) => {
51
+ const target = document.createElement('div')
52
+ document.body.appendChild(target)
53
+ const props = $state({
54
+ data,
55
+ columns: cols,
56
+ features,
57
+ _rowModels: {
58
+ coreRowModel: createCoreRowModel(),
59
+ filteredRowModel: createFilteredRowModel(),
60
+ sortedRowModel: createSortedRowModel(sortFns),
61
+ },
62
+ rowHeight: 32,
63
+ containerHeight: 400,
64
+ virtualization: true,
65
+ filterMode: 'menu' as const,
66
+ showColumnFilters: true,
67
+ onApiReady(api: SvGridApi<typeof features, Row>) {
68
+ res({
69
+ target,
70
+ api,
71
+ setData: (next: Row[]) => (props.data = next),
72
+ destroy: () => { unmount(app); target.remove() },
73
+ })
74
+ },
75
+ })
76
+ const app = mount(SvGrid, { target, props: props as any })
77
+ queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
78
+ })
79
+ }
80
+
81
+ /** Open the funnel popover on the first column and return the menu element. */
82
+ async function openFilterMenu(target: HTMLElement, columnIndex = 0) {
83
+ const btn = target.querySelectorAll('.sv-grid-col-filter-btn')[
84
+ columnIndex
85
+ ] as HTMLButtonElement
86
+ btn.click()
87
+ // Poll until the popover mounts AND its value checklist has rendered.
88
+ //
89
+ // Waiting only for the popover element returns while the listbox is still
90
+ // empty: the menu element appears, and the values are snapshotted and
91
+ // windowed in a later flush. Callers then read option counts from a list that
92
+ // has not populated yet. It survives locally because those flushes land in
93
+ // the same task, but under parallel load the gap widens - it failed once that
94
+ // way in a full-suite run. Every caller here opens a menu on a column that has
95
+ // values, so a populated list is the settled state to wait for.
96
+ //
97
+ // The default budget is enough because `beforeAll` has already paid for the
98
+ // overlay chunk; this only waits on flushes.
99
+ await vi.waitFor(() => {
100
+ const menu = target.querySelector('.sv-grid-filter-menu')
101
+ expect(menu).not.toBeNull()
102
+ expect(menu!.querySelectorAll('[role="option"]').length).toBeGreaterThan(0)
103
+ })
104
+ return target.querySelector('.sv-grid-filter-menu') as HTMLElement
105
+ }
106
+
107
+ // The LABEL only. The row also carries a trailing match count, which these
108
+ // tests are not about - reading the whole row's text would couple them to it.
109
+ const labelOf = (el: Element) =>
110
+ (el.querySelector('.sv-listbox__label') ?? el).textContent?.trim() ?? ''
111
+
112
+ const optionLabels = (menu: HTMLElement) =>
113
+ Array.from(menu.querySelectorAll('[role="option"]')).map(labelOf)
114
+
115
+ const findOption = (menu: HTMLElement, label: string) => {
116
+ const hit = Array.from(menu.querySelectorAll('[role="option"]')).find(
117
+ (el) => labelOf(el) === label,
118
+ )
119
+ if (!hit) throw new Error(`no option labelled "${label}" in [${optionLabels(menu)}]`)
120
+ return hit as HTMLElement
121
+ }
122
+
123
+ describe('filter menu value checklist', () => {
124
+ // Load the overlay chunk once, before the clock starts on any test.
125
+ //
126
+ // The menus live in a chunk the grid `import()`s the first time one opens,
127
+ // and vitest transforms + instruments that chunk on demand. Under `test:lib`
128
+ // (`--coverage`, workers in parallel) that first transform pushed this file's
129
+ // first test past the 5s per-test budget, while the other eleven - reading
130
+ // the module from cache - passed in about a second each. Paying for it here
131
+ // leaves every test measuring the checklist rather than Vite's transform
132
+ // speed, and the hook reports a slow import as a slow import.
133
+ beforeAll(async () => {
134
+ await import('./GridMenus.svelte')
135
+ }, 60_000)
136
+
137
+ it('windows a high-cardinality column instead of mounting every value', async () => {
138
+ const rows = makeRows(120)
139
+ const { target, destroy } = await mountGrid(rows)
140
+ await tick()
141
+ const menu = await openFilterMenu(target)
142
+
143
+ const list = menu.querySelector('.sv-listbox')
144
+ expect(list).not.toBeNull()
145
+ expect(list!.classList.contains('is-virtual')).toBe(true)
146
+
147
+ const rendered = menu.querySelectorAll('[role="option"]').length
148
+ expect(rendered).toBeGreaterThan(0)
149
+ expect(rendered).toBeLessThan(60)
150
+
151
+ destroy()
152
+ })
153
+
154
+ it('is a single tab stop, not one per value', async () => {
155
+ const { target, destroy } = await mountGrid(makeRows(120))
156
+ await tick()
157
+ const menu = await openFilterMenu(target)
158
+
159
+ // Roving tabindex: the listbox root is focusable, its options are not.
160
+ expect(menu.querySelectorAll('.sv-listbox[tabindex="0"]').length).toBe(1)
161
+ expect(menu.querySelectorAll('[role="option"][tabindex]').length).toBe(0)
162
+ // No focusable checkbox per row any more.
163
+ expect(menu.querySelectorAll('.sv-listbox input').length).toBe(0)
164
+
165
+ destroy()
166
+ })
167
+
168
+ it('keeps a short list fully rendered (no fixed-height box)', async () => {
169
+ const { target, destroy } = await mountGrid(makeRows(9))
170
+ await tick()
171
+ const menu = await openFilterMenu(target)
172
+
173
+ const list = menu.querySelector('.sv-listbox')!
174
+ expect(list.classList.contains('is-virtual')).toBe(false)
175
+ expect(menu.querySelectorAll('[role="option"]').length).toBe(9)
176
+
177
+ destroy()
178
+ })
179
+
180
+ it('snapshots the offered values, so a live data tick does not rebuild them', async () => {
181
+ const { target, setData, destroy } = await mountGrid(makeRows(6, 'OLD'))
182
+ await tick()
183
+ const menu = await openFilterMenu(target)
184
+ const before = optionLabels(menu)
185
+ expect(before[0]).toBe('OLD00000')
186
+
187
+ // A streaming grid replaces its row array; the open menu must not re-scan.
188
+ setData(makeRows(6, 'NEW'))
189
+ await tick()
190
+ expect(optionLabels(menu)).toEqual(before)
191
+
192
+ destroy()
193
+ })
194
+
195
+ it('re-reads the values on the next open', async () => {
196
+ const { target, setData, destroy } = await mountGrid(makeRows(6, 'OLD'))
197
+ await tick()
198
+ await openFilterMenu(target)
199
+ setData(makeRows(6, 'NEW'))
200
+ await tick()
201
+
202
+ // Close, then reopen: the snapshot is taken fresh.
203
+ ;(target.querySelector('.sv-grid-menu-backdrop') as HTMLElement).click()
204
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-filter-menu')).toBeNull())
205
+ const reopened = await openFilterMenu(target)
206
+ expect(optionLabels(reopened)[0]).toBe('NEW00000')
207
+
208
+ destroy()
209
+ })
210
+
211
+ it('unchecking a value filters the rows out', async () => {
212
+ const { target, api, destroy } = await mountGrid(makeRows(9))
213
+ await tick()
214
+ const menu = await openFilterMenu(target)
215
+
216
+ const first = menu.querySelector('[role="option"]') as HTMLElement
217
+ expect(first.getAttribute('aria-selected')).toBe('true')
218
+ first.click()
219
+ await tick()
220
+
221
+ expect(first.getAttribute('aria-selected')).toBe('false')
222
+ expect(api.getDisplayedRows().length).toBe(8)
223
+
224
+ destroy()
225
+ })
226
+
227
+ it('an in/notIn box offers a windowed checklist, not a capped datalist', async () => {
228
+ const { target, destroy } = await mountGrid(makeRows(300))
229
+ await tick()
230
+ const menu = await openFilterMenu(target)
231
+
232
+ const op = menu.querySelector('.sv-grid-menu-operator-select') as HTMLSelectElement
233
+ op.value = 'in'
234
+ op.dispatchEvent(new Event('change', { bubbles: true }))
235
+ await tick()
236
+
237
+ expect(menu.querySelector('datalist')).toBeNull()
238
+ const box = menu.querySelector('.sv-grid-menu-condition-value') as HTMLInputElement
239
+ expect(box.getAttribute('list')).toBeNull()
240
+
241
+ box.dispatchEvent(new FocusEvent('focus'))
242
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
243
+ const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
244
+
245
+ // Uncapped source list, windowed render.
246
+ expect(suggest.querySelector('.sv-listbox')!.classList.contains('is-virtual')).toBe(true)
247
+ expect(suggest.querySelectorAll('[role="option"]').length).toBeLessThan(60)
248
+
249
+ destroy()
250
+ })
251
+
252
+ it('picking a suggestion replaces the fragment being typed', async () => {
253
+ const { target, destroy } = await mountGrid(makeRows(9))
254
+ await tick()
255
+ const menu = await openFilterMenu(target)
256
+
257
+ const op = menu.querySelector('.sv-grid-menu-operator-select') as HTMLSelectElement
258
+ op.value = 'in'
259
+ op.dispatchEvent(new Event('change', { bubbles: true }))
260
+ await tick()
261
+
262
+ const box = menu.querySelector('.sv-grid-menu-condition-value') as HTMLInputElement
263
+ box.dispatchEvent(new FocusEvent('focus'))
264
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
265
+
266
+ // Commit one token, then start typing a second.
267
+ box.value = 'SYM00003, SYM0000'
268
+ box.dispatchEvent(new Event('input', { bubbles: true }))
269
+ await tick()
270
+
271
+ const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
272
+ expect(optionLabels(suggest).length).toBeGreaterThan(1)
273
+
274
+ // Pick another: the fragment is consumed, not left beside it.
275
+ const pick = findOption(suggest, 'SYM00005')
276
+ pick.click()
277
+ await tick()
278
+
279
+ expect(splitInTokens(box.value).sort()).toEqual(['SYM00003', 'SYM00005'])
280
+ // ...and both read as selected straight away. A value written by the
281
+ // dropdown has no trailing separator, so anything that mistook the last
282
+ // token for a half-typed fragment would show it unchecked.
283
+ const checked = suggest.querySelectorAll('[role="option"][aria-selected="true"]')
284
+ expect(Array.from(checked).map(labelOf).sort()).toEqual([
285
+ 'SYM00003',
286
+ 'SYM00005',
287
+ ])
288
+
289
+ destroy()
290
+ })
291
+
292
+ it('a value whose label contains a comma survives being picked', async () => {
293
+ const rows: Row[] = [
294
+ { id: 1, symbol: 'Aug 17, 2026', team: 'A' },
295
+ { id: 2, symbol: '1,234 - 5,678', team: 'B' },
296
+ { id: 3, symbol: 'plain', team: 'C' },
297
+ ]
298
+ const { target, api, destroy } = await mountGrid(rows)
299
+ await tick()
300
+ const menu = await openFilterMenu(target)
301
+
302
+ const op = menu.querySelector('.sv-grid-menu-operator-select') as HTMLSelectElement
303
+ op.value = 'in'
304
+ op.dispatchEvent(new Event('change', { bubbles: true }))
305
+ await tick()
306
+
307
+ const box = menu.querySelector('.sv-grid-menu-condition-value') as HTMLInputElement
308
+ box.dispatchEvent(new FocusEvent('focus'))
309
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
310
+ const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
311
+
312
+ const pick = findOption(suggest, 'Aug 17, 2026')
313
+ pick.click()
314
+ await tick()
315
+
316
+ // The comma inside the value must not split it into two filter tokens.
317
+ expect(splitInTokens(box.value)).toEqual(['Aug 17, 2026'])
318
+ expect(api.getDisplayedRows().length).toBe(1)
319
+
320
+ destroy()
321
+ })
322
+
323
+ it('a search-hidden value stays checked when another is toggled', async () => {
324
+ const { target, api, destroy } = await mountGrid(makeRows(9))
325
+ await tick()
326
+ const menu = await openFilterMenu(target)
327
+
328
+ // Uncheck SYM00000 while everything is visible.
329
+ ;(menu.querySelector('[role="option"]') as HTMLElement).click()
330
+ await tick()
331
+ expect(api.getDisplayedRows().length).toBe(8)
332
+
333
+ // Narrow to a single value, uncheck it too. The first exclusion must
334
+ // survive - it is only hidden by the search box, not deselected.
335
+ const search = menu.querySelector('.sv-grid-menu-search') as HTMLInputElement
336
+ search.value = 'SYM00005'
337
+ search.dispatchEvent(new Event('input', { bubbles: true }))
338
+ await tick()
339
+ expect(menu.querySelectorAll('[role="option"]').length).toBe(1)
340
+ ;(menu.querySelector('[role="option"]') as HTMLElement).click()
341
+ await tick()
342
+
343
+ expect(api.getDisplayedRows().length).toBe(7)
344
+
345
+ destroy()
346
+ })
347
+
348
+ it('shows how many rows each value matches', async () => {
349
+ // 9 rows over teams A/B/C, so three each.
350
+ const { target, destroy } = await mountGrid(makeRows(9))
351
+ await tick()
352
+ const menu = await openFilterMenu(target, 1)
353
+ const rows = Array.from(menu.querySelectorAll('[role="option"]')).map((el) => ({
354
+ label: el.querySelector('.sv-listbox__label')?.textContent?.trim(),
355
+ hint: el.querySelector('.sv-listbox__hint')?.textContent?.trim(),
356
+ }))
357
+ expect(rows).toEqual([
358
+ { label: 'A', hint: '3' },
359
+ { label: 'B', hint: '3' },
360
+ { label: 'C', hint: '3' },
361
+ ])
362
+ destroy()
363
+ })
364
+
365
+ it('offers only the values still reachable under the other columns\' filters', async () => {
366
+ // Symbols SYM00000..2 are teams A, B, C in turn. Filtering Symbol down to
367
+ // one row must leave the Team list showing that row's team alone - the
368
+ // list used to be built from the whole dataset, so it offered every team
369
+ // and two of the three were a one-click route to an empty grid.
370
+ const { target, api, destroy } = await mountGrid(makeRows(9))
371
+ await tick()
372
+ api.setFilter('symbol', { operator: 'equals', value: 'SYM00001' })
373
+ await tick()
374
+ expect(api.getDisplayedRows().length).toBe(1)
375
+
376
+ const menu = await openFilterMenu(target, 1)
377
+ const labels = optionLabels(menu)
378
+ expect(labels).toEqual(['B'])
379
+
380
+ destroy()
381
+ })
382
+ })