@svgrid/grid 2.6.22 → 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 (331) hide show
  1. package/CHANGELOG.md +88 -88
  2. package/README.md +199 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +181 -181
  5. package/dist/GridMenus.svelte +184 -6
  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 +48 -1
  23. package/dist/SvGrid.controller.svelte.js +214 -52
  24. package/dist/SvGrid.css +221 -0
  25. package/dist/SvGrid.helpers.d.ts +55 -0
  26. package/dist/SvGrid.helpers.js +83 -0
  27. package/dist/SvGrid.svelte +145 -0
  28. package/dist/SvGrid.types.d.ts +105 -0
  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/date-format-CcP1tafP.js +1395 -0
  89. package/dist/cdn/date-format-DZT7T1wf.js +1393 -0
  90. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  91. package/dist/cdn/{src-C9Hihx1W.js → src-COC0vnpg.js} +7233 -6701
  92. package/dist/cdn/{src-D1lXwq1l.js → src-pYGV2rMR.js} +5747 -5215
  93. package/dist/cdn/svgrid.js +15 -13
  94. package/dist/cdn/svgrid.svelte-external.js +15 -13
  95. package/dist/cell-values.d.ts +16 -0
  96. package/dist/cell-values.js +28 -0
  97. package/dist/chart-export.js +8 -8
  98. package/dist/clipboard.d.ts +22 -0
  99. package/dist/clipboard.js +405 -27
  100. package/dist/editing.js +12 -7
  101. package/dist/editors/cell-editors.d.ts +11 -0
  102. package/dist/editors/cell-editors.js +18 -1
  103. package/dist/grid-messages.d.ts +15 -0
  104. package/dist/grid-messages.js +15 -0
  105. package/dist/index.d.ts +4 -0
  106. package/dist/index.js +4 -0
  107. package/dist/list-option.d.ts +6 -0
  108. package/dist/menus.js +40 -7
  109. package/dist/sanitize-html.d.ts +37 -0
  110. package/dist/sanitize-html.js +234 -0
  111. package/dist/selection-bar-view.svelte.d.ts +25 -0
  112. package/dist/selection-bar-view.svelte.js +13 -0
  113. package/dist/selection.d.ts +2 -1
  114. package/dist/selection.js +129 -13
  115. package/package.json +11 -11
  116. package/src/FlexRender.svelte +96 -96
  117. package/src/GridFooter.svelte +181 -181
  118. package/src/GridMenus.svelte +184 -6
  119. package/src/SvAutoComplete.svelte +169 -165
  120. package/src/SvAvatar.svelte +75 -75
  121. package/src/SvCalendar.svelte +503 -503
  122. package/src/SvCalendar.test.ts +226 -226
  123. package/src/SvCarousel.svelte +141 -141
  124. package/src/SvCheckBox.svelte +102 -102
  125. package/src/SvCircularProgress.svelte +109 -109
  126. package/src/SvColorInput.svelte +181 -181
  127. package/src/SvComboBox.svelte +279 -275
  128. package/src/SvContextMenu.svelte +116 -116
  129. package/src/SvCountryInput.svelte +163 -163
  130. package/src/SvDateRangeInput.svelte +25 -3
  131. package/src/SvDrawer.svelte +254 -254
  132. package/src/SvDropDownList.svelte +378 -374
  133. package/src/SvDurationInput.svelte +126 -126
  134. package/src/SvField.svelte +293 -293
  135. package/src/SvForm.svelte +437 -437
  136. package/src/SvForm.test.ts +411 -411
  137. package/src/SvGrid.controller.svelte.ts +251 -61
  138. package/src/SvGrid.css +221 -0
  139. package/src/SvGrid.helpers.ts +87 -0
  140. package/src/SvGrid.svelte +145 -0
  141. package/src/SvGrid.types.ts +2092 -1980
  142. package/src/SvGridCellEditor.svelte +744 -662
  143. package/src/SvGridChart.svelte +1716 -1716
  144. package/src/SvGridChartPanel.svelte +485 -485
  145. package/src/SvGridChartView.svelte +70 -70
  146. package/src/SvGridDropdown.svelte +728 -728
  147. package/src/SvGridSelect.svelte +270 -266
  148. package/src/SvGroupCell.svelte +116 -116
  149. package/src/SvListBox.svelte +334 -326
  150. package/src/SvMaskedInput.svelte +122 -122
  151. package/src/SvMenu.svelte +124 -124
  152. package/src/SvMenu.test.ts +97 -97
  153. package/src/SvMenuList.svelte +146 -146
  154. package/src/SvMultiSelect.svelte +293 -289
  155. package/src/SvNumberInput.svelte +172 -168
  156. package/src/SvOtpInput.svelte +158 -158
  157. package/src/SvPasswordInput.svelte +151 -151
  158. package/src/SvPhoneInput.svelte +133 -133
  159. package/src/SvPopover.svelte +197 -197
  160. package/src/SvProgress.svelte +116 -116
  161. package/src/SvRadioGroup.svelte +107 -107
  162. package/src/SvRating.svelte +112 -112
  163. package/src/SvResult.svelte +73 -73
  164. package/src/SvRichCell.svelte +125 -0
  165. package/src/SvRichText.svelte +211 -211
  166. package/src/SvRowGroupPanel.svelte +170 -170
  167. package/src/SvScrollArea.svelte +61 -61
  168. package/src/SvSlider.svelte +203 -203
  169. package/src/SvSwitchButton.svelte +108 -108
  170. package/src/SvTagsInput.svelte +115 -115
  171. package/src/SvTextArea.svelte +5 -1
  172. package/src/SvTextInput.svelte +147 -147
  173. package/src/SvTimePicker.svelte +245 -245
  174. package/src/SvToaster.svelte +159 -159
  175. package/src/SvToaster.test.ts +95 -95
  176. package/src/SvToggleButton.svelte +85 -85
  177. package/src/SvTooltip.svelte +161 -161
  178. package/src/SvTour.svelte +208 -208
  179. package/src/SvTree.svelte +444 -444
  180. package/src/SvTreeSelect.svelte +239 -235
  181. package/src/a11y/dismissable.test.ts +119 -119
  182. package/src/a11y/dismissable.ts +114 -114
  183. package/src/a11y.contract.test.ts +49 -49
  184. package/src/a11y.test.ts +59 -59
  185. package/src/a11y.ts +61 -61
  186. package/src/ai.test.ts +502 -502
  187. package/src/ai.ts +1419 -1419
  188. package/src/build-api.coverage.test.ts +633 -633
  189. package/src/build-api.ts +846 -846
  190. package/src/builtin-editors.grid.test.ts +83 -83
  191. package/src/builtin-editors.test.ts +18 -1
  192. package/src/builtin-editors.ts +19 -3
  193. package/src/cell-formatting.ts +171 -171
  194. package/src/cell-render.test.ts +513 -513
  195. package/src/cell-render.ts +496 -496
  196. package/src/cell-values.ts +148 -118
  197. package/src/chart-export.ts +202 -202
  198. package/src/chart-view.svelte.ts +36 -36
  199. package/src/chart.ts +2321 -2321
  200. package/src/clipboard.test.ts +93 -20
  201. package/src/clipboard.ts +433 -33
  202. package/src/collaboration.test.ts +104 -104
  203. package/src/collaboration.ts +167 -167
  204. package/src/column-groups.ts +78 -78
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1865 -1865
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCombobox.svelte.ts +191 -191
  209. package/src/createCountryInput.svelte.ts +157 -157
  210. package/src/createDropdownList.svelte.ts +168 -168
  211. package/src/createForm.svelte.ts +386 -386
  212. package/src/createGrid.svelte.ts +42 -42
  213. package/src/createGrid.test.ts +10 -10
  214. package/src/createGridState.svelte.ts +17 -17
  215. package/src/createListbox.svelte.ts +250 -250
  216. package/src/createMenu.svelte.ts +224 -224
  217. package/src/createPopoverSelect.svelte.ts +213 -213
  218. package/src/createSlider.svelte.ts +191 -191
  219. package/src/createTooltip.svelte.ts +144 -144
  220. package/src/createTree.svelte.ts +322 -322
  221. package/src/date-string-column.test.ts +87 -0
  222. package/src/datetime/date-core.ts +206 -206
  223. package/src/datetime/date-restrict.ts +61 -61
  224. package/src/datetime/timezone.ts +135 -135
  225. package/src/dock-manager-model.ts +596 -596
  226. package/src/dock-model.ts +374 -374
  227. package/src/editing.test.ts +974 -949
  228. package/src/editing.ts +609 -606
  229. package/src/editor-block-prop.test.ts +132 -0
  230. package/src/editor-contract.ts +171 -171
  231. package/src/editor-registry.grid.test.ts +144 -144
  232. package/src/editor-registry.ts +122 -122
  233. package/src/editors/cell-editors.ts +27 -1
  234. package/src/export-data-api.test.ts +126 -126
  235. package/src/export-format.test.ts +107 -107
  236. package/src/export-format.ts +601 -601
  237. package/src/filter-operators.ts +160 -160
  238. package/src/filtering/excel-filters.ts +325 -325
  239. package/src/flex-render.ts +3 -3
  240. package/src/form-field.ts +127 -127
  241. package/src/grid-messages.ts +34 -0
  242. package/src/group-display.test.ts +167 -167
  243. package/src/group-display.ts +200 -200
  244. package/src/headless.ts +87 -87
  245. package/src/index.ts +13 -0
  246. package/src/js-scroller.svelte.ts +173 -173
  247. package/src/keyboard-handlers.ts +270 -270
  248. package/src/keyboard.test.ts +59 -59
  249. package/src/keyboard.ts +97 -97
  250. package/src/list-nav.test.ts +49 -49
  251. package/src/list-nav.ts +29 -29
  252. package/src/list-option.test.ts +56 -56
  253. package/src/list-option.ts +179 -173
  254. package/src/menus.test.ts +43 -0
  255. package/src/menus.ts +597 -567
  256. package/src/merge-objects.ts +48 -48
  257. package/src/move-cells.test.ts +850 -0
  258. package/src/number-editor-literal.test.ts +84 -0
  259. package/src/number-editor.grid.test.ts +160 -0
  260. package/src/overlays.test.ts +90 -90
  261. package/src/positioning.ts +268 -268
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag-touch.ts +211 -211
  264. package/src/row-drag.test.ts +353 -353
  265. package/src/row-drag.ts +415 -415
  266. package/src/row-resize.test.ts +524 -524
  267. package/src/row-resize.ts +228 -228
  268. package/src/sanitize-html.test.ts +182 -0
  269. package/src/sanitize-html.ts +235 -0
  270. package/src/scheduler-ical.ts +181 -181
  271. package/src/scheduler-model.test.ts +562 -562
  272. package/src/scheduler-model.ts +873 -873
  273. package/src/selection-bar-view.svelte.ts +36 -0
  274. package/src/selection.test.ts +900 -743
  275. package/src/selection.ts +131 -13
  276. package/src/server-data-source.test.ts +383 -383
  277. package/src/server-data-source.ts +469 -469
  278. package/src/sparkline.test.ts +68 -68
  279. package/src/sparkline.ts +169 -169
  280. package/src/spreadsheet.test.ts +488 -488
  281. package/src/spreadsheet.ts +312 -312
  282. package/src/static-functions.ts +11 -11
  283. package/src/subscribe.ts +38 -38
  284. package/src/summaries.ts +113 -113
  285. package/src/svgrid-wrapper.types.ts +563 -563
  286. package/src/svgrid.async-editor-options.test.ts +273 -273
  287. package/src/svgrid.auto-row-height.test.ts +204 -204
  288. package/src/svgrid.behavior.test.ts +910 -910
  289. package/src/svgrid.charting.test.ts +534 -534
  290. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  291. package/src/svgrid.context-menu.test.ts +147 -147
  292. package/src/svgrid.features.test.ts +157 -157
  293. package/src/svgrid.filter-depth.test.ts +163 -163
  294. package/src/svgrid.filter-menu-listbox.svelte.test.ts +377 -331
  295. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  296. package/src/svgrid.grand-total.test.ts +188 -188
  297. package/src/svgrid.group-display-mode.test.ts +171 -171
  298. package/src/svgrid.group-footers.test.ts +121 -121
  299. package/src/svgrid.group-pagination.test.ts +153 -153
  300. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  301. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  302. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  303. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  304. package/src/svgrid.tree-data.test.ts +186 -186
  305. package/src/svgrid.upsell-license.test.ts +117 -0
  306. package/src/svgrid.wrapper.test.ts +63 -63
  307. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  308. package/src/test-setup.ts +62 -62
  309. package/src/themes/index.ts +288 -288
  310. package/src/toast-store.svelte.ts +250 -250
  311. package/src/toast-store.test.ts +147 -147
  312. package/src/tree-row-model.test.ts +168 -168
  313. package/src/ui-buttons.test.ts +144 -144
  314. package/src/ui-inputs.test.ts +118 -118
  315. package/src/ui-localization.test.ts +113 -113
  316. package/src/ui-range.test.ts +70 -70
  317. package/src/ui-selection.test.ts +155 -155
  318. package/src/ui-tier1.test.ts +142 -142
  319. package/src/virtual.test.ts +88 -88
  320. package/src/virtualization/column-virtualizer.test.ts +27 -27
  321. package/src/virtualization/column-virtualizer.ts +30 -30
  322. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  323. package/src/virtualization/types.ts +30 -30
  324. package/src/virtualization/virtualizer.test.ts +47 -47
  325. package/src/virtualization/virtualizer.ts +322 -322
  326. package/dist/cdn/GridMenus-BuoBPqxx.js +0 -493
  327. package/dist/cdn/GridMenus-n4llxoOI.js +0 -494
  328. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  329. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  330. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  331. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
