@svgrid/grid 3.0.0 → 3.0.2

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 (304) 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 +190 -181
  5. package/dist/GridFooter.svelte.d.ts +8 -0
  6. package/dist/GridMenus.svelte +18 -10
  7. package/dist/GridMenus.svelte.d.ts +2 -1
  8. package/dist/SvAutoComplete.svelte +169 -169
  9. package/dist/SvAvatar.svelte +75 -75
  10. package/dist/SvCalendar.svelte +503 -503
  11. package/dist/SvCarousel.svelte +141 -141
  12. package/dist/SvCheckBox.svelte +102 -102
  13. package/dist/SvCircularProgress.svelte +109 -109
  14. package/dist/SvColorInput.svelte +181 -181
  15. package/dist/SvComboBox.svelte +279 -279
  16. package/dist/SvContextMenu.svelte +116 -116
  17. package/dist/SvCountryInput.svelte +163 -163
  18. package/dist/SvDrawer.svelte +254 -254
  19. package/dist/SvDropDownList.svelte +378 -378
  20. package/dist/SvDurationInput.svelte +126 -126
  21. package/dist/SvField.svelte +293 -293
  22. package/dist/SvForm.svelte +437 -437
  23. package/dist/SvGrid.controller.svelte.d.ts +17 -0
  24. package/dist/SvGrid.controller.svelte.js +128 -14
  25. package/dist/SvGrid.css +79 -10
  26. package/dist/SvGrid.svelte +191 -186
  27. package/dist/SvGrid.types.d.ts +42 -1
  28. package/dist/SvGridCellEditor.svelte +748 -744
  29. package/dist/SvGridChart.svelte +2246 -1716
  30. package/dist/SvGridChart.svelte.d.ts +63 -1
  31. package/dist/SvGridChartPanel.svelte +633 -485
  32. package/dist/SvGridChartView.svelte +79 -70
  33. package/dist/SvGridChartView.svelte.d.ts +7 -0
  34. package/dist/SvGridDropdown.svelte +728 -728
  35. package/dist/SvGridSelect.svelte +270 -270
  36. package/dist/SvGroupCell.svelte +141 -116
  37. package/dist/SvGroupCell.svelte.d.ts +8 -0
  38. package/dist/SvListBox.svelte +334 -334
  39. package/dist/SvMaskedInput.svelte +122 -122
  40. package/dist/SvMenu.svelte +124 -124
  41. package/dist/SvMenuList.svelte +146 -146
  42. package/dist/SvMultiSelect.svelte +293 -293
  43. package/dist/SvNumberInput.svelte +172 -172
  44. package/dist/SvOtpInput.svelte +158 -158
  45. package/dist/SvPasswordInput.svelte +151 -151
  46. package/dist/SvPhoneInput.svelte +133 -133
  47. package/dist/SvPopover.svelte +197 -197
  48. package/dist/SvProgress.svelte +116 -116
  49. package/dist/SvRadioGroup.svelte +107 -107
  50. package/dist/SvRating.svelte +112 -112
  51. package/dist/SvResult.svelte +73 -73
  52. package/dist/SvRichText.svelte +211 -211
  53. package/dist/SvRowGroupPanel.svelte +186 -170
  54. package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
  55. package/dist/SvScrollArea.svelte +61 -61
  56. package/dist/SvSlider.svelte +203 -203
  57. package/dist/SvSwitchButton.svelte +108 -108
  58. package/dist/SvTagsInput.svelte +115 -115
  59. package/dist/SvTextInput.svelte +147 -147
  60. package/dist/SvTimePicker.svelte +245 -245
  61. package/dist/SvToaster.svelte +159 -159
  62. package/dist/SvToggleButton.svelte +85 -85
  63. package/dist/SvTooltip.svelte +161 -161
  64. package/dist/SvTour.svelte +208 -208
  65. package/dist/SvTree.svelte +444 -444
  66. package/dist/SvTreeSelect.svelte +239 -239
  67. package/dist/ai.d.ts +11 -2
  68. package/dist/ai.js +51 -7
  69. package/dist/cdn/GridMenus-DyFA-QxA.js +602 -0
  70. package/dist/cdn/GridMenus-M2XP9t0_.js +603 -0
  71. package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
  72. package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
  73. package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
  74. package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
  75. package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
  76. package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
  77. package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
  78. package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
  79. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
  80. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
  81. package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-CxfakxR0.js} +5 -3
  82. package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-Dm_YEx9L.js} +5 -3
  83. package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
  84. package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
  85. package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
  86. package/dist/cdn/chart-CKJRCPOk.js +1652 -0
  87. package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
  88. package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
  89. package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
  90. package/dist/cdn/{src-B_YS5AOc.js → src-BJ2opd0j.js} +3642 -3450
  91. package/dist/cdn/{src-DYXXpuSk.js → src-DNbz94Ct.js} +8649 -8457
  92. package/dist/cdn/svgrid.js +16 -15
  93. package/dist/cdn/svgrid.svelte-external.js +16 -15
  94. package/dist/cell-formatting.d.ts +7 -0
  95. package/dist/cell-formatting.js +7 -1
  96. package/dist/chart-export.d.ts +23 -2
  97. package/dist/chart-export.js +88 -14
  98. package/dist/chart.d.ts +410 -18
  99. package/dist/chart.js +824 -24
  100. package/dist/editing.js +22 -13
  101. package/dist/grid-icons.d.ts +49 -0
  102. package/dist/grid-icons.js +86 -0
  103. package/dist/index.d.ts +3 -2
  104. package/dist/index.js +3 -2
  105. package/package.json +1 -1
  106. package/src/FlexRender.svelte +96 -96
  107. package/src/GridFooter.svelte +190 -181
  108. package/src/GridMenus.svelte +18 -10
  109. package/src/SvAutoComplete.svelte +169 -169
  110. package/src/SvAvatar.svelte +75 -75
  111. package/src/SvCalendar.svelte +503 -503
  112. package/src/SvCalendar.test.ts +226 -226
  113. package/src/SvCarousel.svelte +141 -141
  114. package/src/SvCheckBox.svelte +102 -102
  115. package/src/SvCircularProgress.svelte +109 -109
  116. package/src/SvColorInput.svelte +181 -181
  117. package/src/SvComboBox.svelte +279 -279
  118. package/src/SvContextMenu.svelte +116 -116
  119. package/src/SvCountryInput.svelte +163 -163
  120. package/src/SvDrawer.svelte +254 -254
  121. package/src/SvDropDownList.svelte +378 -378
  122. package/src/SvDurationInput.svelte +126 -126
  123. package/src/SvField.svelte +293 -293
  124. package/src/SvForm.svelte +437 -437
  125. package/src/SvForm.test.ts +411 -411
  126. package/src/SvGrid.controller.svelte.ts +146 -13
  127. package/src/SvGrid.css +79 -10
  128. package/src/SvGrid.svelte +191 -186
  129. package/src/SvGrid.types.ts +2133 -2092
  130. package/src/SvGridCellEditor.svelte +748 -744
  131. package/src/SvGridChart.svelte +2246 -1716
  132. package/src/SvGridChart.test.ts +243 -1
  133. package/src/SvGridChartPanel.svelte +633 -485
  134. package/src/SvGridChartView.svelte +79 -70
  135. package/src/SvGridDropdown.svelte +728 -728
  136. package/src/SvGridSelect.svelte +270 -270
  137. package/src/SvGroupCell.svelte +141 -116
  138. package/src/SvListBox.svelte +334 -334
  139. package/src/SvMaskedInput.svelte +122 -122
  140. package/src/SvMenu.svelte +124 -124
  141. package/src/SvMenu.test.ts +97 -97
  142. package/src/SvMenuList.svelte +146 -146
  143. package/src/SvMultiSelect.svelte +293 -293
  144. package/src/SvNumberInput.svelte +172 -172
  145. package/src/SvOtpInput.svelte +158 -158
  146. package/src/SvPasswordInput.svelte +151 -151
  147. package/src/SvPhoneInput.svelte +133 -133
  148. package/src/SvPopover.svelte +197 -197
  149. package/src/SvProgress.svelte +116 -116
  150. package/src/SvRadioGroup.svelte +107 -107
  151. package/src/SvRating.svelte +112 -112
  152. package/src/SvResult.svelte +73 -73
  153. package/src/SvRichText.svelte +211 -211
  154. package/src/SvRowGroupPanel.svelte +186 -170
  155. package/src/SvScrollArea.svelte +61 -61
  156. package/src/SvSlider.svelte +203 -203
  157. package/src/SvSwitchButton.svelte +108 -108
  158. package/src/SvTagsInput.svelte +115 -115
  159. package/src/SvTextInput.svelte +147 -147
  160. package/src/SvTimePicker.svelte +245 -245
  161. package/src/SvToaster.svelte +159 -159
  162. package/src/SvToaster.test.ts +95 -95
  163. package/src/SvToggleButton.svelte +85 -85
  164. package/src/SvTooltip.svelte +161 -161
  165. package/src/SvTour.svelte +208 -208
  166. package/src/SvTree.svelte +444 -444
  167. package/src/SvTreeSelect.svelte +239 -239
  168. package/src/a11y/dismissable.test.ts +119 -119
  169. package/src/a11y/dismissable.ts +114 -114
  170. package/src/a11y.contract.test.ts +49 -49
  171. package/src/a11y.test.ts +59 -59
  172. package/src/a11y.ts +61 -61
  173. package/src/ai.test.ts +561 -502
  174. package/src/ai.ts +1472 -1419
  175. package/src/build-api.coverage.test.ts +633 -633
  176. package/src/build-api.ts +846 -846
  177. package/src/builtin-editors.grid.test.ts +83 -83
  178. package/src/cell-formatting.ts +177 -171
  179. package/src/cell-render.test.ts +513 -513
  180. package/src/cell-render.ts +496 -496
  181. package/src/cell-values.ts +148 -148
  182. package/src/chart-export.test.ts +112 -0
  183. package/src/chart-export.ts +270 -202
  184. package/src/chart-view.svelte.ts +36 -36
  185. package/src/chart.test.ts +830 -1
  186. package/src/chart.ts +3342 -2321
  187. package/src/collaboration.test.ts +104 -104
  188. package/src/collaboration.ts +167 -167
  189. package/src/column-groups.ts +78 -78
  190. package/src/core.performance.test.ts +30 -30
  191. package/src/core.ts +1865 -1865
  192. package/src/createAutocomplete.svelte.ts +132 -132
  193. package/src/createCombobox.svelte.ts +191 -191
  194. package/src/createCountryInput.svelte.ts +157 -157
  195. package/src/createDropdownList.svelte.ts +168 -168
  196. package/src/createForm.svelte.ts +386 -386
  197. package/src/createGrid.svelte.ts +42 -42
  198. package/src/createGrid.test.ts +10 -10
  199. package/src/createGridState.svelte.ts +17 -17
  200. package/src/createListbox.svelte.ts +250 -250
  201. package/src/createMenu.svelte.ts +224 -224
  202. package/src/createPopoverSelect.svelte.ts +213 -213
  203. package/src/createSlider.svelte.ts +191 -191
  204. package/src/createTooltip.svelte.ts +144 -144
  205. package/src/createTree.svelte.ts +322 -322
  206. package/src/datetime/date-core.ts +206 -206
  207. package/src/datetime/date-restrict.ts +61 -61
  208. package/src/datetime/timezone.ts +135 -135
  209. package/src/dock-manager-model.ts +596 -596
  210. package/src/dock-model.ts +374 -374
  211. package/src/editing.test.ts +974 -974
  212. package/src/editing.ts +618 -609
  213. package/src/editor-contract.ts +171 -171
  214. package/src/editor-registry.grid.test.ts +144 -144
  215. package/src/editor-registry.ts +122 -122
  216. package/src/export-data-api.test.ts +126 -126
  217. package/src/export-format.test.ts +107 -107
  218. package/src/export-format.ts +601 -601
  219. package/src/filter-operators.ts +160 -160
  220. package/src/filtering/excel-filters.ts +325 -325
  221. package/src/flex-render.ts +3 -3
  222. package/src/form-field.ts +127 -127
  223. package/src/grid-icons.test.ts +46 -0
  224. package/src/grid-icons.ts +184 -0
  225. package/src/group-display.test.ts +167 -167
  226. package/src/group-display.ts +200 -200
  227. package/src/headless.ts +87 -87
  228. package/src/icon-overrides.grid.test.ts +188 -0
  229. package/src/icon-seam.test.ts +100 -0
  230. package/src/index.ts +19 -0
  231. package/src/js-scroller.svelte.ts +173 -173
  232. package/src/keyboard-handlers.ts +270 -270
  233. package/src/keyboard.test.ts +59 -59
  234. package/src/keyboard.ts +97 -97
  235. package/src/list-nav.test.ts +49 -49
  236. package/src/list-nav.ts +29 -29
  237. package/src/list-option.test.ts +56 -56
  238. package/src/list-option.ts +179 -179
  239. package/src/menus.ts +597 -597
  240. package/src/merge-objects.ts +48 -48
  241. package/src/overlays.test.ts +90 -90
  242. package/src/positioning.ts +268 -268
  243. package/src/render-component.ts +28 -28
  244. package/src/row-drag.test.ts +401 -401
  245. package/src/row-drag.ts +419 -419
  246. package/src/row-resize.test.ts +524 -524
  247. package/src/row-resize.ts +228 -228
  248. package/src/scheduler-ical.ts +181 -181
  249. package/src/scheduler-model.test.ts +562 -562
  250. package/src/scheduler-model.ts +873 -873
  251. package/src/selection.test.ts +885 -885
  252. package/src/server-data-source.test.ts +383 -383
  253. package/src/server-data-source.ts +469 -469
  254. package/src/sparkline.test.ts +68 -68
  255. package/src/sparkline.ts +169 -169
  256. package/src/spreadsheet.test.ts +488 -488
  257. package/src/spreadsheet.ts +312 -312
  258. package/src/static-functions.ts +11 -11
  259. package/src/subscribe.ts +38 -38
  260. package/src/summaries.ts +113 -113
  261. package/src/svgrid-wrapper.types.ts +563 -563
  262. package/src/svgrid.async-editor-options.test.ts +273 -273
  263. package/src/svgrid.auto-row-height.test.ts +204 -204
  264. package/src/svgrid.behavior.test.ts +910 -910
  265. package/src/svgrid.charting.test.ts +654 -534
  266. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  267. package/src/svgrid.context-menu.test.ts +147 -147
  268. package/src/svgrid.features.test.ts +157 -157
  269. package/src/svgrid.filter-depth.test.ts +163 -163
  270. package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
  271. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  272. package/src/svgrid.grand-total.test.ts +188 -188
  273. package/src/svgrid.group-display-mode.test.ts +171 -171
  274. package/src/svgrid.group-footers.test.ts +121 -121
  275. package/src/svgrid.group-pagination.test.ts +153 -153
  276. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  277. package/src/svgrid.tree-data.test.ts +186 -186
  278. package/src/svgrid.wrapper.test.ts +63 -63
  279. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  280. package/src/test-setup.ts +62 -62
  281. package/src/themes/index.ts +288 -288
  282. package/src/toast-store.svelte.ts +250 -250
  283. package/src/toast-store.test.ts +147 -147
  284. package/src/tree-row-model.test.ts +168 -168
  285. package/src/ui-buttons.test.ts +144 -144
  286. package/src/ui-inputs.test.ts +118 -118
  287. package/src/ui-localization.test.ts +113 -113
  288. package/src/ui-range.test.ts +70 -70
  289. package/src/ui-selection.test.ts +155 -155
  290. package/src/ui-tier1.test.ts +142 -142
  291. package/src/virtual.test.ts +88 -88
  292. package/src/virtualization/column-virtualizer.test.ts +27 -27
  293. package/src/virtualization/column-virtualizer.ts +30 -30
  294. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  295. package/src/virtualization/types.ts +30 -30
  296. package/src/virtualization/virtualizer.test.ts +47 -47
  297. package/src/virtualization/virtualizer.ts +322 -322
  298. package/dist/cdn/GridMenus-DMihUtma.js +0 -600
  299. package/dist/cdn/GridMenus-nmNDj1a3.js +0 -601
  300. package/dist/cdn/SvGridChart-Bs2GIR2Q.js +0 -1202
  301. package/dist/cdn/SvGridChart-CwhFz7GV.js +0 -1203
  302. package/dist/cdn/SvGridChartPanel-1WXuStzM.js +0 -561
  303. package/dist/cdn/SvGridChartPanel-e5_nqNP9.js +0 -585
  304. package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
@@ -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
+ })