@svgrid/grid 2.2.16 → 2.2.17

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 (290) hide show
  1. package/README.md +1 -1
  2. package/dist/GridFooter.svelte +12 -11
  3. package/dist/SvAnchor.svelte +65 -0
  4. package/dist/SvAnchor.svelte.d.ts +24 -0
  5. package/dist/SvAspectRatio.svelte +38 -0
  6. package/dist/SvAspectRatio.svelte.d.ts +16 -0
  7. package/dist/SvAutoComplete.svelte +52 -7
  8. package/dist/SvAutoComplete.svelte.d.ts +4 -1
  9. package/dist/SvBlockquote.svelte +43 -0
  10. package/dist/SvBlockquote.svelte.d.ts +16 -0
  11. package/dist/SvCalendar.svelte +33 -2
  12. package/dist/SvCalendar.svelte.d.ts +8 -1
  13. package/dist/SvCheckBox.svelte +6 -2
  14. package/dist/SvCode.svelte +52 -0
  15. package/dist/SvCode.svelte.d.ts +19 -0
  16. package/dist/SvCollapsible.svelte +75 -0
  17. package/dist/SvCollapsible.svelte.d.ts +27 -0
  18. package/dist/SvColorInput.svelte +11 -17
  19. package/dist/SvColorInput.svelte.d.ts +2 -0
  20. package/dist/SvComboBox.svelte +85 -8
  21. package/dist/SvComboBox.svelte.d.ts +14 -0
  22. package/dist/SvCountryInput.svelte +2 -2
  23. package/dist/SvCountryInput.svelte.d.ts +1 -1
  24. package/dist/SvDropDownList.svelte +154 -51
  25. package/dist/SvDropDownList.svelte.d.ts +17 -3
  26. package/dist/SvDurationInput.svelte +59 -41
  27. package/dist/SvDurationInput.svelte.d.ts +18 -0
  28. package/dist/SvField.svelte +203 -12
  29. package/dist/SvField.svelte.d.ts +45 -8
  30. package/dist/SvForm.svelte +195 -21
  31. package/dist/SvForm.svelte.d.ts +23 -0
  32. package/dist/SvGrid.controller.svelte.d.ts +12 -1
  33. package/dist/SvGrid.controller.svelte.js +87 -7
  34. package/dist/SvGrid.svelte +383 -181
  35. package/dist/SvGrid.types.d.ts +123 -2
  36. package/dist/SvGridChartView.svelte +70 -0
  37. package/dist/SvGridChartView.svelte.d.ts +11 -0
  38. package/dist/SvGridDropdown.svelte +9 -2
  39. package/dist/SvGridSelect.svelte +5 -2
  40. package/dist/SvGridSelect.svelte.d.ts +1 -1
  41. package/dist/SvGroup.svelte +41 -0
  42. package/dist/SvGroup.svelte.d.ts +27 -0
  43. package/dist/SvHoverCard.svelte +70 -0
  44. package/dist/SvHoverCard.svelte.d.ts +41 -0
  45. package/dist/SvKbd.svelte +51 -0
  46. package/dist/SvKbd.svelte.d.ts +18 -0
  47. package/dist/SvList.svelte +55 -0
  48. package/dist/SvList.svelte.d.ts +23 -0
  49. package/dist/SvListBox.svelte +166 -77
  50. package/dist/SvListBox.svelte.d.ts +17 -4
  51. package/dist/SvLoadingOverlay.svelte +53 -0
  52. package/dist/SvLoadingOverlay.svelte.d.ts +28 -0
  53. package/dist/SvMark.svelte +36 -0
  54. package/dist/SvMark.svelte.d.ts +14 -0
  55. package/dist/SvMaskedInput.svelte +50 -35
  56. package/dist/SvMaskedInput.svelte.d.ts +17 -2
  57. package/dist/SvMenuList.svelte +32 -3
  58. package/dist/SvMenubar.svelte +200 -0
  59. package/dist/SvMenubar.svelte.d.ts +18 -0
  60. package/dist/SvMultiSelect.svelte +7 -4
  61. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  62. package/dist/SvNumberInput.svelte +79 -42
  63. package/dist/SvNumberInput.svelte.d.ts +25 -0
  64. package/dist/SvOtpInput.svelte +2 -2
  65. package/dist/SvPasswordInput.svelte +51 -31
  66. package/dist/SvPasswordInput.svelte.d.ts +17 -0
  67. package/dist/SvPhoneInput.svelte +39 -28
  68. package/dist/SvPhoneInput.svelte.d.ts +4 -0
  69. package/dist/SvPopconfirm.svelte +79 -0
  70. package/dist/SvPopconfirm.svelte.d.ts +42 -0
  71. package/dist/SvPopover.svelte +87 -29
  72. package/dist/SvPopover.svelte.d.ts +18 -4
  73. package/dist/SvRadioGroup.svelte +5 -1
  74. package/dist/SvResult.svelte +73 -0
  75. package/dist/SvResult.svelte.d.ts +28 -0
  76. package/dist/SvSegmented.svelte +114 -0
  77. package/dist/SvSegmented.svelte.d.ts +29 -0
  78. package/dist/SvSimpleGrid.svelte +41 -0
  79. package/dist/SvSimpleGrid.svelte.d.ts +25 -0
  80. package/dist/SvSlider.svelte +23 -10
  81. package/dist/SvSpinner.svelte +43 -0
  82. package/dist/SvSpinner.svelte.d.ts +20 -0
  83. package/dist/SvStack.svelte +37 -0
  84. package/dist/SvStack.svelte.d.ts +24 -0
  85. package/dist/SvSwitchButton.svelte +6 -1
  86. package/dist/SvTagsInput.svelte +9 -5
  87. package/dist/SvText.svelte +76 -0
  88. package/dist/SvText.svelte.d.ts +31 -0
  89. package/dist/SvTextInput.svelte +75 -43
  90. package/dist/SvTextInput.svelte.d.ts +28 -1
  91. package/dist/SvTimePicker.svelte +32 -2
  92. package/dist/SvTimePicker.svelte.d.ts +8 -9
  93. package/dist/SvTitle.svelte +57 -0
  94. package/dist/SvTitle.svelte.d.ts +24 -0
  95. package/dist/SvToaster.svelte +41 -6
  96. package/dist/SvToggleButton.svelte +4 -1
  97. package/dist/SvTooltip.svelte +68 -34
  98. package/dist/SvTooltip.svelte.d.ts +14 -2
  99. package/dist/SvTree.svelte +81 -21
  100. package/dist/SvTree.svelte.d.ts +3 -2
  101. package/dist/SvTreeSelect.svelte +5 -2
  102. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  103. package/dist/SvVisuallyHidden.svelte +49 -0
  104. package/dist/SvVisuallyHidden.svelte.d.ts +19 -0
  105. package/dist/ai.d.ts +289 -0
  106. package/dist/ai.js +1013 -0
  107. package/dist/board-view.svelte.d.ts +22 -0
  108. package/dist/board-view.svelte.js +13 -0
  109. package/dist/build-api.js +36 -6
  110. package/dist/cdn/GridMenus-0AK9O7qG.js +420 -0
  111. package/dist/cdn/GridMenus-BjUoCXoD.js +416 -0
  112. package/dist/cdn/SvDateTimePicker-CYC7gRER.js +2176 -0
  113. package/dist/cdn/SvDateTimePicker-DtQKd4Ns.js +2198 -0
  114. package/dist/cdn/SvGridChart-BcLCYvw9.js +1195 -0
  115. package/dist/cdn/SvGridChart-BhnFdLd0.js +1203 -0
  116. package/dist/cdn/SvGridChartPanel-Cmx9DHAS.js +561 -0
  117. package/dist/cdn/SvGridChartPanel-DvjwxLvK.js +577 -0
  118. package/dist/cdn/SvGridChartView-B40DTJSX.js +54 -0
  119. package/dist/cdn/SvGridChartView-PwqI4s02.js +53 -0
  120. package/dist/cdn/SvGridDropdown-DjALc_4f.js +255 -0
  121. package/dist/cdn/SvGridDropdown-hpnHvOGt.js +254 -0
  122. package/dist/cdn/chart-T1usDN0o.js +1212 -0
  123. package/dist/cdn/disclose-version-DoD9AFD_.js +2958 -0
  124. package/dist/cdn/export-format-JT5pWVT8.js +561 -0
  125. package/dist/cdn/popover-P-9P2YYn.js +69 -0
  126. package/dist/cdn/rolldown-runtime-Dy4uBu1J.js +11 -0
  127. package/dist/cdn/src-BLhdLmoN.js +25707 -0
  128. package/dist/cdn/src-BwnUVj3T.js +25623 -0
  129. package/dist/cdn/svgrid.js +9 -32897
  130. package/dist/cdn/svgrid.svelte-external.js +9 -30052
  131. package/dist/cell-formatting.d.ts +2 -2
  132. package/dist/cell-render.js +4 -4
  133. package/dist/chart-view.svelte.d.ts +25 -0
  134. package/dist/chart-view.svelte.js +14 -0
  135. package/dist/createCombobox.svelte.d.ts +5 -0
  136. package/dist/createCombobox.svelte.js +21 -5
  137. package/dist/createDropdownList.svelte.d.ts +3 -0
  138. package/dist/createDropdownList.svelte.js +8 -2
  139. package/dist/createForm.svelte.d.ts +18 -0
  140. package/dist/createForm.svelte.js +51 -6
  141. package/dist/createMenu.svelte.js +27 -0
  142. package/dist/createPopoverSelect.svelte.d.ts +3 -0
  143. package/dist/createPopoverSelect.svelte.js +9 -1
  144. package/dist/createSlider.svelte.d.ts +8 -21
  145. package/dist/createSlider.svelte.js +34 -0
  146. package/dist/createTooltip.svelte.d.ts +12 -0
  147. package/dist/createTooltip.svelte.js +72 -9
  148. package/dist/editor-contract.d.ts +15 -0
  149. package/dist/export-provider.d.ts +27 -0
  150. package/dist/export-provider.js +9 -0
  151. package/dist/filter-operators.d.ts +6 -0
  152. package/dist/filter-operators.js +33 -0
  153. package/dist/form-field.d.ts +45 -2
  154. package/dist/grid-messages.d.ts +61 -0
  155. package/dist/grid-messages.js +72 -0
  156. package/dist/group-display.d.ts +62 -0
  157. package/dist/group-display.js +78 -0
  158. package/dist/index.d.ts +36 -4
  159. package/dist/index.js +43 -2
  160. package/dist/js-scroller.svelte.d.ts +35 -0
  161. package/dist/js-scroller.svelte.js +154 -0
  162. package/dist/list-option.d.ts +46 -0
  163. package/dist/list-option.js +53 -0
  164. package/dist/menus.js +25 -11
  165. package/dist/pivot-view.svelte.d.ts +72 -0
  166. package/dist/pivot-view.svelte.js +13 -0
  167. package/dist/positioning.d.ts +111 -0
  168. package/dist/positioning.js +191 -0
  169. package/dist/scheduler-model.d.ts +3 -0
  170. package/dist/scheduler-model.js +22 -5
  171. package/dist/svgrid-wrapper.types.d.ts +18 -0
  172. package/dist/toast-store.svelte.d.ts +54 -0
  173. package/dist/toast-store.svelte.js +65 -15
  174. package/package.json +9 -1
  175. package/src/GridFooter.svelte +12 -11
  176. package/src/SvAnchor.svelte +65 -0
  177. package/src/SvAspectRatio.svelte +38 -0
  178. package/src/SvAutoComplete.svelte +52 -7
  179. package/src/SvBlockquote.svelte +43 -0
  180. package/src/SvCalendar.svelte +33 -2
  181. package/src/SvCalendar.test.ts +15 -0
  182. package/src/SvCheckBox.svelte +6 -2
  183. package/src/SvCode.svelte +52 -0
  184. package/src/SvCollapsible.svelte +75 -0
  185. package/src/SvColorInput.svelte +11 -17
  186. package/src/SvComboBox.svelte +85 -8
  187. package/src/SvCountryInput.svelte +2 -2
  188. package/src/SvDropDownList.svelte +154 -51
  189. package/src/SvDurationInput.svelte +59 -41
  190. package/src/SvField.svelte +203 -12
  191. package/src/SvForm.svelte +195 -21
  192. package/src/SvForm.test.ts +112 -0
  193. package/src/SvGrid.controller.svelte.ts +88 -7
  194. package/src/SvGrid.svelte +383 -181
  195. package/src/SvGrid.types.ts +125 -1
  196. package/src/SvGridChartView.svelte +70 -0
  197. package/src/SvGridDropdown.svelte +9 -2
  198. package/src/SvGridSelect.svelte +5 -2
  199. package/src/SvGroup.svelte +41 -0
  200. package/src/SvHoverCard.svelte +70 -0
  201. package/src/SvKbd.svelte +51 -0
  202. package/src/SvList.svelte +55 -0
  203. package/src/SvListBox.svelte +166 -77
  204. package/src/SvLoadingOverlay.svelte +53 -0
  205. package/src/SvMark.svelte +36 -0
  206. package/src/SvMaskedInput.svelte +50 -35
  207. package/src/SvMenuList.svelte +32 -3
  208. package/src/SvMenubar.svelte +200 -0
  209. package/src/SvMultiSelect.svelte +7 -4
  210. package/src/SvNumberInput.svelte +79 -42
  211. package/src/SvOtpInput.svelte +2 -2
  212. package/src/SvPasswordInput.svelte +51 -31
  213. package/src/SvPhoneInput.svelte +39 -28
  214. package/src/SvPopconfirm.svelte +79 -0
  215. package/src/SvPopover.svelte +87 -29
  216. package/src/SvRadioGroup.svelte +5 -1
  217. package/src/SvResult.svelte +73 -0
  218. package/src/SvSegmented.svelte +114 -0
  219. package/src/SvSimpleGrid.svelte +41 -0
  220. package/src/SvSlider.svelte +23 -10
  221. package/src/SvSpinner.svelte +43 -0
  222. package/src/SvStack.svelte +37 -0
  223. package/src/SvSwitchButton.svelte +6 -1
  224. package/src/SvTagsInput.svelte +9 -5
  225. package/src/SvText.svelte +76 -0
  226. package/src/SvTextInput.svelte +75 -43
  227. package/src/SvTimePicker.svelte +32 -2
  228. package/src/SvTitle.svelte +57 -0
  229. package/src/SvToaster.svelte +41 -6
  230. package/src/SvToaster.test.ts +32 -0
  231. package/src/SvToggleButton.svelte +4 -1
  232. package/src/SvTooltip.svelte +68 -34
  233. package/src/SvTree.svelte +81 -21
  234. package/src/SvTreeSelect.svelte +5 -2
  235. package/src/SvVisuallyHidden.svelte +49 -0
  236. package/src/ai.test.ts +502 -0
  237. package/src/ai.ts +1391 -0
  238. package/src/board-view.svelte.ts +33 -0
  239. package/src/build-api.coverage.test.ts +101 -0
  240. package/src/build-api.ts +43 -18
  241. package/src/builtin-editors.grid.test.ts +1 -1
  242. package/src/cell-formatting.ts +5 -5
  243. package/src/cell-render.ts +4 -4
  244. package/src/chart-view.svelte.ts +36 -0
  245. package/src/createCombobox.svelte.ts +17 -5
  246. package/src/createDropdownList.svelte.ts +8 -2
  247. package/src/createForm.svelte.ts +59 -6
  248. package/src/createMenu.svelte.ts +23 -0
  249. package/src/createPopoverSelect.svelte.ts +8 -1
  250. package/src/createSlider.svelte.ts +20 -0
  251. package/src/createTooltip.svelte.ts +67 -9
  252. package/src/editor-contract.ts +16 -0
  253. package/src/export-provider.ts +40 -0
  254. package/src/filter-operators.ts +37 -0
  255. package/src/form-field.ts +42 -2
  256. package/src/grid-messages.test.ts +24 -0
  257. package/src/grid-messages.ts +137 -0
  258. package/src/group-display.test.ts +77 -0
  259. package/src/group-display.ts +117 -0
  260. package/src/i18n.grid.test.ts +73 -0
  261. package/src/index.ts +91 -2
  262. package/src/js-scroller.svelte.ts +173 -0
  263. package/src/list-option.test.ts +56 -0
  264. package/src/list-option.ts +81 -0
  265. package/src/menus.test.ts +32 -0
  266. package/src/menus.ts +25 -12
  267. package/src/pivot-view.svelte.ts +90 -0
  268. package/src/pivot-view.test.ts +38 -0
  269. package/src/pivot.grid.test.ts +102 -0
  270. package/src/positioning.test.ts +141 -0
  271. package/src/positioning.ts +266 -0
  272. package/src/scheduler-model.test.ts +16 -0
  273. package/src/scheduler-model.ts +24 -5
  274. package/src/svgrid-wrapper.types.ts +23 -0
  275. package/src/svgrid.charting.test.ts +8 -1
  276. package/src/svgrid.comments-autocomplete.test.ts +7 -2
  277. package/src/svgrid.context-menu.test.ts +8 -3
  278. package/src/toast-store.svelte.ts +98 -0
  279. package/src/toast-store.test.ts +62 -1
  280. package/src/ui-breadth.test.ts +138 -0
  281. package/src/ui-buttons.test.ts +39 -0
  282. package/src/ui-inputs.test.ts +2 -2
  283. package/src/ui-range.test.ts +9 -0
  284. package/src/ui-selection.test.ts +36 -0
  285. package/src/ui-tier1.test.ts +1 -1
  286. package/src/virtual.test.ts +20 -6
  287. package/dist/SvGridBoard.svelte +0 -2339
  288. package/dist/SvGridBoard.svelte.d.ts +0 -31
  289. package/src/SvGridBoard.svelte +0 -2339
  290. package/src/board.test.ts +0 -927
