@svgrid/grid 2.6.21 → 2.7.0

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 (358) hide show
  1. package/CHANGELOG.md +150 -88
  2. package/README.md +221 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +181 -181
  5. package/dist/GridMenus.svelte +201 -18
  6. package/dist/SvAutoComplete.svelte +169 -165
  7. package/dist/SvAvatar.svelte +75 -75
  8. package/dist/SvCalendar.svelte +503 -503
  9. package/dist/SvCarousel.svelte +141 -141
  10. package/dist/SvCheckBox.svelte +102 -102
  11. package/dist/SvCircularProgress.svelte +109 -109
  12. package/dist/SvColorInput.svelte +181 -181
  13. package/dist/SvComboBox.svelte +279 -275
  14. package/dist/SvContextMenu.svelte +116 -116
  15. package/dist/SvCountryInput.svelte +163 -163
  16. package/dist/SvDateRangeInput.svelte +25 -3
  17. package/dist/SvDrawer.svelte +254 -254
  18. package/dist/SvDropDownList.svelte +378 -374
  19. package/dist/SvDurationInput.svelte +126 -126
  20. package/dist/SvField.svelte +293 -293
  21. package/dist/SvForm.svelte +437 -437
  22. package/dist/SvGrid.controller.svelte.d.ts +61 -9
  23. package/dist/SvGrid.controller.svelte.js +363 -123
  24. package/dist/SvGrid.css +222 -1
  25. package/dist/SvGrid.helpers.d.ts +55 -0
  26. package/dist/SvGrid.helpers.js +83 -0
  27. package/dist/SvGrid.svelte +255 -56
  28. package/dist/SvGrid.types.d.ts +146 -1
  29. package/dist/SvGridCellEditor.svelte +744 -662
  30. package/dist/SvGridChart.svelte +1716 -1716
  31. package/dist/SvGridChartPanel.svelte +485 -485
  32. package/dist/SvGridChartView.svelte +70 -70
  33. package/dist/SvGridDropdown.svelte +728 -728
  34. package/dist/SvGridSelect.svelte +270 -266
  35. package/dist/SvGridSelect.svelte.d.ts +1 -1
  36. package/dist/SvGroupCell.svelte +116 -116
  37. package/dist/SvListBox.svelte +334 -326
  38. package/dist/SvMaskedInput.svelte +122 -122
  39. package/dist/SvMenu.svelte +124 -124
  40. package/dist/SvMenuList.svelte +146 -146
  41. package/dist/SvMultiSelect.svelte +293 -289
  42. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  43. package/dist/SvNumberInput.svelte +172 -168
  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/SvRichCell.svelte +125 -0
  53. package/dist/SvRichCell.svelte.d.ts +15 -0
  54. package/dist/SvRichText.svelte +211 -211
  55. package/dist/SvRowGroupPanel.svelte +170 -170
  56. package/dist/SvScrollArea.svelte +61 -61
  57. package/dist/SvSlider.svelte +203 -203
  58. package/dist/SvSwitchButton.svelte +108 -108
  59. package/dist/SvTagsInput.svelte +115 -115
  60. package/dist/SvTextArea.svelte +5 -1
  61. package/dist/SvTextInput.svelte +147 -147
  62. package/dist/SvTimePicker.svelte +245 -245
  63. package/dist/SvToaster.svelte +159 -159
  64. package/dist/SvToggleButton.svelte +85 -85
  65. package/dist/SvTooltip.svelte +161 -161
  66. package/dist/SvTour.svelte +208 -208
  67. package/dist/SvTree.svelte +444 -444
  68. package/dist/SvTreeSelect.svelte +239 -235
  69. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  70. package/dist/builtin-editors.d.ts +2 -2
  71. package/dist/builtin-editors.js +19 -3
  72. package/dist/cdn/GridMenus-C4oLsJyj.js +600 -0
  73. package/dist/cdn/GridMenus-yEivjNwl.js +601 -0
  74. package/dist/cdn/SvDateRangeInput-Bh0A0JkF.js +199 -0
  75. package/dist/cdn/SvDateRangeInput-DflbiP7N.js +198 -0
  76. package/dist/cdn/SvDateTimePicker-BWwpfB_o.js +812 -0
  77. package/dist/cdn/SvDateTimePicker-Bivn8dAP.js +816 -0
  78. package/dist/cdn/SvGridCellEditor-AXL8cHGO.js +550 -0
  79. package/dist/cdn/SvGridCellEditor-Ba7rY3eu.js +549 -0
  80. package/dist/cdn/{SvGridChart-BwVos976.js → SvGridChart-Bs2GIR2Q.js} +1 -1
  81. package/dist/cdn/{SvGridChart-BEJmNNx9.js → SvGridChart-CwhFz7GV.js} +1 -1
  82. package/dist/cdn/{SvGridChartPanel-BOknOkrP.js → SvGridChartPanel-1WXuStzM.js} +1 -1
  83. package/dist/cdn/{SvGridChartPanel-D2PjII4O.js → SvGridChartPanel-e5_nqNP9.js} +1 -1
  84. package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-SmPW10dI.js} +1 -1
  85. package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-eSPuJE6g.js} +1 -1
  86. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-B1ZcLpgs.js} +1 -1
  87. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CtdbHcIS.js} +1 -1
  88. package/dist/cdn/column-resize-DsfNXMom.js +102 -0
  89. package/dist/cdn/date-format-CcP1tafP.js +1395 -0
  90. package/dist/cdn/date-format-DZT7T1wf.js +1393 -0
  91. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  92. package/dist/cdn/row-resize-BRcimkUT.js +95 -0
  93. package/dist/cdn/{src-BYq-qyrp.js → src-COC0vnpg.js} +7846 -7317
  94. package/dist/cdn/{src-DBel9wRZ.js → src-pYGV2rMR.js} +10033 -9504
  95. package/dist/cdn/svgrid.js +15 -11
  96. package/dist/cdn/svgrid.svelte-external.js +15 -11
  97. package/dist/cell-values.d.ts +16 -0
  98. package/dist/cell-values.js +28 -0
  99. package/dist/chart-export.js +8 -8
  100. package/dist/clipboard.d.ts +22 -0
  101. package/dist/clipboard.js +405 -27
  102. package/dist/column-groups.js +1 -1
  103. package/dist/column-resize.d.ts +46 -0
  104. package/dist/column-resize.js +205 -0
  105. package/dist/columns.d.ts +0 -3
  106. package/dist/columns.js +0 -57
  107. package/dist/core.d.ts +19 -4
  108. package/dist/core.js +460 -119
  109. package/dist/editing.js +12 -7
  110. package/dist/editors/cell-editors.d.ts +11 -0
  111. package/dist/editors/cell-editors.js +18 -1
  112. package/dist/filtering/excel-filters.js +28 -0
  113. package/dist/grid-messages.d.ts +15 -0
  114. package/dist/grid-messages.js +15 -0
  115. package/dist/group-display.d.ts +1 -1
  116. package/dist/index.d.ts +6 -1
  117. package/dist/index.js +10 -0
  118. package/dist/list-option.d.ts +6 -0
  119. package/dist/menus.js +41 -8
  120. package/dist/row-resize.d.ts +11 -0
  121. package/dist/row-resize.js +7 -1
  122. package/dist/sanitize-html.d.ts +37 -0
  123. package/dist/sanitize-html.js +234 -0
  124. package/dist/selection-bar-view.svelte.d.ts +25 -0
  125. package/dist/selection-bar-view.svelte.js +13 -0
  126. package/dist/selection.d.ts +2 -1
  127. package/dist/selection.js +133 -8
  128. package/dist/spreadsheet.d.ts +1 -1
  129. package/dist/spreadsheet.js +1 -1
  130. package/package.json +11 -11
  131. package/src/FlexRender.svelte +96 -96
  132. package/src/GridFooter.svelte +181 -181
  133. package/src/GridMenus.svelte +201 -18
  134. package/src/SvAutoComplete.svelte +169 -165
  135. package/src/SvAvatar.svelte +75 -75
  136. package/src/SvCalendar.svelte +503 -503
  137. package/src/SvCalendar.test.ts +226 -226
  138. package/src/SvCarousel.svelte +141 -141
  139. package/src/SvCheckBox.svelte +102 -102
  140. package/src/SvCircularProgress.svelte +109 -109
  141. package/src/SvColorInput.svelte +181 -181
  142. package/src/SvComboBox.svelte +279 -275
  143. package/src/SvContextMenu.svelte +116 -116
  144. package/src/SvCountryInput.svelte +163 -163
  145. package/src/SvDateRangeInput.svelte +25 -3
  146. package/src/SvDrawer.svelte +254 -254
  147. package/src/SvDropDownList.svelte +378 -374
  148. package/src/SvDurationInput.svelte +126 -126
  149. package/src/SvField.svelte +293 -293
  150. package/src/SvForm.svelte +437 -437
  151. package/src/SvForm.test.ts +411 -411
  152. package/src/SvGrid.controller.svelte.ts +404 -133
  153. package/src/SvGrid.css +222 -1
  154. package/src/SvGrid.helpers.ts +87 -0
  155. package/src/SvGrid.svelte +255 -56
  156. package/src/SvGrid.types.ts +2092 -1940
  157. package/src/SvGridCellEditor.svelte +744 -662
  158. package/src/SvGridChart.svelte +1716 -1716
  159. package/src/SvGridChartPanel.svelte +485 -485
  160. package/src/SvGridChartView.svelte +70 -70
  161. package/src/SvGridDropdown.svelte +728 -728
  162. package/src/SvGridSelect.svelte +270 -266
  163. package/src/SvGroupCell.svelte +116 -116
  164. package/src/SvListBox.svelte +334 -326
  165. package/src/SvMaskedInput.svelte +122 -122
  166. package/src/SvMenu.svelte +124 -124
  167. package/src/SvMenu.test.ts +97 -97
  168. package/src/SvMenuList.svelte +146 -146
  169. package/src/SvMultiSelect.svelte +293 -289
  170. package/src/SvNumberInput.svelte +172 -168
  171. package/src/SvOtpInput.svelte +158 -158
  172. package/src/SvPasswordInput.svelte +151 -151
  173. package/src/SvPhoneInput.svelte +133 -133
  174. package/src/SvPopover.svelte +197 -197
  175. package/src/SvProgress.svelte +116 -116
  176. package/src/SvRadioGroup.svelte +107 -107
  177. package/src/SvRating.svelte +112 -112
  178. package/src/SvResult.svelte +73 -73
  179. package/src/SvRichCell.svelte +125 -0
  180. package/src/SvRichText.svelte +211 -211
  181. package/src/SvRowGroupPanel.svelte +170 -170
  182. package/src/SvScrollArea.svelte +61 -61
  183. package/src/SvSlider.svelte +203 -203
  184. package/src/SvSwitchButton.svelte +108 -108
  185. package/src/SvTagsInput.svelte +115 -115
  186. package/src/SvTextArea.svelte +5 -1
  187. package/src/SvTextInput.svelte +147 -147
  188. package/src/SvTimePicker.svelte +245 -245
  189. package/src/SvToaster.svelte +159 -159
  190. package/src/SvToaster.test.ts +95 -95
  191. package/src/SvToggleButton.svelte +85 -85
  192. package/src/SvTooltip.svelte +161 -161
  193. package/src/SvTour.svelte +208 -208
  194. package/src/SvTree.svelte +444 -444
  195. package/src/SvTreeSelect.svelte +239 -235
  196. package/src/a11y/dismissable.test.ts +119 -119
  197. package/src/a11y/dismissable.ts +114 -114
  198. package/src/a11y.contract.test.ts +49 -49
  199. package/src/a11y.test.ts +59 -59
  200. package/src/a11y.ts +61 -61
  201. package/src/ai.test.ts +502 -502
  202. package/src/ai.ts +1419 -1419
  203. package/src/build-api.coverage.test.ts +633 -633
  204. package/src/build-api.ts +846 -846
  205. package/src/builtin-editors.grid.test.ts +83 -83
  206. package/src/builtin-editors.test.ts +18 -1
  207. package/src/builtin-editors.ts +19 -3
  208. package/src/cell-formatting.ts +171 -171
  209. package/src/cell-render.test.ts +513 -513
  210. package/src/cell-render.ts +496 -496
  211. package/src/cell-values.ts +148 -118
  212. package/src/chart-export.ts +202 -202
  213. package/src/chart-view.svelte.ts +36 -36
  214. package/src/chart.ts +2321 -2321
  215. package/src/clipboard.test.ts +93 -20
  216. package/src/clipboard.ts +433 -33
  217. package/src/collaboration.test.ts +104 -104
  218. package/src/collaboration.ts +167 -167
  219. package/src/column-groups.ts +78 -78
  220. package/src/column-resize.test.ts +381 -0
  221. package/src/column-resize.ts +227 -0
  222. package/src/columns.test.ts +0 -103
  223. package/src/columns.ts +0 -58
  224. package/src/core.aggregate.test.ts +134 -0
  225. package/src/core.filter.test.ts +156 -0
  226. package/src/core.grouping.test.ts +146 -0
  227. package/src/core.performance.test.ts +30 -30
  228. package/src/core.row-shape.test.ts +119 -0
  229. package/src/core.rowmodel-cache.test.ts +121 -0
  230. package/src/core.sort.test.ts +293 -0
  231. package/src/core.ts +1865 -1468
  232. package/src/createAutocomplete.svelte.ts +132 -132
  233. package/src/createCombobox.svelte.ts +191 -191
  234. package/src/createCountryInput.svelte.ts +157 -157
  235. package/src/createDropdownList.svelte.ts +168 -168
  236. package/src/createForm.svelte.ts +386 -386
  237. package/src/createGrid.svelte.ts +42 -42
  238. package/src/createGrid.test.ts +10 -10
  239. package/src/createGridState.svelte.ts +17 -17
  240. package/src/createListbox.svelte.ts +250 -250
  241. package/src/createMenu.svelte.ts +224 -224
  242. package/src/createPopoverSelect.svelte.ts +213 -213
  243. package/src/createSlider.svelte.ts +191 -191
  244. package/src/createTooltip.svelte.ts +144 -144
  245. package/src/createTree.svelte.ts +322 -322
  246. package/src/date-string-column.test.ts +87 -0
  247. package/src/datetime/date-core.ts +206 -206
  248. package/src/datetime/date-restrict.ts +61 -61
  249. package/src/datetime/timezone.ts +135 -135
  250. package/src/dock-manager-model.ts +596 -596
  251. package/src/dock-model.ts +374 -374
  252. package/src/editing.test.ts +974 -949
  253. package/src/editing.ts +609 -606
  254. package/src/editor-block-prop.test.ts +132 -0
  255. package/src/editor-contract.ts +171 -171
  256. package/src/editor-registry.grid.test.ts +144 -144
  257. package/src/editor-registry.ts +122 -122
  258. package/src/editors/cell-editors.ts +27 -1
  259. package/src/export-data-api.test.ts +126 -126
  260. package/src/export-format.test.ts +107 -107
  261. package/src/export-format.ts +601 -601
  262. package/src/filter-operators.ts +160 -160
  263. package/src/filtering/excel-filters.ts +325 -295
  264. package/src/filtering/normalize-fast-path.test.ts +104 -0
  265. package/src/flex-render.ts +3 -3
  266. package/src/form-field.ts +127 -127
  267. package/src/grid-messages.ts +34 -0
  268. package/src/group-display.test.ts +167 -167
  269. package/src/group-display.ts +200 -200
  270. package/src/headless.ts +87 -87
  271. package/src/index.ts +25 -1
  272. package/src/js-scroller.svelte.ts +173 -173
  273. package/src/keyboard-handlers.ts +270 -270
  274. package/src/keyboard.test.ts +59 -59
  275. package/src/keyboard.ts +97 -97
  276. package/src/list-nav.test.ts +49 -49
  277. package/src/list-nav.ts +29 -29
  278. package/src/list-option.test.ts +56 -56
  279. package/src/list-option.ts +179 -173
  280. package/src/menus.test.ts +43 -0
  281. package/src/menus.ts +597 -567
  282. package/src/merge-objects.ts +48 -48
  283. package/src/move-cells.test.ts +850 -0
  284. package/src/number-editor-literal.test.ts +84 -0
  285. package/src/number-editor.grid.test.ts +160 -0
  286. package/src/overlays.test.ts +90 -90
  287. package/src/positioning.ts +268 -268
  288. package/src/render-component.ts +28 -28
  289. package/src/resize-props.test.ts +361 -0
  290. package/src/row-drag-touch.ts +211 -211
  291. package/src/row-drag.test.ts +353 -353
  292. package/src/row-drag.ts +415 -415
  293. package/src/row-resize.test.ts +524 -493
  294. package/src/row-resize.ts +228 -210
  295. package/src/sanitize-html.test.ts +182 -0
  296. package/src/sanitize-html.ts +235 -0
  297. package/src/scheduler-ical.ts +181 -181
  298. package/src/scheduler-model.test.ts +562 -562
  299. package/src/scheduler-model.ts +873 -873
  300. package/src/selection-bar-view.svelte.ts +36 -0
  301. package/src/selection.test.ts +900 -743
  302. package/src/selection.ts +135 -8
  303. package/src/server-data-source.test.ts +383 -383
  304. package/src/server-data-source.ts +469 -469
  305. package/src/sparkline.test.ts +68 -68
  306. package/src/sparkline.ts +169 -169
  307. package/src/spreadsheet.test.ts +488 -488
  308. package/src/spreadsheet.ts +312 -312
  309. package/src/static-functions.ts +11 -11
  310. package/src/subscribe.ts +38 -38
  311. package/src/summaries.ts +113 -113
  312. package/src/svgrid-wrapper.types.ts +563 -563
  313. package/src/svgrid.async-editor-options.test.ts +273 -273
  314. package/src/svgrid.auto-row-height.test.ts +204 -204
  315. package/src/svgrid.behavior.test.ts +910 -910
  316. package/src/svgrid.charting.test.ts +534 -534
  317. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  318. package/src/svgrid.context-menu.test.ts +147 -147
  319. package/src/svgrid.features.test.ts +157 -157
  320. package/src/svgrid.filter-depth.test.ts +163 -163
  321. package/src/svgrid.filter-menu-listbox.svelte.test.ts +377 -331
  322. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  323. package/src/svgrid.grand-total.test.ts +188 -188
  324. package/src/svgrid.group-display-mode.test.ts +171 -171
  325. package/src/svgrid.group-footers.test.ts +121 -121
  326. package/src/svgrid.group-pagination.test.ts +153 -153
  327. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  328. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  329. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  330. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  331. package/src/svgrid.tree-data.test.ts +186 -186
  332. package/src/svgrid.upsell-license.test.ts +117 -0
  333. package/src/svgrid.wrapper.test.ts +63 -63
  334. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  335. package/src/test-setup.ts +62 -62
  336. package/src/themes/index.ts +288 -288
  337. package/src/toast-store.svelte.ts +250 -250
  338. package/src/toast-store.test.ts +147 -147
  339. package/src/tree-row-model.test.ts +168 -168
  340. package/src/ui-buttons.test.ts +144 -144
  341. package/src/ui-inputs.test.ts +118 -118
  342. package/src/ui-localization.test.ts +113 -113
  343. package/src/ui-range.test.ts +70 -70
  344. package/src/ui-selection.test.ts +155 -155
  345. package/src/ui-tier1.test.ts +142 -142
  346. package/src/virtual.test.ts +88 -88
  347. package/src/virtualization/column-virtualizer.test.ts +27 -27
  348. package/src/virtualization/column-virtualizer.ts +30 -30
  349. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  350. package/src/virtualization/types.ts +30 -30
  351. package/src/virtualization/virtualizer.test.ts +47 -47
  352. package/src/virtualization/virtualizer.ts +322 -322
  353. package/dist/cdn/GridMenus-BfTAKn84.js +0 -488
  354. package/dist/cdn/GridMenus-C3bJd7w8.js +0 -489
  355. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  356. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  357. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  358. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
