@svgrid/grid 2.2.27 → 2.2.29

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 (248) hide show
  1. package/README.md +199 -199
  2. package/dist/FlexRender.svelte +96 -96
  3. package/dist/GridFooter.svelte +179 -179
  4. package/dist/GridMenus.svelte +782 -734
  5. package/dist/SvAutoComplete.svelte +165 -165
  6. package/dist/SvCalendar.svelte +503 -503
  7. package/dist/SvCarousel.svelte +141 -141
  8. package/dist/SvCheckBox.svelte +101 -101
  9. package/dist/SvColorInput.svelte +181 -181
  10. package/dist/SvComboBox.svelte +275 -275
  11. package/dist/SvContextMenu.svelte +116 -116
  12. package/dist/SvCountryInput.svelte +160 -160
  13. package/dist/SvDrawer.svelte +254 -250
  14. package/dist/SvDropDownList.svelte +374 -374
  15. package/dist/SvDurationInput.svelte +126 -126
  16. package/dist/SvField.svelte +293 -293
  17. package/dist/SvForm.svelte +437 -437
  18. package/dist/SvGrid.controller.svelte.d.ts +14 -1
  19. package/dist/SvGrid.controller.svelte.js +112 -28
  20. package/dist/SvGrid.css +2704 -2723
  21. package/dist/SvGrid.svelte +3361 -3333
  22. package/dist/SvGrid.types.d.ts +20 -5
  23. package/dist/SvGridChart.svelte +1724 -1724
  24. package/dist/SvGridChartPanel.svelte +485 -485
  25. package/dist/SvGridChartView.svelte +70 -70
  26. package/dist/SvGridDropdown.svelte +696 -696
  27. package/dist/SvGridSelect.svelte +259 -259
  28. package/dist/SvGroupCell.svelte +109 -109
  29. package/dist/SvListBox.svelte +326 -287
  30. package/dist/SvListBox.svelte.d.ts +10 -1
  31. package/dist/SvMaskedInput.svelte +122 -122
  32. package/dist/SvMenu.svelte +124 -124
  33. package/dist/SvMenuList.svelte +146 -146
  34. package/dist/SvMultiSelect.svelte +281 -281
  35. package/dist/SvNumberInput.svelte +168 -168
  36. package/dist/SvOtpInput.svelte +158 -158
  37. package/dist/SvPasswordInput.svelte +151 -151
  38. package/dist/SvPhoneInput.svelte +133 -131
  39. package/dist/SvPopover.svelte +197 -197
  40. package/dist/SvRadioGroup.svelte +107 -107
  41. package/dist/SvRowGroupPanel.svelte +170 -170
  42. package/dist/SvScrollArea.svelte +61 -61
  43. package/dist/SvSlider.svelte +200 -200
  44. package/dist/SvSwitchButton.svelte +107 -107
  45. package/dist/SvTagsInput.svelte +112 -112
  46. package/dist/SvTextInput.svelte +147 -147
  47. package/dist/SvTimePicker.svelte +245 -245
  48. package/dist/SvToaster.svelte +159 -159
  49. package/dist/SvToggleButton.svelte +85 -85
  50. package/dist/SvTooltip.svelte +161 -161
  51. package/dist/SvTour.svelte +204 -204
  52. package/dist/SvTree.svelte +437 -437
  53. package/dist/SvTreeSelect.svelte +233 -233
  54. package/dist/cdn/GridMenus-Da_p7SKS.js +486 -0
  55. package/dist/cdn/GridMenus-Ds3OpzZT.js +490 -0
  56. package/dist/cdn/{SvDateTimePicker-B1sDc9Pz.js → SvDateTimePicker-B8GopjhC.js} +2 -2
  57. package/dist/cdn/{SvDateTimePicker-Bh0FTs1E.js → SvDateTimePicker-DB0aIEk8.js} +2 -2
  58. package/dist/cdn/{SvGridChart-Bgk4hDs8.js → SvGridChart-BcLCYvw9.js} +1 -1
  59. package/dist/cdn/{SvGridChart-BKQsO5fZ.js → SvGridChart-BhnFdLd0.js} +1 -1
  60. package/dist/cdn/{SvGridChartPanel-DkCc2Z7b.js → SvGridChartPanel-Cmx9DHAS.js} +1 -1
  61. package/dist/cdn/{SvGridChartPanel-DSkHIQDd.js → SvGridChartPanel-DvjwxLvK.js} +1 -1
  62. package/dist/cdn/{SvGridChartView-BrbVaELm.js → SvGridChartView-B40DTJSX.js} +1 -1
  63. package/dist/cdn/{SvGridChartView-i9rWvSKg.js → SvGridChartView-PwqI4s02.js} +1 -1
  64. package/dist/cdn/{SvGridDropdown-D08sVmAy.js → SvGridDropdown-DjALc_4f.js} +1 -1
  65. package/dist/cdn/{SvGridDropdown-CGxxopQ4.js → SvGridDropdown-hpnHvOGt.js} +1 -1
  66. package/dist/cdn/{src-cgs1wcUS.js → src-BLJfdyL0.js} +4034 -3889
  67. package/dist/cdn/{src-Cw8d9U8j.js → src-DYDCiC0D.js} +7053 -6908
  68. package/dist/cdn/svgrid.js +9 -9
  69. package/dist/cdn/svgrid.svelte-external.js +9 -9
  70. package/dist/chart-export.js +8 -8
  71. package/dist/column-groups.js +8 -10
  72. package/dist/column-id.d.ts +9 -0
  73. package/dist/column-id.js +25 -0
  74. package/dist/core.js +2 -1
  75. package/dist/createListbox.svelte.d.ts +15 -2
  76. package/dist/createListbox.svelte.js +27 -6
  77. package/dist/editing.d.ts +1 -0
  78. package/dist/editing.js +45 -10
  79. package/dist/filtering/excel-filters.d.ts +30 -10
  80. package/dist/filtering/excel-filters.js +96 -15
  81. package/dist/index.d.ts +2 -2
  82. package/dist/index.js +2 -2
  83. package/dist/menus.d.ts +2 -0
  84. package/dist/menus.js +25 -0
  85. package/dist/row-resize.js +40 -0
  86. package/dist/selection.js +1 -1
  87. package/package.json +10 -10
  88. package/src/FlexRender.svelte +96 -96
  89. package/src/GridFooter.svelte +179 -179
  90. package/src/GridMenus.svelte +782 -734
  91. package/src/SvAutoComplete.svelte +165 -165
  92. package/src/SvCalendar.svelte +503 -503
  93. package/src/SvCalendar.test.ts +226 -226
  94. package/src/SvCarousel.svelte +141 -141
  95. package/src/SvCheckBox.svelte +101 -101
  96. package/src/SvColorInput.svelte +181 -181
  97. package/src/SvComboBox.svelte +275 -275
  98. package/src/SvContextMenu.svelte +116 -116
  99. package/src/SvCountryInput.svelte +160 -160
  100. package/src/SvDrawer.svelte +254 -250
  101. package/src/SvDrawer.test.ts +18 -1
  102. package/src/SvDropDownList.svelte +374 -374
  103. package/src/SvDurationInput.svelte +126 -126
  104. package/src/SvField.svelte +293 -293
  105. package/src/SvForm.svelte +437 -437
  106. package/src/SvForm.test.ts +411 -411
  107. package/src/SvGrid.controller.svelte.ts +3335 -3239
  108. package/src/SvGrid.css +2704 -2723
  109. package/src/SvGrid.svelte +3361 -3333
  110. package/src/SvGrid.types.ts +1628 -1613
  111. package/src/SvGridChart.svelte +1724 -1724
  112. package/src/SvGridChartPanel.svelte +485 -485
  113. package/src/SvGridChartView.svelte +70 -70
  114. package/src/SvGridDropdown.svelte +696 -696
  115. package/src/SvGridSelect.svelte +259 -259
  116. package/src/SvGroupCell.svelte +109 -109
  117. package/src/SvListBox.svelte +326 -287
  118. package/src/SvMaskedInput.svelte +122 -122
  119. package/src/SvMenu.svelte +124 -124
  120. package/src/SvMenu.test.ts +97 -97
  121. package/src/SvMenuList.svelte +146 -146
  122. package/src/SvMultiSelect.svelte +281 -281
  123. package/src/SvNavPane.test.ts +15 -4
  124. package/src/SvNumberInput.svelte +168 -168
  125. package/src/SvOtpInput.svelte +158 -158
  126. package/src/SvPasswordInput.svelte +151 -151
  127. package/src/SvPhoneInput.svelte +133 -131
  128. package/src/SvPopover.svelte +197 -197
  129. package/src/SvRadioGroup.svelte +107 -107
  130. package/src/SvRowGroupPanel.svelte +170 -170
  131. package/src/SvScrollArea.svelte +61 -61
  132. package/src/SvSlider.svelte +200 -200
  133. package/src/SvSwitchButton.svelte +107 -107
  134. package/src/SvTagsInput.svelte +112 -112
  135. package/src/SvTextInput.svelte +147 -147
  136. package/src/SvTimePicker.svelte +245 -245
  137. package/src/SvToaster.svelte +159 -159
  138. package/src/SvToaster.test.ts +95 -95
  139. package/src/SvToggleButton.svelte +85 -85
  140. package/src/SvTooltip.svelte +161 -161
  141. package/src/SvTour.svelte +204 -204
  142. package/src/SvTree.svelte +437 -437
  143. package/src/SvTreeSelect.svelte +233 -233
  144. package/src/a11y/dismissable.test.ts +119 -119
  145. package/src/a11y/dismissable.ts +114 -114
  146. package/src/a11y.contract.test.ts +49 -49
  147. package/src/a11y.test.ts +59 -59
  148. package/src/a11y.ts +61 -61
  149. package/src/ai.test.ts +502 -502
  150. package/src/ai.ts +1391 -1391
  151. package/src/build-api.coverage.test.ts +633 -633
  152. package/src/build-api.ts +895 -895
  153. package/src/builtin-editors.grid.test.ts +85 -85
  154. package/src/cell-formatting.ts +169 -169
  155. package/src/cell-render.ts +469 -469
  156. package/src/chart-export.ts +201 -201
  157. package/src/chart-view.svelte.ts +36 -36
  158. package/src/chart.ts +2296 -2296
  159. package/src/collaboration.test.ts +104 -104
  160. package/src/collaboration.ts +167 -167
  161. package/src/column-groups.test.ts +48 -0
  162. package/src/column-groups.ts +8 -10
  163. package/src/column-id.ts +30 -0
  164. package/src/core.performance.test.ts +30 -30
  165. package/src/core.ts +1112 -1111
  166. package/src/createAutocomplete.svelte.ts +132 -132
  167. package/src/createCombobox.svelte.ts +191 -191
  168. package/src/createCountryInput.svelte.ts +157 -157
  169. package/src/createDropdownList.svelte.ts +168 -168
  170. package/src/createForm.svelte.ts +386 -386
  171. package/src/createGrid.svelte.ts +42 -42
  172. package/src/createGrid.test.ts +10 -10
  173. package/src/createGridState.svelte.ts +17 -17
  174. package/src/createListbox.svelte.ts +39 -7
  175. package/src/createMenu.svelte.ts +225 -225
  176. package/src/createPopoverSelect.svelte.ts +213 -213
  177. package/src/createSlider.svelte.ts +191 -191
  178. package/src/createTooltip.svelte.ts +144 -144
  179. package/src/createTree.svelte.ts +319 -319
  180. package/src/editing.test.ts +949 -859
  181. package/src/editing.ts +709 -675
  182. package/src/editor-contract.ts +157 -157
  183. package/src/export-data-api.test.ts +126 -126
  184. package/src/export-format.test.ts +107 -107
  185. package/src/export-format.ts +598 -598
  186. package/src/filter-operators.ts +157 -157
  187. package/src/filtering/excel-filters.test.ts +44 -1
  188. package/src/filtering/excel-filters.ts +91 -14
  189. package/src/flex-render.ts +3 -3
  190. package/src/form-field.ts +127 -127
  191. package/src/index.ts +715 -713
  192. package/src/js-scroller.svelte.ts +173 -173
  193. package/src/keyboard.test.ts +59 -59
  194. package/src/keyboard.ts +97 -97
  195. package/src/list-nav.test.ts +49 -49
  196. package/src/list-nav.ts +29 -29
  197. package/src/list-option.test.ts +56 -56
  198. package/src/list-option.ts +172 -172
  199. package/src/menus.test.ts +736 -700
  200. package/src/menus.ts +679 -652
  201. package/src/merge-objects.ts +48 -48
  202. package/src/overlays.test.ts +90 -90
  203. package/src/render-component.ts +28 -28
  204. package/src/row-resize.test.ts +100 -0
  205. package/src/row-resize.ts +36 -0
  206. package/src/scheduler-model.test.ts +562 -562
  207. package/src/scheduler-model.ts +872 -872
  208. package/src/selection.test.ts +754 -754
  209. package/src/selection.ts +600 -600
  210. package/src/server-data-source.test.ts +289 -289
  211. package/src/server-data-source.ts +413 -413
  212. package/src/sparkline.test.ts +68 -68
  213. package/src/sparkline.ts +169 -169
  214. package/src/spreadsheet.test.ts +489 -489
  215. package/src/spreadsheet.ts +312 -312
  216. package/src/static-functions.ts +11 -11
  217. package/src/subscribe.ts +38 -38
  218. package/src/svgrid-wrapper.types.ts +524 -524
  219. package/src/svgrid.api-extensions.test.ts +8 -3
  220. package/src/svgrid.behavior.test.ts +706 -706
  221. package/src/svgrid.cell-dom-ids.test.ts +96 -0
  222. package/src/svgrid.charting.test.ts +534 -534
  223. package/src/svgrid.conditional-stat-scope.test.ts +111 -0
  224. package/src/svgrid.features.test.ts +157 -157
  225. package/src/svgrid.filter-menu-listbox.svelte.test.ts +308 -0
  226. package/src/svgrid.filter-menu-scroll.test.ts +3 -1
  227. package/src/svgrid.group-header-ids.test.ts +121 -0
  228. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  229. package/src/svgrid.wrapper.test.ts +40 -40
  230. package/src/test-setup.ts +62 -62
  231. package/src/themes/index.ts +215 -215
  232. package/src/toast-store.svelte.ts +246 -246
  233. package/src/toast-store.test.ts +147 -147
  234. package/src/ui-buttons.test.ts +144 -144
  235. package/src/ui-inputs.test.ts +118 -118
  236. package/src/ui-range.test.ts +70 -70
  237. package/src/ui-selection.test.ts +155 -155
  238. package/src/ui-tier1.test.ts +142 -142
  239. package/src/virtual.test.ts +88 -88
  240. package/src/virtualization/column-virtualizer.test.ts +27 -27
  241. package/src/virtualization/column-virtualizer.ts +30 -30
  242. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  243. package/src/virtualization/types.ts +30 -30
  244. package/src/virtualization/virtualizer.test.ts +47 -47
  245. package/src/virtualization/virtualizer.ts +296 -296
  246. package/themes/ember.css +71 -71
  247. package/dist/cdn/GridMenus-BzxEYNtW.js +0 -421
  248. package/dist/cdn/GridMenus-Dr-nGEXn.js +0 -417