@@ -26,6 +26,8 @@
26
26
  autoOpen?: boolean
27
27
  /** Override the built-in popover strings. */
28
28
  messages?: Partial<ColorMessages>
29
+ /** Stretch to the container width (otherwise hugs its content). */
30
+ block?: boolean
29
31
  }
30
32
 
31
33
  let {
@@ -44,8 +46,10 @@
44
46
  hint,
45
47
  dir,
46
48
  id,
49
+ loading = false,
47
50
  autoOpen = false,
48
51
  messages,
52
+ block = false,
49
53
  }: Props = $props()
50
54
 
51
55
  const autoId = nextEditorId('sv-color')
@@ -100,12 +104,11 @@
100
104
  }
101
105
  </script>
102
106
 
103
- <SvField id={uid} {label} {hint} {error} {required} {dir}>
107
+ <SvField frame id={uid} {label} {hint} {error} {required} {dir} {size} {invalid} {disabled} {readonly} {loading} {block} width={block ? undefined : 'fit-content'}>
104
108
  <button
105
109
  bind:this={triggerEl}
106
- class="sv-color sv-color--{size}"
110
+ class="sv-color__trigger"
107
111
  class:is-disabled={disabled}
108
- class:is-invalid={invalid}
109
112
  {...col.swatchProps()}
