@svgrid/grid 2.2.25 → 2.2.27

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