@svgrid/grid 2.2.27 → 2.2.28

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 (212) 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 +734 -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.css +2723 -2723
  19. package/dist/SvGrid.svelte +3333 -3333
  20. package/dist/SvGridChart.svelte +1724 -1724
  21. package/dist/SvGridChartPanel.svelte +485 -485
  22. package/dist/SvGridChartView.svelte +70 -70
  23. package/dist/SvGridDropdown.svelte +696 -696
  24. package/dist/SvGridSelect.svelte +259 -259
  25. package/dist/SvGroupCell.svelte +109 -109
  26. package/dist/SvListBox.svelte +287 -287
  27. package/dist/SvMaskedInput.svelte +122 -122
  28. package/dist/SvMenu.svelte +124 -124
  29. package/dist/SvMenuList.svelte +146 -146
  30. package/dist/SvMultiSelect.svelte +281 -281
  31. package/dist/SvNumberInput.svelte +168 -168
  32. package/dist/SvOtpInput.svelte +158 -158
  33. package/dist/SvPasswordInput.svelte +151 -151
  34. package/dist/SvPhoneInput.svelte +133 -131
  35. package/dist/SvPopover.svelte +197 -197
  36. package/dist/SvRadioGroup.svelte +107 -107
  37. package/dist/SvRowGroupPanel.svelte +170 -170
  38. package/dist/SvScrollArea.svelte +61 -61
  39. package/dist/SvSlider.svelte +200 -200
  40. package/dist/SvSwitchButton.svelte +107 -107
  41. package/dist/SvTagsInput.svelte +112 -112
  42. package/dist/SvTextInput.svelte +147 -147
  43. package/dist/SvTimePicker.svelte +245 -245
  44. package/dist/SvToaster.svelte +159 -159
  45. package/dist/SvToggleButton.svelte +85 -85
  46. package/dist/SvTooltip.svelte +161 -161
  47. package/dist/SvTour.svelte +204 -204
  48. package/dist/SvTree.svelte +437 -437
  49. package/dist/SvTreeSelect.svelte +233 -233
  50. package/dist/cdn/{GridMenus-BzxEYNtW.js → GridMenus-BbzEvTYO.js} +2 -2
  51. package/dist/cdn/{GridMenus-Dr-nGEXn.js → GridMenus-E220X2kM.js} +2 -2
  52. package/dist/cdn/{SvDateTimePicker-B1sDc9Pz.js → SvDateTimePicker-B8GopjhC.js} +2 -2
  53. package/dist/cdn/{SvDateTimePicker-Bh0FTs1E.js → SvDateTimePicker-DB0aIEk8.js} +2 -2
  54. package/dist/cdn/{SvGridChart-Bgk4hDs8.js → SvGridChart-BcLCYvw9.js} +1 -1
  55. package/dist/cdn/{SvGridChart-BKQsO5fZ.js → SvGridChart-BhnFdLd0.js} +1 -1
  56. package/dist/cdn/{SvGridChartPanel-DkCc2Z7b.js → SvGridChartPanel-Cmx9DHAS.js} +1 -1
  57. package/dist/cdn/{SvGridChartPanel-DSkHIQDd.js → SvGridChartPanel-DvjwxLvK.js} +1 -1
  58. package/dist/cdn/{SvGridChartView-BrbVaELm.js → SvGridChartView-B40DTJSX.js} +1 -1
  59. package/dist/cdn/{SvGridChartView-i9rWvSKg.js → SvGridChartView-PwqI4s02.js} +1 -1
  60. package/dist/cdn/{SvGridDropdown-D08sVmAy.js → SvGridDropdown-DjALc_4f.js} +1 -1
  61. package/dist/cdn/{SvGridDropdown-CGxxopQ4.js → SvGridDropdown-hpnHvOGt.js} +1 -1
  62. package/dist/cdn/{src-cgs1wcUS.js → src-BxNEslEo.js} +25 -25
  63. package/dist/cdn/{src-Cw8d9U8j.js → src-C4yKQZ5t.js} +26 -26
  64. package/dist/cdn/svgrid.js +5 -5
  65. package/dist/cdn/svgrid.svelte-external.js +5 -5
  66. package/dist/chart-export.js +8 -8
  67. package/package.json +10 -10
  68. package/src/FlexRender.svelte +96 -96
  69. package/src/GridFooter.svelte +179 -179
  70. package/src/GridMenus.svelte +734 -734
  71. package/src/SvAutoComplete.svelte +165 -165
  72. package/src/SvCalendar.svelte +503 -503
  73. package/src/SvCalendar.test.ts +226 -226
  74. package/src/SvCarousel.svelte +141 -141
  75. package/src/SvCheckBox.svelte +101 -101
  76. package/src/SvColorInput.svelte +181 -181
  77. package/src/SvComboBox.svelte +275 -275
  78. package/src/SvContextMenu.svelte +116 -116
  79. package/src/SvCountryInput.svelte +160 -160
  80. package/src/SvDrawer.svelte +254 -250
  81. package/src/SvDrawer.test.ts +18 -1
  82. package/src/SvDropDownList.svelte +374 -374
  83. package/src/SvDurationInput.svelte +126 -126
  84. package/src/SvField.svelte +293 -293
  85. package/src/SvForm.svelte +437 -437
  86. package/src/SvForm.test.ts +411 -411
  87. package/src/SvGrid.controller.svelte.ts +3239 -3239
  88. package/src/SvGrid.css +2723 -2723
  89. package/src/SvGrid.svelte +3333 -3333
  90. package/src/SvGrid.types.ts +1613 -1613
  91. package/src/SvGridChart.svelte +1724 -1724
  92. package/src/SvGridChartPanel.svelte +485 -485
  93. package/src/SvGridChartView.svelte +70 -70
  94. package/src/SvGridDropdown.svelte +696 -696
  95. package/src/SvGridSelect.svelte +259 -259
  96. package/src/SvGroupCell.svelte +109 -109
  97. package/src/SvListBox.svelte +287 -287
  98. package/src/SvMaskedInput.svelte +122 -122
  99. package/src/SvMenu.svelte +124 -124
  100. package/src/SvMenu.test.ts +97 -97
  101. package/src/SvMenuList.svelte +146 -146
  102. package/src/SvMultiSelect.svelte +281 -281
  103. package/src/SvNavPane.test.ts +15 -4
  104. package/src/SvNumberInput.svelte +168 -168
  105. package/src/SvOtpInput.svelte +158 -158
  106. package/src/SvPasswordInput.svelte +151 -151
  107. package/src/SvPhoneInput.svelte +133 -131
  108. package/src/SvPopover.svelte +197 -197
  109. package/src/SvRadioGroup.svelte +107 -107
  110. package/src/SvRowGroupPanel.svelte +170 -170
  111. package/src/SvScrollArea.svelte +61 -61
  112. package/src/SvSlider.svelte +200 -200
  113. package/src/SvSwitchButton.svelte +107 -107
  114. package/src/SvTagsInput.svelte +112 -112
  115. package/src/SvTextInput.svelte +147 -147
  116. package/src/SvTimePicker.svelte +245 -245
  117. package/src/SvToaster.svelte +159 -159
  118. package/src/SvToaster.test.ts +95 -95
  119. package/src/SvToggleButton.svelte +85 -85
  120. package/src/SvTooltip.svelte +161 -161
  121. package/src/SvTour.svelte +204 -204
  122. package/src/SvTree.svelte +437 -437
  123. package/src/SvTreeSelect.svelte +233 -233
  124. package/src/a11y/dismissable.test.ts +119 -119
  125. package/src/a11y/dismissable.ts +114 -114
  126. package/src/a11y.contract.test.ts +49 -49
  127. package/src/a11y.test.ts +59 -59
  128. package/src/a11y.ts +61 -61
  129. package/src/ai.test.ts +502 -502
  130. package/src/ai.ts +1391 -1391
  131. package/src/build-api.coverage.test.ts +633 -633
  132. package/src/build-api.ts +895 -895
  133. package/src/builtin-editors.grid.test.ts +85 -85
  134. package/src/cell-formatting.ts +169 -169
  135. package/src/cell-render.ts +469 -469
  136. package/src/chart-export.ts +201 -201
  137. package/src/chart-view.svelte.ts +36 -36
  138. package/src/chart.ts +2296 -2296
  139. package/src/collaboration.test.ts +104 -104
  140. package/src/collaboration.ts +167 -167
  141. package/src/core.performance.test.ts +30 -30
  142. package/src/core.ts +1111 -1111
  143. package/src/createAutocomplete.svelte.ts +132 -132
  144. package/src/createCombobox.svelte.ts +191 -191
  145. package/src/createCountryInput.svelte.ts +157 -157
  146. package/src/createDropdownList.svelte.ts +168 -168
  147. package/src/createForm.svelte.ts +386 -386
  148. package/src/createGrid.svelte.ts +42 -42
  149. package/src/createGrid.test.ts +10 -10
  150. package/src/createGridState.svelte.ts +17 -17
  151. package/src/createMenu.svelte.ts +225 -225
  152. package/src/createPopoverSelect.svelte.ts +213 -213
  153. package/src/createSlider.svelte.ts +191 -191
  154. package/src/createTooltip.svelte.ts +144 -144
  155. package/src/createTree.svelte.ts +319 -319
  156. package/src/editing.test.ts +859 -859
  157. package/src/editing.ts +675 -675
  158. package/src/editor-contract.ts +157 -157
  159. package/src/export-data-api.test.ts +126 -126
  160. package/src/export-format.test.ts +107 -107
  161. package/src/export-format.ts +598 -598
  162. package/src/filter-operators.ts +157 -157
  163. package/src/flex-render.ts +3 -3
  164. package/src/form-field.ts +127 -127
  165. package/src/index.ts +713 -713
  166. package/src/js-scroller.svelte.ts +173 -173
  167. package/src/keyboard.test.ts +59 -59
  168. package/src/keyboard.ts +97 -97
  169. package/src/list-nav.test.ts +49 -49
  170. package/src/list-nav.ts +29 -29
  171. package/src/list-option.test.ts +56 -56
  172. package/src/list-option.ts +172 -172
  173. package/src/menus.test.ts +700 -700
  174. package/src/menus.ts +652 -652
  175. package/src/merge-objects.ts +48 -48
  176. package/src/overlays.test.ts +90 -90
  177. package/src/render-component.ts +28 -28
  178. package/src/scheduler-model.test.ts +562 -562
  179. package/src/scheduler-model.ts +872 -872
  180. package/src/selection.test.ts +754 -754
  181. package/src/selection.ts +600 -600
  182. package/src/server-data-source.test.ts +289 -289
  183. package/src/server-data-source.ts +413 -413
  184. package/src/sparkline.test.ts +68 -68
  185. package/src/sparkline.ts +169 -169
  186. package/src/spreadsheet.test.ts +489 -489
  187. package/src/spreadsheet.ts +312 -312
  188. package/src/static-functions.ts +11 -11
  189. package/src/subscribe.ts +38 -38
  190. package/src/svgrid-wrapper.types.ts +524 -524
  191. package/src/svgrid.behavior.test.ts +706 -706
  192. package/src/svgrid.charting.test.ts +534 -534
  193. package/src/svgrid.features.test.ts +157 -157
  194. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  195. package/src/svgrid.wrapper.test.ts +40 -40
  196. package/src/test-setup.ts +62 -62
  197. package/src/themes/index.ts +215 -215
  198. package/src/toast-store.svelte.ts +246 -246
  199. package/src/toast-store.test.ts +147 -147
  200. package/src/ui-buttons.test.ts +144 -144
  201. package/src/ui-inputs.test.ts +118 -118
  202. package/src/ui-range.test.ts +70 -70
  203. package/src/ui-selection.test.ts +155 -155
  204. package/src/ui-tier1.test.ts +142 -142
  205. package/src/virtual.test.ts +88 -88
  206. package/src/virtualization/column-virtualizer.test.ts +27 -27
  207. package/src/virtualization/column-virtualizer.ts +30 -30
  208. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  209. package/src/virtualization/types.ts +30 -30
  210. package/src/virtualization/virtualizer.test.ts +47 -47
  211. package/src/virtualization/virtualizer.ts +296 -296
  212. package/themes/ember.css +71 -71
@@ -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>