@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
@@ -10,7 +10,7 @@
10
10
  * ```
11
11
  */
12
12
  import { portalToBody } from './popover'
13
- import { toastStore, dismissToast, pauseToast, resumeToast, type Toast } from './toast-store.svelte'
13
+ import { toastStore, dismissToast, pauseToast, resumeToast, type Toast, type ToastAction } from './toast-store.svelte'
14
14
 
15
15
  type Position =
16
16
  | 'top-left' | 'top-center' | 'top-right'
@@ -32,6 +32,12 @@
32
32
  // Swipe-to-dismiss: drag a toast sideways past ~60px to dismiss it.
33
33
  let drag = $state<{ id: number; startX: number; dx: number } | null>(null)
34
34
  function onDown(e: PointerEvent, id: number) {
35
+ // Only the primary button starts a swipe.
36
+ if (e.button !== 0) return
37
+ // Don't hijack presses on the toast's own controls (dismiss X, action /
38
+ // cancel buttons). Capturing the pointer here would redirect the pointerup
39
+ // to the toast and swallow the button's click.
40
+ if (e.target instanceof Element && e.target.closest('button')) return
35
41
  drag = { id, startX: e.clientX, dx: 0 }
36
42
  ;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
37
43
  pauseToast(id)
@@ -47,6 +53,12 @@
47
53
  else resumeToast(id)
48
54
  }
49
55
  const dragDx = (id: number) => (drag?.id === id ? drag.dx : 0)
56
+
57
+ // Run a toast action, then dismiss the toast unless it opted to stay open.
58
+ function runAction(a: ToastAction, id: number) {
59
+ a.onClick?.(id)
60
+ if (!a.keepOpen) dismissToast(id)
61
+ }
50
62
  </script>
51
63
 
52
64
  <div class="sv-toaster sv-toaster--{position}" use:portalToBody role="region" aria-label="Notifications">
@@ -67,11 +79,25 @@
67
79
  onpointerup={() => onUp(t.id)}
68
80
  onpointercancel={() => onUp(t.id)}
69
81
  >
70
- <span class="sv-toast__icon" aria-hidden="true">{icon(t.variant)}</span>
71
- <div class="sv-toast__content">
72
- {#if t.title}<div class="sv-toast__title">{t.title}</div>{/if}
73
- <div class="sv-toast__msg">{t.message}</div>
74
- </div>
82
+ {#if t.render}
83
+ <div class="sv-toast__content">{@render t.render(t)}</div>
84
+ {:else}
85
+ <span class="sv-toast__icon" aria-hidden="true">{icon(t.variant)}</span>
86
+ <div class="sv-toast__content">
87
+ {#if t.title}<div class="sv-toast__title">{t.title}</div>{/if}
88
+ <div class="sv-toast__msg">{t.message}</div>
89
+ {#if t.action || t.cancel}
90
+ <div class="sv-toast__actions">
91
+ {#if t.cancel}
92
+ <button type="button" class="sv-toast__action sv-toast__action--cancel" onclick={() => runAction(t.cancel!, t.id)}>{t.cancel.label}</button>
93
+ {/if}
94
+ {#if t.action}
95
+ <button type="button" class="sv-toast__action" onclick={() => runAction(t.action!, t.id)}>{t.action.label}</button>
96
+ {/if}
97
+ </div>
98
+ {/if}
99
+ </div>
100
+ {/if}
75
101
  {#if t.dismissible}
76
102
  <button type="button" class="sv-toast__x" aria-label="Dismiss" onclick={() => dismissToast(t.id)}>&times;</button>
77
103
  {/if}
@@ -116,6 +142,15 @@
116
142
  :global(.sv-toast__content) { flex: 1; min-width: 0; }
117
143
  :global(.sv-toast__title) { font-weight: 650; margin-bottom: 1px; }
118
144
  :global(.sv-toast__msg) { color: var(--sg-fg, #0f172a); overflow-wrap: anywhere; }
145
+ :global(.sv-toast__actions) { display: flex; gap: 8px; margin-top: 9px; }
146
+ :global(.sv-toast__action) {
147
+ font: inherit; font-size: 12.5px; font-weight: 600; padding: 4px 11px; border-radius: 6px;
148
+ border: 1px solid var(--_accent, var(--sg-accent, #2563eb)); background: none; color: var(--_accent, var(--sg-accent, #2563eb));
149
+ cursor: pointer;
150
+ }
151
+ :global(.sv-toast__action:hover) { background: color-mix(in srgb, var(--_accent, #2563eb) 12%, transparent); }
152
+ :global(.sv-toast__action--cancel) { border-color: var(--sg-border, #e2e8f0); color: var(--sg-muted, #64748b); }
153
+ :global(.sv-toast__action--cancel:hover) { background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
119
154
  :global(.sv-toast__x) {
120
155
  flex: none; width: 22px; height: 22px; display: grid; place-items: center; margin: -2px -2px 0 0;
121
156
  background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; border-radius: 5px; font-size: 17px; line-height: 1;
@@ -47,6 +47,38 @@ describe('SvToaster', () => {
47
47
  } finally { destroy() }
48
48
  })
49
49
 
50
+ it('clicking the dismiss (x) button removes the toast', () => {
51
+ const { destroy } = mountToaster()
52
+ try {
53
+ toast('Close me', { duration: 0 })
54
+ flushSync()
55
+ const x = document.body.querySelector<HTMLButtonElement>('.sv-toast__x')!
56
+ expect(x).not.toBeNull()
57
+ x.click()
58
+ flushSync()
59
+ expect(toastStore.toasts).toHaveLength(0)
60
+ expect(toastEl()).toBeNull()
61
+ } finally { destroy() }
62
+ })
63
+
64
+ it('a press that starts on the dismiss button does not begin a swipe', () => {
65
+ // The swipe handler must ignore presses on the toast's own controls,
66
+ // otherwise pointer-capture on the toast swallows the button's click.
67
+ const { destroy } = mountToaster()
68
+ try {
69
+ toast('Keep me', { duration: 0 })
70
+ flushSync()
71
+ const el = toastEl()!
72
+ const x = el.querySelector<HTMLButtonElement>('.sv-toast__x')!
73
+ x.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100, button: 0 }))
74
+ el.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 240 })) // dx 140 > 60
75
+ el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 240 }))
76
+ flushSync()
77
+ // No swipe was started, so the toast survives (the click path owns dismiss).
78
+ expect(toastStore.toasts).toHaveLength(1)
79
+ } finally { destroy() }
80
+ })
81
+
50
82
  it('a small swipe does not dismiss', () => {
51
83
  const { destroy } = mountToaster()
52
84
  try {
@@ -18,6 +18,7 @@
18
18
  pressed = false,
19
19
  onChange,
20
20
  disabled = false,
21
+ readonly = false,
21
22
  size = 'md',
22
23
  ariaLabel,
23
24
  invalid = false,
@@ -36,7 +37,7 @@
36
37
  // The styled toggle is just a renderer over the headless core.
37
38
  const t = createToggle({
38
39
  pressed: () => pressed,
39
- onChange: (v) => onChange?.(v),
40
+ onChange: (v) => { if (readonly) return; onChange?.(v) },
40
41
  disabled: () => disabled,
41
42
  ariaLabel: () => ariaLabel,
42
43
  id: () => uid,
@@ -52,6 +53,7 @@
52
53
  class="sv-toggle sv-toggle--{size}"
53
54
  class:is-pressed={pressed}
54
55
  class:is-invalid={invalid}
56
+ class:is-readonly={readonly}
55
57
  {...t.buttonProps()}
56
58
  >
57
59
  {#if children}{@render children()}{/if}
@@ -77,6 +79,7 @@
77
79
  background: var(--_accent); color: var(--sg-on-accent, #fff); border-color: var(--_accent);
78
80
  }
79
81
  .sv-toggle[disabled] { opacity: 0.55; cursor: not-allowed; }
82
+ .sv-toggle.is-readonly { cursor: default; }
80
83
  .sv-toggle.is-invalid { border-color: var(--sg-danger, #dc2626); }
81
84
  .sv-toggle:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; }
82
85
  </style>
@@ -1,64 +1,90 @@
1
1
  <script lang="ts">
2
2
  /**
3
3
  * SvTooltip - a small hover/focus tooltip anchored to its child, portalled to
4
- * <body> so it is never clipped. Shows after a short delay, hides on leave /
4
+ * <body> so it is never clipped. Positioned by the shared engine (full placement
5
+ * matrix + flip + shift + arrow), shows after a short delay, hides on leave /
5
6
  * blur / Escape, and is wired via `aria-describedby` (role="tooltip") onto the
6
7
  * focusable child itself, so AT users get it announced on focus, not just
7
8
  * sighted users on hover.
8
9
  *
10
+ * Set `group` on several tooltips so scanning between them skips the re-delay,
11
+ * `closeDelay` + `interactive` for a hoverable tip (e.g. one with a link).
12
+ *
9
13
  * ```svelte