110
113
  use:focusOpen
111
114
  >
@@ -153,22 +156,13 @@
153
156
  {#if name}<input type="hidden" {name} value={col.normalized} />{/if}
154
157
 
155
158
  <style>
156
- .sv-color {
157
- --_accent: var(--sg-accent, #2563eb);
158
- display: inline-flex; align-items: center; gap: 8px;
159
- background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
160
- border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
161
- padding: 0 10px; cursor: pointer; font: inherit;
159
+ .sv-color__trigger {
160
+ display: flex; align-items: center; gap: 8px; width: 100%; height: 100%;
161
+ background: none; border: 0; padding: 0 10px; cursor: pointer; font: inherit; color: inherit;
162
162
  }
163
- .sv-color--sm { height: 28px; font-size: 12px; }
164
- .sv-color--md { height: 34px; font-size: 13px; }
165
- .sv-color--lg { height: 40px; font-size: 15px; }
166
- .sv-color.is-disabled { opacity: 0.6; cursor: not-allowed; }
167
- .sv-color.is-invalid { border-color: var(--sg-danger, #dc2626); }
168
- .sv-color.is-invalid:focus-within { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-danger, #dc2626) 22%, transparent); }
169
- .sv-color:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; }
163
+ .sv-color__trigger.is-disabled { cursor: not-allowed; }
170
164
  .sv-color__swatch { width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.15); flex: none; }
171
- .sv-color__hex { font-variant-numeric: tabular-nums; text-transform: lowercase; }
165
+ .sv-color__hex { font-variant-numeric: tabular-nums; text-transform: lowercase; white-space: nowrap; }
172
166
 
173
167
  :global(.sv-color__panel) {
174
168
  z-index: 2147483647; width: 208px; padding: 12px;
@@ -8,11 +8,13 @@
8
8
  * keyboard + ARIA); only portal/measure/scroll render concerns live here.
9
9
  */
10
10
  import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
11
+ import { startPanelResize } from './panel-resize'
11
12
  import { createDismissableLayer } from './a11y/dismissable'
12
- import { groupOptions, type ListOption } from './list-option'
13
+ import { groupOptions, normalizeOptions, type ListOption } from './list-option'
13
14
  import SvField from './SvField.svelte'
14
15
  import { nextEditorId, resolveMessages, type SvEditorProps } from './editor-contract'
15
16
  import { createCombobox } from './createCombobox.svelte'
17
+ import type { Snippet } from 'svelte'
16
18
 
17
19
  /** User-facing strings (localizable via `messages`). */
18
20
  type ComboMessages = { noResults: string; loading: string; typeMore: string }
@@ -24,6 +26,11 @@
24
26
  onChange?: (value: string | number | null) => void
25
27
  placeholder?: string
26
28
  autoOpen?: boolean
29
+ /** Show a clear (x) button when a value is selected. */
30
+ clearable?: boolean
31
+ /** Show a bottom drag grip so the user can resize the open panel's height
32
+ * (hidden when the panel flips upward, where there is no room to grow). */
33
+ resizable?: boolean
27
34
  /** Load options from a server as the user types (debounced). Disables local
28
35
  * filtering; the returned list is shown as-is. */
29
36
  loadOptions?: (query: string) => Promise<ListOption[]>
@@ -33,6 +40,14 @@
33
40
  debounce?: number
34
41
  /** Override the built-in strings (empty-state / loading text). */
35
42
  messages?: Partial<ComboMessages>
43
+ /** Custom render for each option (receives the ListOption). */
44
+ item?: Snippet<[ListOption]>
45
+ /** Content pinned above the option list. */
46
+ header?: Snippet
47
+ /** Content pinned below the option list. */
48
+ footer?: Snippet
49
+ /** Shown when the (non-loading) list is empty; overrides the default text. */
50
+ noData?: Snippet
36
51
  }
37
52
 
38
53
  let {
@@ -41,6 +56,9 @@
41
56
  onChange,
42
57
  placeholder = 'Select…',
43
58
  disabled = false,
59
+ readonly = false,
60
+ clearable = false,
61
+ resizable = false,
44
62
  name,
45
63
  size = 'md',
46
64
  ariaLabel,
@@ -51,11 +69,16 @@
51
69
  hint,
52
70
  dir,
53
71
  id,
72
+ loading: loadingProp = false,
54
73
  autoOpen = false,
55
74
  loadOptions,
56
75
  minLength = 1,
57
76
  debounce = 250,
58
77
  messages,
78
+ item,
79
+ header,
80
+ footer,
81
+ noData,
59
82
  }: Props = $props()
60
83
 
61
84
  const autoId = nextEditorId('sv-combo')
@@ -66,6 +89,14 @@
66
89
  let fieldEl = $state<HTMLDivElement | null>(null)
67
90
  let panelEl = $state<HTMLDivElement | null>(null)
68
91
  let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
92
+ // User-chosen panel height (px) once they drag the resize grip; null = auto.
93
+ let userHeight = $state<number | null>(null)
94
+ // Default comfortable cap (px) before the list scrolls; the grip can grow past it.
95
+ const DEFAULT_CAP = 288
96
+ const panelMaxH = $derived(
97
+ resizable && userHeight != null ? Math.min(userHeight, rect.availHeight) : Math.min(rect.availHeight, DEFAULT_CAP),
98
+ )
99
+ const showGrip = $derived(resizable && !rect.openUpward)
69
100
 
70
101
  // Remote data source: fetch options as the query changes (debounced).
71
102
  let remoteOptions = $state<ListOption[]>([])
@@ -73,13 +104,14 @@
73
104
  let searched = $state(false)
74
105
  let debTimer: ReturnType<typeof setTimeout> | undefined
75
106
  let reqSeq = 0
76
- const effectiveOptions = $derived(loadOptions ? remoteOptions : options)
107
+ const effectiveOptions = $derived(normalizeOptions(loadOptions ? remoteOptions : options))
77
108
 
78
109
  const combo = createCombobox({
79
110
  options: () => effectiveOptions,
80
111
  value: () => value,
81
112
  onChange: (v) => onChange?.(v),
82
113
  disabled: () => disabled,
114
+ readonly: () => readonly,
83
115
  ariaLabel: () => ariaLabel,
84
116
  localFilter: () => !loadOptions,
85
117
  focusInput: () => inputEl?.focus(),
@@ -114,8 +146,20 @@
114
146
  // estimatedHeight only picks the flip direction (up vs down); the panel's
115
147
  // real max-height comes from availHeight below, so it takes its natural
116
148
  // content height (options + group labels) and scrolls only when it truly
117
- // overflows the room - never from an estimate that under-counts a row.
118
- rect = anchoredRect(fieldEl.getBoundingClientRect(), { estimatedHeight: Math.min(combo.filtered.length, 8) * 34 + 12 })
149
+ // overflows the room - never from an estimate that under-counts a row. When
150
+ // the user has dragged the grip, anchor the flip decision to THAT height.
151
+ const est = resizable && userHeight != null ? userHeight : Math.min(combo.filtered.length, 8) * 34 + 12
152
+ rect = anchoredRect(fieldEl.getBoundingClientRect(), { estimatedHeight: est })
153
+ }
154
+
155
+ function startResize(e: PointerEvent) {
156
+ if (!panelEl) return
157
+ startPanelResize(e, {
158
+ startHeight: panelEl.clientHeight,
159
+ min: 96,
160
+ max: () => rect.availHeight,
161
+ onHeight: (h) => { userHeight = h; updatePos() },
162
+ })
119
163
  }
120
164
 
121
165
  // Position + reposition + outside-click close are render concerns (need the DOM).
@@ -137,7 +181,7 @@
137
181
  function focusOpen(node: HTMLInputElement) { if (autoOpen) { node.focus() } }
138
182
  </script>
139
183
 
140
- <SvField id={uid} {label} {hint} {error} {required} {dir}>
184
+ <SvField id={uid} {label} {hint} {error} {required} {dir} loading={loadingProp || loading}>
141
185
  <div bind:this={fieldEl} class="sv-combo sv-combo--{size}" class:is-open={combo.open} class:is-disabled={disabled} class:is-invalid={invalid}>
142
186
  <input
143
187
  bind:this={inputEl}
@@ -147,6 +191,11 @@
147
191
  {...combo.inputProps()}
148
192
  use:focusOpen
149
193
  />
194
+ {#if clearable && combo.value != null && !disabled && !readonly}
195
+ <button type="button" class="sv-combo__clear" tabindex="-1" aria-label="Clear" onclick={() => { combo.clear(); inputEl?.focus() }}>
196
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
197
+ </button>
198
+ {/if}
150
199
  <button class="sv-combo__chev" {...combo.triggerProps()}>
151
200
  <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>
152
201
  </button>
@@ -154,7 +203,8 @@
154
203
  </SvField>
155
204
 
156
205
  {#if combo.open}
157
- <div bind:this={panelEl} class="sv-ddl__panel" use:portalToBody use:popIn={{ up: rect.openUpward }} style:position="fixed" style:top={rect.openUpward ? undefined : `${rect.top}px`} style:bottom={rect.openUpward ? `${rect.bottom}px` : undefined} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} style:max-height={`${Math.min(rect.availHeight, 288)}px`} {...combo.listboxProps()}>
206
+ <div bind:this={panelEl} class="sv-ddl__panel" class:is-resizable={showGrip} use:portalToBody use:popIn={{ up: rect.openUpward }} style:position="fixed" style:top={rect.openUpward ? undefined : `${rect.top}px`} style:bottom={rect.openUpward ? `${rect.bottom}px` : undefined} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} style:max-height={`${panelMaxH}px`} style:height={resizable && userHeight != null && !rect.openUpward ? `${panelMaxH}px` : undefined} {...combo.listboxProps()}>
207
+ {#if header}<div class="sv-ddl__header">{@render header()}</div>{/if}
158
208
  {#if loading}
159
209
  <div class="sv-ddl__empty sv-ddl__loading"><svg class="sv-ddl__spin" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.6" /></svg>{M.loading}</div>
160
210
  {:else if loadOptions && !searched && combo.query.trim().length < minLength}
@@ -162,14 +212,23 @@
162
212
  {:else if combo.filtered.length}
163
213
  {#each groupOptions(combo.filtered) as g (g.group ?? ' ')}
164
214
  {#if g.group != null}<div class="sv-ddl__group-label" aria-hidden="true">{g.group}</div>{/if}
165
- {#each g.options as opt (opt.value)}
215
+ {#each g.options as opt (opt.index)}
166
216
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
167
- <div class="sv-ddl__opt" class:is-active={combo.isActive(opt.index)} class:is-selected={combo.isSelected(opt)} class:is-disabled={opt.disabled} {...combo.optionProps(opt.index)}>{opt.label}</div>
217
+ <div class="sv-ddl__opt" class:is-active={combo.isActive(opt.index)} class:is-selected={combo.isSelected(opt)} class:is-disabled={opt.disabled} {...combo.optionProps(opt.index)}>{#if item}{@render item(opt)}{:else}{opt.label}{/if}</div>
168
218
  {/each}
169
219
  {/each}
220
+ {:else if noData}
221
+ {@render noData()}
170
222
  {:else}
171
223
  <div class="sv-ddl__empty">{M.noResults}</div>
172
224
  {/if}
225
+ {#if footer}<div class="sv-ddl__footer">{@render footer()}</div>{/if}
226
+ {#if showGrip}
227
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
228
+ <div class="sv-ddl__grip" onpointerdown={startResize} title="Drag to resize" aria-hidden="true">
229
+ <span class="sv-ddl__grip-dots"></span>
230
+ </div>
231
+ {/if}
173
232
  </div>
174
233
  {/if}
175
234
  {#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
@@ -189,10 +248,28 @@
189
248
  .sv-combo.is-invalid.is-open, .sv-combo.is-invalid:focus-within { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-danger, #dc2626) 22%, transparent); }
190
249
  .sv-combo.is-disabled { opacity: 0.6; }
191
250
  .sv-combo__input { flex: 1; min-width: 0; border: 0; background: none; outline: none; color: inherit; font: inherit; padding-block: 0; padding-inline: 10px 4px; }
251
+ .sv-combo__clear { display: grid; place-items: center; width: 22px; align-self: center; flex: none; background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; border-radius: 4px; }
252
+ .sv-combo__clear:hover { color: var(--sg-danger, #dc2626); }
192
253
  .sv-combo__chev { display: grid; place-items: center; width: 28px; align-self: stretch; background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; }
193
254
  :global(.sv-ddl__empty) { padding: 8px 10px; color: var(--sg-muted, #94a3b8); font-size: 13px; }
255
+ :global(.sv-ddl__header) { padding: 8px 10px; border-bottom: 1px solid var(--sg-border, #e2e8f0); font-size: 12.5px; color: var(--sg-muted, #64748b); }
256
+ :global(.sv-ddl__footer) { padding: 8px 10px; border-top: 1px solid var(--sg-border, #e2e8f0); font-size: 12.5px; }
194
257
  :global(.sv-ddl__loading) { display: flex; align-items: center; gap: 7px; }
195
258
  :global(.sv-ddl__spin) { animation: sv-combo-spin 0.7s linear infinite; }
196
259
  @keyframes sv-combo-spin { to { transform: rotate(360deg); } }
197
260
  @media (prefers-reduced-motion: reduce) { :global(.sv-ddl__spin) { animation: none; } }
261
+ /* Bottom resize grip (shared "····" handle - see SvDropDownList / panel-resize). */
262
+ :global(.sv-ddl__panel.is-resizable) { padding-bottom: 0; }
263
+ :global(.sv-ddl__grip) {
264
+ position: sticky; bottom: 0; z-index: 1; margin: 2px -4px -4px; height: 15px;
265
+ display: flex; align-items: center; justify-content: center; cursor: ns-resize;
266
+ background: var(--sg-bg, #fff); border-top: 1px solid var(--sg-border, #e2e8f0);
267
+ border-radius: 0 0 10px 10px; touch-action: none;
268
+ }
269
+ :global(.sv-ddl__grip-dots) {
270
+ width: 26px; height: 4px; color: var(--sg-muted, #94a3b8); opacity: 0.6;
271
+ background-image: radial-gradient(currentColor 1px, transparent 1.6px);
272
+ background-size: 6px 4px; background-position: center; background-repeat: repeat-x;
273
+ }
274
+ :global(.sv-ddl__grip:hover .sv-ddl__grip-dots) { opacity: 1; }
198
275
  </style>
@@ -29,7 +29,7 @@
29
29
  }
30
30
 
31
31
  let {
32
- value = null,
32
+ value = $bindable<string | null>(null),
33
33
  onChange,
34
34
  showDial = false,
35
35
  placeholder = 'Select country…',
@@ -58,7 +58,7 @@
58
58
 
59
59
  const ci = createCountryInput({
60
60
  value: () => value,
61
- onChange: (c) => onChange?.(c),
61
+ onChange: (c) => { value = c; onChange?.(c) },
62
62
  disabled: () => disabled,
63
63
  ariaLabel: () => ariaLabel,
64
64
  searchLabel: () => M.search,
@@ -10,11 +10,13 @@
10
10
  import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
11
11
  import { startPanelResize } from './panel-resize'
12
12
  import { createDismissableLayer } from './a11y/dismissable'
13
- import { groupOptions, hasGroups, type ListOption } from './list-option'
14
- import { virtualRange, scrollToIndex } from './virtual'
13
+ import { groupOptions, normalizeOptions, flattenForVirtual, type ListOption, type IndexedOption, type RowHeight } from './list-option'
14
+ import { createSvelteVirtualizer } from './virtualization/svelte-virtualizer.svelte'
15
+ import { createJsScroller } from './js-scroller.svelte'
15
16
  import SvField from './SvField.svelte'
16
17
  import { nextEditorId, type SvEditorProps } from './editor-contract'
17
18
  import { createDropdownList } from './createDropdownList.svelte'
19
+ import { untrack, type Snippet } from 'svelte'
18
20
 
19
21
  type Props = SvEditorProps & {
20
22
  options: ReadonlyArray<ListOption>
@@ -24,11 +26,24 @@
24
26
  autoOpen?: boolean
25
27
  /** Window the option list (render only visible) for large sets. */
26
28
  virtual?: boolean
27
- /** Fixed option height in px (must match the CSS). Default 34. */
28
- rowHeight?: number
29
+ /** Option height in px - a number, or a per-option function for variable
30
+ * heights. Must match the rendered row height. Default 34. */
31
+ rowHeight?: RowHeight
32
+ /** Height (px) of a group heading row when virtualized + grouped. Default 28. */
33
+ groupHeaderHeight?: number
34
+ /** Max options shown before the panel scrolls (native-select behaviour). Default 8. */
35
+ maxRows?: number
29
36
  /** Show a bottom drag grip so the user can resize the open panel's height
30
37
  * (only while it opens downward). Off by default. */
31
38
  resizable?: boolean
39
+ /** Custom render for each option (receives the ListOption). */
40
+ item?: Snippet<[ListOption]>
41
+ /** Content pinned above the option list. */
42
+ header?: Snippet
43
+ /** Content pinned below the option list. */
44
+ footer?: Snippet
45
+ /** Shown when there are no options. */
46
+ noData?: Snippet
32
47
  }
33
48
 
34
49
  let {
@@ -37,6 +52,7 @@
37
52
  onChange,
38
53
  placeholder = 'Select…',
39
54
  disabled = false,
55
+ readonly = false,
40
56
  name,
41
57
  size = 'md',
42
58
  ariaLabel,
@@ -50,18 +66,66 @@
50
66
  autoOpen = false,
51
67
  virtual = false,
52
68
  rowHeight = 34,
69
+ groupHeaderHeight = 28,
70
+ maxRows = 8,
53
71
  resizable = false,
72
+ item,
73
+ header,
74
+ footer,
75
+ noData,
54
76
  }: Props = $props()
55
77
 
56
78
  const autoId = nextEditorId('sv-ddl')
57
79
  const uid = $derived(id ?? autoId)
58
80
 
59
- // Virtualization applies to a flat (ungrouped) list.
60
- const useVirtual = $derived(virtual && !hasGroups(options))
61
- let scrollTop = $state(0)
81
+ // Tolerate primitive / partial options - normalize so labels render + keys are stable.
82
+ const norm = $derived(normalizeOptions(options))
83
+
84
+ // Virtualization windows the option list (grouped lists too, via the shared
85
+ // virtualizer + a flattened header/option model with per-row sizes).
86
+ const useVirtual = $derived(virtual)
62
87
  let viewportH = $state(0)
63
- const vr = $derived(virtualRange({ scrollTop, viewportHeight: viewportH, rowHeight, count: options.length, overscan: 10 }))
64
- const windowed = $derived(useVirtual ? options.slice(vr.start, vr.end).map((o, i) => ({ ...o, index: vr.start + i })) : [])
88
+ const rowHeightBase = $derived(typeof rowHeight === 'number' ? rowHeight : 34)
89
+ const flat = $derived(flattenForVirtual(norm, { rowHeight, groupHeaderHeight }))
90
+ const uniform = $derived(!flat.hasGroups && typeof rowHeight === 'number')
91
+ const estimateSize = $derived(uniform ? rowHeightBase : (i: number) => flat.sizeAt(i))
92
+
93
+ // Generous overscan buffers fast flings; effective viewport never 0 (see SvListBox).
94
+ const OVERSCAN = 20
95
+ const effViewport = $derived(viewportH > 0 ? viewportH : Math.max(1, maxRows * rowHeightBase))
96
+
97
+ // Seed with the initial (untracked) values so the first paint of the open panel
98
+ // renders a full window; the effects below keep it synced.
99
+ const virtualizer = createSvelteVirtualizer(
100
+ untrack(() => ({ count: flat.entries.length, estimateSize, overscan: OVERSCAN, viewportHeight: effViewport })),
101
+ )
102
+ // untrack keeps the virtualizer's internal `version` write out of these
103
+ // effects' dependencies, so they re-run only on their real inputs.
104
+ $effect(() => {
105
+ const count = flat.entries.length
106
+ const es = estimateSize
107
+ untrack(() => virtualizer.setOptions({ count, estimateSize: es }))
108
+ })
109
+ $effect(() => {
110
+ const vh = effViewport
111
+ untrack(() => virtualizer.setViewportHeight(vh))
112
+ })
113
+ const vItems = $derived.by(() => {
114
+ virtualizer.version
115
+ return virtualizer.getVirtualItems()
116
+ })
117
+ const totalSize = $derived.by(() => {
118
+ virtualizer.version
119
+ return virtualizer.getTotalSize()
120
+ })
121
+ // JS-driven scroll (see SvListBox / js-scroller): no native scroll, so a fast
122
+ // thumb-drag never flashes the empty panel.
123
+ const scroller = createJsScroller({
124
+ virtualizer,
125
+ totalSize: () => totalSize,
126
+ viewport: () => effViewport,
127
+ lineStep: () => rowHeightBase,
128
+ })
65
129
 
66
130
  let triggerEl = $state<HTMLButtonElement | null>(null)
67
131
  let panelEl = $state<HTMLDivElement | null>(null)
@@ -75,11 +139,24 @@
75
139
  )
76
140
  const showGrip = $derived(resizable && !rect.openUpward)
77
141
 
142
+ // The virtual panel uses NO native scroll, so its rows are absolutely
143
+ // positioned and can't size the panel. Give it an explicit height (content up
144
+ // to `maxRows`, capped by the resized / bounds-aware ceiling) so the flex
145
+ // viewport (`.sv-ddl__vp`) has a definite height to fill.
146
+ const virtualPanelH = $derived(
147
+ Math.min(
148
+ Math.min(totalSize, Math.max(rowHeightBase, maxRows * rowHeightBase)) + 8,
149
+ rect.maxHeight || Number.POSITIVE_INFINITY,
150
+ resizable && userHeight != null ? panelMaxH : Number.POSITIVE_INFINITY,
151
+ ),
152
+ )
153
+
78
154
  const ddl = createDropdownList({
79
- options: () => options,
155
+ options: () => norm,
80
156
  value: () => value,
81
157
  onChange: (v) => onChange?.(v),
82
158
  disabled: () => disabled,
159
+ readonly: () => readonly,
83
160
  ariaLabel: () => ariaLabel,
84
161
  focusTrigger: () => triggerEl?.focus(),
85
162
  id: () => uid,
@@ -94,9 +171,13 @@
94
171
  function updatePos() {
95
172
  if (!triggerEl) return
96
173
  // When the user has resized, anchor to THAT height so the flip decision and
97
- // upward top stay correct; otherwise estimate from the option count.
174
+ // upward top stay correct; otherwise size to the content up to `maxRows` items
175
+ // (native-select behaviour: no scrollbar until the list exceeds the cap). The
176
+ // +PANEL_CHROME budgets the panel's own padding (8) + border (2) so a list
177
+ // that exactly fills `maxRows` is not clipped by the border-box max-height.
178
+ const PANEL_CHROME = 10
98
179
  const estimatedHeight =
99
- resizable && userHeight != null ? userHeight : Math.min(options.length, 8) * 34 + 8
180
+ resizable && userHeight != null ? userHeight : Math.min(norm.length, maxRows) * rowHeightBase + PANEL_CHROME
100
181
  rect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight })
101
182
  }
102
183
 
@@ -130,14 +211,15 @@
130
211
  layer.activate()
131
212
  return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); layer.release() }
132
213
  })
133
- // Keep the active option scrolled into view (render concern).
214
+ // Keep the active option scrolled into view (render concern) - via the scroller
215
+ // (virtual) or scrollIntoView (native non-virtual).
134
216
  $effect(() => {
135
217
  if (!ddl.open) return
136
218
  const i = ddl.activeIndex
137
219
  if (useVirtual) {
138
- if (!panelEl) return
139
- const next = scrollToIndex(i, panelEl.scrollTop, panelEl.clientHeight, rowHeight)
140
- if (next !== panelEl.scrollTop) { panelEl.scrollTop = next; scrollTop = next }
220
+ const flatIdx = flat.optionFlatIndex[i]
221
+ if (flatIdx == null) return
222
+ untrack(() => scroller.ensureIndexVisible(flatIdx))
141
223
  } else {
142
224
  queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${i}"]`)?.scrollIntoView({ block: 'nearest' }))
143
225
  }
@@ -160,33 +242,56 @@
160
242
  </button>
161
243
  </SvField>
162
244
 
245
+ {#snippet optRow(opt: IndexedOption, h?: number, top?: number)}
246
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
247
+ <div class="sv-ddl__opt" style:height={h != null ? `${h}px` : undefined} style:transform={top == null ? undefined : `translateY(${top}px)`} class:is-active={ddl.isActive(opt.index)} class:is-selected={ddl.isSelected(opt)} class:is-disabled={opt.disabled} {...ddl.optionProps(opt.index)}>
248
+ {#if item}{@render item(opt)}{:else}{#if opt.color}<span class="sv-ddl__swatch" style:background={opt.color}></span>{/if}{opt.label}{/if}
249
+ </div>
250
+ {/snippet}
251
+
163
252
  {#if ddl.open}
164
- <div bind:this={panelEl} class="sv-ddl__panel" class:is-virtual={useVirtual} class:is-resizable={showGrip} use:portalToBody use:popIn={{ up: rect.openUpward }} style:--sv-row-h={`${rowHeight}px`} style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} style:max-height={`${panelMaxH}px`} style:height={resizable && userHeight != null ? `${panelMaxH}px` : undefined} onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)} bind:clientHeight={viewportH} {...ddl.listboxProps()}>
165
- {#if useVirtual}
166
- <div class="sv-ddl__spacer" aria-hidden="true" style:height={`${vr.padTop}px`}></div>
167
- {#each windowed as opt (opt.value)}
168
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
169
- <div class="sv-ddl__opt" style:height={`${rowHeight}px`} class:is-active={ddl.isActive(opt.index)} class:is-selected={ddl.isSelected(opt)} class:is-disabled={opt.disabled} {...ddl.optionProps(opt.index)}>{#if opt.color}<span class="sv-ddl__swatch" style:background={opt.color}></span>{/if}{opt.label}</div>
170
- {/each}
171
- <div class="sv-ddl__spacer" aria-hidden="true" style:height={`${vr.padBottom}px`}></div>
253
+ <div bind:this={panelEl} class="sv-ddl__panel" class:is-virtual={useVirtual} class:is-resizable={showGrip} use:portalToBody use:popIn={{ up: rect.openUpward }} style:--sv-row-h={`${rowHeightBase}px`} style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} style:max-height={`${panelMaxH}px`} style:height={useVirtual ? `${virtualPanelH}px` : resizable && userHeight != null ? `${panelMaxH}px` : undefined} onwheel={useVirtual ? scroller.onWheel : undefined} {...ddl.listboxProps()}>
254
+ {#if header}<div class="sv-ddl__header">{@render header()}</div>{/if}
255
+ {#if !norm.length}
256
+ {#if noData}{@render noData()}{:else}<div class="sv-ddl__empty">No options</div>{/if}
257
+ {:else if useVirtual}
258
+ <!-- JS-driven scroller: one clipped viewport holds the windowed rows (moved
259
+ by scrollOffset) + a custom scrollbar. No native scroll = no async gap
260
+ = no blank flash on a fast thumb-drag. -->
261
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
262
+ <div class="sv-ddl__vp" bind:clientHeight={viewportH} onpointerdown={scroller.onContentDown} onpointermove={scroller.onContentMove} onpointerup={scroller.onContentUp} onpointercancel={scroller.onContentUp}>
263
+ {#each vItems as vi (vi.key)}
264
+ {@const entry = flat.entries[vi.index]}
265
+ {#if entry?.type === 'group'}
266
+ <div class="sv-ddl__group-label sv-ddl__group-label--abs" aria-hidden="true" style:height={`${vi.size}px`} style:transform={`translateY(${vi.start - scroller.scrollOffset}px)`}>{entry.label}</div>
267
+ {:else if entry}
268
+ {@render optRow(entry.opt, vi.size, vi.start - scroller.scrollOffset)}
269
+ {/if}
270
+ {/each}
271
+ {#if scroller.maxOffset > 0}
272
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
273
+ <div class="sv-ddl__sb" onpointerdown={scroller.onTrackDown} aria-hidden="true">
274
+ <div class="sv-ddl__thumb" class:is-dragging={scroller.dragging} style:height={`${scroller.thumbH}px`} style:transform={`translateY(${scroller.thumbTop}px)`} onpointerdown={scroller.onThumbDown} onpointermove={scroller.onThumbMove} onpointerup={scroller.onThumbUp} onpointercancel={scroller.onThumbUp}></div>
275
+ </div>
276
+ {/if}
277
+ </div>
172
278
  {:else}
173
- {#each groupOptions(options) as g (g.group ?? '')}
279
+ {#each groupOptions(norm) as g (g.group ?? '')}
174
280
  {#if g.group != null}
175
281
  <div class="sv-ddl__group" role="group" aria-label={g.group}>
176
282
  <div class="sv-ddl__group-label" aria-hidden="true">{g.group}</div>
177
- {#each g.options as opt (opt.value)}
178
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
179
- <div class="sv-ddl__opt" class:is-active={ddl.isActive(opt.index)} class:is-selected={ddl.isSelected(opt)} class:is-disabled={opt.disabled} {...ddl.optionProps(opt.index)}>{#if opt.color}<span class="sv-ddl__swatch" style:background={opt.color}></span>{/if}{opt.label}</div>
283
+ {#each g.options as opt (opt.index)}
284
+ {@render optRow(opt)}
180
285
  {/each}
181
286
  </div>
182
287
  {:else}
183
- {#each g.options as opt (opt.value)}
184
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
185
- <div class="sv-ddl__opt" class:is-active={ddl.isActive(opt.index)} class:is-selected={ddl.isSelected(opt)} class:is-disabled={opt.disabled} {...ddl.optionProps(opt.index)}>{#if opt.color}<span class="sv-ddl__swatch" style:background={opt.color}></span>{/if}{opt.label}</div>
288
+ {#each g.options as opt (opt.index)}
289
+ {@render optRow(opt)}
186
290
  {/each}
187
291
  {/if}
188
292
  {/each}
189
293
  {/if}
294
+ {#if footer}<div class="sv-ddl__footer">{@render footer()}</div>{/if}
190
295
  {#if showGrip}
191
296
  <!-- svelte-ignore a11y_no_static_element_interactions -->
192
297
  <div class="sv-ddl__grip" onpointerdown={startResize} title="Drag to resize" aria-hidden="true">
@@ -221,7 +326,18 @@
221
326
  background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
222
327
  border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; box-shadow: 0 16px 48px -12px rgba(15,23,42,0.35);
223
328
  }
224
- :global(.sv-ddl__opt) { box-sizing: border-box; display: flex; align-items: center; padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
329
+ /* Virtual panel: a flex column (header / JS-driven viewport / footer / grip)
330
+ with NO native scroll - the viewport moves its rows by `scrollOffset`. */
331
+ :global(.sv-ddl__panel.is-virtual) { display: flex; flex-direction: column; overflow: hidden; }
332
+ :global(.sv-ddl__vp) { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; touch-action: none; }
333
+ :global(.sv-ddl__panel.is-virtual .sv-ddl__opt), :global(.sv-ddl__group-label--abs) { position: absolute; left: 0; right: 0; top: 0; }
334
+ :global(.sv-ddl__sb) { position: absolute; top: 0; right: 1px; bottom: 0; width: 10px; z-index: 2; }
335
+ :global(.sv-ddl__thumb) {
336
+ position: absolute; left: 2px; right: 2px; top: 0; min-height: 24px; border-radius: 4px;
337
+ background: color-mix(in srgb, currentColor 26%, transparent); cursor: default; touch-action: none;
338
+ }
339
+ :global(.sv-ddl__thumb:hover), :global(.sv-ddl__thumb.is-dragging) { background: color-mix(in srgb, currentColor 42%, transparent); }
340
+ :global(.sv-ddl__opt) { box-sizing: border-box; flex: none; display: flex; align-items: center; padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
225
341
  /* Color swatch shown before an option's label (option.color) - in both the
226
342
  trigger value and the portalled panel options. */
227
343
  :global(.sv-ddl__swatch) {
@@ -244,28 +360,15 @@
244
360
  background-size: 6px 4px; background-position: center; background-repeat: repeat-x;
245
361
  }
246
362
  :global(.sv-ddl__grip:hover .sv-ddl__grip-dots) { opacity: 1; }
247
- /* Faint per-row skeleton so a fast scrollbar-thumb drag reveals placeholder
248
- rows in this off-screen padding instead of blank, until JS re-windows. */
249
- :global(.sv-ddl__spacer) {
250
- padding: 0; margin: 0; flex: none; pointer-events: none;
251
- background-image: linear-gradient(
252
- to bottom,
253
- transparent 0,
254
- transparent calc((var(--sv-row-h, 34px) - 10px) / 2),
255
- var(--sg-skeleton, color-mix(in srgb, currentColor 9%, transparent)) calc((var(--sv-row-h, 34px) - 10px) / 2),
256
- var(--sg-skeleton, color-mix(in srgb, currentColor 9%, transparent)) calc((var(--sv-row-h, 34px) + 10px) / 2),
257
- transparent calc((var(--sv-row-h, 34px) + 10px) / 2),
258
- transparent var(--sv-row-h, 34px)
259
- );
260
- background-size: 62% var(--sv-row-h, 34px);
261
- background-repeat: repeat-y;
262
- background-position: 12px 0;
263
- }
264
363
  :global(.sv-ddl__opt.is-active) { background: var(--sg-row-hover-bg, #f1f5f9); }
265
364
  :global(.sv-ddl__opt.is-selected) { color: var(--sg-accent, #2563eb); font-weight: 600; }
266
365
  :global(.sv-ddl__opt.is-disabled) { opacity: 0.4; cursor: not-allowed; }
267
366
  :global(.sv-ddl__group-label) {
268
- padding: 8px 10px 3px; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
269
- color: var(--sg-muted, #94a3b8);
367
+ box-sizing: border-box; padding: 8px 10px 3px; font-size: 10.5px; font-weight: 700;
368
+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--sg-muted, #94a3b8);
369
+ display: flex; align-items: flex-end;
270
370
  }
371
+ :global(.sv-ddl__empty) { padding: 12px 12px; font-size: 13px; color: var(--sg-muted, #64748b); text-align: center; }
372
+ :global(.sv-ddl__header) { padding: 8px 10px; border-bottom: 1px solid var(--sg-border, #e2e8f0); font-size: 12.5px; color: var(--sg-muted, #64748b); }
373
+ :global(.sv-ddl__footer) { padding: 8px 10px; border-top: 1px solid var(--sg-border, #e2e8f0); font-size: 12.5px; }
271
374
  </style>