@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,696 +1,696 @@
1
- <script lang="ts">
2
- /**
3
- * Internal dropdown / listbox control used by the `list` and `chips` cell
4
- * editors. We deliberately don't reuse the browser-native `<select>` -
5
- * the native dropdown can't be styled to match the grid theme, picks up
6
- * the OS color scheme, and on Windows draws the option list with an
7
- * opaque white system surface that fights the dark theme.
8
- *
9
- * Modes:
10
- * single - picking an option fires `onChange(value)` and `onCommit()`.
11
- * multi - toggling fires `onChange(values: array)`; the user
12
- * presses Enter / clicks "Done" to fire `onCommit()`.
13
- *
14
- * Keyboard model:
15
- * ArrowDown / ArrowUp - move highlight (opens the panel if closed).
16
- * Enter - single: toggle highlighted + commit.
17
- * multi: toggle highlighted (stay open).
18
- * Escape - close + onCancel.
19
- * Tab - commit and let focus escape.
20
- */
21
- import type { CellEditorOption } from './editors/cell-editors'
22
- import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
23
-
24
- type Props = {
25
- options: ReadonlyArray<CellEditorOption>
26
- value: unknown
27
- multiple?: boolean
28
- placeholder?: string
29
- /** When provided, the trigger renders as removable chips of the current value(s). */
30
- renderChipsInTrigger?: boolean
31
- /** Add a typeahead input at the top of the popover that filters the
32
- * option list as the user types. Used by the rich-select editor. */
33
- searchable?: boolean
34
- /** Focus the trigger and open the panel on mount. True for in-cell editing
35
- * (you clicked into the cell to edit); set false for form fields, which
36
- * should stay closed and unfocused until the user acts. Default true. */
37
- autoOpen?: boolean
38
- /** Called with the new full value (scalar for single, array for multi). */
39
- onChange?: (next: unknown) => void
40
- /** Called when the user finalizes the selection (single pick, Enter, blur with selection). */
41
- onCommit?: () => void
42
- /** Called when the user dismisses (Escape, blur with no change). */
43
- onCancel?: () => void
44
- }
45
-
46
- let {
47
- options,
48
- value,
49
- multiple = false,
50
- placeholder = 'Select…',
51
- renderChipsInTrigger = false,
52
- searchable = false,
53
- autoOpen = true,
54
- onChange,
55
- onCommit,
56
- onCancel,
57
- }: Props = $props()
58
-
59
- /** Stable per-instance id used to build option ids for
60
- * `aria-activedescendant` - screen readers need a real id to announce
61
- * the highlighted option while arrow-keying through the list. */
62
- const instanceId = $props.id()
63
- function optionId(index: number): string | undefined {
64
- return index >= 0 && index < visibleOptions.length ? `${instanceId}-opt-${index}` : undefined
65
- }
66
-
67
- // Options that carry a `color` render as full pills (see the option list
68
- // below), which are taller than a plain text row. The panel-sizing math must
69
- // reserve that height, otherwise the panel is capped to the plain-row estimate
70
- // and clips the last option (e.g. a 3-item coloured list). ~40px per chip row
71
- // vs ~32px for plain text.
72
- const optionRowHeight = $derived(options.some((o) => o.color) ? 40 : 32)
73
-
74
- let searchQuery = $state('')
75
- const visibleOptions = $derived(
76
- !searchable || !searchQuery.trim()
77
- ? options
78
- : (() => {
79
- const q = searchQuery.trim().toLowerCase()
80
- return options.filter((o) => o.label.toLowerCase().includes(q))
81
- })(),
82
- )
83
-
84
- let open = $state(false)
85
- let highlighted = $state(-1)
86
- let rootEl: HTMLDivElement | null = $state(null)
87
- let triggerEl: HTMLButtonElement | null = $state(null)
88
- let panelEl: HTMLDivElement | null = $state(null)
89
-
90
- /** Viewport-anchored panel position. We `position: fixed` the panel so it
91
- * escapes the grid's overflow:hidden scroll container - otherwise the
92
- * bottom of the list gets clipped by the pager or the grid's footer. */
93
- let panelRect = $state<AnchoredRect>({
94
- top: 0,
95
- left: 0,
96
- width: 0,
97
- openUpward: false,
98
- maxHeight: 0,
99
- availHeight: 0,
100
- })
101
-
102
- const selectedArr = $derived(
103
- Array.isArray(value)
104
- ? (value as Array<string | number>)
105
- : value == null || value === ''
106
- ? []
107
- : [value as string | number],
108
- )
109
-
110
- const triggerSummary = $derived.by(() => {
111
- if (selectedArr.length === 0) return placeholder
112
- if (!multiple) {
113
- const v = selectedArr[0]!
114
- const match = options.find((o) => String(o.value) === String(v))
115
- return match ? match.label : String(v)
116
- }
117
- return `${selectedArr.length} selected`
118
- })
119
-
120
- /** Color of the single selected option, if any (for colored trigger). */
121
- const selectedColor = $derived.by(() => {
122
- if (multiple || selectedArr.length !== 1) return undefined
123
- const v = selectedArr[0]!
124
- return options.find((o) => String(o.value) === String(v))?.color
125
- })
126
-
127
- function isSelected(opt: CellEditorOption): boolean {
128
- return selectedArr.some((v) => String(v) === String(opt.value))
129
- }
130
-
131
- function toggleOption(opt: CellEditorOption) {
132
- if (multiple) {
133
- const exists = isSelected(opt)
134
- const next = exists
135
- ? selectedArr.filter((v) => String(v) !== String(opt.value))
136
- : [...selectedArr, opt.value]
137
- onChange?.(next)
138
- } else {
139
- onChange?.(opt.value)
140
- open = false
141
- onCommit?.()
142
- }
143
- }
144
-
145
- function openPanel() {
146
- if (open) return
147
- open = true
148
- // Seed the highlight to the first selected option, or top of the list.
149
- if (highlighted < 0) {
150
- const idx = options.findIndex((o) => isSelected(o))
151
- highlighted = idx >= 0 ? idx : 0
152
- }
153
- updatePanelPosition()
154
- }
155
-
156
- /** Recompute the panel's fixed-position rect from the trigger. Auto-
157
- * flips upward when there isn't enough room below the trigger. */
158
- function updatePanelPosition() {
159
- if (!triggerEl) return
160
- // Use the smaller of (option count, 10) for the upward-flip math so
161
- // a 3-option list doesn't think it needs 320px of headroom.
162
- const visibleCount = Math.min(options.length, 10)
163
- const estimatedHeight = visibleCount * optionRowHeight + (multiple ? 40 : 0) + 8
164
- panelRect = anchoredRect(triggerEl.getBoundingClientRect(), {
165
- estimatedHeight,
166
- // Preserve the original behavior: anchor to the trigger, no clamping.
167
- clampHorizontal: false,
168
- })
169
- }
170
-
171
- // Keep the panel anchored as the page scrolls or resizes - without this,
172
- // scrolling the underlying grid would leave the panel hanging in space.
173
- $effect(() => {
174
- if (!open) return
175
- const reposition = () => updatePanelPosition()
176
- window.addEventListener('scroll', reposition, true)
177
- window.addEventListener('resize', reposition)
178
- return () => {
179
- window.removeEventListener('scroll', reposition, true)
180
- window.removeEventListener('resize', reposition)
181
- }
182
- })
183
-
184
- function closePanel() {
185
- open = false
186
- highlighted = -1
187
- }
188
-
189
- function onKeyDown(event: KeyboardEvent) {
190
- event.stopPropagation()
191
- if (event.key === 'Escape') {
192
- event.preventDefault()
193
- closePanel()
194
- onCancel?.()
195
- return
196
- }
197
- if (event.key === 'ArrowDown') {
198
- event.preventDefault()
199
- if (!open) {
200
- openPanel()
201
- return
202
- }
203
- highlighted = Math.min(highlighted + 1, options.length - 1)
204
- scrollHighlightedIntoView()
205
- } else if (event.key === 'ArrowUp') {
206
- event.preventDefault()
207
- if (!open) {
208
- openPanel()
209
- return
210
- }
211
- highlighted = Math.max(highlighted - 1, 0)
212
- scrollHighlightedIntoView()
213
- } else if (event.key === 'Home') {
214
- if (!open) return
215
- event.preventDefault()
216
- highlighted = 0
217
- scrollHighlightedIntoView()
218
- } else if (event.key === 'End') {
219
- if (!open) return
220
- event.preventDefault()
221
- highlighted = options.length - 1
222
- scrollHighlightedIntoView()
223
- } else if (event.key === 'Enter') {
224
- event.preventDefault()
225
- if (!open) {
226
- openPanel()
227
- return
228
- }
229
- const opt = options[highlighted]
230
- if (opt) toggleOption(opt)
231
- else if (multiple) {
232
- closePanel()
233
- onCommit?.()
234
- }
235
- } else if (event.key === ' ' && open) {
236
- event.preventDefault()
237
- const opt = options[highlighted]
238
- if (opt) toggleOption(opt)
239
- } else if (event.key === 'Tab') {
240
- if (open) closePanel()
241
- onCommit?.()
242
- }
243
- }
244
-
245
- function scrollHighlightedIntoView() {
246
- queueMicrotask(() => {
247
- if (!panelEl) return
248
- const el = panelEl.querySelector<HTMLElement>(`[data-opt-idx="${highlighted}"]`)
249
- el?.scrollIntoView({ block: 'nearest' })
250
- })
251
- }
252
-
253
- function onRootBlur(event: FocusEvent) {
254
- // Defer to the next tick so a click on an option (mousedown → option
255
- // commits, then blur) doesn't race-cancel the selection.
256
- const next = event.relatedTarget as Node | null
257
- if (next && rootEl?.contains(next)) return
258
- if (open) closePanel()
259
- onCommit?.()
260
- }
261
-
262
- function removeChip(v: string | number, event: MouseEvent) {
263
- event.preventDefault()
264
- event.stopPropagation()
265
- const next = selectedArr.filter((x) => String(x) !== String(v))
266
- onChange?.(multiple ? next : next[0] ?? null)
267
- }
268
-
269
- function focusTrigger(node: HTMLButtonElement) {
270
- // Form fields (autoOpen=false) stay closed + unfocused until the user acts;
271
- // in-cell editing auto-focuses + opens to match clicking into a cell.
272
- if (!autoOpen) return
273
- node.focus()
274
- openPanel()
275
- }
276
-
277
- /** Close the panel when the user clicks outside both trigger AND
278
- * panel - `onfocusout` on the dropdown root no longer catches this
279
- * because the portal'd panel is a sibling of body, not a descendant
280
- * of rootEl. Wired up only while the panel is open. */
281
- $effect(() => {
282
- if (!open) return
283
- const onDocPointerDown = (event: PointerEvent) => {
284
- const target = event.target as Node | null
285
- if (!target) return
286
- if (rootEl?.contains(target)) return
287
- if (panelEl?.contains(target)) return
288
- closePanel()
289
- onCommit?.()
290
- }
291
- document.addEventListener('pointerdown', onDocPointerDown, true)
292
- return () => document.removeEventListener('pointerdown', onDocPointerDown, true)
293
- })
294
-
295
- /** Inline style for a chip with a configured `color`. Mirrors the
296
- * helper in SvGrid.svelte so colorful chips look identical in the
297
- * trigger and the in-cell readonly render. */
298
- function colorfulChipStyleInline(color: string): string {
299
- return (
300
- `background: color-mix(in srgb, ${color} 22%, transparent);` +
301
- `border-color: color-mix(in srgb, ${color} 45%, transparent);` +
302
- `color: color-mix(in srgb, ${color} 80%, var(--sg-fg, #0f172a));`
303
- )
304
- }
305
- </script>
306
-
307
- <!-- All pointer/click events are swallowed at the root so they never reach
308
- the surrounding cell's selection / pointerdown handlers - picking a
309
- dropdown option must not also toggle cell selection. -->
310
- <div
311
- class="sv-grid-dropdown"
312
- class:sv-grid-dropdown-open={open}
313
- bind:this={rootEl}
314
- onfocusout={onRootBlur}
315
- onclick={(event) => event.stopPropagation()}
316
- ondblclick={(event) => event.stopPropagation()}
317
- onpointerdown={(event) => event.stopPropagation()}
318
- onmousedown={(event) => event.stopPropagation()}
319
- >
320
- <!-- The implicit "button" role isn't in ARIA's supported-roles list for
321
- aria-activedescendant, but focus deliberately stays on the trigger
322
- (not the portal'd listbox) so arrow-key highlighting has to be
323
- announced from here - without it, screen-reader users get no
324
- feedback about which option is highlighted while navigating. -->
325
- <!-- svelte-ignore a11y_role_supports_aria_props_implicit -->
326
- <button
327
- type="button"
328
- class="sv-grid-dropdown-trigger"
329
- class:sv-grid-dropdown-trigger-chips={renderChipsInTrigger && selectedArr.length > 0}
330
- aria-haspopup="listbox"
331
- aria-expanded={open}
332
- aria-activedescendant={open ? optionId(highlighted) : undefined}
333
- bind:this={triggerEl}
334
- use:focusTrigger
335
- onclick={() => (open ? closePanel() : openPanel())}
336
- onkeydown={onKeyDown}
337
- >
338
- {#if renderChipsInTrigger && selectedArr.length > 0}
339
- <span class="sv-grid-dropdown-chips">
340
- {#each selectedArr as v (String(v))}
341
- {@const opt = options.find((o) => String(o.value) === String(v))}
342
- <span
343
- class="sv-grid-chip sv-grid-chip-removable"
344
- style={opt?.color ? colorfulChipStyleInline(opt.color) : ''}
345
- >
346
- {opt ? opt.label : String(v)}
347
- <!-- A <button> can't be nested inside the trigger <button>; this is a
348
- pointer-only affordance (tabindex -1), so a role="button" span is
349
- valid HTML and preserves identical behaviour. -->
350
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
351
- <span
352
- class="sv-grid-chip-remove"
353
- role="button"
354
- aria-label="Remove {opt ? opt.label : String(v)}"
355
- tabindex={-1}
356
- onmousedown={(event) => event.preventDefault()}
357
- onclick={(event) => removeChip(v, event)}
358
- >×</span>
359
- </span>
360
- {/each}
361
- </span>
362
- {:else if !multiple && selectedArr.length === 1 && selectedColor}
363
- <!-- Single-select list whose chosen option carries a color → show it
364
- as a colored chip inside the trigger, matching how the cell
365
- renders when not in edit mode. -->
366
- <span class="sv-grid-chip" style={colorfulChipStyleInline(selectedColor)}>
367
- {triggerSummary}
368
- </span>
369
- {:else}
370
- <span class="sv-grid-dropdown-label">{triggerSummary}</span>
371
- {/if}
372
- <span class="sv-grid-dropdown-caret" aria-hidden="true">▾</span>
373
- </button>
374
-
375
- {#if open}
376
- <!-- Sizing rule:
377
- - ≤ 10 options → no max-height. The panel grows to its content,
378
- overflow:auto stays inert, and no scrollbar is ever drawn.
379
- This matters because measuring "10 × 32px" against the real
380
- rendered option height (which depends on font + line-height)
381
- rounds inconsistently across themes and used to produce a
382
- 1-pixel scrollbar on lists as short as 3 items.
383
- - > 10 options → cap at 10 items + Done-footer chrome so the
384
- panel never overflows the viewport, and the scrollbar appears.
385
- Panel is `position: fixed` + portal'd to <body> so it escapes
386
- the grid's overflow:hidden scroll container. -->
387
- {@const needsScroll = options.length > 10}
388
- <!-- Cap to the comfortable 10-row height AND the room the viewport allows,
389
- so a panel near the bottom edge scrolls instead of overflowing. -->
390
- {@const panelMax = Math.min(
391
- needsScroll ? 10 * optionRowHeight + (multiple ? 40 : 0) + 8 : Number.POSITIVE_INFINITY,
392
- panelRect.maxHeight,
393
- )}
394
- <div
395
- class="sv-grid-dropdown-panel"
396
- class:sv-grid-dropdown-panel-fits={!needsScroll}
397
- role="listbox"
398
- aria-multiselectable={multiple}
399
- bind:this={panelEl}
400
- use:portalToBody
401
- use:popIn={{ up: panelRect.openUpward }}
402
- style:position="fixed"
403
- style:top={`${panelRect.top}px`}
404
- style:left={`${panelRect.left}px`}
405
- style:width={`${panelRect.width}px`}
406
- style:max-height={panelMax !== null ? `${panelMax}px` : null}
407
- style:z-index="2147483647"
408
- onpointerdown={(event) => event.stopPropagation()}
409
- onmousedown={(event) => event.stopPropagation()}
410
- onclick={(event) => event.stopPropagation()}
411
- >
412
- {#if searchable}
413
- <input
414
- type="search"
415
- class="sv-grid-dropdown-search"
416
- placeholder="Search…"
417
- bind:value={searchQuery}
418
- onmousedown={(event) => event.stopPropagation()}
419
- onclick={(event) => event.stopPropagation()}
420
- onkeydown={(event) => {
421
- if (event.key === 'Escape') { event.preventDefault(); open = false; onCancel?.() }
422
- }}
423
- />
424
- {/if}
425
- {#if visibleOptions.length === 0}
426
- <div class="sv-grid-dropdown-empty">{searchable && searchQuery ? `No matches for "${searchQuery}"` : 'No options'}</div>
427
- {:else}
428
- {#each visibleOptions as opt, i (String(opt.value))}
429
- {@const selected = isSelected(opt)}
430
- <div
431
- class="sv-grid-dropdown-option"
432
- class:sv-grid-dropdown-option-selected={selected}
433
- class:sv-grid-dropdown-option-highlighted={i === highlighted}
434
- role="option"
435
- id={optionId(i)}
436
- aria-selected={selected}
437
- data-opt-idx={i}
438
- onmousedown={(event) => {
439
- event.preventDefault()
440
- toggleOption(opt)
441
- }}
442
- onmouseenter={() => (highlighted = i)}
443
- >
444
- {#if multiple}
445
- <span class="sv-grid-dropdown-check" aria-hidden="true">
446
- {selected ? '✓' : ''}
447
- </span>
448
- {/if}
449
- {#if opt.color}
450
- <!-- Colored options render as a full chip (pill) so the
451
- dropdown choices match how the value looks in the cell. -->
452
- <span class="sv-grid-dropdown-option-chip" style={colorfulChipStyleInline(opt.color)}>{opt.label}</span>
453
- {:else}
454
- <span class="sv-grid-dropdown-option-label">{opt.label}</span>
455
- {/if}
456
- </div>
457
- {/each}
458
- {/if}
459
-
460
- {#if multiple}
461
- <div class="sv-grid-dropdown-footer">
462
- <button
463
- type="button"
464
- class="sv-grid-dropdown-done"
465
- onmousedown={(event) => event.preventDefault()}
466
- onclick={() => {
467
- closePanel()
468
- onCommit?.()
469
- }}
470
- >Done</button>
471
- </div>
472
- {/if}
473
- </div>
474
- {/if}
475
- </div>
476
-
477
- <style>
478
- /* All colors derive from the grid's host CSS vars so light/dark themes
479
- "just work". Hard fallbacks are kept so the component is still
480
- usable in an unthemed page. */
481
- .sv-grid-dropdown {
482
- position: absolute;
483
- top: 0;
484
- left: 0;
485
- right: 0;
486
- height: 100%;
487
- background: var(--sg-bg, #ffffff);
488
- color: var(--sg-fg, #0f172a);
489
- }
490
-
491
- .sv-grid-dropdown-trigger {
492
- display: flex;
493
- align-items: center;
494
- gap: 6px;
495
- width: 100%;
496
- height: 100%;
497
- min-height: 28px;
498
- padding: 0 8px;
499
- background: var(--sg-bg, #ffffff);
500
- color: var(--sg-fg, #0f172a);
501
- border: 0;
502
- outline: none;
503
- font: inherit;
504
- text-align: start;
505
- cursor: pointer;
506
- box-sizing: border-box;
507
- }
508
- .sv-grid-dropdown-trigger:focus {
509
- outline: 2px solid var(--sg-accent, #2563eb);
510
- outline-offset: -2px;
511
- }
512
-
513
- .sv-grid-dropdown-trigger-chips {
514
- align-items: center;
515
- padding: 4px 8px;
516
- flex-wrap: wrap;
517
- }
518
- .sv-grid-dropdown-chips {
519
- display: inline-flex;
520
- flex-wrap: wrap;
521
- gap: 4px;
522
- flex: 1 1 auto;
523
- min-width: 0;
524
- align-items: center;
525
- }
526
-
527
- .sv-grid-dropdown-label {
528
- flex: 1 1 auto;
529
- min-width: 0;
530
- overflow: hidden;
531
- text-overflow: ellipsis;
532
- white-space: nowrap;
533
- }
534
- .sv-grid-dropdown-caret {
535
- flex: 0 0 auto;
536
- font-size: 10px;
537
- opacity: 0.7;
538
- line-height: 1;
539
- }
540
-
541
- /* Popover sits absolutely below the trigger. The parent
542
- `.sv-grid-cell-editing` has `overflow: visible`, so this can hang
543
- past the row. Pulls its surface color from the grid's header bg
544
- so it reads as a slightly elevated layer on either theme. */
545
- /* Panel is `position: fixed` AND portal'd into document.body via
546
- `use:portalToBody`, so it escapes every ancestor overflow / clip /
547
- transform / stacking context. Coords come from updatePanelPosition(). */
548
- :global(.sv-grid-dropdown-panel) {
549
- overflow-y: auto;
550
- background: var(--sg-header-bg, var(--sg-bg, #ffffff));
551
- color: var(--sg-fg, #0f172a);
552
- border: 1px solid var(--sg-accent, #2563eb);
553
- border-radius: 6px;
554
- box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
555
- padding: 4px 0;
556
- font-family: inherit;
557
- }
558
- /* When the option list fits in one panel we want NO scrollbar at all -
559
- * not even a sub-pixel ghost track. `overflow: visible` rules out the
560
- * scrollbar gutter that some browsers still reserve under
561
- * `overflow: auto` when content equals max-height. */
562
- :global(.sv-grid-dropdown-panel.sv-grid-dropdown-panel-fits) {
563
- overflow: visible;
564
- }
565
- :global(.sv-grid-dropdown-option) {
566
- display: flex;
567
- align-items: center;
568
- gap: 8px;
569
- padding: 6px 10px;
570
- cursor: pointer;
571
- user-select: none;
572
- color: var(--sg-fg, #0f172a);
573
- }
574
- :global(.sv-grid-dropdown-option-highlighted) {
575
- background: var(--sg-row-hover-bg, rgba(37, 99, 235, 0.1));
576
- }
577
- :global(.sv-grid-dropdown-option-selected) {
578
- font-weight: 600;
579
- background: var(--sg-selection-bg, rgba(37, 99, 235, 0.15));
580
- }
581
- :global(.sv-grid-dropdown-option-selected.sv-grid-dropdown-option-highlighted) {
582
- background: var(--sg-row-hover-bg, rgba(37, 99, 235, 0.18));
583
- }
584
- :global(.sv-grid-dropdown-option-label) {
585
- flex: 1 1 auto;
586
- min-width: 0;
587
- overflow: hidden;
588
- text-overflow: ellipsis;
589
- white-space: nowrap;
590
- }
591
- :global(.sv-grid-dropdown-check) {
592
- flex: 0 0 16px;
593
- display: inline-flex;
594
- align-items: center;
595
- justify-content: center;
596
- color: var(--sg-accent, #2563eb);
597
- font-weight: 700;
598
- }
599
- :global(.sv-grid-dropdown-swatch) {
600
- flex: 0 0 12px;
601
- width: 12px;
602
- height: 12px;
603
- border-radius: 999px;
604
- border: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 25%, transparent);
605
- }
606
- /* Colored option chip. Declared `:global` because the option panel is
607
- portal'd to <body>, where Svelte's scoped classes don't reach. The
608
- per-chip background / border / text color come from an inline style
609
- (see colorfulChipStyleInline) so each option matches its cell chip. */
610
- :global(.sv-grid-dropdown-option-chip) {
611
- display: inline-flex;
612
- align-items: center;
613
- max-width: 100%;
614
- padding: 2px 11px;
615
- border: 1px solid transparent;
616
- border-radius: 999px;
617
- font-size: 0.9em;
618
- font-weight: 600;
619
- line-height: 1.5;
620
- overflow: hidden;
621
- text-overflow: ellipsis;
622
- white-space: nowrap;
623
- }
624
- :global(.sv-grid-dropdown-empty) {
625
- padding: 8px 10px;
626
- color: var(--sg-muted, rgba(15, 23, 42, 0.55));
627
- font-style: italic;
628
- font-size: 0.9em;
629
- }
630
- :global(.sv-grid-dropdown-footer) {
631
- display: flex;
632
- justify-content: flex-end;
633
- padding: 4px 6px;
634
- border-top: 1px solid var(--sg-border, rgba(15, 23, 42, 0.08));
635
- margin-top: 2px;
636
- }
637
- :global(.sv-grid-dropdown-done) {
638
- font-size: 11px;
639
- text-transform: uppercase;
640
- letter-spacing: 0.04em;
641
- color: #fff;
642
- background: var(--sg-accent, #2563eb);
643
- border: 0;
644
- border-radius: 4px;
645
- padding: 4px 10px;
646
- cursor: pointer;
647
- }
648
- :global(.sv-grid-dropdown-done:hover) {
649
- filter: brightness(1.12);
650
- }
651
-
652
- /* Note: option / footer / swatch styles are declared `:global(...)`
653
- above because the panel is portal'd to <body> and Svelte's scoped
654
- class won't survive on children of an element living outside the
655
- component subtree. */
656
-
657
- /* Theme-aware chip badges. Inside the trigger they sit on the cell
658
- background, so we need a tone that contrasts on BOTH themes - a
659
- translucent accent works well in dark mode and reads as a soft
660
- blue pill in light mode. */
661
- .sv-grid-chip {
662
- display: inline-flex;
663
- align-items: center;
664
- gap: 4px;
665
- padding: 2px 8px;
666
- font-size: 0.85em;
667
- line-height: 1.4;
668
- background: color-mix(in srgb, var(--sg-accent, #2563eb) 18%, transparent);
669
- color: var(--sg-fg, #0f172a);
670
- border-radius: 999px;
671
- border: 1px solid color-mix(in srgb, var(--sg-accent, #2563eb) 35%, transparent);
672
- white-space: nowrap;
673
- }
674
- .sv-grid-chip-removable {
675
- padding-inline-end: 2px;
676
- }
677
- .sv-grid-chip-remove {
678
- display: inline-flex;
679
- align-items: center;
680
- justify-content: center;
681
- width: 16px;
682
- height: 16px;
683
- border: 0;
684
- border-radius: 999px;
685
- background: color-mix(in srgb, var(--sg-fg, #0f172a) 18%, transparent);
686
- color: inherit;
687
- font-size: 12px;
688
- line-height: 1;
689
- cursor: pointer;
690
- padding: 0;
691
- }
692
- .sv-grid-chip-remove:hover {
693
- background: rgba(220, 38, 38, 0.7);
694
- color: #fff;
695
- }
696
- </style>
1
+ <script lang="ts">
2
+ /**
3
+ * Internal dropdown / listbox control used by the `list` and `chips` cell
4
+ * editors. We deliberately don't reuse the browser-native `<select>` -
5
+ * the native dropdown can't be styled to match the grid theme, picks up
6
+ * the OS color scheme, and on Windows draws the option list with an
7
+ * opaque white system surface that fights the dark theme.
8
+ *
9
+ * Modes:
10
+ * single - picking an option fires `onChange(value)` and `onCommit()`.
11
+ * multi - toggling fires `onChange(values: array)`; the user
12
+ * presses Enter / clicks "Done" to fire `onCommit()`.
13
+ *
14
+ * Keyboard model:
15
+ * ArrowDown / ArrowUp - move highlight (opens the panel if closed).
16
+ * Enter - single: toggle highlighted + commit.
17
+ * multi: toggle highlighted (stay open).
18
+ * Escape - close + onCancel.
19
+ * Tab - commit and let focus escape.
20
+ */
21
+ import type { CellEditorOption } from './editors/cell-editors'
22
+ import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
23
+
24
+ type Props = {
25
+ options: ReadonlyArray<CellEditorOption>
26
+ value: unknown
27
+ multiple?: boolean
28
+ placeholder?: string
29
+ /** When provided, the trigger renders as removable chips of the current value(s). */
30
+ renderChipsInTrigger?: boolean
31
+ /** Add a typeahead input at the top of the popover that filters the
32
+ * option list as the user types. Used by the rich-select editor. */
33
+ searchable?: boolean
34
+ /** Focus the trigger and open the panel on mount. True for in-cell editing
35
+ * (you clicked into the cell to edit); set false for form fields, which
36
+ * should stay closed and unfocused until the user acts. Default true. */
37
+ autoOpen?: boolean
38
+ /** Called with the new full value (scalar for single, array for multi). */
39
+ onChange?: (next: unknown) => void
40
+ /** Called when the user finalizes the selection (single pick, Enter, blur with selection). */
41
+ onCommit?: () => void
42
+ /** Called when the user dismisses (Escape, blur with no change). */
43
+ onCancel?: () => void
44
+ }
45
+
46
+ let {
47
+ options,
48
+ value,
49
+ multiple = false,
50
+ placeholder = 'Select…',
51
+ renderChipsInTrigger = false,
52
+ searchable = false,
53
+ autoOpen = true,
54
+ onChange,
55
+ onCommit,
56
+ onCancel,
57
+ }: Props = $props()
58
+
59
+ /** Stable per-instance id used to build option ids for
60
+ * `aria-activedescendant` - screen readers need a real id to announce
61
+ * the highlighted option while arrow-keying through the list. */
62
+ const instanceId = $props.id()
63
+ function optionId(index: number): string | undefined {
64
+ return index >= 0 && index < visibleOptions.length ? `${instanceId}-opt-${index}` : undefined
65
+ }
66
+
67
+ // Options that carry a `color` render as full pills (see the option list
68
+ // below), which are taller than a plain text row. The panel-sizing math must
69
+ // reserve that height, otherwise the panel is capped to the plain-row estimate
70
+ // and clips the last option (e.g. a 3-item coloured list). ~40px per chip row
71
+ // vs ~32px for plain text.
72
+ const optionRowHeight = $derived(options.some((o) => o.color) ? 40 : 32)
73
+
74
+ let searchQuery = $state('')
75
+ const visibleOptions = $derived(
76
+ !searchable || !searchQuery.trim()
77
+ ? options
78
+ : (() => {
79
+ const q = searchQuery.trim().toLowerCase()
80
+ return options.filter((o) => o.label.toLowerCase().includes(q))
81
+ })(),
82
+ )
83
+
84
+ let open = $state(false)
85
+ let highlighted = $state(-1)
86
+ let rootEl: HTMLDivElement | null = $state(null)
87
+ let triggerEl: HTMLButtonElement | null = $state(null)
88
+ let panelEl: HTMLDivElement | null = $state(null)
89
+
90
+ /** Viewport-anchored panel position. We `position: fixed` the panel so it
91
+ * escapes the grid's overflow:hidden scroll container - otherwise the
92
+ * bottom of the list gets clipped by the pager or the grid's footer. */
93
+ let panelRect = $state<AnchoredRect>({
94
+ top: 0,
95
+ left: 0,
96
+ width: 0,
97
+ openUpward: false,
98
+ maxHeight: 0,
99
+ availHeight: 0,
100
+ })
101
+
102
+ const selectedArr = $derived(
103
+ Array.isArray(value)
104
+ ? (value as Array<string | number>)
105
+ : value == null || value === ''
106
+ ? []
107
+ : [value as string | number],
108
+ )
109
+
110
+ const triggerSummary = $derived.by(() => {
111
+ if (selectedArr.length === 0) return placeholder
112
+ if (!multiple) {
113
+ const v = selectedArr[0]!
114
+ const match = options.find((o) => String(o.value) === String(v))
115
+ return match ? match.label : String(v)
116
+ }
117
+ return `${selectedArr.length} selected`
118
+ })
119
+
120
+ /** Color of the single selected option, if any (for colored trigger). */
121
+ const selectedColor = $derived.by(() => {
122
+ if (multiple || selectedArr.length !== 1) return undefined
123
+ const v = selectedArr[0]!
124
+ return options.find((o) => String(o.value) === String(v))?.color
125
+ })
126
+
127
+ function isSelected(opt: CellEditorOption): boolean {
128
+ return selectedArr.some((v) => String(v) === String(opt.value))
129
+ }
130
+
131
+ function toggleOption(opt: CellEditorOption) {
132
+ if (multiple) {
133
+ const exists = isSelected(opt)
134
+ const next = exists
135
+ ? selectedArr.filter((v) => String(v) !== String(opt.value))
136
+ : [...selectedArr, opt.value]
137
+ onChange?.(next)
138
+ } else {
139
+ onChange?.(opt.value)
140
+ open = false
141
+ onCommit?.()
142
+ }
143
+ }
144
+
145
+ function openPanel() {
146
+ if (open) return
147
+ open = true
148
+ // Seed the highlight to the first selected option, or top of the list.
149
+ if (highlighted < 0) {
150
+ const idx = options.findIndex((o) => isSelected(o))
151
+ highlighted = idx >= 0 ? idx : 0
152
+ }
153
+ updatePanelPosition()
154
+ }
155
+
156
+ /** Recompute the panel's fixed-position rect from the trigger. Auto-
157
+ * flips upward when there isn't enough room below the trigger. */
158
+ function updatePanelPosition() {
159
+ if (!triggerEl) return
160
+ // Use the smaller of (option count, 10) for the upward-flip math so
161
+ // a 3-option list doesn't think it needs 320px of headroom.
162
+ const visibleCount = Math.min(options.length, 10)
163
+ const estimatedHeight = visibleCount * optionRowHeight + (multiple ? 40 : 0) + 8
164
+ panelRect = anchoredRect(triggerEl.getBoundingClientRect(), {
165
+ estimatedHeight,
166
+ // Preserve the original behavior: anchor to the trigger, no clamping.
167
+ clampHorizontal: false,
168
+ })
169
+ }
170
+
171
+ // Keep the panel anchored as the page scrolls or resizes - without this,
172
+ // scrolling the underlying grid would leave the panel hanging in space.
173
+ $effect(() => {
174
+ if (!open) return
175
+ const reposition = () => updatePanelPosition()
176
+ window.addEventListener('scroll', reposition, true)
177
+ window.addEventListener('resize', reposition)
178
+ return () => {
179
+ window.removeEventListener('scroll', reposition, true)
180
+ window.removeEventListener('resize', reposition)
181
+ }
182
+ })
183
+
184
+ function closePanel() {
185
+ open = false
186
+ highlighted = -1
187
+ }
188
+
189
+ function onKeyDown(event: KeyboardEvent) {
190
+ event.stopPropagation()
191
+ if (event.key === 'Escape') {
192
+ event.preventDefault()
193
+ closePanel()
194
+ onCancel?.()
195
+ return
196
+ }
197
+ if (event.key === 'ArrowDown') {
198
+ event.preventDefault()
199
+ if (!open) {
200
+ openPanel()
201
+ return
202
+ }
203
+ highlighted = Math.min(highlighted + 1, options.length - 1)
204
+ scrollHighlightedIntoView()
205
+ } else if (event.key === 'ArrowUp') {
206
+ event.preventDefault()
207
+ if (!open) {
208
+ openPanel()
209
+ return
210
+ }
211
+ highlighted = Math.max(highlighted - 1, 0)
212
+ scrollHighlightedIntoView()
213
+ } else if (event.key === 'Home') {
214
+ if (!open) return
215
+ event.preventDefault()
216
+ highlighted = 0
217
+ scrollHighlightedIntoView()
218
+ } else if (event.key === 'End') {
219
+ if (!open) return
220
+ event.preventDefault()
221
+ highlighted = options.length - 1
222
+ scrollHighlightedIntoView()
223
+ } else if (event.key === 'Enter') {
224
+ event.preventDefault()
225
+ if (!open) {
226
+ openPanel()
227
+ return
228
+ }
229
+ const opt = options[highlighted]
230
+ if (opt) toggleOption(opt)
231
+ else if (multiple) {
232
+ closePanel()
233
+ onCommit?.()
234
+ }
235
+ } else if (event.key === ' ' && open) {
236
+ event.preventDefault()
237
+ const opt = options[highlighted]
238
+ if (opt) toggleOption(opt)
239
+ } else if (event.key === 'Tab') {
240
+ if (open) closePanel()
241
+ onCommit?.()
242
+ }
243
+ }
244
+
245
+ function scrollHighlightedIntoView() {
246
+ queueMicrotask(() => {
247
+ if (!panelEl) return
248
+ const el = panelEl.querySelector<HTMLElement>(`[data-opt-idx="${highlighted}"]`)
249
+ el?.scrollIntoView({ block: 'nearest' })
250
+ })
251
+ }
252
+
253
+ function onRootBlur(event: FocusEvent) {
254
+ // Defer to the next tick so a click on an option (mousedown → option
255
+ // commits, then blur) doesn't race-cancel the selection.
256
+ const next = event.relatedTarget as Node | null
257
+ if (next && rootEl?.contains(next)) return
258
+ if (open) closePanel()
259
+ onCommit?.()
260
+ }
261
+
262
+ function removeChip(v: string | number, event: MouseEvent) {
263
+ event.preventDefault()
264
+ event.stopPropagation()
265
+ const next = selectedArr.filter((x) => String(x) !== String(v))
266
+ onChange?.(multiple ? next : next[0] ?? null)
267
+ }
268
+
269
+ function focusTrigger(node: HTMLButtonElement) {
270
+ // Form fields (autoOpen=false) stay closed + unfocused until the user acts;
271
+ // in-cell editing auto-focuses + opens to match clicking into a cell.
272
+ if (!autoOpen) return
273
+ node.focus()
274
+ openPanel()
275
+ }
276
+
277
+ /** Close the panel when the user clicks outside both trigger AND
278
+ * panel - `onfocusout` on the dropdown root no longer catches this
279
+ * because the portal'd panel is a sibling of body, not a descendant
280
+ * of rootEl. Wired up only while the panel is open. */
281
+ $effect(() => {
282
+ if (!open) return
283
+ const onDocPointerDown = (event: PointerEvent) => {
284
+ const target = event.target as Node | null
285
+ if (!target) return
286
+ if (rootEl?.contains(target)) return
287
+ if (panelEl?.contains(target)) return
288
+ closePanel()
289
+ onCommit?.()
290
+ }
291
+ document.addEventListener('pointerdown', onDocPointerDown, true)
292
+ return () => document.removeEventListener('pointerdown', onDocPointerDown, true)
293
+ })
294
+
295
+ /** Inline style for a chip with a configured `color`. Mirrors the
296
+ * helper in SvGrid.svelte so colorful chips look identical in the
297
+ * trigger and the in-cell readonly render. */
298
+ function colorfulChipStyleInline(color: string): string {
299
+ return (
300
+ `background: color-mix(in srgb, ${color} 22%, transparent);` +
301
+ `border-color: color-mix(in srgb, ${color} 45%, transparent);` +
302
+ `color: color-mix(in srgb, ${color} 80%, var(--sg-fg, #0f172a));`
303
+ )
304
+ }
305
+ </script>
306
+
307
+ <!-- All pointer/click events are swallowed at the root so they never reach
308
+ the surrounding cell's selection / pointerdown handlers - picking a
309
+ dropdown option must not also toggle cell selection. -->
310
+ <div
311
+ class="sv-grid-dropdown"
312
+ class:sv-grid-dropdown-open={open}
313
+ bind:this={rootEl}
314
+ onfocusout={onRootBlur}
315
+ onclick={(event) => event.stopPropagation()}
316
+ ondblclick={(event) => event.stopPropagation()}
317
+ onpointerdown={(event) => event.stopPropagation()}
318
+ onmousedown={(event) => event.stopPropagation()}
319
+ >
320
+ <!-- The implicit "button" role isn't in ARIA's supported-roles list for
321
+ aria-activedescendant, but focus deliberately stays on the trigger
322
+ (not the portal'd listbox) so arrow-key highlighting has to be
323
+ announced from here - without it, screen-reader users get no
324
+ feedback about which option is highlighted while navigating. -->
325
+ <!-- svelte-ignore a11y_role_supports_aria_props_implicit -->
326
+ <button
327
+ type="button"
328
+ class="sv-grid-dropdown-trigger"
329
+ class:sv-grid-dropdown-trigger-chips={renderChipsInTrigger && selectedArr.length > 0}
330
+ aria-haspopup="listbox"
331
+ aria-expanded={open}
332
+ aria-activedescendant={open ? optionId(highlighted) : undefined}
333
+ bind:this={triggerEl}
334
+ use:focusTrigger
335
+ onclick={() => (open ? closePanel() : openPanel())}
336
+ onkeydown={onKeyDown}
337
+ >
338
+ {#if renderChipsInTrigger && selectedArr.length > 0}
339
+ <span class="sv-grid-dropdown-chips">
340
+ {#each selectedArr as v (String(v))}
341
+ {@const opt = options.find((o) => String(o.value) === String(v))}
342
+ <span
343
+ class="sv-grid-chip sv-grid-chip-removable"
344
+ style={opt?.color ? colorfulChipStyleInline(opt.color) : ''}
345
+ >
346
+ {opt ? opt.label : String(v)}
347
+ <!-- A <button> can't be nested inside the trigger <button>; this is a
348
+ pointer-only affordance (tabindex -1), so a role="button" span is
349
+ valid HTML and preserves identical behaviour. -->
350
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
351
+ <span
352
+ class="sv-grid-chip-remove"
353
+ role="button"
354
+ aria-label="Remove {opt ? opt.label : String(v)}"
355
+ tabindex={-1}
356
+ onmousedown={(event) => event.preventDefault()}
357
+ onclick={(event) => removeChip(v, event)}
358
+ >×</span>
359
+ </span>
360
+ {/each}
361
+ </span>
362
+ {:else if !multiple && selectedArr.length === 1 && selectedColor}
363
+ <!-- Single-select list whose chosen option carries a color → show it
364
+ as a colored chip inside the trigger, matching how the cell
365
+ renders when not in edit mode. -->
366
+ <span class="sv-grid-chip" style={colorfulChipStyleInline(selectedColor)}>
367
+ {triggerSummary}
368
+ </span>
369
+ {:else}
370
+ <span class="sv-grid-dropdown-label">{triggerSummary}</span>
371
+ {/if}
372
+ <span class="sv-grid-dropdown-caret" aria-hidden="true">▾</span>
373
+ </button>
374
+
375
+ {#if open}
376
+ <!-- Sizing rule:
377
+ - ≤ 10 options → no max-height. The panel grows to its content,
378
+ overflow:auto stays inert, and no scrollbar is ever drawn.
379
+ This matters because measuring "10 × 32px" against the real
380
+ rendered option height (which depends on font + line-height)
381
+ rounds inconsistently across themes and used to produce a
382
+ 1-pixel scrollbar on lists as short as 3 items.
383
+ - > 10 options → cap at 10 items + Done-footer chrome so the
384
+ panel never overflows the viewport, and the scrollbar appears.
385
+ Panel is `position: fixed` + portal'd to <body> so it escapes
386
+ the grid's overflow:hidden scroll container. -->
387
+ {@const needsScroll = options.length > 10}
388
+ <!-- Cap to the comfortable 10-row height AND the room the viewport allows,
389
+ so a panel near the bottom edge scrolls instead of overflowing. -->
390
+ {@const panelMax = Math.min(
391
+ needsScroll ? 10 * optionRowHeight + (multiple ? 40 : 0) + 8 : Number.POSITIVE_INFINITY,
392
+ panelRect.maxHeight,
393
+ )}
394
+ <div
395
+ class="sv-grid-dropdown-panel"
396
+ class:sv-grid-dropdown-panel-fits={!needsScroll}
397
+ role="listbox"
398
+ aria-multiselectable={multiple}
399
+ bind:this={panelEl}
400
+ use:portalToBody
401
+ use:popIn={{ up: panelRect.openUpward }}
402
+ style:position="fixed"
403
+ style:top={`${panelRect.top}px`}
404
+ style:left={`${panelRect.left}px`}
405
+ style:width={`${panelRect.width}px`}
406
+ style:max-height={panelMax !== null ? `${panelMax}px` : null}
407
+ style:z-index="2147483647"
408
+ onpointerdown={(event) => event.stopPropagation()}
409
+ onmousedown={(event) => event.stopPropagation()}
410
+ onclick={(event) => event.stopPropagation()}
411
+ >
412
+ {#if searchable}
413
+ <input
414
+ type="search"
415
+ class="sv-grid-dropdown-search"
416
+ placeholder="Search…"
417
+ bind:value={searchQuery}
418
+ onmousedown={(event) => event.stopPropagation()}
419
+ onclick={(event) => event.stopPropagation()}
420
+ onkeydown={(event) => {
421
+ if (event.key === 'Escape') { event.preventDefault(); open = false; onCancel?.() }
422
+ }}
423
+ />
424
+ {/if}
425
+ {#if visibleOptions.length === 0}
426
+ <div class="sv-grid-dropdown-empty">{searchable && searchQuery ? `No matches for "${searchQuery}"` : 'No options'}</div>
427
+ {:else}
428
+ {#each visibleOptions as opt, i (String(opt.value))}
429
+ {@const selected = isSelected(opt)}
430
+ <div
431
+ class="sv-grid-dropdown-option"
432
+ class:sv-grid-dropdown-option-selected={selected}
433
+ class:sv-grid-dropdown-option-highlighted={i === highlighted}
434
+ role="option"
435
+ id={optionId(i)}
436
+ aria-selected={selected}
437
+ data-opt-idx={i}
438
+ onmousedown={(event) => {
439
+ event.preventDefault()
440
+ toggleOption(opt)
441
+ }}
442
+ onmouseenter={() => (highlighted = i)}
443
+ >
444
+ {#if multiple}
445
+ <span class="sv-grid-dropdown-check" aria-hidden="true">
446
+ {selected ? '✓' : ''}
447
+ </span>
448
+ {/if}
449
+ {#if opt.color}
450
+ <!-- Colored options render as a full chip (pill) so the
451
+ dropdown choices match how the value looks in the cell. -->
452
+ <span class="sv-grid-dropdown-option-chip" style={colorfulChipStyleInline(opt.color)}>{opt.label}</span>
453
+ {:else}
454
+ <span class="sv-grid-dropdown-option-label">{opt.label}</span>
455
+ {/if}
456
+ </div>
457
+ {/each}
458
+ {/if}
459
+
460
+ {#if multiple}
461
+ <div class="sv-grid-dropdown-footer">
462
+ <button
463
+ type="button"
464
+ class="sv-grid-dropdown-done"
465
+ onmousedown={(event) => event.preventDefault()}
466
+ onclick={() => {
467
+ closePanel()
468
+ onCommit?.()
469
+ }}
470
+ >Done</button>
471
+ </div>
472
+ {/if}
473
+ </div>
474
+ {/if}
475
+ </div>
476
+
477
+ <style>
478
+ /* All colors derive from the grid's host CSS vars so light/dark themes
479
+ "just work". Hard fallbacks are kept so the component is still
480
+ usable in an unthemed page. */
481
+ .sv-grid-dropdown {
482
+ position: absolute;
483
+ top: 0;
484
+ left: 0;
485
+ right: 0;
486
+ height: 100%;
487
+ background: var(--sg-bg, #ffffff);
488
+ color: var(--sg-fg, #0f172a);
489
+ }
490
+
491
+ .sv-grid-dropdown-trigger {
492
+ display: flex;
493
+ align-items: center;
494
+ gap: 6px;
495
+ width: 100%;
496
+ height: 100%;
497
+ min-height: 28px;
498
+ padding: 0 8px;
499
+ background: var(--sg-bg, #ffffff);
500
+ color: var(--sg-fg, #0f172a);
501
+ border: 0;
502
+ outline: none;
503
+ font: inherit;
504
+ text-align: start;
505
+ cursor: pointer;
506
+ box-sizing: border-box;
507
+ }
508
+ .sv-grid-dropdown-trigger:focus {
509
+ outline: 2px solid var(--sg-accent, #2563eb);
510
+ outline-offset: -2px;
511
+ }
512
+
513
+ .sv-grid-dropdown-trigger-chips {
514
+ align-items: center;
515
+ padding: 4px 8px;
516
+ flex-wrap: wrap;
517
+ }
518
+ .sv-grid-dropdown-chips {
519
+ display: inline-flex;
520
+ flex-wrap: wrap;
521
+ gap: 4px;
522
+ flex: 1 1 auto;
523
+ min-width: 0;
524
+ align-items: center;
525
+ }
526
+
527
+ .sv-grid-dropdown-label {
528
+ flex: 1 1 auto;
529
+ min-width: 0;
530
+ overflow: hidden;
531
+ text-overflow: ellipsis;
532
+ white-space: nowrap;
533
+ }
534
+ .sv-grid-dropdown-caret {
535
+ flex: 0 0 auto;
536
+ font-size: 10px;
537
+ opacity: 0.7;
538
+ line-height: 1;
539
+ }
540
+
541
+ /* Popover sits absolutely below the trigger. The parent
542
+ `.sv-grid-cell-editing` has `overflow: visible`, so this can hang
543
+ past the row. Pulls its surface color from the grid's header bg
544
+ so it reads as a slightly elevated layer on either theme. */
545
+ /* Panel is `position: fixed` AND portal'd into document.body via
546
+ `use:portalToBody`, so it escapes every ancestor overflow / clip /
547
+ transform / stacking context. Coords come from updatePanelPosition(). */
548
+ :global(.sv-grid-dropdown-panel) {
549
+ overflow-y: auto;
550
+ background: var(--sg-header-bg, var(--sg-bg, #ffffff));
551
+ color: var(--sg-fg, #0f172a);
552
+ border: 1px solid var(--sg-accent, #2563eb);
553
+ border-radius: 6px;
554
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
555
+ padding: 4px 0;
556
+ font-family: inherit;
557
+ }
558
+ /* When the option list fits in one panel we want NO scrollbar at all -
559
+ * not even a sub-pixel ghost track. `overflow: visible` rules out the
560
+ * scrollbar gutter that some browsers still reserve under
561
+ * `overflow: auto` when content equals max-height. */
562
+ :global(.sv-grid-dropdown-panel.sv-grid-dropdown-panel-fits) {
563
+ overflow: visible;
564
+ }
565
+ :global(.sv-grid-dropdown-option) {
566
+ display: flex;
567
+ align-items: center;
568
+ gap: 8px;
569
+ padding: 6px 10px;
570
+ cursor: pointer;
571
+ user-select: none;
572
+ color: var(--sg-fg, #0f172a);
573
+ }
574
+ :global(.sv-grid-dropdown-option-highlighted) {
575
+ background: var(--sg-row-hover-bg, rgba(37, 99, 235, 0.1));
576
+ }
577
+ :global(.sv-grid-dropdown-option-selected) {
578
+ font-weight: 600;
579
+ background: var(--sg-selection-bg, rgba(37, 99, 235, 0.15));
580
+ }
581
+ :global(.sv-grid-dropdown-option-selected.sv-grid-dropdown-option-highlighted) {
582
+ background: var(--sg-row-hover-bg, rgba(37, 99, 235, 0.18));
583
+ }
584
+ :global(.sv-grid-dropdown-option-label) {
585
+ flex: 1 1 auto;
586
+ min-width: 0;
587
+ overflow: hidden;
588
+ text-overflow: ellipsis;
589
+ white-space: nowrap;
590
+ }
591
+ :global(.sv-grid-dropdown-check) {
592
+ flex: 0 0 16px;
593
+ display: inline-flex;
594
+ align-items: center;
595
+ justify-content: center;
596
+ color: var(--sg-accent, #2563eb);
597
+ font-weight: 700;
598
+ }
599
+ :global(.sv-grid-dropdown-swatch) {
600
+ flex: 0 0 12px;
601
+ width: 12px;
602
+ height: 12px;
603
+ border-radius: 999px;
604
+ border: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 25%, transparent);
605
+ }
606
+ /* Colored option chip. Declared `:global` because the option panel is
607
+ portal'd to <body>, where Svelte's scoped classes don't reach. The
608
+ per-chip background / border / text color come from an inline style
609
+ (see colorfulChipStyleInline) so each option matches its cell chip. */
610
+ :global(.sv-grid-dropdown-option-chip) {
611
+ display: inline-flex;
612
+ align-items: center;
613
+ max-width: 100%;
614
+ padding: 2px 11px;
615
+ border: 1px solid transparent;
616
+ border-radius: 999px;
617
+ font-size: 0.9em;
618
+ font-weight: 600;
619
+ line-height: 1.5;
620
+ overflow: hidden;
621
+ text-overflow: ellipsis;
622
+ white-space: nowrap;
623
+ }
624
+ :global(.sv-grid-dropdown-empty) {
625
+ padding: 8px 10px;
626
+ color: var(--sg-muted, rgba(15, 23, 42, 0.55));
627
+ font-style: italic;
628
+ font-size: 0.9em;
629
+ }
630
+ :global(.sv-grid-dropdown-footer) {
631
+ display: flex;
632
+ justify-content: flex-end;
633
+ padding: 4px 6px;
634
+ border-top: 1px solid var(--sg-border, rgba(15, 23, 42, 0.08));
635
+ margin-top: 2px;
636
+ }
637
+ :global(.sv-grid-dropdown-done) {
638
+ font-size: 11px;
639
+ text-transform: uppercase;
640
+ letter-spacing: 0.04em;
641
+ color: #fff;
642
+ background: var(--sg-accent, #2563eb);
643
+ border: 0;
644
+ border-radius: 4px;
645
+ padding: 4px 10px;
646
+ cursor: pointer;
647
+ }
648
+ :global(.sv-grid-dropdown-done:hover) {
649
+ filter: brightness(1.12);
650
+ }
651
+
652
+ /* Note: option / footer / swatch styles are declared `:global(...)`
653
+ above because the panel is portal'd to <body> and Svelte's scoped
654
+ class won't survive on children of an element living outside the
655
+ component subtree. */
656
+
657
+ /* Theme-aware chip badges. Inside the trigger they sit on the cell
658
+ background, so we need a tone that contrasts on BOTH themes - a
659
+ translucent accent works well in dark mode and reads as a soft
660
+ blue pill in light mode. */
661
+ .sv-grid-chip {
662
+ display: inline-flex;
663
+ align-items: center;
664
+ gap: 4px;
665
+ padding: 2px 8px;
666
+ font-size: 0.85em;
667
+ line-height: 1.4;
668
+ background: color-mix(in srgb, var(--sg-accent, #2563eb) 18%, transparent);
669
+ color: var(--sg-fg, #0f172a);
670
+ border-radius: 999px;
671
+ border: 1px solid color-mix(in srgb, var(--sg-accent, #2563eb) 35%, transparent);
672
+ white-space: nowrap;
673
+ }
674
+ .sv-grid-chip-removable {
675
+ padding-inline-end: 2px;
676
+ }
677
+ .sv-grid-chip-remove {
678
+ display: inline-flex;
679
+ align-items: center;
680
+ justify-content: center;
681
+ width: 16px;
682
+ height: 16px;
683
+ border: 0;
684
+ border-radius: 999px;
685
+ background: color-mix(in srgb, var(--sg-fg, #0f172a) 18%, transparent);
686
+ color: inherit;
687
+ font-size: 12px;
688
+ line-height: 1;
689
+ cursor: pointer;
690
+ padding: 0;
691
+ }
692
+ .sv-grid-chip-remove:hover {
693
+ background: rgba(220, 38, 38, 0.7);
694
+ color: #fff;
695
+ }
696
+ </style>