package/src/menus.ts CHANGED
@@ -1,567 +1,597 @@
1
- // menus handlers extracted from the controller. Imperative event handlers
2
- // reading/writing controller state via the `ctx` handle; the reactive core
3
- // ($state/$derived/$effect) stays in the controller.
4
- import {
5
- type RowData,
6
- type TableFeatures,
7
- } from "./index";
8
- import "./sv-grid-scrollbar";
9
- import { untrack } from "svelte";
10
- import type {
11
- FilterOperator,
12
- } from "./SvGrid.types";
13
- import {
14
- clampMenuX,
15
- cssEscape,
16
- } from "./SvGrid.helpers";
17
- import { splitInTokens, joinInTokens } from "./filtering/excel-filters";
18
-
19
- export function createMenus<
20
- TFeatures extends TableFeatures = TableFeatures,
21
- TData extends RowData = RowData,
22
- >(ctx: any) {
23
- function updateFilterRow(columnId: string, value: string) {
24
- ctx.filterRowValues = { ...ctx.filterRowValues, [columnId]: value };
25
- const current = ctx.filterMenuValues[columnId] ?? {
26
- operator: "contains" as const,
27
- value: "",
28
- };
29
- ctx.filterMenuValues = {
30
- ...ctx.filterMenuValues,
31
- [columnId]: { ...current, value },
32
- };
33
- }
34
-
35
- function updateFilterOperator(columnId: string, operator: FilterOperator) {
36
- const current = ctx.filterMenuValues[columnId] ?? {
37
- operator: "contains" as const,
38
- value: "",
39
- };
40
- ctx.filterMenuValues = {
41
- ...ctx.filterMenuValues,
42
- [columnId]: { ...current, operator },
43
- };
44
- }
45
-
46
- function updateFilterMenuValue(columnId: string, value: string) {
47
- const current = ctx.filterMenuValues[columnId] ?? {
48
- operator: "contains" as const,
49
- value: "",
50
- };
51
- ctx.filterMenuValues = {
52
- ...ctx.filterMenuValues,
53
- [columnId]: { ...current, value },
54
- };
55
- ctx.filterRowValues = { ...ctx.filterRowValues, [columnId]: value };
56
- }
57
-
58
- /** Upper-bound input for the `between` operator. Only relevant when
59
- * the column's current operator is `between`; harmless to call
60
- * otherwise. */
61
- function updateFilterMenuValueTo(columnId: string, valueTo: string) {
62
- const current = ctx.filterMenuValues[columnId] ?? {
63
- operator: "between" as const,
64
- value: "",
65
- };
66
- ctx.filterMenuValues = {
67
- ...ctx.filterMenuValues,
68
- [columnId]: { ...current, valueTo },
69
- };
70
- }
71
-
72
- /** Current `in` / `notIn` token list for a column (from the filter-row value). */
73
- function filterTokens(columnId: string): string[] {
74
- return splitInTokens(ctx.filterRowValues[columnId] ?? "");
75
- }
76
-
77
- /** Add a value to a column's `in` / `notIn` list (no-op if already present). */
78
- function addFilterToken(columnId: string, token: string) {
79
- const t = token.trim();
80
- if (!t) return;
81
- const tokens = filterTokens(columnId);
82
- if (!tokens.includes(t)) tokens.push(t);
83
- updateFilterRow(columnId, joinInTokens(tokens));
84
- }
85
-
86
- /** Remove a value from a column's `in` / `notIn` list. */
87
- function removeFilterToken(columnId: string, token: string) {
88
- updateFilterRow(
89
- columnId,
90
- joinInTokens(filterTokens(columnId).filter((t) => t !== token)),
91
- );
92
- }
93
-
94
- /** Toggle a value in a column's `in` / `notIn` list (checklist behaviour). */
95
- function toggleFilterToken(columnId: string, token: string) {
96
- if (filterTokens(columnId).includes(token)) removeFilterToken(columnId, token);
97
- else addFilterToken(columnId, token);
98
- }
99
-
100
- /**
101
- * Replace a column's `in` / `notIn` list wholesale. The suggestions dropdown
102
- * hands back the full token set after a click, which keeps tokens the user
103
- * typed by hand (or that the search box is currently hiding) intact.
104
- */
105
- function setFilterTokens(columnId: string, tokens: Iterable<string>) {
106
- const list = [...tokens].map((t) => t.trim()).filter(Boolean);
107
- updateFilterRow(columnId, joinInTokens(list));
108
- }
109
-
110
- function toggleCheckboxWithKeyboard(
111
- event: KeyboardEvent,
112
- toggle: () => void,
113
- ) {
114
- if (event.key !== "Enter" && event.key !== " ") return;
115
- event.preventDefault();
116
- toggle();
117
- }
118
-
119
- function isColumnFiltered(columnId: string) {
120
- if (ctx.valueFilters[columnId]) return true;
121
- const menuFilter = ctx.filterMenuValues[columnId];
122
- return Boolean(
123
- menuFilter &&
124
- (menuFilter.operator === "isBlank" ||
125
- menuFilter.operator === "isNotBlank" ||
126
- menuFilter.value.trim()),
127
- );
128
- }
129
-
130
- function closeMenus() {
131
- ctx.columnMenuFor = null;
132
- ctx.filterMenuFor = null;
133
- ctx.operatorMenuFor = null;
134
- ctx.inSuggestFor = null;
135
- ctx.chooseColumnsPos = null;
136
- }
137
-
138
- /** Open the `in` / `notIn` value-suggestions dropdown under a chip input. */
139
- function openInSuggest(anchor: HTMLElement, columnId: string) {
140
- const rect = anchor.getBoundingClientRect();
141
- ctx.inSuggestPos = { x: clampMenuX(rect.left, 220), y: rect.bottom + 4 };
142
- ctx.inSuggestQuery = "";
143
- ctx.inSuggestFor = columnId;
144
- }
145
-
146
- function closeInSuggest() {
147
- ctx.inSuggestFor = null;
148
- ctx.inSuggestQuery = "";
149
- }
150
-
151
- function openChooseColumns(event: MouseEvent) {
152
- event.stopPropagation();
153
- // Submenu behavior: keep the parent operations menu open and float the
154
- // Choose Columns panel out to the right of it, submenu-style. Falls
155
- // back to below the item if the popover would clip the viewport.
156
- const trigger = event.currentTarget as HTMLElement;
157
- const menuEl = trigger.closest(".sv-grid-menu") as HTMLElement | null;
158
- const anchor = (menuEl ?? trigger).getBoundingClientRect();
159
- const panelWidth = 240;
160
- const wantRight = anchor.right + 4;
161
- const x =
162
- wantRight + panelWidth + 8 > window.innerWidth
163
- ? clampMenuX(anchor.left - panelWidth - 4, panelWidth)
164
- : clampMenuX(wantRight, panelWidth);
165
- ctx.chooseColumnsPos = { x, y: anchor.top };
166
- }
167
-
168
- function openColumnMenu(event: MouseEvent, columnId: string) {
169
- event.stopPropagation();
170
- const wasOpen = ctx.columnMenuFor === columnId;
171
- closeMenus();
172
- if (wasOpen) return;
173
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
174
- ctx.columnMenuPos = {
175
- x: clampMenuX(rect.right - 240, 240),
176
- y: rect.bottom + 4,
177
- };
178
- ctx.columnMenuTab = "general";
179
- ctx.columnMenuFor = columnId;
180
- }
181
-
182
- function openFilterMenu(event: MouseEvent, columnId: string) {
183
- event.stopPropagation();
184
- const wasOpen = ctx.filterMenuFor === columnId;
185
- closeMenus();
186
- if (wasOpen) return;
187
-
188
- // If the consumer is running in row-mode (or any mode where the
189
- // funnel popover wouldn't render), redirect the click to the
190
- // inline filter input for this column. Without this the funnel
191
- // would be a dead button - it visibly clicks but nothing happens
192
- // because the menu is gated behind `showColumnFiltersEffective`.
193
- if (!ctx.showColumnFiltersEffective && ctx.showFilterRowEffective) {
194
- // Find the row-mode filter input and focus it. The input is
195
- // tagged with `data-svgrid-filter-col` so we can target by id
196
- // without depending on column position.
197
- const input = (ctx.scrollContainer as HTMLElement | null)?.querySelector<HTMLInputElement>(
198
- `[data-svgrid-filter-col="${cssEscape(columnId)}"]`,
199
- );
200
- if (input) {
201
- input.focus();
202
- input.select();
203
- // Brief pulse so the user sees where focus landed.
204
- input.classList.add("sv-grid-filter-value-pulse");
205
- setTimeout(() => input.classList.remove("sv-grid-filter-value-pulse"), 700);
206
- }
207
- return;
208
- }
209
-
210
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
211
- ctx.filterMenuPos = {
212
- x: clampMenuX(rect.right - 260, 260),
213
- y: rect.bottom + 4,
214
- };
215
- ctx.columnMenuSearch = "";
216
- ctx.filterMenuFor = columnId;
217
- }
218
-
219
- function openOperatorMenu(event: MouseEvent, columnId: string) {
220
- event.stopPropagation();
221
- const wasOpen = ctx.operatorMenuFor === columnId;
222
- closeMenus();
223
- if (wasOpen) return;
224
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
225
- ctx.operatorMenuPos = { x: clampMenuX(rect.left, 184), y: rect.bottom + 4 };
226
- ctx.operatorMenuFor = columnId;
227
- }
228
-
229
- function sortColumnFromMenu(columnId: string, desc: boolean) {
230
- ctx.grid.store.setState((prev: any) => ({
231
- ...prev,
232
- sorting: [{ id: columnId, desc }],
233
- }));
234
- closeMenus();
235
- }
236
-
237
- function clearColumnSort(columnId: string) {
238
- ctx.grid.store.setState((prev: any) => ({
239
- ...prev,
240
- sorting: (prev.sorting ?? []).filter(
241
- (entry: { id: string }) => entry.id !== columnId,
242
- ),
243
- }));
244
- closeMenus();
245
- }
246
-
247
- function groupByColumnFromMenu(columnId: string) {
248
- const current = ctx.grid.getState().grouping ?? [];
249
- if (current.includes(columnId)) {
250
- closeMenus();
251
- return;
252
- }
253
- ctx.grid.setGrouping([...current, columnId]);
254
- closeMenus();
255
- }
256
-
257
- function clearGroupingFromMenu(columnId: string) {
258
- const current = ctx.grid.getState().grouping ?? [];
259
- ctx.grid.setGrouping(current.filter((id: string) => id !== columnId));
260
- closeMenus();
261
- }
262
-
263
- function isFacetChecked(columnId: string, value: string) {
264
- const selected = ctx.valueFilters[columnId];
265
- return selected ? selected.has(value) : true;
266
- }
267
-
268
- function toggleFacetValue(columnId: string, value: string) {
269
- const allValues = ctx.columnMenuFacetValues;
270
- const next = new Set(ctx.valueFilters[columnId] ?? allValues);
271
- if (next.has(value)) next.delete(value);
272
- else next.add(value);
273
- if (next.size === allValues.length) {
274
- const copy = { ...ctx.valueFilters };
275
- delete copy[columnId];
276
- ctx.valueFilters = copy;
277
- } else {
278
- ctx.valueFilters = { ...ctx.valueFilters, [columnId]: next };
279
- }
280
- }
281
-
282
- /**
283
- * Replace a column's checked-facet set. "Everything checked" is stored as an
284
- * ABSENT entry (so an unfiltered high-cardinality column never holds a set of
285
- * every value), which is why the full-size case deletes instead of writing.
286
- */
287
- function setFacetSelection(columnId: string, next: ReadonlySet<string>) {
288
- if (next.size >= ctx.columnMenuFacetValues.length) {
289
- const copy = { ...ctx.valueFilters };
290
- delete copy[columnId];
291
- ctx.valueFilters = copy;
292
- return;
293
- }
294
- ctx.valueFilters = { ...ctx.valueFilters, [columnId]: new Set(next) };
295
- }
296
-
297
- function isAllFacetsChecked(columnId: string) {
298
- const selected = ctx.valueFilters[columnId];
299
- return !selected || selected.size >= ctx.columnMenuFacetValues.length;
300
- }
301
-
302
- function toggleAllFacets(columnId: string) {
303
- if (isAllFacetsChecked(columnId)) {
304
- ctx.valueFilters = { ...ctx.valueFilters, [columnId]: new Set<string>() };
305
- } else {
306
- const copy = { ...ctx.valueFilters };
307
- delete copy[columnId];
308
- ctx.valueFilters = copy;
309
- }
310
- }
311
-
312
- function clearColumnFilter(columnId: string) {
313
- // `untrack`: read-modify-write on the filter maps. Keeps the method safe to
314
- // call from a consumer's reactive `$effect` (e.g. via `api.clearFilter`)
315
- // without capturing these reads as a dependency and looping. See
316
- // `build-api`'s `setFilter` for the full rationale.
317
- untrack(() => {
318
- if (ctx.valueFilters[columnId]) {
319
- const next = { ...ctx.valueFilters };
320
- delete next[columnId];
321
- ctx.valueFilters = next;
322
- }
323
- if (ctx.filterMenuValues[columnId]) {
324
- const next = { ...ctx.filterMenuValues };
325
- delete next[columnId];
326
- ctx.filterMenuValues = next;
327
- }
328
- if (ctx.filterRowValues[columnId]) {
329
- const next = { ...ctx.filterRowValues };
330
- delete next[columnId];
331
- ctx.filterRowValues = next;
332
- }
333
- });
334
- }
335
-
336
- // External pagination is controlled: emit the requested page/size and let
337
- // the consumer fetch + update props. Never mutate local pagination state.
338
- function emitExternalPagination(pageIndex: number, pageSize: number): boolean {
339
- if (ctx.props.externalPagination !== true) return false;
340
- ctx.props.onPaginationChange?.({ pageIndex: Math.max(0, pageIndex), pageSize });
341
- ctx.scrollContainer?.scrollTo?.({ top: 0 });
342
- return true;
343
- }
344
-
345
- function changePage(delta: number) {
346
- if (
347
- emitExternalPagination(
348
- (ctx.props.pageIndex ?? 0) + delta,
349
- ctx.props.pageSize ?? 10,
350
- )
351
- )
352
- return;
353
- ctx.grid.setPagination((prev: any) => ({
354
- ...prev,
355
- pageIndex: Math.max((prev?.pageIndex ?? 0) + delta, 0),
356
- }));
357
- ctx.scrollContainer?.scrollTo?.({ top: 0 });
358
- }
359
-
360
- function goToPage(pageIndex: number) {
361
- if (emitExternalPagination(pageIndex, ctx.props.pageSize ?? 10)) return;
362
- ctx.grid.setPagination((prev: any) => ({
363
- ...prev,
364
- pageIndex: Math.max(0, pageIndex),
365
- }));
366
- ctx.scrollContainer?.scrollTo?.({ top: 0 });
367
- }
368
-
369
- function setPageSize(pageSize: number) {
370
- // External mode: changing page size resets to the first page.
371
- if (emitExternalPagination(0, pageSize)) return;
372
- ctx.grid.setPagination((prev: any) => {
373
- const oldSize = prev?.pageSize ?? 10;
374
- const oldIndex = prev?.pageIndex ?? 0;
375
- // Keep the first visible row in view when the page size changes.
376
- const firstVisibleRow = oldIndex * oldSize;
377
- return {
378
- ...prev,
379
- pageSize,
380
- pageIndex: Math.max(0, Math.floor(firstVisibleRow / pageSize)),
381
- };
382
- });
383
- }
384
-
385
- // ---- Right-click context menu --------------------------------------
386
- type CtxTarget = { rowIndex: number; colIndex: number; columnId: string; rowId: string; row: TData | null };
387
- type ResolvedItem = { key: string; label: string; separator?: boolean; disabled?: boolean; run?: () => void };
388
-
389
- function insertRowAt(t: CtxTarget, offset: number) {
390
- const data = ctx.internalData.slice() as TData[];
391
- const idx = t.row ? data.indexOf(t.row) : -1;
392
- const at = idx < 0 ? data.length : idx + offset;
393
- data.splice(at, 0, {} as TData);
394
- ctx.internalData = data;
395
- }
396
- function removeRowAt(t: CtxTarget) {
397
- if (!t.row) return;
398
- ctx.internalData = (ctx.internalData as TData[]).filter((r) => r !== t.row);
399
- }
400
- function removeColAt(t: CtxTarget) {
401
- ctx.internalColumns = ctx.internalColumns.filter(
402
- (d: any) => (d.id ?? d.field) !== t.columnId,
403
- );
404
- }
405
-
406
- // Built-in items, keyed. `run` reads the live target passed at click time.
407
- // `msg` names the GridMessages key so the label localizes (falls back to the
408
- // English `label` when no `localeText` is set).
409
- const builtins: Record<string, { label: string; msg?: string; run: (t: CtxTarget) => void }> = {
410
- copy: { label: "Copy", msg: "menuCopy", run: () => ctx.copySelectionToClipboard() },
411
- cut: { label: "Cut", msg: "menuCut", run: () => ctx.cutSelectionToClipboard() },
412
- paste: { label: "Paste", msg: "menuPaste", run: () => void ctx.pasteFromClipboard() },
413
- clear: { label: "Clear", msg: "menuClear", run: () => ctx.clearSelectedCells() },
414
- row_above: { label: "Insert row above", msg: "menuInsertRowAbove", run: (t) => insertRowAt(t, 0) },
415
- row_below: { label: "Insert row below", msg: "menuInsertRowBelow", run: (t) => insertRowAt(t, 1) },
416
- remove_row: { label: "Remove row", msg: "menuRemoveRow", run: removeRowAt },
417
- remove_col: { label: "Remove column", msg: "menuRemoveColumn", run: removeColAt },
418
- comment: { label: "Edit comment", msg: "menuEditComment", run: (t) => openCommentEditor(t) },
419
- // Charting: open the chart panel scoped to the current cell selection. Gated
420
- // on `chartingEnabled` in the resolver below.
421
- chart: { label: "Chart selected range", msg: "chartRange", run: () => { ctx.chartPanelOpen = true; } },
422
- };
423
- const DEFAULT_ITEMS = [
424
- "copy", "cut", "paste", "clear", "separator",
425
- "row_above", "row_below", "remove_row", "separator", "remove_col",
426
- ];
427
-
428
- function openContextMenu(
429
- event: MouseEvent,
430
- rowIndex: number,
431
- colIndex: number,
432
- columnId: string,
433
- ) {
434
- if (!ctx.props.contextMenu) return;
435
- event.preventDefault();
436
- closeMenus();
437
- ctx.contextMenuFor = null;
438
- const rowModel = ctx.allRows[rowIndex];
439
- const row = rowModel?.original ?? null;
440
- const rowId = rowModel?.id ?? String(rowIndex);
441
- // Reserve ~280px for the menu body. If the click is in the bottom third,
442
- // flip the menu above the cursor so it stays fully in the viewport.
443
- const menuHeight = 280;
444
- const y = event.clientY + menuHeight > window.innerHeight
445
- ? Math.max(8, event.clientY - menuHeight)
446
- : event.clientY;
447
- ctx.contextMenuPos = {
448
- x: clampMenuX(event.clientX, 220),
449
- y,
450
- };
451
- ctx.contextMenuFor = { rowIndex, colIndex, columnId, rowId, row };
452
- }
453
-
454
- function closeContextMenu() {
455
- ctx.contextMenuFor = null;
456
- }
457
-
458
- // ---- Editable comments ---------------------------------------------
459
- function openCommentEditor(t: CtxTarget) {
460
- const cur =
461
- ctx.noteOverrides?.[t.rowId]?.[t.columnId] ??
462
- ctx.props.notes?.[t.rowId]?.[t.columnId] ??
463
- "";
464
- ctx.commentDraft = cur;
465
- ctx.commentEditFor = { rowId: t.rowId, columnId: t.columnId, x: ctx.contextMenuPos.x, y: ctx.contextMenuPos.y };
466
- ctx.contextMenuFor = null;
467
- }
468
- function writeNote(rowId: string, columnId: string, note: string) {
469
- const next = { ...ctx.noteOverrides };
470
- next[rowId] = { ...(next[rowId] ?? {}), [columnId]: note };
471
- ctx.noteOverrides = next;
472
- ctx.props.onNoteChange?.({ rowId, columnId, note });
473
- }
474
- function saveComment() {
475
- const e = ctx.commentEditFor;
476
- if (!e) return;
477
- writeNote(e.rowId, e.columnId, ctx.commentDraft);
478
- ctx.commentEditFor = null;
479
- }
480
- function removeComment() {
481
- const e = ctx.commentEditFor;
482
- if (!e) return;
483
- writeNote(e.rowId, e.columnId, "");
484
- ctx.commentEditFor = null;
485
- }
486
- function closeCommentEditor() {
487
- ctx.commentEditFor = null;
488
- }
489
-
490
- /** Resolve the configured items against the current target for rendering. */
491
- function contextMenuItems(): ResolvedItem[] {
492
- const target = ctx.contextMenuFor as CtxTarget | null;
493
- if (!target) return [];
494
- const cfg = ctx.props.contextMenu;
495
- const messages = ctx.messages;
496
- const entries: any[] = Array.isArray(cfg)
497
- ? cfg
498
- : [
499
- ...DEFAULT_ITEMS,
500
- ...(ctx.chartingEnabled ? ["separator", "chart"] : []),
501
- ...(ctx.props.editableComments ? ["separator", "comment"] : []),
502
- ];
503
- const out: ResolvedItem[] = [];
504
- for (const entry of entries) {
505
- if (entry === "separator") { out.push({ key: "sep" + out.length, label: "", separator: true }); continue; }
506
- if (typeof entry === "string") {
507
- // The chart item only makes sense when charting is enabled.
508
- if (entry === "chart" && !ctx.chartingEnabled) continue;
509
- const b = builtins[entry];
510
- if (!b) continue;
511
- const label = (b.msg && messages?.[b.msg]) || b.label;
512
- out.push({ key: entry, label, run: () => b.run(target) });
513
- continue;
514
- }
515
- if (entry.hidden?.(target)) continue;
516
- out.push({
517
- key: entry.key,
518
- label: entry.label,
519
- disabled: entry.disabled?.(target) ?? false,
520
- run: () => entry.action(target),
521
- });
522
- }
523
- // Drop leading/trailing/double separators.
524
- return out.filter((it, i, a) =>
525
- !it.separator || (i > 0 && i < a.length - 1 && !a[i - 1]?.separator),
526
- );
527
- }
528
-
529
- return {
530
- updateFilterRow,
531
- updateFilterOperator,
532
- updateFilterMenuValue,
533
- updateFilterMenuValueTo,
534
- addFilterToken,
535
- removeFilterToken,
536
- toggleFilterToken,
537
- toggleCheckboxWithKeyboard,
538
- isColumnFiltered,
539
- closeMenus,
540
- openInSuggest,
541
- closeInSuggest,
542
- openChooseColumns,
543
- openColumnMenu,
544
- openFilterMenu,
545
- openOperatorMenu,
546
- sortColumnFromMenu,
547
- clearColumnSort,
548
- groupByColumnFromMenu,
549
- clearGroupingFromMenu,
550
- isFacetChecked,
551
- toggleFacetValue,
552
- setFacetSelection,
553
- setFilterTokens,
554
- isAllFacetsChecked,
555
- toggleAllFacets,
556
- clearColumnFilter,
557
- changePage,
558
- goToPage,
559
- setPageSize,
560
- openContextMenu,
561
- closeContextMenu,
562
- contextMenuItems,
563
- saveComment,
564
- removeComment,
565
- closeCommentEditor,
566
- };
567
- }
1
+ // menus handlers extracted from the controller. Imperative event handlers
2
+ // reading/writing controller state via the `ctx` handle; the reactive core
3
+ // ($state/$derived/$effect) stays in the controller.
4
+ import {
5
+ type RowData,
6
+ type TableFeatures,
7
+ } from "./index";
8
+ import "./sv-grid-scrollbar";
9
+ import { untrack } from "svelte";
10
+ import type {
11
+ FilterOperator,
12
+ } from "./SvGrid.types";
13
+ import {
14
+ clampMenuX,
15
+ cssEscape,
16
+ } from "./SvGrid.helpers";
17
+ import { splitInTokens, joinInTokens } from "./filtering/excel-filters";
18
+
19
+ export function createMenus<
20
+ TFeatures extends TableFeatures = TableFeatures,
21
+ TData extends RowData = RowData,
22
+ >(ctx: any) {
23
+ function updateFilterRow(columnId: string, value: string) {
24
+ ctx.filterRowValues = { ...ctx.filterRowValues, [columnId]: value };
25
+ const current = ctx.filterMenuValues[columnId] ?? {
26
+ operator: "contains" as const,
27
+ value: "",
28
+ };
29
+ ctx.filterMenuValues = {
30
+ ...ctx.filterMenuValues,
31
+ [columnId]: { ...current, value },
32
+ };
33
+ }
34
+
35
+ function updateFilterOperator(columnId: string, operator: FilterOperator) {
36
+ const current = ctx.filterMenuValues[columnId] ?? {
37
+ operator: "contains" as const,
38
+ value: "",
39
+ };
40
+ ctx.filterMenuValues = {
41
+ ...ctx.filterMenuValues,
42
+ [columnId]: { ...current, operator },
43
+ };
44
+ }
45
+
46
+ function updateFilterMenuValue(columnId: string, value: string) {
47
+ const current = ctx.filterMenuValues[columnId] ?? {
48
+ operator: "contains" as const,
49
+ value: "",
50
+ };
51
+ ctx.filterMenuValues = {
52
+ ...ctx.filterMenuValues,
53
+ [columnId]: { ...current, value },
54
+ };
55
+ ctx.filterRowValues = { ...ctx.filterRowValues, [columnId]: value };
56
+ }
57
+
58
+ /** Upper-bound input for the `between` operator. Only relevant when
59
+ * the column's current operator is `between`; harmless to call
60
+ * otherwise. */
61
+ function updateFilterMenuValueTo(columnId: string, valueTo: string) {
62
+ const current = ctx.filterMenuValues[columnId] ?? {
63
+ operator: "between" as const,
64
+ value: "",
65
+ };
66
+ ctx.filterMenuValues = {
67
+ ...ctx.filterMenuValues,
68
+ [columnId]: { ...current, valueTo },
69
+ };
70
+ }
71
+
72
+ /** Current `in` / `notIn` token list for a column (from the filter-row value). */
73
+ function filterTokens(columnId: string): string[] {
74
+ return splitInTokens(ctx.filterRowValues[columnId] ?? "");
75
+ }
76
+
77
+ /** Add a value to a column's `in` / `notIn` list (no-op if already present). */
78
+ function addFilterToken(columnId: string, token: string) {
79
+ const t = token.trim();
80
+ if (!t) return;
81
+ const tokens = filterTokens(columnId);
82
+ if (!tokens.includes(t)) tokens.push(t);
83
+ updateFilterRow(columnId, joinInTokens(tokens));
84
+ }
85
+
86
+ /** Remove a value from a column's `in` / `notIn` list. */
87
+ function removeFilterToken(columnId: string, token: string) {
88
+ updateFilterRow(
89
+ columnId,
90
+ joinInTokens(filterTokens(columnId).filter((t) => t !== token)),
91
+ );
92
+ }
93
+
94
+ /** Toggle a value in a column's `in` / `notIn` list (checklist behaviour). */
95
+ function toggleFilterToken(columnId: string, token: string) {
96
+ if (filterTokens(columnId).includes(token)) removeFilterToken(columnId, token);
97
+ else addFilterToken(columnId, token);
98
+ }
99
+
100
+ /**
101
+ * Replace a column's `in` / `notIn` list wholesale. The suggestions dropdown
102
+ * hands back the full token set after a click, which keeps tokens the user
103
+ * typed by hand (or that the search box is currently hiding) intact.
104
+ */
105
+ function setFilterTokens(columnId: string, tokens: Iterable<string>) {
106
+ const list = [...tokens].map((t) => t.trim()).filter(Boolean);
107
+ updateFilterRow(columnId, joinInTokens(list));
108
+ }
109
+
110
+ function toggleCheckboxWithKeyboard(
111
+ event: KeyboardEvent,
112
+ toggle: () => void,
113
+ ) {
114
+ if (event.key !== "Enter" && event.key !== " ") return;
115
+ event.preventDefault();
116
+ toggle();
117
+ }
118
+
119
+ function isColumnFiltered(columnId: string) {
120
+ if (ctx.valueFilters[columnId]) return true;
121
+ const menuFilter = ctx.filterMenuValues[columnId];
122
+ return Boolean(
123
+ menuFilter &&
124
+ (menuFilter.operator === "isBlank" ||
125
+ menuFilter.operator === "isNotBlank" ||
126
+ menuFilter.value.trim()),
127
+ );
128
+ }
129
+
130
+ function closeMenus() {
131
+ ctx.columnMenuFor = null;
132
+ ctx.filterMenuFor = null;
133
+ ctx.operatorMenuFor = null;
134
+ ctx.inSuggestFor = null;
135
+ ctx.chooseColumnsPos = null;
136
+ }
137
+
138
+ /** Open the `in` / `notIn` value-suggestions dropdown under a chip input. */
139
+ function openInSuggest(anchor: HTMLElement, columnId: string) {
140
+ const rect = anchor.getBoundingClientRect();
141
+ ctx.inSuggestPos = { x: clampMenuX(rect.left, 220), y: rect.bottom + 4 };
142
+ ctx.inSuggestQuery = "";
143
+ ctx.inSuggestFor = columnId;
144
+ }
145
+
146
+ function closeInSuggest() {
147
+ ctx.inSuggestFor = null;
148
+ ctx.inSuggestQuery = "";
149
+ }
150
+
151
+ function openChooseColumns(event: MouseEvent) {
152
+ event.stopPropagation();
153
+ // Submenu behavior: keep the parent operations menu open and float the
154
+ // Choose Columns panel out to the right of it, submenu-style. Falls
155
+ // back to below the item if the popover would clip the viewport.
156
+ const trigger = event.currentTarget as HTMLElement;
157
+ const menuEl = trigger.closest(".sv-grid-menu") as HTMLElement | null;
158
+ const anchor = (menuEl ?? trigger).getBoundingClientRect();
159
+ const panelWidth = 240;
160
+ const wantRight = anchor.right + 4;
161
+ const x =
162
+ wantRight + panelWidth + 8 > window.innerWidth
163
+ ? clampMenuX(anchor.left - panelWidth - 4, panelWidth)
164
+ : clampMenuX(wantRight, panelWidth);
165
+ ctx.chooseColumnsPos = { x, y: anchor.top };
166
+ }
167
+
168
+ function openColumnMenu(event: MouseEvent, columnId: string) {
169
+ event.stopPropagation();
170
+ const wasOpen = ctx.columnMenuFor === columnId;
171
+ closeMenus();
172
+ if (wasOpen) return;
173
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
174
+ ctx.columnMenuPos = {
175
+ x: clampMenuX(rect.right - 240, 240),
176
+ y: rect.bottom + 4,
177
+ };
178
+ ctx.columnMenuTab = "general";
179
+ ctx.columnMenuFor = columnId;
180
+ }
181
+
182
+ function openFilterMenu(event: MouseEvent, columnId: string) {
183
+ event.stopPropagation();
184
+ const wasOpen = ctx.filterMenuFor === columnId;
185
+ closeMenus();
186
+ if (wasOpen) return;
187
+
188
+ // If the consumer is running in row-mode (or any mode where the
189
+ // funnel popover wouldn't render), redirect the click to the
190
+ // inline filter input for this column. Without this the funnel
191
+ // would be a dead button - it visibly clicks but nothing happens
192
+ // because the menu is gated behind `showColumnFiltersEffective`.
193
+ if (!ctx.showColumnFiltersEffective && ctx.showFilterRowEffective) {
194
+ // Find the row-mode filter input and focus it. The input is
195
+ // tagged with `data-svgrid-filter-col` so we can target by id
196
+ // without depending on column position.
197
+ const input = (ctx.scrollContainer as HTMLElement | null)?.querySelector<HTMLInputElement>(
198
+ `[data-svgrid-filter-col="${cssEscape(columnId)}"]`,
199
+ );
200
+ if (input) {
201
+ input.focus();
202
+ input.select();
203
+ // Brief pulse so the user sees where focus landed.
204
+ input.classList.add("sv-grid-filter-value-pulse");
205
+ setTimeout(() => input.classList.remove("sv-grid-filter-value-pulse"), 700);
206
+ }
207
+ return;
208
+ }
209
+
210
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
211
+ ctx.filterMenuPos = {
212
+ x: clampMenuX(rect.right - 260, 260),
213
+ y: rect.bottom + 4,
214
+ };
215
+ ctx.columnMenuSearch = "";
216
+ ctx.filterMenuFor = columnId;
217
+ }
218
+
219
+ function openOperatorMenu(event: MouseEvent, columnId: string) {
220
+ event.stopPropagation();
221
+ const wasOpen = ctx.operatorMenuFor === columnId;
222
+ closeMenus();
223
+ if (wasOpen) return;
224
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
225
+ ctx.operatorMenuPos = { x: clampMenuX(rect.left, 184), y: rect.bottom + 4 };
226
+ ctx.operatorMenuFor = columnId;
227
+ }
228
+
229
+ function sortColumnFromMenu(columnId: string, desc: boolean) {
230
+ ctx.grid.store.setState((prev: any) => ({
231
+ ...prev,
232
+ sorting: [{ id: columnId, desc }],
233
+ }));
234
+ closeMenus();
235
+ }
236
+
237
+ function clearColumnSort(columnId: string) {
238
+ ctx.grid.store.setState((prev: any) => ({
239
+ ...prev,
240
+ sorting: (prev.sorting ?? []).filter(
241
+ (entry: { id: string }) => entry.id !== columnId,
242
+ ),
243
+ }));
244
+ closeMenus();
245
+ }
246
+
247
+ function groupByColumnFromMenu(columnId: string) {
248
+ const current = ctx.grid.getState().grouping ?? [];
249
+ if (current.includes(columnId)) {
250
+ closeMenus();
251
+ return;
252
+ }
253
+ ctx.grid.setGrouping([...current, columnId]);
254
+ closeMenus();
255
+ }
256
+
257
+ function clearGroupingFromMenu(columnId: string) {
258
+ const current = ctx.grid.getState().grouping ?? [];
259
+ ctx.grid.setGrouping(current.filter((id: string) => id !== columnId));
260
+ closeMenus();
261
+ }
262
+
263
+ function isFacetChecked(columnId: string, value: string) {
264
+ const selected = ctx.valueFilters[columnId];
265
+ return selected ? selected.has(value) : true;
266
+ }
267
+
268
+ function toggleFacetValue(columnId: string, value: string) {
269
+ const allValues = ctx.columnMenuFacetValues;
270
+ const next = new Set(ctx.valueFilters[columnId] ?? allValues);
271
+ if (next.has(value)) next.delete(value);
272
+ else next.add(value);
273
+ if (next.size === allValues.length) {
274
+ const copy = { ...ctx.valueFilters };
275
+ delete copy[columnId];
276
+ ctx.valueFilters = copy;
277
+ } else {
278
+ ctx.valueFilters = { ...ctx.valueFilters, [columnId]: next };
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Replace a column's checked-facet set. "Everything checked" is stored as an
284
+ * ABSENT entry (so an unfiltered high-cardinality column never holds a set of
285
+ * every value), which is why the full-size case deletes instead of writing.
286
+ */
287
+ function setFacetSelection(columnId: string, next: ReadonlySet<string>) {
288
+ if (next.size >= ctx.columnMenuFacetValues.length) {
289
+ const copy = { ...ctx.valueFilters };
290
+ delete copy[columnId];
291
+ ctx.valueFilters = copy;
292
+ return;
293
+ }
294
+ ctx.valueFilters = { ...ctx.valueFilters, [columnId]: new Set(next) };
295
+ }
296
+
297
+ /**
298
+ * With a search query typed, "select all" means the RESULTS - the same thing
299
+ * it means in a spreadsheet. It used to mean the whole column regardless, so
300
+ * the standard flow (type a query, tick select-all, get just the matches)
301
+ * cleared the entire selection instead: `isAllFacetsChecked` saw every value
302
+ * ticked, read that as "on", and turned everything off.
303
+ */
304
+ function isAllFacetsChecked(columnId: string) {
305
+ const selected = ctx.valueFilters[columnId];
306
+ const visible = ctx.columnMenuVisibleFacets as Array<string>;
307
+ const searching = visible.length !== ctx.columnMenuFacetValues.length;
308
+ if (!selected) return true;
309
+ if (searching) return visible.every((value) => selected.has(value));
310
+ return selected.size >= ctx.columnMenuFacetValues.length;
311
+ }
312
+
313
+ function toggleAllFacets(columnId: string) {
314
+ const visible = ctx.columnMenuVisibleFacets as Array<string>;
315
+ const all = ctx.columnMenuFacetValues as Array<string>;
316
+ const searching = visible.length !== all.length;
317
+ const on = isAllFacetsChecked(columnId);
318
+
319
+ if (!searching) {
320
+ if (on) {
321
+ ctx.valueFilters = { ...ctx.valueFilters, [columnId]: new Set<string>() };
322
+ } else {
323
+ const copy = { ...ctx.valueFilters };
324
+ delete copy[columnId];
325
+ ctx.valueFilters = copy;
326
+ }
327
+ return;
328
+ }
329
+ // Searching: add or remove just the matches, leaving the rest of the
330
+ // selection alone. An absent entry means "everything", so it has to be
331
+ // materialized before a subtraction can mean anything.
332
+ const current = ctx.valueFilters[columnId]
333
+ ? new Set<string>(ctx.valueFilters[columnId])
334
+ : new Set<string>(all);
335
+ for (const value of visible) {
336
+ if (on) current.delete(value);
337
+ else current.add(value);
338
+ }
339
+ ctx.valueFilters = { ...ctx.valueFilters, [columnId]: current };
340
+ }
341
+
342
+ function clearColumnFilter(columnId: string) {
343
+ // `untrack`: read-modify-write on the filter maps. Keeps the method safe to
344
+ // call from a consumer's reactive `$effect` (e.g. via `api.clearFilter`)
345
+ // without capturing these reads as a dependency and looping. See
346
+ // `build-api`'s `setFilter` for the full rationale.
347
+ untrack(() => {
348
+ if (ctx.valueFilters[columnId]) {
349
+ const next = { ...ctx.valueFilters };
350
+ delete next[columnId];
351
+ ctx.valueFilters = next;
352
+ }
353
+ if (ctx.filterMenuValues[columnId]) {
354
+ const next = { ...ctx.filterMenuValues };
355
+ delete next[columnId];
356
+ ctx.filterMenuValues = next;
357
+ }
358
+ if (ctx.filterRowValues[columnId]) {
359
+ const next = { ...ctx.filterRowValues };
360
+ delete next[columnId];
361
+ ctx.filterRowValues = next;
362
+ }
363
+ });
364
+ }
365
+
366
+ // External pagination is controlled: emit the requested page/size and let
367
+ // the consumer fetch + update props. Never mutate local pagination state.
368
+ function emitExternalPagination(pageIndex: number, pageSize: number): boolean {
369
+ if (ctx.props.externalPagination !== true) return false;
370
+ ctx.props.onPaginationChange?.({ pageIndex: Math.max(0, pageIndex), pageSize });
371
+ ctx.scrollContainer?.scrollTo?.({ top: 0 });
372
+ return true;
373
+ }
374
+
375
+ function changePage(delta: number) {
376
+ if (
377
+ emitExternalPagination(
378
+ (ctx.props.pageIndex ?? 0) + delta,
379
+ ctx.props.pageSize ?? 10,
380
+ )
381
+ )
382
+ return;
383
+ ctx.grid.setPagination((prev: any) => ({
384
+ ...prev,
385
+ pageIndex: Math.max((prev?.pageIndex ?? 0) + delta, 0),
386
+ }));
387
+ ctx.scrollContainer?.scrollTo?.({ top: 0 });
388
+ }
389
+
390
+ function goToPage(pageIndex: number) {
391
+ if (emitExternalPagination(pageIndex, ctx.props.pageSize ?? 10)) return;
392
+ ctx.grid.setPagination((prev: any) => ({
393
+ ...prev,
394
+ pageIndex: Math.max(0, pageIndex),
395
+ }));
396
+ ctx.scrollContainer?.scrollTo?.({ top: 0 });
397
+ }
398
+
399
+ function setPageSize(pageSize: number) {
400
+ // External mode: changing page size resets to the first page.
401
+ if (emitExternalPagination(0, pageSize)) return;
402
+ ctx.grid.setPagination((prev: any) => {
403
+ const oldSize = prev?.pageSize ?? 10;
404
+ const oldIndex = prev?.pageIndex ?? 0;
405
+ // Keep the first visible row in view when the page size changes.
406
+ const firstVisibleRow = oldIndex * oldSize;
407
+ return {
408
+ ...prev,
409
+ pageSize,
410
+ pageIndex: Math.max(0, Math.floor(firstVisibleRow / pageSize)),
411
+ };
412
+ });
413
+ }
414
+
415
+ // ---- Right-click context menu --------------------------------------
416
+ type CtxTarget = { rowIndex: number; colIndex: number; columnId: string; rowId: string; row: TData | null };
417
+ type ResolvedItem = { key: string; label: string; separator?: boolean; disabled?: boolean; run?: () => void };
418
+
419
+ function insertRowAt(t: CtxTarget, offset: number) {
420
+ const data = ctx.internalData.slice() as TData[];
421
+ const idx = t.row ? data.indexOf(t.row) : -1;
422
+ const at = idx < 0 ? data.length : idx + offset;
423
+ data.splice(at, 0, {} as TData);
424
+ ctx.internalData = data;
425
+ }
426
+ function removeRowAt(t: CtxTarget) {
427
+ if (!t.row) return;
428
+ ctx.internalData = (ctx.internalData as TData[]).filter((r) => r !== t.row);
429
+ }
430
+ function removeColAt(t: CtxTarget) {
431
+ ctx.internalColumns = ctx.internalColumns.filter(
432
+ (d: any) => (d.id ?? d.field) !== t.columnId,
433
+ );
434
+ }
435
+
436
+ // Built-in items, keyed. `run` reads the live target passed at click time.
437
+ // `msg` names the GridMessages key so the label localizes (falls back to the
438
+ // English `label` when no `localeText` is set).
439
+ const builtins: Record<string, { label: string; msg?: string; run: (t: CtxTarget) => void }> = {
440
+ copy: { label: "Copy", msg: "menuCopy", run: () => ctx.copySelectionToClipboard() },
441
+ cut: { label: "Cut", msg: "menuCut", run: () => ctx.cutSelectionToClipboard() },
442
+ paste: { label: "Paste", msg: "menuPaste", run: () => void ctx.pasteFromClipboard() },
443
+ clear: { label: "Clear", msg: "menuClear", run: () => ctx.clearSelectedCells() },
444
+ row_above: { label: "Insert row above", msg: "menuInsertRowAbove", run: (t) => insertRowAt(t, 0) },
445
+ row_below: { label: "Insert row below", msg: "menuInsertRowBelow", run: (t) => insertRowAt(t, 1) },
446
+ remove_row: { label: "Remove row", msg: "menuRemoveRow", run: removeRowAt },
447
+ remove_col: { label: "Remove column", msg: "menuRemoveColumn", run: removeColAt },
448
+ comment: { label: "Edit comment", msg: "menuEditComment", run: (t) => openCommentEditor(t) },
449
+ // Charting: open the chart panel scoped to the current cell selection. Gated
450
+ // on `chartingEnabled` in the resolver below.
451
+ chart: { label: "Chart selected range", msg: "chartRange", run: () => { ctx.chartPanelOpen = true; } },
452
+ };
453
+ const DEFAULT_ITEMS = [
454
+ "copy", "cut", "paste", "clear", "separator",
455
+ "row_above", "row_below", "remove_row", "separator", "remove_col",
456
+ ];
457
+
458
+ function openContextMenu(
459
+ event: MouseEvent,
460
+ rowIndex: number,
461
+ colIndex: number,
462
+ columnId: string,
463
+ ) {
464
+ if (!ctx.props.contextMenu) return;
465
+ event.preventDefault();
466
+ closeMenus();
467
+ ctx.contextMenuFor = null;
468
+ const rowModel = ctx.allRows[rowIndex];
469
+ const row = rowModel?.original ?? null;
470
+ const rowId = rowModel?.id ?? String(rowIndex);
471
+ // Reserve ~280px for the menu body. If the click is in the bottom third,
472
+ // flip the menu above the cursor so it stays fully in the viewport.
473
+ const menuHeight = 280;
474
+ const y = event.clientY + menuHeight > window.innerHeight
475
+ ? Math.max(8, event.clientY - menuHeight)
476
+ : event.clientY;
477
+ ctx.contextMenuPos = {
478
+ x: clampMenuX(event.clientX, 220),
479
+ y,
480
+ };
481
+ ctx.contextMenuFor = { rowIndex, colIndex, columnId, rowId, row };
482
+ }
483
+
484
+ function closeContextMenu() {
485
+ ctx.contextMenuFor = null;
486
+ }
487
+
488
+ // ---- Editable comments ---------------------------------------------
489
+ function openCommentEditor(t: CtxTarget) {
490
+ const cur =
491
+ ctx.noteOverrides?.[t.rowId]?.[t.columnId] ??
492
+ ctx.props.notes?.[t.rowId]?.[t.columnId] ??
493
+ "";
494
+ ctx.commentDraft = cur;
495
+ ctx.commentEditFor = { rowId: t.rowId, columnId: t.columnId, x: ctx.contextMenuPos.x, y: ctx.contextMenuPos.y };
496
+ ctx.contextMenuFor = null;
497
+ }
498
+ function writeNote(rowId: string, columnId: string, note: string) {
499
+ const next = { ...ctx.noteOverrides };
500
+ next[rowId] = { ...(next[rowId] ?? {}), [columnId]: note };
501
+ ctx.noteOverrides = next;
502
+ ctx.props.onNoteChange?.({ rowId, columnId, note });
503
+ }
504
+ function saveComment() {
505
+ const e = ctx.commentEditFor;
506
+ if (!e) return;
507
+ writeNote(e.rowId, e.columnId, ctx.commentDraft);
508
+ ctx.commentEditFor = null;
509
+ }
510
+ function removeComment() {
511
+ const e = ctx.commentEditFor;
512
+ if (!e) return;
513
+ writeNote(e.rowId, e.columnId, "");
514
+ ctx.commentEditFor = null;
515
+ }
516
+ function closeCommentEditor() {
517
+ ctx.commentEditFor = null;
518
+ }
519
+
520
+ /** Resolve the configured items against the current target for rendering. */
521
+ function contextMenuItems(): ResolvedItem[] {
522
+ const target = ctx.contextMenuFor as CtxTarget | null;
523
+ if (!target) return [];
524
+ const cfg = ctx.props.contextMenu;
525
+ const messages = ctx.messages;
526
+ const entries: any[] = Array.isArray(cfg)
527
+ ? cfg
528
+ : [
529
+ ...DEFAULT_ITEMS,
530
+ ...(ctx.chartingEnabled ? ["separator", "chart"] : []),
531
+ ...(ctx.props.editableComments ? ["separator", "comment"] : []),
532
+ ];
533
+ const out: ResolvedItem[] = [];
534
+ for (const entry of entries) {
535
+ if (entry === "separator") { out.push({ key: "sep" + out.length, label: "", separator: true }); continue; }
536
+ if (typeof entry === "string") {
537
+ // The chart item only makes sense when charting is enabled.
538
+ if (entry === "chart" && !ctx.chartingEnabled) continue;
539
+ const b = builtins[entry];
540
+ if (!b) continue;
541
+ const label = (b.msg && messages?.[b.msg]) || b.label;
542
+ out.push({ key: entry, label, run: () => b.run(target) });
543
+ continue;
544
+ }
545
+ if (entry.hidden?.(target)) continue;
546
+ out.push({
547
+ key: entry.key,
548
+ label: entry.label,
549
+ disabled: entry.disabled?.(target) ?? false,
550
+ run: () => entry.action(target),
551
+ });
552
+ }
553
+ // Drop leading/trailing/double separators.
554
+ return out.filter((it, i, a) =>
555
+ !it.separator || (i > 0 && i < a.length - 1 && !a[i - 1]?.separator),
556
+ );
557
+ }
558
+
559
+ return {
560
+ updateFilterRow,
561
+ updateFilterOperator,
562
+ updateFilterMenuValue,
563
+ updateFilterMenuValueTo,
564
+ addFilterToken,
565
+ removeFilterToken,
566
+ toggleFilterToken,
567
+ toggleCheckboxWithKeyboard,
568
+ isColumnFiltered,
569
+ closeMenus,
570
+ openInSuggest,
571
+ closeInSuggest,
572
+ openChooseColumns,
573
+ openColumnMenu,
574
+ openFilterMenu,
575
+ openOperatorMenu,
576
+ sortColumnFromMenu,
577
+ clearColumnSort,
578
+ groupByColumnFromMenu,
579
+ clearGroupingFromMenu,
580
+ isFacetChecked,
581
+ toggleFacetValue,
582
+ setFacetSelection,
583
+ setFilterTokens,
584
+ isAllFacetsChecked,
585
+ toggleAllFacets,
586
+ clearColumnFilter,
587
+ changePage,
588
+ goToPage,
589
+ setPageSize,
590
+ openContextMenu,
591
+ closeContextMenu,
592
+ contextMenuItems,
593
+ saveComment,
594
+ removeComment,
595
+ closeCommentEditor,
596
+ };
597
+ }