10
14
  * <SvTooltip text="Delete row"><button aria-label="Delete">🗑</button></SvTooltip>
11
15
  * ```
12
16
  */
13
17
  import type { Snippet } from 'svelte'
14
18
  import { portalToBody, popIn } from './popover'
19
+ import { computePosition, autoUpdate, type Placement, type ComputePositionResult } from './positioning'
15
20
  import { getFocusable } from './a11y/focus-trap'
16
21
  import { createTooltip } from './createTooltip.svelte'
17
22
 
18
23
  type Props = {
19
24
  text: string
20
- placement?: 'top' | 'bottom'
25
+ /** Preferred placement; flips when there is no room. Default `top`. */
26
+ placement?: Placement
21
27
  /** Show delay in ms. Default 300. */
22
28
  delay?: number
29
+ /** Hide delay in ms after leave (give it room for an interactive tip). Default 0. */
30
+ closeDelay?: number
31
+ /** Delay-group id: grouped tooltips open instantly while the group is warm. */
32
+ group?: string
33
+ /** Let the pointer move onto the tip without closing it (e.g. a tip with a link). */
34
+ interactive?: boolean
23
35
  disabled?: boolean
24
36
  children?: Snippet
25
37
  }
26
38
 
27
- let { text, placement = 'top', delay = 300, disabled = false, children }: Props = $props()
39
+ let {
40
+ text,
41
+ placement = 'top',
42
+ delay = 300,
43
+ closeDelay = 0,
44
+ group,
45
+ interactive = false,
46
+ disabled = false,
47
+ children,
48
+ }: Props = $props()
49
+
50
+ const ARROW = 8
28
51
 
29
52
  let anchorEl = $state<HTMLSpanElement | null>(null)
30
53
  let tipEl = $state<HTMLDivElement | null>(null)
31
- let pos = $state({ top: 0, left: 0, arrowX: 12 })
54
+ let pos = $state<ComputePositionResult>({
55
+ x: 0, y: 0, placement, side: 'top', align: 'center', maxWidth: 0, maxHeight: 0, arrow: { x: 12 },
56
+ })
32
57
 
33
- // Open state, show-delay and Escape-to-hide live in the shared headless core;
34
- // this component owns only the DOM concerns (positioning + describedby merge).
35
- const tip = createTooltip({ text: () => text, disabled: () => disabled, delay: () => delay })
58
+ // Open state, show/close delay, delay-group and Escape-to-hide live in the
59
+ // shared headless core; this component owns only the DOM concerns (positioning
60
+ // + describedby merge). Interactive tips get a small closeDelay by default so
61
+ // the pointer can travel from anchor to tip.
62
+ const tip = createTooltip({
63
+ text: () => text,
64
+ disabled: () => disabled,
65
+ delay: () => delay,
66
+ closeDelay: () => (closeDelay || (interactive ? 120 : 0)),
67
+ group: () => group,
68
+ })
36
69
  const tid = tip.tipId
37
70
 
38
71
  function place() {
39
72
  if (!anchorEl) return
40
73
  const r = anchorEl.getBoundingClientRect()
41
- const tipH = tipEl?.offsetHeight ?? 30
42
- const tipW = tipEl?.offsetWidth ?? 0
43
- const top = placement === 'top' ? r.top - tipH - 8 : r.bottom + 8
44
- let left = r.left + r.width / 2 - tipW / 2
45
- left = Math.max(4, Math.min(left, window.innerWidth - tipW - 4))
46
- // Keep the arrow under the anchor's center even when the tip is clamped.
47
- const arrowX = Math.max(10, Math.min(tipW - 10, r.left + r.width / 2 - left))
48
- pos = { top, left, arrowX }
74
+ const floating = { width: tipEl?.offsetWidth || 0, height: tipEl?.offsetHeight || 30 }
75
+ pos = computePosition(
76
+ { x: r.left, y: r.top, width: r.width, height: r.height },
77
+ floating,
78
+ { placement, offset: 8, padding: 4, arrow: { size: ARROW } },
79
+ )
49
80
  }
50
81
 
51
- // Reposition while open (and on scroll/resize). Escape-to-hide is the core's.
82
+ // Reposition while open (measure the tip after it mounts, then keep in sync).
52
83
  $effect(() => {
53
- if (!tip.open) return
84
+ if (!tip.open || !anchorEl || !tipEl) return
54
85
  queueMicrotask(place)
55
- const rp = () => place()
56
- window.addEventListener('scroll', rp, true)
57
- window.addEventListener('resize', rp)
58
- return () => {
59
- window.removeEventListener('scroll', rp, true)
60
- window.removeEventListener('resize', rp)
61
- }
86
+ const stop = autoUpdate(anchorEl, tipEl, place)
87
+ return () => stop()
62
88
  })
63
89
 
64
90
  // The wrapper span above is never itself Tab-focusable, so putting
@@ -98,15 +124,18 @@
98
124
  <div
99
125
  bind:this={tipEl}
100
126
  id={tid}
101
- class="sv-tip"
127
+ class="sv-tip sv-tip--{pos.side}"
128
+ class:sv-tip--interactive={interactive}
102
129
  role="tooltip"
103
130
  use:portalToBody
104
- use:popIn={{ up: placement === 'top', duration: 110, scale: 0.9 }}
105
- class:sv-tip--bottom={placement === 'bottom'}
131
+ use:popIn={{ up: pos.side === 'top', duration: 110, scale: 0.9 }}
106
132
  style:position="fixed"
107
- style:top={`${pos.top}px`}
108
- style:left={`${pos.left}px`}
109
- style:--tip-arrow-x={`${pos.arrowX}px`}
133
+ style:top={`${pos.y}px`}
134
+ style:left={`${pos.x}px`}
135
+ style:--tip-arrow-x={pos.arrow?.x != null ? `${pos.arrow.x}px` : undefined}
136
+ style:--tip-arrow-y={pos.arrow?.y != null ? `${pos.arrow.y}px` : undefined}
137
+ onpointerenter={interactive ? tip.keepOpen : undefined}
138
+ onpointerleave={interactive ? tip.scheduleHide : undefined}
110
139
  >{text}<span class="sv-tip__arrow" aria-hidden="true"></span></div>
111
140
  {/if}
112
141
 
@@ -118,10 +147,15 @@
118
147
  background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff);
119
148
  border-radius: 6px; box-shadow: 0 6px 20px -6px rgba(15, 23, 42, 0.5); white-space: normal;
120
149
  }
150
+ :global(.sv-tip--interactive) { pointer-events: auto; }
121
151
  :global(.sv-tip__arrow) {
122
- position: absolute; inset-inline-start: var(--tip-arrow-x, 12px); width: 8px; height: 8px;
123
- background: var(--sg-fg, #0f172a); transform: translateX(-50%) rotate(45deg);
152
+ position: absolute; width: 8px; height: 8px;
153
+ background: var(--sg-fg, #0f172a); transform: rotate(45deg);
124
154
  }
125
- :global(.sv-tip:not(.sv-tip--bottom) .sv-tip__arrow) { bottom: -3px; }
126
- :global(.sv-tip--bottom .sv-tip__arrow) { top: -3px; }
155
+ /* Arrow sits on the edge facing the anchor. top/bottom use --tip-arrow-x,
156
+ left/right use --tip-arrow-y. */
157
+ :global(.sv-tip--top .sv-tip__arrow) { bottom: -3px; inset-inline-start: var(--tip-arrow-x, 12px); }
158
+ :global(.sv-tip--bottom .sv-tip__arrow) { top: -3px; inset-inline-start: var(--tip-arrow-x, 12px); }
159
+ :global(.sv-tip--left .sv-tip__arrow) { right: -3px; top: var(--tip-arrow-y, 12px); }
160
+ :global(.sv-tip--right .sv-tip__arrow) { left: -3px; top: var(--tip-arrow-y, 12px); }
127
161
  </style>
package/src/SvTree.svelte CHANGED
@@ -13,9 +13,10 @@
13
13
  * `createTree` core; this component renders it, and owns the DOM concerns
14
14
  * (windowing, focus, and merging lazily-loaded children into the node tree).
15
15
  */
16
- import { tick, flushSync } from 'svelte'
16
+ import { tick, untrack } from 'svelte'
17
17
  import { type EditorDir } from './editor-contract'
18
- import { virtualRange, scrollToIndex } from './virtual'
18
+ import { createSvelteVirtualizer } from './virtualization/svelte-virtualizer.svelte'
19
+ import { createJsScroller } from './js-scroller.svelte'
19
20
  import { createTree, sortTreeNodes, type TreeNode, type TreeDropPosition } from './createTree.svelte'
20
21
 
21
22
  type Props = {
@@ -35,8 +36,9 @@
35
36
  dir?: EditorDir
36
37
  /** Window rows (render only visible) - for large trees. Needs `height`. */
37
38
  virtual?: boolean
38
- /** Fixed row height in px (must match the CSS). Default 30. */
39
- rowHeight?: number
39
+ /** Row height in px - a number, or a per-node function for variable
40
+ * heights. Must match the rendered row height. Default 30. */
41
+ rowHeight?: number | ((node: TreeNode, index: number) => number)
40
42
  /** Scroll-viewport height in px. Enables scrolling (required for `virtual`). */
41
43
  height?: number
42
44
  /** Load a lazy node's children on first expand (node has `lazy: true`). */
@@ -197,14 +199,58 @@
197
199
  })
198
200
 
199
201
  // --- Virtualization ---------------------------------------------------------
202
+ // Windowing runs on the shared `createSvelteVirtualizer` (cumulative offsets +
203
+ // binary search + variable-size support). Rows stay keyed by node id (NOT slot
204
+ // recycling): a tree row carries per-row identity - an in-progress inline
205
+ // rename, an active drag, roving focus - that must stay pinned to its node
206
+ // while scrolling. The engine only supplies the window + each row's offset.
200
207
  const scrollable = $derived(virtual || height != null)
201
208
  let treeEl = $state<HTMLDivElement | null>(null)
202
- let scrollTop = $state(0)
203
209
  let viewportH = $state(0)
204
- const vr = $derived(
205
- virtualRange({ scrollTop, viewportHeight: viewportH, rowHeight, count: tree.rows.length, overscan: 10 }),
210
+ const rowHeightBase = $derived(typeof rowHeight === 'number' ? rowHeight : 30)
211
+ const uniform = $derived(typeof rowHeight === 'number')
212
+ const rowSize = (i: number) => {
213
+ const r = tree.rows[i]
214
+ return r ? (typeof rowHeight === 'function' ? rowHeight(r.node, i) : rowHeight) : rowHeightBase
215
+ }
216
+
217
+ const estimateSize = $derived(uniform ? rowHeightBase : (i: number) => rowSize(i))
218
+ // Generous overscan buffers fast flings; effective viewport never 0 (see SvListBox).
219
+ const OVERSCAN = 20
220
+ const effViewport = $derived(viewportH > 0 ? viewportH : Math.max(1, height ?? 320))
221
+ // Seed with the initial (untracked) values so the first paint renders a full window.
222
+ const virtualizer = createSvelteVirtualizer(
223
+ untrack(() => ({ count: tree.rows.length, estimateSize, overscan: OVERSCAN, viewportHeight: effViewport })),
206
224
  )
207
- const visibleRows = $derived(virtual ? tree.rows.slice(vr.start, vr.end) : tree.rows)
225
+ // untrack keeps the virtualizer's internal `version` write out of these
226
+ // effects' dependencies, so they re-run only on their real inputs.
227
+ $effect(() => {
228
+ const count = tree.rows.length
229
+ const es = estimateSize
230
+ untrack(() => virtualizer.setOptions({ count, estimateSize: es }))
231
+ })
232
+ $effect(() => {
233
+ const vh = effViewport
234
+ untrack(() => virtualizer.setViewportHeight(vh))
235
+ })
236
+ const totalSize = $derived.by(() => { virtualizer.version; return virtualizer.getTotalSize() })
237
+ const visibleRows = $derived.by(() => {
238
+ if (!virtual) return tree.rows
239
+ virtualizer.version
240
+ const st = virtualizer.getState()
241
+ return tree.rows.slice(st.startIndex, st.endIndex + 1)
242
+ })
243
+ const rowTop = (i: number) => virtualizer.getOffsetForIndex(i)
244
+ const rowHeightPx = (i: number) => (virtual ? virtualizer.getSizeForIndex(i) : rowSize(i))
245
+
246
+ // JS-driven scroll for virtual mode (see js-scroller): no native scroll, so a
247
+ // fast thumb-drag never flashes an empty tree.
248
+ const scroller = createJsScroller({
249
+ virtualizer,
250
+ totalSize: () => totalSize,
251
+ viewport: () => effViewport,
252
+ lineStep: () => rowHeightBase,
253
+ })
208
254
 
209
255
  // Follow the core's roving focus row; scroll it into view first when windowed.
210
256
  let lastFocusTick = 0
@@ -212,10 +258,7 @@
212
258
  if (tree.focusTick === lastFocusTick) return
213
259
  lastFocusTick = tree.focusTick
214
260
  const i = tree.activeIndex
215
- if (virtual && treeEl) {
216
- const next = scrollToIndex(i, scrollTop, treeEl.clientHeight, rowHeight)
217
- if (next !== scrollTop) { treeEl.scrollTop = next; scrollTop = next }
218
- }
261
+ if (virtual) untrack(() => scroller.ensureIndexVisible(i))
219
262
  tick().then(() => treeEl?.querySelector<HTMLElement>(`[data-row="${i}"]`)?.focus())
220
263
  })
221
264
  </script>
@@ -228,25 +271,35 @@
228
271
  {#if query}<button type="button" class="sv-tree__search-clear" aria-label="Clear search" onclick={() => (query = '')}>&times;</button>{/if}
229
272
  </div>
230
273
  {/if}
274
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
231
275
  <div
232
276
  bind:this={treeEl}
233
277
  class="sv-tree"
234
- class:sv-tree--scroll={scrollable}
278
+ class:sv-tree--scroll={scrollable && !virtual}
235
279
  class:is-virtual={virtual}
236
280
  dir={resolvedDir}
237
281
  style:height={height != null ? `${height}px` : undefined}
238
- onscroll={scrollable ? (e) => { scrollTop = e.currentTarget.scrollTop; if (virtual) flushSync() } : undefined}
282
+ onwheel={virtual ? scroller.onWheel : undefined}
283
+ onpointerdown={virtual ? scroller.onContentDown : undefined}
284
+ onpointermove={virtual ? scroller.onContentMove : undefined}
285
+ onpointerup={virtual ? scroller.onContentUp : undefined}
286
+ onpointercancel={virtual ? scroller.onContentUp : undefined}
239
287
  bind:clientHeight={viewportH}
240
288
  {...tree.treeProps()}
241
289
  >
242
- {#if virtual}<div class="sv-tree__sizer" aria-hidden="true" style:height={`${vr.totalHeight}px`}></div>{/if}
243
290
  {#each visibleRows as item (item.node.id)}
244
291
  {@render treeRow(item)}
245
292
  {/each}
293
+ {#if virtual && scroller.maxOffset > 0}
294
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
295
+ <div class="sv-tree__sb" onpointerdown={scroller.onTrackDown} aria-hidden="true">
296
+ <div class="sv-tree__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>
297
+ </div>
298
+ {/if}
246
299
  </div>
247
300
  </div>
248
301
 
249
- {#snippet treeRow(item)}
302
+ {#snippet treeRow(item: (typeof tree.rows)[number])}
250
303
  {@const cs = checkable ? tree.checkStateOf(item.node) : 'unchecked'}
251
304
  {@const isLoading = loadingIds.has(item.node.id)}
252
305
  {@const rowProps = tree.itemProps(item)}
@@ -258,9 +311,9 @@
258
311
  class:drop-before={dropId === item.node.id && dropPos === 'before'}
259
312
  class:drop-after={dropId === item.node.id && dropPos === 'after'}
260
313
  class:drop-inside={dropId === item.node.id && dropPos === 'inside'}
261
- style:height={`${rowHeight}px`}
314
+ style:height={`${rowHeightPx(item.index)}px`}
262
315
  style:padding-inline-start={`${item.depth * 18 + 6}px`}
263
- style:transform={virtual ? `translateY(${item.index * rowHeight}px)` : undefined}
316
+ style:transform={virtual ? `translateY(${rowTop(item.index) - scroller.scrollOffset}px)` : undefined}
264
317
  draggable={reorderable && editingId !== item.node.id && !item.node.disabled}
265
318
  ondragstart={reorderable ? (e) => onRowDragStart(e, item.node) : undefined}
266
319
  ondragover={reorderable ? (e) => onRowDragOver(e, item.node) : undefined}
@@ -329,10 +382,17 @@
329
382
  border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
330
383
  font-size: 13px;
331
384
  }
332
- .sv-tree--scroll { overflow-y: auto; }
333
- .sv-tree.is-virtual { position: relative; will-change: scroll-position; }
334
- .sv-tree__sizer { padding: 0; margin: 0; pointer-events: none; }
385
+ /* Non-virtual (height-capped) tree uses native scroll. */
386
+ .sv-tree--scroll { overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; }
387
+ /* Virtual tree: JS-driven, no native scroll - rows are moved by `scrollOffset`. */
388
+ .sv-tree.is-virtual { position: relative; overflow: hidden; touch-action: none; }
335
389
  .sv-tree.is-virtual .sv-tree__row { position: absolute; inset-inline: 4px; top: 4px; }
390
+ .sv-tree__sb { position: absolute; top: 4px; right: 2px; bottom: 4px; width: 10px; z-index: 2; }
391
+ .sv-tree__thumb {
392
+ position: absolute; left: 2px; right: 2px; top: 0; min-height: 24px; border-radius: 4px;
393
+ background: color-mix(in srgb, currentColor 26%, transparent); cursor: default; touch-action: none;
394
+ }
395
+ .sv-tree__thumb:hover, .sv-tree__thumb.is-dragging { background: color-mix(in srgb, currentColor 42%, transparent); }
336
396
  .sv-tree__row {
337
397
  display: flex; align-items: center; gap: 4px; box-sizing: border-box; padding-inline-end: 8px;
338
398
  border-radius: 6px; cursor: pointer; outline: none;
@@ -20,7 +20,7 @@
20
20
 
21
21
  type Props = Pick<
22
22
  SvEditorProps,
23
- 'disabled' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel'
23
+ 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'
24
24
  > & {
25
25
  nodes: ReadonlyArray<TreeSelectNode>
26
26
  value?: string | number | null
@@ -42,6 +42,7 @@
42
42
  showPath = false,
43
43
  expandedIds = [],
44
44
  disabled = false,
45
+ readonly = false,
45
46
  label,
46
47
  hint,
47
48
  error,
@@ -52,6 +53,7 @@
52
53
  name,
53
54
  dir,
54
55
  ariaLabel,
56
+ loading = false,
55
57
  }: Props = $props()
56
58
 
57
59
  const autoId = nextEditorId('sv-ts')
@@ -94,6 +96,7 @@
94
96
  const ts = createPopoverSelect({
95
97
  itemCount: () => rows.length,
96
98
  disabled: (i) => !!rows[i]?.node.disabled,
99
+ readonly: () => readonly,
97
100
  onSelect: (i) => { const row = rows[i]; if (row) onRowClick(row) },
98
101
  getTrigger: () => triggerEl,
99
102
  getPanel: () => panelEl,
@@ -127,7 +130,7 @@
127
130
  })
128
131
  </script>
129
132
 
130
- <SvField id={uid} {label} {hint} {error} {required} {dir}>
133
+ <SvField id={uid} {label} {hint} {error} {required} {dir} {loading}>
131
134
  <div class="sv-ts sv-ts--{size}" class:is-disabled={disabled}>
132
135
  <button
133
136
  bind:this={triggerEl}
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvVisuallyHidden - content available to screen readers but visually hidden
4
+ * (the accessible "sr-only" pattern). Use for extra context on icon-only
5
+ * controls, live-region text, and skip links (which become visible on focus
6
+ * via `focusable`).
7
+ *
8
+ * <button><Icon /><SvVisuallyHidden>Delete row</SvVisuallyHidden></button>
9
+ */
10
+ import type { Snippet } from 'svelte'
11
+
12
+ type Props = {
13
+ /** Reveal the content when it (or a child) receives focus - for skip links. */
14
+ focusable?: boolean
15
+ /** Element/tag to render. */
16
+ as?: string
17
+ children?: Snippet
18
+ }
19
+
20
+ let { focusable = false, as = 'span', children }: Props = $props()
21
+ </script>
22
+
23
+ <svelte:element this={as} class="sv-vh" class:is-focusable={focusable}>
24
+ {@render children?.()}
25
+ </svelte:element>
26
+
27
+ <style>
28
+ .sv-vh {
29
+ position: absolute;
30
+ width: 1px;
31
+ height: 1px;
32
+ padding: 0;
33
+ margin: -1px;
34
+ overflow: hidden;
35
+ clip: rect(0 0 0 0);
36
+ white-space: nowrap;
37
+ border: 0;
38
+ }
39
+ .sv-vh.is-focusable:focus,
40
+ .sv-vh.is-focusable:focus-within {
41
+ position: static;
42
+ width: auto;
43
+ height: auto;
44
+ margin: 0;
45
+ overflow: visible;
46
+ clip: auto;
47
+ white-space: normal;
48
+ }
49
+ </style>