@@ -1,734 +1,782 @@
1
- <script lang="ts" generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData">
2
- import {
3
- applyExcelFilter,
4
- normalizeForFilter,
5
- splitInTokens,
6
- createColumnVirtualizer,
7
- createCoreRowModel,
8
- createExpandedRowModel,
9
- createFilteredRowModel,
10
- createGroupedRowModel,
11
- createPaginatedRowModel,
12
- createSvelteVirtualizer,
13
- createSortedRowModel,
14
- createSvGrid,
15
- filterFns,
16
- getGridCellA11yProps,
17
- getGridCellDomId,
18
- getGridHeaderA11yProps,
19
- getGridRootA11yProps,
20
- getGridRowA11yProps,
21
- parseEditorValue,
22
- normalizeEditorOptions,
23
- sortFns,
24
- tableFeatures,
25
- rowSortingFeature,
26
- columnFilteringFeature,
27
- columnGroupingFeature,
28
- type CellContext,
29
- type EditorContext,
30
- type CellEditorOption,
31
- type CellEditorType,
32
- type CellFormatter,
33
- type CellFormatConfig,
34
- type Column,
35
- type ColumnDef,
36
- type Row,
37
- type RowData,
38
- type SvGridApi,
39
- type TableFeatures,
40
- } from "./index";
41
- import "./sv-grid-scrollbar";
42
- import { onScrollOutside } from "./a11y/dismissable";
43
- import type { Snippet } from "svelte";
44
- import { getKeyboardIntent, getNextActiveCell } from "./keyboard";
45
- import {
46
- formatNumericWithConfig,
47
- getDateFormatter,
48
- resolveDatePattern,
49
- } from "./cell-formatting";
50
- import {
51
- RenderSnippetConfig,
52
- RenderComponentConfig,
53
- } from "./render-component";
54
- import { buildFillPattern } from "./fill-patterns";
55
- import { buildSparkline, toSparklineValues } from "./sparkline";
56
- import {
57
- resolveCellFormat,
58
- computeColumnStat,
59
- formatsNeedingStats,
60
- type ColumnStat,
61
- type ResolvedCellFormat,
62
- } from "./conditional-formatting";
63
- import SvGridDropdown from "./SvGridDropdown.svelte";
64
- import type {
65
- Props,
66
- SelectionPoint,
67
- SelectionRange,
68
- CellEditState,
69
- FilterOperator,
70
- FilterOption,
71
- MenuPosition,
72
- } from "./SvGrid.types";
73
- import {
74
- cfTextStyle,
75
- fmtStat,
76
- getCellKey,
77
- resolveClassList,
78
- toDateInputValue,
79
- toDateTimeLocalInputValue,
80
- getEditableInputValue,
81
- getEditorInputType,
82
- toValueArray,
83
- getOptionLabel,
84
- getOptionColor,
85
- colorfulChipStyle,
86
- getEditorClass,
87
- asDate,
88
- clampMenuX,
89
- cssEscape,
90
- rawToNumber,
91
- formatFacetNumber,
92
- formatFacetDate,
93
- } from "./SvGrid.helpers";
94
-
95
- import type { SvGridController } from "./SvGrid.controller.svelte";
96
-
97
- let { ctrl, icon }: { ctrl: SvGridController<TFeatures, TData>; icon: Snippet<[string]> } = $props();
98
-
99
- // View facade: re-bind the controller's reactive members so the markup
100
- // (moved verbatim from SvGrid.svelte) stays identical.
101
- const groupingControlsEnabled = $derived(ctrl.groupingControlsEnabled);
102
- const filterMenuValues = $derived(ctrl.filterMenuValues);
103
- const tooltip = $derived(ctrl.tooltip);
104
- const showTooltipFor = $derived(ctrl.showTooltipFor);
105
- const columnMenuFor = $derived(ctrl.columnMenuFor);
106
- const columnMenuPos = $derived(ctrl.columnMenuPos);
107
- const filterMenuFor = $derived(ctrl.filterMenuFor);
108
- const filterMenuPos = $derived(ctrl.filterMenuPos);
109
- const operatorMenuFor = $derived(ctrl.operatorMenuFor);
110
- const operatorMenuPos = $derived(ctrl.operatorMenuPos);
111
- const chooseColumnsPos = $derived(ctrl.chooseColumnsPos);
112
- const showColumnFiltersEffective = $derived(ctrl.showColumnFiltersEffective);
113
- const columnMenuTabsEnabled = $derived(ctrl.props.columnMenuTabs === true);
114
- const grid = $derived(ctrl.grid);
115
- const allColumns = $derived(ctrl.allColumns);
116
- const isColumnPinned = $derived(ctrl.isColumnPinned);
117
- const pinColumnLeft = $derived(ctrl.pinColumnLeft);
118
- const pinColumnRight = $derived(ctrl.pinColumnRight);
119
- const unpinColumn = $derived(ctrl.unpinColumn);
120
- const sortDirectionByColumn = $derived(ctrl.sortDirectionByColumn);
121
- const groupingColumns = $derived(ctrl.groupingColumns);
122
- const columnVirtualizationEnabled = $derived(ctrl.columnVirtualizationEnabled);
123
- const updateFilterOperator = $derived(ctrl.updateFilterOperator);
124
- const updateFilterMenuValue = $derived(ctrl.updateFilterMenuValue);
125
- const updateFilterMenuValueTo = $derived(ctrl.updateFilterMenuValueTo);
126
- const operatorsForColumn = $derived(ctrl.operatorsForColumn);
127
- const defaultOperatorFor = $derived(ctrl.defaultOperatorFor);
128
- const operatorLabelFor = $derived(ctrl.operatorLabelFor);
129
- const filterRowValues = $derived(ctrl.filterRowValues);
130
- const inSuggestFor = $derived(ctrl.inSuggestFor);
131
- const inSuggestPos = $derived(ctrl.inSuggestPos);
132
- const inSuggestValues = $derived(ctrl.inSuggestValues);
133
- const toggleFilterToken = $derived(ctrl.toggleFilterToken);
134
- const facetValuesForColumn = $derived(ctrl.facetValuesForColumn);
135
-
136
- // Patch a column's menu filter (used for the optional 2nd condition + join
137
- // of multi-condition filtering). Merges into the existing entry, creating a
138
- // base entry from the column's default operator when none exists yet.
139
- function patchFilter(columnId: string, patch: Record<string, unknown>) {
140
- const col = allColumns.find((c) => c.id === columnId);
141
- const cur = ctrl.filterMenuValues[columnId] ?? { operator: defaultOperatorFor(col), value: "" };
142
- ctrl.filterMenuValues = { ...ctrl.filterMenuValues, [columnId]: { ...cur, ...patch } };
143
- }
144
- const isColumnFiltered = $derived(ctrl.isColumnFiltered);
145
- const closeMenus = $derived(ctrl.closeMenus);
146
-
147
- // Column/filter/operator menus are position:fixed and anchored to their
148
- // trigger by a one-time measurement taken when they open. On scroll they'd
149
- // otherwise detach and float over the wrong place (#88), so close them when
150
- // anything OUTSIDE them scrolls, or on resize. Scrolling the menu's own body
151
- // or its facet list has to keep it open - hence onScrollOutside rather than a
152
- // bare capture-phase listener.
153
- $effect(() => {
154
- if (!(columnMenuFor || filterMenuFor || operatorMenuFor || chooseColumnsPos)) return;
155
- const close = () => closeMenus();
156
- const offScroll = onScrollOutside(
157
- () => Array.from(document.querySelectorAll<HTMLElement>('.sv-grid-menu')),
158
- close,
159
- );
160
- window.addEventListener('resize', close);
161
- return () => {
162
- offScroll();
163
- window.removeEventListener('resize', close);
164
- };
165
- });
166
-
167
- const autosizeColumn = $derived(ctrl.autosizeColumn);
168
- const autosizeAllColumns = $derived(ctrl.autosizeAllColumns);
169
- const resetColumns = $derived(ctrl.resetColumns);
170
- const openChooseColumns = $derived(ctrl.openChooseColumns);
171
- const sortColumnFromMenu = $derived(ctrl.sortColumnFromMenu);
172
- const clearColumnSort = $derived(ctrl.clearColumnSort);
173
- const groupByColumnFromMenu = $derived(ctrl.groupByColumnFromMenu);
174
- const clearGroupingFromMenu = $derived(ctrl.clearGroupingFromMenu);
175
- const columnMenuFacetValues = $derived(ctrl.columnMenuFacetValues);
176
- const columnMenuVisibleFacets = $derived(ctrl.columnMenuVisibleFacets);
177
- const isFacetChecked = $derived(ctrl.isFacetChecked);
178
- const toggleFacetValue = $derived(ctrl.toggleFacetValue);
179
- const isAllFacetsChecked = $derived(ctrl.isAllFacetsChecked);
180
- const toggleAllFacets = $derived(ctrl.toggleAllFacets);
181
- const clearColumnFilter = $derived(ctrl.clearColumnFilter);
182
- const contextMenuFor = $derived(ctrl.contextMenuFor);
183
- const contextMenuPos = $derived(ctrl.contextMenuPos);
184
- const contextMenuItems = $derived(ctrl.contextMenuItems);
185
- const closeContextMenu = $derived(ctrl.closeContextMenu);
186
- const commentEditFor = $derived(ctrl.commentEditFor);
187
- const saveComment = $derived(ctrl.saveComment);
188
- const removeComment = $derived(ctrl.removeComment);
189
- const closeCommentEditor = $derived(ctrl.closeCommentEditor);
190
- </script>
191
-
192
- {#if tooltip}
193
- <!-- Custom hover tooltip. Floats above the grid; positioned by
194
- showTooltipFor(). Notes and column tooltips both surface here. -->
195
- <div
196
- class="sv-grid-tooltip"
197
- class:sv-grid-tooltip-above={!tooltip.below}
198
- style={`left: ${tooltip.x}px; top: ${tooltip.y}px; ${tooltip.below ? '' : 'transform: translateY(-100%);'}`}
199
- role="tooltip"
200
- >{tooltip.text}</div>
201
- {/if}
202
-
203
- {#if columnMenuFor || filterMenuFor || operatorMenuFor || chooseColumnsPos}
204
- <div
205
- class="sv-grid-menu-backdrop"
206
- role="presentation"
207
- onclick={closeMenus}
208
- ></div>
209
- {/if}
210
-
211
- <!-- Shared filter UI: rendered both in the funnel popover and in the column
212
- menu's Filter tab, so the two never diverge. -->
213
- {#snippet filterPanelBody(colId: string)}
214
- {@const menuColumn = allColumns.find((c) => c.id === colId)}
215
- {@const menuOperatorOptions = operatorsForColumn(menuColumn)}
216
- {@const menuActiveOperator = filterMenuValues[colId]?.operator ?? defaultOperatorFor(menuColumn)}
217
- {@const menuInputType = getEditorInputType((menuColumn?.columnDef.editorType ?? "text") as CellEditorType)}
218
- <div class="sv-grid-menu-filter">
219
- <div class="sv-grid-menu-filter-head">
220
- <span class="sv-grid-header-icon">{@render icon("filter")}</span> Filter
221
- condition
222
- </div>
223
- <select
224
- class="sv-grid-menu-operator-select"
225
- aria-label="Filter condition"
226
- value={menuActiveOperator}
227
- onchange={(event) =>
228
- updateFilterOperator(colId, (event.currentTarget as HTMLSelectElement).value as FilterOperator)}
229
- >
230
- {#each menuOperatorOptions as option (option.value)}
231
- <option value={option.value}>{operatorLabelFor(option, menuColumn)}</option>
232
- {/each}
233
- </select>
234
- {#if menuActiveOperator !== "isBlank" && menuActiveOperator !== "isNotBlank"}
235
- {@const isSetOp =
236
- menuActiveOperator === "in" || menuActiveOperator === "notIn"}
237
- <input
238
- class="sv-grid-menu-condition-value"
239
- type={menuActiveOperator === "regex" ? "text" : menuInputType}
240
- list={isSetOp ? `sv-in-list-${colId}` : undefined}
241
- value={filterMenuValues[colId]?.value ?? ""}
242
- placeholder={menuActiveOperator === "between"
243
- ? "From"
244
- : isSetOp
245
- ? "value, value..."
246
- : menuActiveOperator === "regex"
247
- ? "Pattern..."
248
- : "Filter value..."}
249
- oninput={(event) => updateFilterMenuValue(colId, (event.currentTarget as HTMLInputElement).value)}
250
- />
251
- {#if isSetOp}
252
- <datalist id={`sv-in-list-${colId}`}>
253
- {#each facetValuesForColumn(colId).slice(0, 200) as v (v)}
254
- <option value={v}></option>
255
- {/each}
256
- </datalist>
257
- {/if}
258
- {#if menuActiveOperator === "between"}
259
- <input
260
- class="sv-grid-menu-condition-value"
261
- type={menuInputType}
262
- value={filterMenuValues[colId]?.valueTo ?? ""}
263
- placeholder="To"
264
- oninput={(event) => updateFilterMenuValueTo(colId, (event.currentTarget as HTMLInputElement).value)}
265
- />
266
- {/if}
267
- {@const mf = filterMenuValues[colId]}
268
- {#if mf?.operator2}
269
- {@const op2 = mf.operator2 ?? defaultOperatorFor(menuColumn)}
270
- <div class="sv-grid-menu-join" role="radiogroup" aria-label="Combine conditions">
271
- <button type="button" class:is-on={(mf.join ?? "AND") === "AND"}
272
- onclick={() => patchFilter(colId, { join: "AND" })}>AND</button>
273
- <button type="button" class:is-on={mf.join === "OR"}
274
- onclick={() => patchFilter(colId, { join: "OR" })}>OR</button>
275
- <button type="button" class="sv-grid-menu-join-x" title="Remove second condition"
276
- aria-label="Remove second condition"
277
- onclick={() => patchFilter(colId, { operator2: undefined, value2: undefined, valueTo2: undefined, join: undefined })}>×</button>
278
- </div>
279
- <select
280
- class="sv-grid-menu-operator-select"
281
- aria-label="Second filter condition"
282
- value={op2}
283
- onchange={(event) =>
284
- patchFilter(colId, { operator2: (event.currentTarget as HTMLSelectElement).value as FilterOperator })}
285
- >
286
- {#each menuOperatorOptions as option (option.value)}
287
- <option value={option.value}>{operatorLabelFor(option, menuColumn)}</option>
288
- {/each}
289
- </select>
290
- {#if op2 !== "isBlank"}
291
- <input
292
- class="sv-grid-menu-condition-value"
293
- type={menuInputType}
294
- value={mf.value2 ?? ""}
295
- placeholder={op2 === "between" ? "From" : "Filter value..."}
296
- oninput={(event) => patchFilter(colId, { value2: (event.currentTarget as HTMLInputElement).value })}
297
- />
298
- {#if op2 === "between"}
299
- <input
300
- class="sv-grid-menu-condition-value"
301
- type={menuInputType}
302
- value={mf.valueTo2 ?? ""}
303
- placeholder="To"
304
- oninput={(event) => patchFilter(colId, { valueTo2: (event.currentTarget as HTMLInputElement).value })}
305
- />
306
- {/if}
307
- {/if}
308
- {:else}
309
- <button type="button" class="sv-grid-menu-add-cond"
310
- onclick={() => patchFilter(colId, { operator2: defaultOperatorFor(menuColumn), value2: "", join: "AND" })}>
311
- + Add condition
312
- </button>
313
- {/if}
314
- {/if}
315
- <div class="sv-grid-menu-sep"></div>
316
- <div class="sv-grid-menu-filter-head">Values</div>
317
- <input
318
- class="sv-grid-menu-search"
319
- placeholder="Search values..."
320
- bind:value={ctrl.columnMenuSearch}
321
- />
322
- <label class="sv-grid-facet sv-grid-facet-all">
323
- <input type="checkbox" checked={isAllFacetsChecked(colId)} onchange={() => toggleAllFacets(colId)} />
324
- <span class="sv-grid-facet-label">(Select all)</span>
325
- </label>
326
- <div class="sv-grid-facet-list">
327
- {#each columnMenuVisibleFacets as value (value)}
328
- <label class="sv-grid-facet">
329
- <input type="checkbox" checked={isFacetChecked(colId, value)} onchange={() => toggleFacetValue(colId, value)} />
330
- <span class="sv-grid-facet-label">{value === "" ? "(Blanks)" : value}</span>
331
- </label>
332
- {:else}
333
- <div class="sv-grid-facet-empty">No values</div>
334
- {/each}
335
- </div>
336
- {#if columnMenuFacetValues.length > columnMenuVisibleFacets.length}
337
- <div class="sv-grid-facet-note">
338
- Showing {columnMenuVisibleFacets.length} of {columnMenuFacetValues.length}
339
- </div>
340
- {/if}
341
- <div class="sv-grid-menu-actions">
342
- <button
343
- type="button"
344
- class="sv-grid-menu-btn"
345
- disabled={!isColumnFiltered(colId)}
346
- onclick={() => clearColumnFilter(colId)}
347
- >
348
- Clear filter
349
- </button>
350
- <button type="button" class="sv-grid-menu-btn sv-grid-menu-btn-primary" onclick={closeMenus}>
351
- Done
352
- </button>
353
- </div>
354
- </div>
355
- {/snippet}
356
-
357
- {#if columnMenuFor}
358
- {@const menuColumnId = columnMenuFor}
359
- {@const menuCol = allColumns.find((c) => c.id === menuColumnId)}
360
- {@const menuCanSort = menuCol?.getCanSort?.() ?? false}
361
- {@const menuCanFilter = (menuCol?.columnDef.field != null) && (menuCol?.columnDef.filterable !== false)}
362
- {@const menuTab = ctrl.columnMenuTab}
363
- <div
364
- class="sv-grid-menu sv-grid-column-menu"
365
- role="menu"
366
- style={`left: ${columnMenuPos.x}px; top: ${columnMenuPos.y}px; max-height: calc(100vh - ${columnMenuPos.y}px - 12px);`}
367
- >
368
- {#if columnMenuTabsEnabled}
369
- <div class="sv-grid-menu-tabs" role="tablist">
370
- <button type="button" class="sv-grid-menu-tab" class:is-active={menuTab === "general"}
371
- role="tab" aria-selected={menuTab === "general"}
372
- onclick={() => (ctrl.columnMenuTab = "general")}>General</button>
373
- {#if menuCanFilter && showColumnFiltersEffective}
374
- <button type="button" class="sv-grid-menu-tab" class:is-active={menuTab === "filter"}
375
- role="tab" aria-selected={menuTab === "filter"}
376
- onclick={() => (ctrl.columnMenuTab = "filter")}>Filter</button>
377
- {/if}
378
- <button type="button" class="sv-grid-menu-tab" class:is-active={menuTab === "columns"}
379
- role="tab" aria-selected={menuTab === "columns"}
380
- onclick={() => (ctrl.columnMenuTab = "columns")}>Columns</button>
381
- </div>
382
- {/if}
383
- {#if columnMenuTabsEnabled && menuTab === "filter" && menuCanFilter && showColumnFiltersEffective}
384
- {@render filterPanelBody(menuColumnId)}
385
- {:else if columnMenuTabsEnabled && menuTab === "columns"}
386
- <div class="sv-grid-menu-filter-head">Visible columns</div>
387
- <div class="sv-grid-facet-list">
388
- {#each grid.getAllColumns() as column (column.id)}
389
- <label class="sv-grid-facet">
390
- <input
391
- type="checkbox"
392
- checked={!ctrl.hiddenColumns[column.id]}
393
- onchange={(event) => {
394
- const visible = (event.currentTarget as HTMLInputElement).checked;
395
- if (visible) {
396
- const next = { ...ctrl.hiddenColumns };
397
- delete next[column.id];
398
- ctrl.hiddenColumns = next;
399
- } else {
400
- ctrl.hiddenColumns = { ...ctrl.hiddenColumns, [column.id]: true };
401
- }
402
- }}
403
- />
404
- <span class="sv-grid-facet-label"
405
- >{typeof column.columnDef.header === "string" ? column.columnDef.header : column.id}</span
406
- >
407
- </label>
408
- {/each}
409
- </div>
410
- {:else}
411
- {#if menuCanSort}
412
- <button
413
- type="button"
414
- class="sv-grid-menu-item"
415
- role="menuitem"
416
- onclick={() => sortColumnFromMenu(menuColumnId, false)}
417
- >
418
- <span class="sv-grid-header-icon">{@render icon("sort-asc")}</span> Sort
419
- ascending
420
- </button>
421
- <button
422
- type="button"
423
- class="sv-grid-menu-item"
424
- role="menuitem"
425
- onclick={() => sortColumnFromMenu(menuColumnId, true)}
426
- >
427
- <span class="sv-grid-header-icon">{@render icon("sort-desc")}</span> Sort
428
- descending
429
- </button>
430
- <button
431
- type="button"
432
- class="sv-grid-menu-item"
433
- role="menuitem"
434
- disabled={!sortDirectionByColumn[menuColumnId]}
435
- onclick={() => clearColumnSort(menuColumnId)}
436
- >
437
- <span class="sv-grid-header-icon">{@render icon("x")}</span> Remove sort
438
- </button>
439
- {/if}
440
- {#if !columnVirtualizationEnabled}
441
- {@const menuPinSide = isColumnPinned(menuColumnId)}
442
- <div class="sv-grid-menu-sep"></div>
443
- <button
444
- type="button"
445
- class="sv-grid-menu-item"
446
- role="menuitem"
447
- disabled={menuPinSide === "left"}
448
- onclick={() => pinColumnLeft(menuColumnId)}
449
- >
450
- <span class="sv-grid-header-icon"
451
- >{@render icon("op-startsWith")}</span
452
- > Pin to left
453
- </button>
454
- <button
455
- type="button"
456
- class="sv-grid-menu-item"
457
- role="menuitem"
458
- disabled={menuPinSide === "right"}
459
- onclick={() => pinColumnRight(menuColumnId)}
460
- >
461
- <span class="sv-grid-header-icon"
462
- >{@render icon("op-greaterThan")}</span
463
- > Pin to right
464
- </button>
465
- <button
466
- type="button"
467
- class="sv-grid-menu-item"
468
- role="menuitem"
469
- disabled={!menuPinSide}
470
- onclick={() => unpinColumn(menuColumnId)}
471
- >
472
- <span class="sv-grid-header-icon">{@render icon("x")}</span> Unpin column
473
- </button>
474
- {/if}
475
- <div class="sv-grid-menu-sep"></div>
476
- <button
477
- type="button"
478
- class="sv-grid-menu-item"
479
- role="menuitem"
480
- onclick={() => {
481
- autosizeColumn(menuColumnId);
482
- closeMenus();
483
- }}
484
- >
485
- <span class="sv-grid-header-icon">{@render icon("autosize")}</span> Autosize
486
- this column
487
- </button>
488
- <button
489
- type="button"
490
- class="sv-grid-menu-item"
491
- role="menuitem"
492
- onclick={() => {
493
- autosizeAllColumns();
494
- closeMenus();
495
- }}
496
- >
497
- <span class="sv-grid-header-icon">{@render icon("autosize")}</span> Autosize
498
- all columns
499
- </button>
500
- {#if groupingControlsEnabled}
501
- <div class="sv-grid-menu-sep"></div>
502
- <button
503
- type="button"
504
- class="sv-grid-menu-item"
505
- role="menuitem"
506
- disabled={groupingColumns.includes(menuColumnId)}
507
- onclick={() => groupByColumnFromMenu(menuColumnId)}
508
- >
509
- <span class="sv-grid-header-icon">{@render icon("group")}</span> Group
510
- by this column
511
- </button>
512
- <button
513
- type="button"
514
- class="sv-grid-menu-item"
515
- role="menuitem"
516
- disabled={!groupingColumns.includes(menuColumnId)}
517
- onclick={() => clearGroupingFromMenu(menuColumnId)}
518
- >
519
- <span class="sv-grid-header-icon">{@render icon("x")}</span> Remove grouping
520
- </button>
521
- {/if}
522
- <div class="sv-grid-menu-sep"></div>
523
- {#if !columnMenuTabsEnabled}
524
- <!-- Flat menu: reach the column chooser via a submenu (tabbed mode has
525
- a Columns tab instead). -->
526
- <button
527
- type="button"
528
- class="sv-grid-menu-item"
529
- class:is-open={chooseColumnsPos !== null}
530
- role="menuitem"
531
- aria-haspopup="menu"
532
- aria-expanded={chooseColumnsPos !== null}
533
- onclick={(event) => openChooseColumns(event)}
534
- >
535
- <span class="sv-grid-header-icon">{@render icon("columns")}</span>
536
- Choose columns
537
- <span class="sv-grid-header-icon sv-grid-menu-item-chevron">{@render icon("chevron-down")}</span>
538
- </button>
539
- {/if}
540
- <button
541
- type="button"
542
- class="sv-grid-menu-item"
543
- role="menuitem"
544
- onclick={() => {
545
- resetColumns();
546
- closeMenus();
547
- }}
548
- >
549
- <span class="sv-grid-header-icon">{@render icon("reset")}</span> Reset columns
550
- </button>
551
- {/if}
552
- </div>
553
- {/if}
554
-
555
- {#if filterMenuFor && showColumnFiltersEffective}
556
- <div
557
- class="sv-grid-menu sv-grid-filter-menu"
558
- role="menu"
559
- style={`left: ${filterMenuPos.x}px; top: ${filterMenuPos.y}px; max-height: calc(100vh - ${filterMenuPos.y}px - 12px);`}
560
- >
561
- {@render filterPanelBody(filterMenuFor)}
562
- </div>
563
- {/if}
564
-
565
- {#if chooseColumnsPos}
566
- {@const everyColumn = grid.getAllColumns()}
567
- <div
568
- class="sv-grid-menu sv-grid-choose-columns-menu"
569
- role="menu"
570
- style={`left: ${chooseColumnsPos.x}px; top: ${chooseColumnsPos.y}px;`}
571
- >
572
- <div class="sv-grid-menu-filter-head">Visible columns</div>
573
- <div class="sv-grid-facet-list">
574
- {#each everyColumn as column (column.id)}
575
- <label class="sv-grid-facet">
576
- <input
577
- type="checkbox"
578
- checked={!ctrl.hiddenColumns[column.id]}
579
- onchange={(event) => {
580
- const visible = (event.currentTarget as HTMLInputElement)
581
- .checked;
582
- if (visible) {
583
- const next = { ...ctrl.hiddenColumns };
584
- delete next[column.id];
585
- ctrl.hiddenColumns = next;
586
- } else {
587
- ctrl.hiddenColumns = { ...ctrl.hiddenColumns, [column.id]: true };
588
- }
589
- }}
590
- />
591
- <span class="sv-grid-facet-label"
592
- >{typeof column.columnDef.header === "string"
593
- ? column.columnDef.header
594
- : column.id}</span
595
- >
596
- </label>
597
- {/each}
598
- </div>
599
- </div>
600
- {/if}
601
-
602
- {#if operatorMenuFor}
603
- {@const opColumnId = operatorMenuFor}
604
- {@const opColumn = allColumns.find((c) => c.id === opColumnId)}
605
- {@const opOptions = operatorsForColumn(opColumn)}
606
- {@const opActive =
607
- filterMenuValues[opColumnId]?.operator ?? defaultOperatorFor(opColumn)}
608
- <div
609
- class="sv-grid-menu sv-grid-operator-menu"
610
- role="menu"
611
- style={`left: ${operatorMenuPos.x}px; top: ${operatorMenuPos.y}px;`}
612
- >
613
- {#each opOptions as option (option.value)}
614
- <button
615
- type="button"
616
- class="sv-grid-menu-item"
617
- role="menuitemradio"
618
- aria-checked={opActive === option.value}
619
- onclick={() => {
620
- updateFilterOperator(opColumnId, option.value);
621
- closeMenus();
622
- }}
623
- >
624
- <span class="sv-grid-header-icon"
625
- >{@render icon(option.iconName)}</span
626
- >
627
- {operatorLabelFor(option, opColumn)}
628
- </button>
629
- {/each}
630
- </div>
631
- {/if}
632
-
633
- {#if inSuggestFor}
634
- {@const suggestColId = inSuggestFor}
635
- {@const selectedTokens = new Set(
636
- splitInTokens(filterRowValues[suggestColId] ?? ""),
637
- )}
638
- <!-- Value suggestions for an `in` / `notIn` chip input. Items use
639
- `onmousedown` (preventDefault) so clicking one keeps focus in the
640
- chip input and the dropdown stays open for multi-select; the input's
641
- own blur handler closes it when focus truly leaves. -->
642
- <div
643
- class="sv-grid-menu sv-grid-in-suggest"
644
- role="listbox"
645
- aria-multiselectable="true"
646
- aria-label="Filter values"
647
- style={`left: ${inSuggestPos.x}px; top: ${inSuggestPos.y}px;`}
648
- >
649
- {#each inSuggestValues as value (value)}
650
- {@const checked = selectedTokens.has(value)}
651
- <button
652
- type="button"
653
- class="sv-grid-menu-item sv-grid-in-suggest-item"
654
- role="option"
655
- aria-selected={checked}
656
- onmousedown={(event) => {
657
- event.preventDefault();
658
- toggleFilterToken(suggestColId, value);
659
- }}
660
- >
661
- <span class="sv-grid-in-suggest-check" aria-hidden="true"
662
- >{checked ? "✓" : ""}</span
663
- >
664
- <span class="sv-grid-in-suggest-label"
665
- >{value === "" ? "(Blanks)" : value}</span
666
- >
667
- </button>
668
- {:else}
669
- <div class="sv-grid-facet-empty">No values</div>
670
- {/each}
671
- </div>
672
- {/if}
673
-
674
- {#if contextMenuFor}
675
- <div
676
- class="sv-grid-menu-backdrop"
677
- role="presentation"
678
- oncontextmenu={(e) => { e.preventDefault(); closeContextMenu(); }}
679
- onclick={closeContextMenu}
680
- ></div>
681
- <div
682
- class="sv-grid-menu sv-grid-context-menu"
683
- role="menu"
684
- style={`left: ${contextMenuPos.x}px; top: ${contextMenuPos.y}px;`}
685
- >
686
- {#each contextMenuItems() as item (item.key)}
687
- {#if item.separator}
688
- <div class="sv-grid-menu-sep"></div>
689
- {:else}
690
- <button
691
- type="button"
692
- class="sv-grid-menu-item"
693
- role="menuitem"
694
- disabled={item.disabled}
695
- onclick={() => { item.run?.(); closeContextMenu(); }}
696
- >{item.label}</button>
697
- {/if}
698
- {/each}
699
- </div>
700
- {/if}
701
-
702
- {#if commentEditFor}
703
- <div
704
- class="sv-grid-menu-backdrop"
705
- role="presentation"
706
- onclick={closeCommentEditor}
707
- ></div>
708
- <div
709
- class="sv-grid-menu sv-grid-comment-editor"
710
- role="dialog"
711
- aria-label="Edit comment"
712
- style={`left: ${commentEditFor.x}px; top: ${commentEditFor.y}px;`}
713
- >
714
- <!-- svelte-ignore a11y_autofocus -->
715
- <textarea
716
- class="sv-grid-comment-textarea"
717
- rows="3"
718
- placeholder="Add a comment…"
719
- autofocus
720
- bind:value={ctrl.commentDraft}
721
- onkeydown={(e) => {
722
- e.stopPropagation();
723
- if (e.key === "Escape") { e.preventDefault(); closeCommentEditor(); }
724
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveComment(); }
725
- }}
726
- ></textarea>
727
- <div class="sv-grid-comment-actions">
728
- <button type="button" class="sv-grid-comment-remove" onclick={removeComment}>Remove</button>
729
- <span class="sv-grid-comment-spacer"></span>
730
- <button type="button" class="sv-grid-comment-cancel" onclick={closeCommentEditor}>Cancel</button>
731
- <button type="button" class="sv-grid-comment-save" onclick={saveComment}>Save</button>
732
- </div>
733
- </div>
734
- {/if}
1
+ <script lang="ts" generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData">
2
+ import {
3
+ applyExcelFilter,
4
+ normalizeForFilter,
5
+ splitInTokens,
6
+ trailingInToken,
7
+ createColumnVirtualizer,
8
+ createCoreRowModel,
9
+ createExpandedRowModel,
10
+ createFilteredRowModel,
11
+ createGroupedRowModel,
12
+ createPaginatedRowModel,
13
+ createSvelteVirtualizer,
14
+ createSortedRowModel,
15
+ createSvGrid,
16
+ filterFns,
17
+ getGridCellA11yProps,
18
+ getGridCellDomId,
19
+ getGridHeaderA11yProps,
20
+ getGridRootA11yProps,
21
+ getGridRowA11yProps,
22
+ parseEditorValue,
23
+ normalizeEditorOptions,
24
+ sortFns,
25
+ tableFeatures,
26
+ rowSortingFeature,
27
+ columnFilteringFeature,
28
+ columnGroupingFeature,
29
+ type CellContext,
30
+ type EditorContext,
31
+ type CellEditorOption,
32
+ type CellEditorType,
33
+ type CellFormatter,
34
+ type CellFormatConfig,
35
+ type Column,
36
+ type ColumnDef,
37
+ type Row,
38
+ type RowData,
39
+ type SvGridApi,
40
+ type TableFeatures,
41
+ } from "./index";
42
+ import "./sv-grid-scrollbar";
43
+ import { onScrollOutside } from "./a11y/dismissable";
44
+ import type { Snippet } from "svelte";
45
+ import { getKeyboardIntent, getNextActiveCell } from "./keyboard";
46
+ import {
47
+ formatNumericWithConfig,
48
+ getDateFormatter,
49
+ resolveDatePattern,
50
+ } from "./cell-formatting";
51
+ import {
52
+ RenderSnippetConfig,
53
+ RenderComponentConfig,
54
+ } from "./render-component";
55
+ import { buildFillPattern } from "./fill-patterns";
56
+ import { buildSparkline, toSparklineValues } from "./sparkline";
57
+ import {
58
+ resolveCellFormat,
59
+ computeColumnStat,
60
+ formatsNeedingStats,
61
+ type ColumnStat,
62
+ type ResolvedCellFormat,
63
+ } from "./conditional-formatting";
64
+ import SvGridDropdown from "./SvGridDropdown.svelte";
65
+ import SvListBox from "./SvListBox.svelte";
66
+ import type {
67
+ Props,
68
+ SelectionPoint,
69
+ SelectionRange,
70
+ CellEditState,
71
+ FilterOperator,
72
+ FilterOption,
73
+ MenuPosition,
74
+ } from "./SvGrid.types";
75
+ import {
76
+ cfTextStyle,
77
+ fmtStat,
78
+ getCellKey,
79
+ resolveClassList,
80
+ toDateInputValue,
81
+ toDateTimeLocalInputValue,
82
+ getEditableInputValue,
83
+ getEditorInputType,
84
+ toValueArray,
85
+ getOptionLabel,
86
+ getOptionColor,
87
+ colorfulChipStyle,
88
+ getEditorClass,
89
+ asDate,
90
+ clampMenuX,
91
+ cssEscape,
92
+ rawToNumber,
93
+ formatFacetNumber,
94
+ formatFacetDate,
95
+ } from "./SvGrid.helpers";
96
+
97
+ import type { SvGridController } from "./SvGrid.controller.svelte";
98
+
99
+ let { ctrl, icon }: { ctrl: SvGridController<TFeatures, TData>; icon: Snippet<[string]> } = $props();
100
+
101
+ // View facade: re-bind the controller's reactive members so the markup
102
+ // (moved verbatim from SvGrid.svelte) stays identical.
103
+ const groupingControlsEnabled = $derived(ctrl.groupingControlsEnabled);
104
+ const filterMenuValues = $derived(ctrl.filterMenuValues);
105
+ const tooltip = $derived(ctrl.tooltip);
106
+ const showTooltipFor = $derived(ctrl.showTooltipFor);
107
+ const columnMenuFor = $derived(ctrl.columnMenuFor);
108
+ const columnMenuPos = $derived(ctrl.columnMenuPos);
109
+ const filterMenuFor = $derived(ctrl.filterMenuFor);
110
+ const filterMenuPos = $derived(ctrl.filterMenuPos);
111
+ const operatorMenuFor = $derived(ctrl.operatorMenuFor);
112
+ const operatorMenuPos = $derived(ctrl.operatorMenuPos);
113
+ const chooseColumnsPos = $derived(ctrl.chooseColumnsPos);
114
+ const showColumnFiltersEffective = $derived(ctrl.showColumnFiltersEffective);
115
+ const columnMenuTabsEnabled = $derived(ctrl.props.columnMenuTabs === true);
116
+ const grid = $derived(ctrl.grid);
117
+ const allColumns = $derived(ctrl.allColumns);
118
+ const isColumnPinned = $derived(ctrl.isColumnPinned);
119
+ const pinColumnLeft = $derived(ctrl.pinColumnLeft);
120
+ const pinColumnRight = $derived(ctrl.pinColumnRight);
121
+ const unpinColumn = $derived(ctrl.unpinColumn);
122
+ const sortDirectionByColumn = $derived(ctrl.sortDirectionByColumn);
123
+ const groupingColumns = $derived(ctrl.groupingColumns);
124
+ const columnVirtualizationEnabled = $derived(ctrl.columnVirtualizationEnabled);
125
+ const updateFilterOperator = $derived(ctrl.updateFilterOperator);
126
+ const updateFilterMenuValue = $derived(ctrl.updateFilterMenuValue);
127
+ const updateFilterMenuValueTo = $derived(ctrl.updateFilterMenuValueTo);
128
+ const operatorsForColumn = $derived(ctrl.operatorsForColumn);
129
+ const defaultOperatorFor = $derived(ctrl.defaultOperatorFor);
130
+ const operatorLabelFor = $derived(ctrl.operatorLabelFor);
131
+ const filterRowValues = $derived(ctrl.filterRowValues);
132
+ const inSuggestFor = $derived(ctrl.inSuggestFor);
133
+ const inSuggestPos = $derived(ctrl.inSuggestPos);
134
+
135
+ // Patch a column's menu filter (used for the optional 2nd condition + join
136
+ // of multi-condition filtering). Merges into the existing entry, creating a
137
+ // base entry from the column's default operator when none exists yet.
138
+ function patchFilter(columnId: string, patch: Record<string, unknown>) {
139
+ const col = allColumns.find((c) => c.id === columnId);
140
+ const cur = ctrl.filterMenuValues[columnId] ?? { operator: defaultOperatorFor(col), value: "" };
141
+ ctrl.filterMenuValues = { ...ctrl.filterMenuValues, [columnId]: { ...cur, ...patch } };
142
+ }
143
+ const isColumnFiltered = $derived(ctrl.isColumnFiltered);
144
+ const closeMenus = $derived(ctrl.closeMenus);
145
+
146
+ // Column/filter/operator menus are position:fixed and anchored to their
147
+ // trigger by a one-time measurement taken when they open. On scroll they'd
148
+ // otherwise detach and float over the wrong place (#88), so close them when
149
+ // anything OUTSIDE them scrolls, or on resize. Scrolling the menu's own body
150
+ // or its facet list has to keep it open - hence onScrollOutside rather than a
151
+ // bare capture-phase listener.
152
+ $effect(() => {
153
+ if (!(columnMenuFor || filterMenuFor || operatorMenuFor || chooseColumnsPos)) return;
154
+ const close = () => closeMenus();
155
+ const offScroll = onScrollOutside(
156
+ () => Array.from(document.querySelectorAll<HTMLElement>('.sv-grid-menu')),
157
+ close,
158
+ );
159
+ window.addEventListener('resize', close);
160
+ return () => {
161
+ offScroll();
162
+ window.removeEventListener('resize', close);
163
+ };
164
+ });
165
+
166
+ const autosizeColumn = $derived(ctrl.autosizeColumn);
167
+ const autosizeAllColumns = $derived(ctrl.autosizeAllColumns);
168
+ const resetColumns = $derived(ctrl.resetColumns);
169
+ const openChooseColumns = $derived(ctrl.openChooseColumns);
170
+ const sortColumnFromMenu = $derived(ctrl.sortColumnFromMenu);
171
+ const clearColumnSort = $derived(ctrl.clearColumnSort);
172
+ const groupByColumnFromMenu = $derived(ctrl.groupByColumnFromMenu);
173
+ const clearGroupingFromMenu = $derived(ctrl.clearGroupingFromMenu);
174
+ const columnMenuFacetValues = $derived(ctrl.columnMenuFacetValues);
175
+ const columnMenuVisibleFacets = $derived(ctrl.columnMenuVisibleFacets);
176
+ const columnMenuFacetOptions = $derived(ctrl.columnMenuFacetOptions);
177
+ const columnMenuSelectedFacets = $derived(ctrl.columnMenuSelectedFacets);
178
+ const setFacetSelection = $derived(ctrl.setFacetSelection);
179
+ const setFilterTokens = $derived(ctrl.setFilterTokens);
180
+ const inSuggestOptions = $derived(ctrl.inSuggestOptions);
181
+ const openInSuggest = $derived(ctrl.openInSuggest);
182
+ const closeInSuggest = $derived(ctrl.closeInSuggest);
183
+ const isAllFacetsChecked = $derived(ctrl.isAllFacetsChecked);
184
+ const toggleAllFacets = $derived(ctrl.toggleAllFacets);
185
+
186
+ /**
187
+ * Above this many rows the checklists switch to the windowed listbox. Short
188
+ * lists stay fully rendered so the popover hugs its content instead of
189
+ * reserving a fixed `rows`-tall box.
190
+ */
191
+ const FACET_VIRTUAL_THRESHOLD = 40;
192
+
193
+ // The filter panel's `in` / `notIn` box holds the WHOLE token list
194
+ // ("AAPL, MSFT"), so the suggestion query is the trailing fragment being
195
+ // typed - not the full value, which would match nothing.
196
+ function onSetOpFilterFocus(input: HTMLInputElement, columnId: string) {
197
+ openInSuggest(input, columnId);
198
+ ctrl.inSuggestQuery = trailingInToken(input.value);
199
+ }
200
+
201
+ function onSetOpFilterInput(input: HTMLInputElement, columnId: string) {
202
+ ctrl.inSuggestQuery = trailingInToken(input.value);
203
+ if (ctrl.inSuggestFor !== columnId) openInSuggest(input, columnId);
204
+ }
205
+
206
+ /** Columns as checklist options, labelled by their header when it's a string. */
207
+ function columnVisibilityOptions(columns: Array<Column<TData>>) {
208
+ return columns.map((column) => ({
209
+ value: column.id,
210
+ label: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
211
+ }));
212
+ }
213
+
214
+ /** Currently visible column ids - the checklist's selection set. */
215
+ function visibleColumnIds(columns: Array<Column<TData>>) {
216
+ const set = new Set<string | number>();
217
+ for (const column of columns) if (!ctrl.hiddenColumns[column.id]) set.add(column.id);
218
+ return set;
219
+ }
220
+
221
+ /** Write a checklist selection back as the complementary `hiddenColumns` map. */
222
+ function applyColumnVisibility(columns: Array<Column<TData>>, visible: Set<string>) {
223
+ const next: Record<string, boolean> = { ...ctrl.hiddenColumns };
224
+ for (const column of columns) {
225
+ if (visible.has(column.id)) delete next[column.id];
226
+ else next[column.id] = true;
227
+ }
228
+ ctrl.hiddenColumns = next;
229
+ }
230
+ const clearColumnFilter = $derived(ctrl.clearColumnFilter);
231
+ const contextMenuFor = $derived(ctrl.contextMenuFor);
232
+ const contextMenuPos = $derived(ctrl.contextMenuPos);
233
+ const contextMenuItems = $derived(ctrl.contextMenuItems);
234
+ const closeContextMenu = $derived(ctrl.closeContextMenu);
235
+ const commentEditFor = $derived(ctrl.commentEditFor);
236
+ const saveComment = $derived(ctrl.saveComment);
237
+ const removeComment = $derived(ctrl.removeComment);
238
+ const closeCommentEditor = $derived(ctrl.closeCommentEditor);
239
+ </script>
240
+
241
+ {#if tooltip}
242
+ <!-- Custom hover tooltip. Floats above the grid; positioned by
243
+ showTooltipFor(). Notes and column tooltips both surface here. -->
244
+ <div
245
+ class="sv-grid-tooltip"
246
+ class:sv-grid-tooltip-above={!tooltip.below}
247
+ style={`left: ${tooltip.x}px; top: ${tooltip.y}px; ${tooltip.below ? '' : 'transform: translateY(-100%);'}`}
248
+ role="tooltip"
249
+ >{tooltip.text}</div>
250
+ {/if}
251
+
252
+ {#if columnMenuFor || filterMenuFor || operatorMenuFor || chooseColumnsPos}
253
+ <div
254
+ class="sv-grid-menu-backdrop"
255
+ role="presentation"
256
+ onclick={closeMenus}
257
+ ></div>
258
+ {/if}
259
+
260
+ <!-- Shared filter UI: rendered both in the funnel popover and in the column
261
+ menu's Filter tab, so the two never diverge. -->
262
+ {#snippet filterPanelBody(colId: string)}
263
+ {@const menuColumn = allColumns.find((c) => c.id === colId)}
264
+ {@const menuOperatorOptions = operatorsForColumn(menuColumn)}
265
+ {@const menuActiveOperator = filterMenuValues[colId]?.operator ?? defaultOperatorFor(menuColumn)}
266
+ {@const menuInputType = getEditorInputType((menuColumn?.columnDef.editorType ?? "text") as CellEditorType)}
267
+ <div class="sv-grid-menu-filter">
268
+ <div class="sv-grid-menu-filter-head">
269
+ <span class="sv-grid-header-icon">{@render icon("filter")}</span> Filter
270
+ condition
271
+ </div>
272
+ <select
273
+ class="sv-grid-menu-operator-select"
274
+ aria-label="Filter condition"
275
+ value={menuActiveOperator}
276
+ onchange={(event) =>
277
+ updateFilterOperator(colId, (event.currentTarget as HTMLSelectElement).value as FilterOperator)}
278
+ >
279
+ {#each menuOperatorOptions as option (option.value)}
280
+ <option value={option.value}>{operatorLabelFor(option, menuColumn)}</option>
281
+ {/each}
282
+ </select>
283
+ {#if menuActiveOperator !== "isBlank" && menuActiveOperator !== "isNotBlank"}
284
+ {@const isSetOp =
285
+ menuActiveOperator === "in" || menuActiveOperator === "notIn"}
286
+ <!-- in/notIn drives the shared value-suggestions dropdown (a windowed
287
+ checklist) rather than a native datalist, which capped at 200
288
+ values and couldn't show which are already selected. -->
289
+ <input
290
+ class="sv-grid-menu-condition-value"
291
+ type={menuActiveOperator === "regex" ? "text" : menuInputType}
292
+ value={filterMenuValues[colId]?.value ?? ""}
293
+ placeholder={menuActiveOperator === "between"
294
+ ? "From"
295
+ : isSetOp
296
+ ? "value, value..."
297
+ : menuActiveOperator === "regex"
298
+ ? "Pattern..."
299
+ : "Filter value..."}
300
+ onfocus={isSetOp
301
+ ? (event) => onSetOpFilterFocus(event.currentTarget as HTMLInputElement, colId)
302
+ : undefined}
303
+ onblur={isSetOp ? () => closeInSuggest() : undefined}
304
+ oninput={(event) => {
305
+ const input = event.currentTarget as HTMLInputElement;
306
+ updateFilterMenuValue(colId, input.value);
307
+ if (isSetOp) onSetOpFilterInput(input, colId);
308
+ }}
309
+ />
310
+ {#if menuActiveOperator === "between"}
311
+ <input
312
+ class="sv-grid-menu-condition-value"
313
+ type={menuInputType}
314
+ value={filterMenuValues[colId]?.valueTo ?? ""}
315
+ placeholder="To"
316
+ oninput={(event) => updateFilterMenuValueTo(colId, (event.currentTarget as HTMLInputElement).value)}
317
+ />
318
+ {/if}
319
+ {@const mf = filterMenuValues[colId]}
320
+ {#if mf?.operator2}
321
+ {@const op2 = mf.operator2 ?? defaultOperatorFor(menuColumn)}
322
+ <div class="sv-grid-menu-join" role="radiogroup" aria-label="Combine conditions">
323
+ <button type="button" class:is-on={(mf.join ?? "AND") === "AND"}
324
+ onclick={() => patchFilter(colId, { join: "AND" })}>AND</button>
325
+ <button type="button" class:is-on={mf.join === "OR"}
326
+ onclick={() => patchFilter(colId, { join: "OR" })}>OR</button>
327
+ <button type="button" class="sv-grid-menu-join-x" title="Remove second condition"
328
+ aria-label="Remove second condition"
329
+ onclick={() => patchFilter(colId, { operator2: undefined, value2: undefined, valueTo2: undefined, join: undefined })}>×</button>
330
+ </div>
331
+ <select
332
+ class="sv-grid-menu-operator-select"
333
+ aria-label="Second filter condition"
334
+ value={op2}
335
+ onchange={(event) =>
336
+ patchFilter(colId, { operator2: (event.currentTarget as HTMLSelectElement).value as FilterOperator })}
337
+ >
338
+ {#each menuOperatorOptions as option (option.value)}
339
+ <option value={option.value}>{operatorLabelFor(option, menuColumn)}</option>
340
+ {/each}
341
+ </select>
342
+ {#if op2 !== "isBlank"}
343
+ <input
344
+ class="sv-grid-menu-condition-value"
345
+ type={menuInputType}
346
+ value={mf.value2 ?? ""}
347
+ placeholder={op2 === "between" ? "From" : "Filter value..."}
348
+ oninput={(event) => patchFilter(colId, { value2: (event.currentTarget as HTMLInputElement).value })}
349
+ />
350
+ {#if op2 === "between"}
351
+ <input
352
+ class="sv-grid-menu-condition-value"
353
+ type={menuInputType}
354
+ value={mf.valueTo2 ?? ""}
355
+ placeholder="To"
356
+ oninput={(event) => patchFilter(colId, { valueTo2: (event.currentTarget as HTMLInputElement).value })}
357
+ />
358
+ {/if}
359
+ {/if}
360
+ {:else}
361
+ <button type="button" class="sv-grid-menu-add-cond"
362
+ onclick={() => patchFilter(colId, { operator2: defaultOperatorFor(menuColumn), value2: "", join: "AND" })}>
363
+ + Add condition
364
+ </button>
365
+ {/if}
366
+ {/if}
367
+ <div class="sv-grid-menu-sep"></div>
368
+ <div class="sv-grid-menu-filter-head">Values</div>
369
+ <input
370
+ class="sv-grid-menu-search"
371
+ placeholder="Search values..."
372
+ bind:value={ctrl.columnMenuSearch}
373
+ />
374
+ <label class="sv-grid-facet sv-grid-facet-all">
375
+ <input type="checkbox" checked={isAllFacetsChecked(colId)} onchange={() => toggleAllFacets(colId)} />
376
+ <span class="sv-grid-facet-label">(Select all)</span>
377
+ </label>
378
+ <!-- Windowed checklist. A high-cardinality column (10k distinct symbols
379
+ on a live feed) would otherwise mount a label + checkbox per value -
380
+ and 10k focusable checkboxes are 10k tab stops. SvListBox renders
381
+ only the visible rows and is one roving-tabindex stop. -->
382
+ {#if columnMenuFacetOptions.length}
383
+ <SvListBox
384
+ block
385
+ checkbox
386
+ multiple
387
+ virtual={columnMenuFacetOptions.length > FACET_VIRTUAL_THRESHOLD}
388
+ rows={7}
389
+ rowHeight={24}
390
+ size="sm"
391
+ ariaLabel="Filter values"
392
+ options={columnMenuFacetOptions}
393
+ value={columnMenuSelectedFacets}
394
+ onChange={(next) => setFacetSelection(colId, next as Set<string>)}
395
+ />
396
+ {:else}
397
+ <div class="sv-grid-facet-empty">No values</div>
398
+ {/if}
399
+ {#if columnMenuFacetValues.length > columnMenuVisibleFacets.length}
400
+ <div class="sv-grid-facet-note">
401
+ Showing {columnMenuVisibleFacets.length} of {columnMenuFacetValues.length}
402
+ </div>
403
+ {/if}
404
+ <div class="sv-grid-menu-actions">
405
+ <button
406
+ type="button"
407
+ class="sv-grid-menu-btn"
408
+ disabled={!isColumnFiltered(colId)}
409
+ onclick={() => clearColumnFilter(colId)}
410
+ >
411
+ Clear filter
412
+ </button>
413
+ <button type="button" class="sv-grid-menu-btn sv-grid-menu-btn-primary" onclick={closeMenus}>
414
+ Done
415
+ </button>
416
+ </div>
417
+ </div>
418
+ {/snippet}
419
+
420
+ {#if columnMenuFor}
421
+ {@const menuColumnId = columnMenuFor}
422
+ {@const menuCol = allColumns.find((c) => c.id === menuColumnId)}
423
+ {@const menuCanSort = menuCol?.getCanSort?.() ?? false}
424
+ {@const menuCanFilter = (menuCol?.columnDef.field != null) && (menuCol?.columnDef.filterable !== false)}
425
+ {@const menuTab = ctrl.columnMenuTab}
426
+ <div
427
+ class="sv-grid-menu sv-grid-column-menu"
428
+ role="menu"
429
+ style={`left: ${columnMenuPos.x}px; top: ${columnMenuPos.y}px; max-height: calc(100vh - ${columnMenuPos.y}px - 12px);`}
430
+ >
431
+ {#if columnMenuTabsEnabled}
432
+ <div class="sv-grid-menu-tabs" role="tablist">
433
+ <button type="button" class="sv-grid-menu-tab" class:is-active={menuTab === "general"}
434
+ role="tab" aria-selected={menuTab === "general"}
435
+ onclick={() => (ctrl.columnMenuTab = "general")}>General</button>
436
+ {#if menuCanFilter && showColumnFiltersEffective}
437
+ <button type="button" class="sv-grid-menu-tab" class:is-active={menuTab === "filter"}
438
+ role="tab" aria-selected={menuTab === "filter"}
439
+ onclick={() => (ctrl.columnMenuTab = "filter")}>Filter</button>
440
+ {/if}
441
+ <button type="button" class="sv-grid-menu-tab" class:is-active={menuTab === "columns"}
442
+ role="tab" aria-selected={menuTab === "columns"}
443
+ onclick={() => (ctrl.columnMenuTab = "columns")}>Columns</button>
444
+ </div>
445
+ {/if}
446
+ {#if columnMenuTabsEnabled && menuTab === "filter" && menuCanFilter && showColumnFiltersEffective}
447
+ {@render filterPanelBody(menuColumnId)}
448
+ {:else if columnMenuTabsEnabled && menuTab === "columns"}
449
+ {@const tabColumns = grid.getAllColumns()}
450
+ <div class="sv-grid-menu-filter-head">Visible columns</div>
451
+ <SvListBox
452
+ block
453
+ checkbox
454
+ multiple
455
+ virtual={tabColumns.length > FACET_VIRTUAL_THRESHOLD}
456
+ rows={9}
457
+ rowHeight={24}
458
+ size="sm"
459
+ ariaLabel="Visible columns"
460
+ options={columnVisibilityOptions(tabColumns)}
461
+ value={visibleColumnIds(tabColumns)}
462
+ onChange={(next) => applyColumnVisibility(tabColumns, next as Set<string>)}
463
+ />
464
+ {:else}
465
+ {#if menuCanSort}
466
+ <button
467
+ type="button"
468
+ class="sv-grid-menu-item"
469
+ role="menuitem"
470
+ onclick={() => sortColumnFromMenu(menuColumnId, false)}
471
+ >
472
+ <span class="sv-grid-header-icon">{@render icon("sort-asc")}</span> Sort
473
+ ascending
474
+ </button>
475
+ <button
476
+ type="button"
477
+ class="sv-grid-menu-item"
478
+ role="menuitem"
479
+ onclick={() => sortColumnFromMenu(menuColumnId, true)}
480
+ >
481
+ <span class="sv-grid-header-icon">{@render icon("sort-desc")}</span> Sort
482
+ descending
483
+ </button>
484
+ <button
485
+ type="button"
486
+ class="sv-grid-menu-item"
487
+ role="menuitem"
488
+ disabled={!sortDirectionByColumn[menuColumnId]}
489
+ onclick={() => clearColumnSort(menuColumnId)}
490
+ >
491
+ <span class="sv-grid-header-icon">{@render icon("x")}</span> Remove sort
492
+ </button>
493
+ {/if}
494
+ {#if !columnVirtualizationEnabled}
495
+ {@const menuPinSide = isColumnPinned(menuColumnId)}
496
+ <div class="sv-grid-menu-sep"></div>
497
+ <button
498
+ type="button"
499
+ class="sv-grid-menu-item"
500
+ role="menuitem"
501
+ disabled={menuPinSide === "left"}
502
+ onclick={() => pinColumnLeft(menuColumnId)}
503
+ >
504
+ <span class="sv-grid-header-icon"
505
+ >{@render icon("op-startsWith")}</span
506
+ > Pin to left
507
+ </button>
508
+ <button
509
+ type="button"
510
+ class="sv-grid-menu-item"
511
+ role="menuitem"
512
+ disabled={menuPinSide === "right"}
513
+ onclick={() => pinColumnRight(menuColumnId)}
514
+ >
515
+ <span class="sv-grid-header-icon"
516
+ >{@render icon("op-greaterThan")}</span
517
+ > Pin to right
518
+ </button>
519
+ <button
520
+ type="button"
521
+ class="sv-grid-menu-item"
522
+ role="menuitem"
523
+ disabled={!menuPinSide}
524
+ onclick={() => unpinColumn(menuColumnId)}
525
+ >
526
+ <span class="sv-grid-header-icon">{@render icon("x")}</span> Unpin column
527
+ </button>
528
+ {/if}
529
+ <div class="sv-grid-menu-sep"></div>
530
+ <button
531
+ type="button"
532
+ class="sv-grid-menu-item"
533
+ role="menuitem"
534
+ onclick={() => {
535
+ autosizeColumn(menuColumnId);
536
+ closeMenus();
537
+ }}
538
+ >
539
+ <span class="sv-grid-header-icon">{@render icon("autosize")}</span> Autosize
540
+ this column
541
+ </button>
542
+ <button
543
+ type="button"
544
+ class="sv-grid-menu-item"
545
+ role="menuitem"
546
+ onclick={() => {
547
+ autosizeAllColumns();
548
+ closeMenus();
549
+ }}
550
+ >
551
+ <span class="sv-grid-header-icon">{@render icon("autosize")}</span> Autosize
552
+ all columns
553
+ </button>
554
+ {#if groupingControlsEnabled}
555
+ <div class="sv-grid-menu-sep"></div>
556
+ <button
557
+ type="button"
558
+ class="sv-grid-menu-item"
559
+ role="menuitem"
560
+ disabled={groupingColumns.includes(menuColumnId)}
561
+ onclick={() => groupByColumnFromMenu(menuColumnId)}
562
+ >
563
+ <span class="sv-grid-header-icon">{@render icon("group")}</span> Group
564
+ by this column
565
+ </button>
566
+ <button
567
+ type="button"
568
+ class="sv-grid-menu-item"
569
+ role="menuitem"
570
+ disabled={!groupingColumns.includes(menuColumnId)}
571
+ onclick={() => clearGroupingFromMenu(menuColumnId)}
572
+ >
573
+ <span class="sv-grid-header-icon">{@render icon("x")}</span> Remove grouping
574
+ </button>
575
+ {/if}
576
+ <div class="sv-grid-menu-sep"></div>
577
+ {#if !columnMenuTabsEnabled}
578
+ <!-- Flat menu: reach the column chooser via a submenu (tabbed mode has
579
+ a Columns tab instead). -->
580
+ <button
581
+ type="button"
582
+ class="sv-grid-menu-item"
583
+ class:is-open={chooseColumnsPos !== null}
584
+ role="menuitem"
585
+ aria-haspopup="menu"
586
+ aria-expanded={chooseColumnsPos !== null}
587
+ onclick={(event) => openChooseColumns(event)}
588
+ >
589
+ <span class="sv-grid-header-icon">{@render icon("columns")}</span>
590
+ Choose columns
591
+ <span class="sv-grid-header-icon sv-grid-menu-item-chevron">{@render icon("chevron-down")}</span>
592
+ </button>
593
+ {/if}
594
+ <button
595
+ type="button"
596
+ class="sv-grid-menu-item"
597
+ role="menuitem"
598
+ onclick={() => {
599
+ resetColumns();
600
+ closeMenus();
601
+ }}
602
+ >
603
+ <span class="sv-grid-header-icon">{@render icon("reset")}</span> Reset columns
604
+ </button>
605
+ {/if}
606
+ </div>
607
+ {/if}
608
+
609
+ {#if filterMenuFor && showColumnFiltersEffective}
610
+ <div
611
+ class="sv-grid-menu sv-grid-filter-menu"
612
+ role="menu"
613
+ style={`left: ${filterMenuPos.x}px; top: ${filterMenuPos.y}px; max-height: calc(100vh - ${filterMenuPos.y}px - 12px);`}
614
+ >
615
+ {@render filterPanelBody(filterMenuFor)}
616
+ </div>
617
+ {/if}
618
+
619
+ {#if chooseColumnsPos}
620
+ {@const everyColumn = grid.getAllColumns()}
621
+ <div
622
+ class="sv-grid-menu sv-grid-choose-columns-menu"
623
+ role="menu"
624
+ style={`left: ${chooseColumnsPos.x}px; top: ${chooseColumnsPos.y}px;`}
625
+ >
626
+ <div class="sv-grid-menu-filter-head">Visible columns</div>
627
+ <SvListBox
628
+ block
629
+ checkbox
630
+ multiple
631
+ virtual={everyColumn.length > FACET_VIRTUAL_THRESHOLD}
632
+ rows={11}
633
+ rowHeight={24}
634
+ size="sm"
635
+ ariaLabel="Visible columns"
636
+ options={columnVisibilityOptions(everyColumn)}
637
+ value={visibleColumnIds(everyColumn)}
638
+ onChange={(next) => applyColumnVisibility(everyColumn, next as Set<string>)}
639
+ />
640
+ </div>
641
+ {/if}
642
+
643
+ {#if operatorMenuFor}
644
+ {@const opColumnId = operatorMenuFor}
645
+ {@const opColumn = allColumns.find((c) => c.id === opColumnId)}
646
+ {@const opOptions = operatorsForColumn(opColumn)}
647
+ {@const opActive =
648
+ filterMenuValues[opColumnId]?.operator ?? defaultOperatorFor(opColumn)}
649
+ <div
650
+ class="sv-grid-menu sv-grid-operator-menu"
651
+ role="menu"
652
+ style={`left: ${operatorMenuPos.x}px; top: ${operatorMenuPos.y}px;`}
653
+ >
654
+ {#each opOptions as option (option.value)}
655
+ <button
656
+ type="button"
657
+ class="sv-grid-menu-item"
658
+ role="menuitemradio"
659
+ aria-checked={opActive === option.value}
660
+ onclick={() => {
661
+ updateFilterOperator(opColumnId, option.value);
662
+ closeMenus();
663
+ }}
664
+ >
665
+ <span class="sv-grid-header-icon"
666
+ >{@render icon(option.iconName)}</span
667
+ >
668
+ {operatorLabelFor(option, opColumn)}
669
+ </button>
670
+ {/each}
671
+ </div>
672
+ {/if}
673
+
674
+ {#if inSuggestFor}
675
+ {@const suggestColId = inSuggestFor}
676
+ {@const selectedTokens = new Set<string | number>(
677
+ splitInTokens(filterRowValues[suggestColId] ?? ""),
678
+ )}
679
+ <!-- Value suggestions for an `in` / `notIn` input. The wrapper swallows
680
+ `mousedown` so clicking a row keeps focus in the input and the
681
+ dropdown stays open for multi-select; the input's own blur handler
682
+ closes it when focus truly leaves. The list still picks on `click`.
683
+ Tokens the query is currently hiding ride along in the set, so a
684
+ click never silently drops them. -->
685
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
686
+ <div
687
+ class="sv-grid-menu sv-grid-in-suggest"
688
+ style={`left: ${inSuggestPos.x}px; top: ${inSuggestPos.y}px;`}
689
+ onmousedown={(event) => event.preventDefault()}
690
+ >
691
+ {#if inSuggestOptions.length}
692
+ <SvListBox
693
+ block
694
+ checkbox
695
+ multiple
696
+ virtual={inSuggestOptions.length > FACET_VIRTUAL_THRESHOLD}
697
+ rows={8}
698
+ rowHeight={26}
699
+ size="sm"
700
+ ariaLabel="Filter values"
701
+ options={inSuggestOptions}
702
+ value={selectedTokens}
703
+ onChange={(next) => {
704
+ const picked = new Set(next as Set<string>);
705
+ // Boxes that hold the whole list ("AAPL, MS") leave a half-typed
706
+ // fragment in the token set. The pick REPLACES that fragment
707
+ // rather than landing beside it as a stray filter value. The chip
708
+ // input keeps its fragment out of `filterRowValues`, so this is a
709
+ // no-op there.
710
+ const fragment = trailingInToken(filterRowValues[suggestColId] ?? "");
711
+ if (fragment) picked.delete(fragment);
712
+ setFilterTokens(suggestColId, [...picked]);
713
+ ctrl.inSuggestQuery = "";
714
+ }}
715
+ />
716
+ {:else}
717
+ <div class="sv-grid-facet-empty">No values</div>
718
+ {/if}
719
+ </div>
720
+ {/if}
721
+
722
+ {#if contextMenuFor}
723
+ <div
724
+ class="sv-grid-menu-backdrop"
725
+ role="presentation"
726
+ oncontextmenu={(e) => { e.preventDefault(); closeContextMenu(); }}
727
+ onclick={closeContextMenu}
728
+ ></div>
729
+ <div
730
+ class="sv-grid-menu sv-grid-context-menu"
731
+ role="menu"
732
+ style={`left: ${contextMenuPos.x}px; top: ${contextMenuPos.y}px;`}
733
+ >
734
+ {#each contextMenuItems() as item (item.key)}
735
+ {#if item.separator}
736
+ <div class="sv-grid-menu-sep"></div>
737
+ {:else}
738
+ <button
739
+ type="button"
740
+ class="sv-grid-menu-item"
741
+ role="menuitem"
742
+ disabled={item.disabled}
743
+ onclick={() => { item.run?.(); closeContextMenu(); }}
744
+ >{item.label}</button>
745
+ {/if}
746
+ {/each}
747
+ </div>
748
+ {/if}
749
+
750
+ {#if commentEditFor}
751
+ <div
752
+ class="sv-grid-menu-backdrop"
753
+ role="presentation"
754
+ onclick={closeCommentEditor}
755
+ ></div>
756
+ <div
757
+ class="sv-grid-menu sv-grid-comment-editor"
758
+ role="dialog"
759
+ aria-label="Edit comment"
760
+ style={`left: ${commentEditFor.x}px; top: ${commentEditFor.y}px;`}
761
+ >
762
+ <!-- svelte-ignore a11y_autofocus -->
763
+ <textarea
764
+ class="sv-grid-comment-textarea"
765
+ rows="3"
766
+ placeholder="Add a comment…"
767
+ autofocus
768
+ bind:value={ctrl.commentDraft}
769
+ onkeydown={(e) => {
770
+ e.stopPropagation();
771
+ if (e.key === "Escape") { e.preventDefault(); closeCommentEditor(); }
772
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveComment(); }
773
+ }}
774
+ ></textarea>
775
+ <div class="sv-grid-comment-actions">
776
+ <button type="button" class="sv-grid-comment-remove" onclick={removeComment}>Remove</button>
777
+ <span class="sv-grid-comment-spacer"></span>
778
+ <button type="button" class="sv-grid-comment-cancel" onclick={closeCommentEditor}>Cancel</button>
779
+ <button type="button" class="sv-grid-comment-save" onclick={saveComment}>Save</button>
780
+ </div>
781
+ </div>
782
+ {/if}