@@ -1,331 +1,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 { 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) {
83
- const btn = target.querySelector('.sv-grid-col-filter-btn') as HTMLButtonElement
84
- btn.click()
85
- // GridMenus is a lazy chunk, so poll until the popover mounts AND its value
86
- // checklist has rendered.
87
- //
88
- // Waiting only for the popover element returns while the listbox is still
89
- // empty: the chunk arrives, the menu element appears, and the values are
90
- // snapshotted and windowed in a later flush. Callers then read option counts
91
- // from a list that has not populated yet. It survives locally because those
92
- // flushes land in the same task, but under parallel load the gap widens - it
93
- // failed once that way in a full-suite run. Every caller here opens a menu on
94
- // a column that has values, so a populated list is the settled state to wait
95
- // for, and each test's own assertions are unchanged.
96
- //
97
- // The 1s default is not enough for the lazy chunk when `test:lib` runs the
98
- // whole suite with `--coverage`: instrumentation plus parallel load pushes
99
- // the import past a second and the menu is still null when the clock runs
100
- // out. The assertions are unchanged - this only waits longer for a dynamic
101
- // import, so a slow machine reports a real failure rather than a timeout.
102
- await vi.waitFor(
103
- () => {
104
- const menu = target.querySelector('.sv-grid-filter-menu')
105
- expect(menu).not.toBeNull()
106
- expect(menu!.querySelectorAll('[role="option"]').length).toBeGreaterThan(0)
107
- },
108
- { timeout: 5000 },
109
- )
110
- return target.querySelector('.sv-grid-filter-menu') as HTMLElement
111
- }
112
-
113
- const optionLabels = (menu: HTMLElement) =>
114
- Array.from(menu.querySelectorAll('[role="option"]')).map((el) => el.textContent?.trim() ?? '')
115
-
116
- describe('filter menu value checklist', () => {
117
- it('windows a high-cardinality column instead of mounting every value', async () => {
118
- const rows = makeRows(120)
119
- const { target, destroy } = await mountGrid(rows)
120
- await tick()
121
- const menu = await openFilterMenu(target)
122
-
123
- const list = menu.querySelector('.sv-listbox')
124
- expect(list).not.toBeNull()
125
- expect(list!.classList.contains('is-virtual')).toBe(true)
126
-
127
- const rendered = menu.querySelectorAll('[role="option"]').length
128
- expect(rendered).toBeGreaterThan(0)
129
- expect(rendered).toBeLessThan(60)
130
-
131
- destroy()
132
- })
133
-
134
- it('is a single tab stop, not one per value', async () => {
135
- const { target, destroy } = await mountGrid(makeRows(120))
136
- await tick()
137
- const menu = await openFilterMenu(target)
138
-
139
- // Roving tabindex: the listbox root is focusable, its options are not.
140
- expect(menu.querySelectorAll('.sv-listbox[tabindex="0"]').length).toBe(1)
141
- expect(menu.querySelectorAll('[role="option"][tabindex]').length).toBe(0)
142
- // No focusable checkbox per row any more.
143
- expect(menu.querySelectorAll('.sv-listbox input').length).toBe(0)
144
-
145
- destroy()
146
- })
147
-
148
- it('keeps a short list fully rendered (no fixed-height box)', async () => {
149
- const { target, destroy } = await mountGrid(makeRows(9))
150
- await tick()
151
- const menu = await openFilterMenu(target)
152
-
153
- const list = menu.querySelector('.sv-listbox')!
154
- expect(list.classList.contains('is-virtual')).toBe(false)
155
- expect(menu.querySelectorAll('[role="option"]').length).toBe(9)
156
-
157
- destroy()
158
- })
159
-
160
- it('snapshots the offered values, so a live data tick does not rebuild them', async () => {
161
- const { target, setData, destroy } = await mountGrid(makeRows(6, 'OLD'))
162
- await tick()
163
- const menu = await openFilterMenu(target)
164
- const before = optionLabels(menu)
165
- expect(before[0]).toBe('OLD00000')
166
-
167
- // A streaming grid replaces its row array; the open menu must not re-scan.
168
- setData(makeRows(6, 'NEW'))
169
- await tick()
170
- expect(optionLabels(menu)).toEqual(before)
171
-
172
- destroy()
173
- })
174
-
175
- it('re-reads the values on the next open', async () => {
176
- const { target, setData, destroy } = await mountGrid(makeRows(6, 'OLD'))
177
- await tick()
178
- await openFilterMenu(target)
179
- setData(makeRows(6, 'NEW'))
180
- await tick()
181
-
182
- // Close, then reopen: the snapshot is taken fresh.
183
- ;(target.querySelector('.sv-grid-menu-backdrop') as HTMLElement).click()
184
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-filter-menu')).toBeNull())
185
- const reopened = await openFilterMenu(target)
186
- expect(optionLabels(reopened)[0]).toBe('NEW00000')
187
-
188
- destroy()
189
- })
190
-
191
- it('unchecking a value filters the rows out', async () => {
192
- const { target, api, destroy } = await mountGrid(makeRows(9))
193
- await tick()
194
- const menu = await openFilterMenu(target)
195
-
196
- const first = menu.querySelector('[role="option"]') as HTMLElement
197
- expect(first.getAttribute('aria-selected')).toBe('true')
198
- first.click()
199
- await tick()
200
-
201
- expect(first.getAttribute('aria-selected')).toBe('false')
202
- expect(api.getDisplayedRows().length).toBe(8)
203
-
204
- destroy()
205
- })
206
-
207
- it('an in/notIn box offers a windowed checklist, not a capped datalist', async () => {
208
- const { target, destroy } = await mountGrid(makeRows(300))
209
- await tick()
210
- const menu = await openFilterMenu(target)
211
-
212
- const op = menu.querySelector('.sv-grid-menu-operator-select') as HTMLSelectElement
213
- op.value = 'in'
214
- op.dispatchEvent(new Event('change', { bubbles: true }))
215
- await tick()
216
-
217
- expect(menu.querySelector('datalist')).toBeNull()
218
- const box = menu.querySelector('.sv-grid-menu-condition-value') as HTMLInputElement
219
- expect(box.getAttribute('list')).toBeNull()
220
-
221
- box.dispatchEvent(new FocusEvent('focus'))
222
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
223
- const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
224
-
225
- // Uncapped source list, windowed render.
226
- expect(suggest.querySelector('.sv-listbox')!.classList.contains('is-virtual')).toBe(true)
227
- expect(suggest.querySelectorAll('[role="option"]').length).toBeLessThan(60)
228
-
229
- destroy()
230
- })
231
-
232
- it('picking a suggestion replaces the fragment being typed', async () => {
233
- const { target, destroy } = await mountGrid(makeRows(9))
234
- await tick()
235
- const menu = await openFilterMenu(target)
236
-
237
- const op = menu.querySelector('.sv-grid-menu-operator-select') as HTMLSelectElement
238
- op.value = 'in'
239
- op.dispatchEvent(new Event('change', { bubbles: true }))
240
- await tick()
241
-
242
- const box = menu.querySelector('.sv-grid-menu-condition-value') as HTMLInputElement
243
- box.dispatchEvent(new FocusEvent('focus'))
244
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
245
-
246
- // Commit one token, then start typing a second.
247
- box.value = 'SYM00003, SYM0000'
248
- box.dispatchEvent(new Event('input', { bubbles: true }))
249
- await tick()
250
-
251
- const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
252
- expect(optionLabels(suggest).length).toBeGreaterThan(1)
253
-
254
- // Pick another: the fragment is consumed, not left beside it.
255
- const pick = Array.from(suggest.querySelectorAll('[role="option"]')).find(
256
- (el) => el.textContent?.trim() === 'SYM00005',
257
- ) as HTMLElement
258
- pick.click()
259
- await tick()
260
-
261
- expect(splitInTokens(box.value).sort()).toEqual(['SYM00003', 'SYM00005'])
262
- // ...and both read as selected straight away. A value written by the
263
- // dropdown has no trailing separator, so anything that mistook the last
264
- // token for a half-typed fragment would show it unchecked.
265
- const checked = suggest.querySelectorAll('[role="option"][aria-selected="true"]')
266
- expect(Array.from(checked).map((el) => el.textContent?.trim()).sort()).toEqual([
267
- 'SYM00003',
268
- 'SYM00005',
269
- ])
270
-
271
- destroy()
272
- })
273
-
274
- it('a value whose label contains a comma survives being picked', async () => {
275
- const rows: Row[] = [
276
- { id: 1, symbol: 'Aug 17, 2026', team: 'A' },
277
- { id: 2, symbol: '1,234 - 5,678', team: 'B' },
278
- { id: 3, symbol: 'plain', team: 'C' },
279
- ]
280
- const { target, api, destroy } = await mountGrid(rows)
281
- await tick()
282
- const menu = await openFilterMenu(target)
283
-
284
- const op = menu.querySelector('.sv-grid-menu-operator-select') as HTMLSelectElement
285
- op.value = 'in'
286
- op.dispatchEvent(new Event('change', { bubbles: true }))
287
- await tick()
288
-
289
- const box = menu.querySelector('.sv-grid-menu-condition-value') as HTMLInputElement
290
- box.dispatchEvent(new FocusEvent('focus'))
291
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
292
- const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
293
-
294
- const pick = Array.from(suggest.querySelectorAll('[role="option"]')).find(
295
- (el) => el.textContent?.trim() === 'Aug 17, 2026',
296
- ) as HTMLElement
297
- pick.click()
298
- await tick()
299
-
300
- // The comma inside the value must not split it into two filter tokens.
301
- expect(splitInTokens(box.value)).toEqual(['Aug 17, 2026'])
302
- expect(api.getDisplayedRows().length).toBe(1)
303
-
304
- destroy()
305
- })
306
-
307
- it('a search-hidden value stays checked when another is toggled', async () => {
308
- const { target, api, destroy } = await mountGrid(makeRows(9))
309
- await tick()
310
- const menu = await openFilterMenu(target)
311
-
312
- // Uncheck SYM00000 while everything is visible.
313
- ;(menu.querySelector('[role="option"]') as HTMLElement).click()
314
- await tick()
315
- expect(api.getDisplayedRows().length).toBe(8)
316
-
317
- // Narrow to a single value, uncheck it too. The first exclusion must
318
- // survive - it is only hidden by the search box, not deselected.
319
- const search = menu.querySelector('.sv-grid-menu-search') as HTMLInputElement
320
- search.value = 'SYM00005'
321
- search.dispatchEvent(new Event('input', { bubbles: true }))
322
- await tick()
323
- expect(menu.querySelectorAll('[role="option"]').length).toBe(1)
324
- ;(menu.querySelector('[role="option"]') as HTMLElement).click()
325
- await tick()
326
-
327
- expect(api.getDisplayedRows().length).toBe(7)
328
-
329
- destroy()
330
- })
331
- })
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
+ })