@svgrid/grid 2.1.22 → 2.2.0

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 (295) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/FlexRender.svelte +96 -96
  7. package/dist/GridFooter.svelte +178 -178
  8. package/dist/GridMenus.svelte +83 -3
  9. package/dist/SvAutoComplete.svelte +5 -3
  10. package/dist/SvCarousel.svelte +141 -163
  11. package/dist/SvColorInput.svelte +187 -186
  12. package/dist/SvColorInput.svelte.d.ts +1 -1
  13. package/dist/SvComboBox.svelte +7 -3
  14. package/dist/SvCommand.svelte +28 -85
  15. package/dist/SvContextMenu.svelte +116 -116
  16. package/dist/SvCountryInput.svelte +1 -1
  17. package/dist/SvDateRangeInput.svelte +1 -1
  18. package/dist/SvDateTimePicker.svelte +9 -3
  19. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  20. package/dist/SvDockLayout.svelte +244 -0
  21. package/dist/SvDockLayout.svelte.d.ts +77 -0
  22. package/dist/SvDockManager.svelte +922 -0
  23. package/dist/SvDockManager.svelte.d.ts +106 -0
  24. package/dist/SvDrawer.svelte +250 -228
  25. package/dist/SvDrawer.svelte.d.ts +4 -0
  26. package/dist/SvDropDownList.svelte +85 -13
  27. package/dist/SvDropDownList.svelte.d.ts +3 -0
  28. package/dist/SvField.svelte +21 -4
  29. package/dist/SvField.svelte.d.ts +1 -0
  30. package/dist/SvForm.svelte +201 -91
  31. package/dist/SvForm.svelte.d.ts +15 -22
  32. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  33. package/dist/SvGrid.controller.svelte.js +156 -27
  34. package/dist/SvGrid.css +193 -5
  35. package/dist/SvGrid.svelte +3131 -2786
  36. package/dist/SvGrid.types.d.ts +452 -1
  37. package/dist/SvGridBoard.svelte +2339 -2339
  38. package/dist/SvGridChart.svelte +1724 -1724
  39. package/dist/SvGridChartPanel.svelte +485 -485
  40. package/dist/SvGridDropdown.svelte +689 -682
  41. package/dist/SvGridSelect.svelte +1 -0
  42. package/dist/SvGroupCell.svelte +109 -109
  43. package/dist/SvListBox.svelte +36 -14
  44. package/dist/SvMaskedInput.svelte +4 -3
  45. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  46. package/dist/SvMenu.svelte +124 -124
  47. package/dist/SvMenuList.svelte +117 -165
  48. package/dist/SvMenuList.svelte.d.ts +2 -13
  49. package/dist/SvModal.svelte +12 -29
  50. package/dist/SvMultiSelect.svelte +1 -0
  51. package/dist/SvNumberInput.svelte +131 -130
  52. package/dist/SvNumberInput.svelte.d.ts +1 -1
  53. package/dist/SvOtpInput.svelte +158 -158
  54. package/dist/SvPagination.svelte +18 -18
  55. package/dist/SvPasswordInput.svelte +3 -2
  56. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  57. package/dist/SvPhoneInput.svelte +3 -2
  58. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  59. package/dist/SvPopover.svelte +139 -139
  60. package/dist/SvRowGroupPanel.svelte +170 -170
  61. package/dist/SvScrollArea.svelte +61 -61
  62. package/dist/SvStepper.svelte +6 -9
  63. package/dist/SvTagsInput.svelte +3 -2
  64. package/dist/SvTagsInput.svelte.d.ts +1 -1
  65. package/dist/SvTooltip.svelte +127 -138
  66. package/dist/SvTour.svelte +204 -176
  67. package/dist/SvTree.svelte +377 -377
  68. package/dist/SvTreeSelect.svelte +230 -229
  69. package/dist/build-api.js +76 -54
  70. package/dist/cdn/svgrid.js +10913 -6450
  71. package/dist/cdn/svgrid.svelte-external.js +9628 -5481
  72. package/dist/chart-export.js +8 -8
  73. package/dist/columns.js +6 -0
  74. package/dist/conditional-formatting.d.ts +67 -3
  75. package/dist/conditional-formatting.js +124 -17
  76. package/dist/createCarousel.svelte.d.ts +72 -0
  77. package/dist/createCarousel.svelte.js +80 -0
  78. package/dist/createCommand.svelte.d.ts +62 -0
  79. package/dist/createCommand.svelte.js +109 -0
  80. package/dist/createForm.svelte.d.ts +62 -0
  81. package/dist/createForm.svelte.js +374 -0
  82. package/dist/createMenu.svelte.d.ts +88 -0
  83. package/dist/createMenu.svelte.js +153 -0
  84. package/dist/createOverlay.svelte.d.ts +38 -0
  85. package/dist/createOverlay.svelte.js +68 -0
  86. package/dist/createPagination.d.ts +74 -0
  87. package/dist/createPagination.js +71 -0
  88. package/dist/createPopoverSelect.svelte.js +6 -12
  89. package/dist/createStepper.d.ts +44 -0
  90. package/dist/createStepper.js +26 -0
  91. package/dist/createTooltip.svelte.d.ts +32 -0
  92. package/dist/createTooltip.svelte.js +61 -0
  93. package/dist/datetime/timezone.d.ts +36 -0
  94. package/dist/datetime/timezone.js +110 -0
  95. package/dist/dock-context.d.ts +52 -0
  96. package/dist/dock-context.js +1 -0
  97. package/dist/dock-manager-model.d.ts +136 -0
  98. package/dist/dock-manager-model.js +514 -0
  99. package/dist/dock-model.d.ts +105 -0
  100. package/dist/dock-model.js +288 -0
  101. package/dist/dock-popout.d.ts +30 -0
  102. package/dist/dock-popout.js +81 -0
  103. package/dist/editor-contract.d.ts +2 -0
  104. package/dist/filter-operators.d.ts +6 -0
  105. package/dist/filter-operators.js +35 -2
  106. package/dist/filtering/excel-filters.d.ts +18 -1
  107. package/dist/filtering/excel-filters.js +56 -0
  108. package/dist/form-field.d.ts +73 -0
  109. package/dist/form-field.js +15 -0
  110. package/dist/index.d.ts +25 -5
  111. package/dist/index.js +40 -4
  112. package/dist/list-nav.d.ts +18 -0
  113. package/dist/list-nav.js +30 -0
  114. package/dist/list-option.d.ts +2 -0
  115. package/dist/menu-item.d.ts +20 -0
  116. package/dist/menu-item.js +1 -0
  117. package/dist/menus.d.ts +5 -0
  118. package/dist/menus.js +68 -16
  119. package/dist/panel-resize.d.ts +25 -0
  120. package/dist/panel-resize.js +40 -0
  121. package/dist/popover.d.ts +29 -3
  122. package/dist/popover.js +22 -6
  123. package/dist/recurrence.d.ts +33 -7
  124. package/dist/recurrence.js +151 -13
  125. package/dist/row-drag.d.ts +8 -0
  126. package/dist/row-drag.js +11 -0
  127. package/dist/scheduler-ical.d.ts +30 -0
  128. package/dist/scheduler-ical.js +180 -0
  129. package/dist/scheduler-model.d.ts +269 -0
  130. package/dist/scheduler-model.js +602 -0
  131. package/dist/scheduler-view.svelte.d.ts +22 -0
  132. package/dist/scheduler-view.svelte.js +13 -0
  133. package/dist/spreadsheet.js +20 -12
  134. package/dist/ui-app.types.d.ts +3 -0
  135. package/package.json +14 -10
  136. package/src/DockNodeView.svelte +306 -0
  137. package/src/DockPopoutHost.svelte +17 -0
  138. package/src/FlexRender.svelte +96 -96
  139. package/src/GridFooter.svelte +178 -178
  140. package/src/GridMenus.svelte +83 -3
  141. package/src/SvAutoComplete.svelte +5 -3
  142. package/src/SvCarousel.svelte +141 -163
  143. package/src/SvCarousel.test.ts +68 -0
  144. package/src/SvColorInput.svelte +187 -186
  145. package/src/SvComboBox.svelte +7 -3
  146. package/src/SvCommand.svelte +28 -85
  147. package/src/SvCommand.test.ts +78 -0
  148. package/src/SvContextMenu.svelte +116 -116
  149. package/src/SvCountryInput.svelte +1 -1
  150. package/src/SvDateRangeInput.svelte +1 -1
  151. package/src/SvDateTimePicker.svelte +9 -3
  152. package/src/SvDockLayout.svelte +244 -0
  153. package/src/SvDockManager.svelte +922 -0
  154. package/src/SvDrawer.svelte +250 -228
  155. package/src/SvDropDownList.svelte +85 -13
  156. package/src/SvField.svelte +21 -4
  157. package/src/SvForm.svelte +201 -91
  158. package/src/SvForm.test.ts +299 -0
  159. package/src/SvGrid.controller.svelte.ts +3158 -3034
  160. package/src/SvGrid.css +193 -5
  161. package/src/SvGrid.svelte +3131 -2786
  162. package/src/SvGrid.types.ts +1489 -1027
  163. package/src/SvGridBoard.svelte +2339 -2339
  164. package/src/SvGridChart.svelte +1724 -1724
  165. package/src/SvGridChartPanel.svelte +485 -485
  166. package/src/SvGridDropdown.svelte +689 -682
  167. package/src/SvGridSelect.svelte +1 -0
  168. package/src/SvGroupCell.svelte +109 -109
  169. package/src/SvListBox.svelte +36 -14
  170. package/src/SvMaskedInput.svelte +4 -3
  171. package/src/SvMenu.svelte +124 -124
  172. package/src/SvMenu.test.ts +97 -73
  173. package/src/SvMenuList.svelte +117 -165
  174. package/src/SvModal.svelte +12 -29
  175. package/src/SvModal.test.ts +82 -0
  176. package/src/SvMultiSelect.svelte +1 -0
  177. package/src/SvNumberInput.svelte +131 -130
  178. package/src/SvOtpInput.svelte +158 -158
  179. package/src/SvPagination.svelte +18 -18
  180. package/src/SvPasswordInput.svelte +3 -2
  181. package/src/SvPhoneInput.svelte +3 -2
  182. package/src/SvPopover.svelte +139 -139
  183. package/src/SvRowGroupPanel.svelte +170 -170
  184. package/src/SvScrollArea.svelte +61 -61
  185. package/src/SvStepper.svelte +6 -9
  186. package/src/SvTagsInput.svelte +3 -2
  187. package/src/SvTooltip.svelte +127 -138
  188. package/src/SvTooltip.test.ts +73 -0
  189. package/src/SvTour.svelte +204 -176
  190. package/src/SvTree.svelte +377 -377
  191. package/src/SvTreeSelect.svelte +230 -229
  192. package/src/a11y.contract.test.ts +49 -49
  193. package/src/a11y.test.ts +59 -59
  194. package/src/a11y.ts +61 -61
  195. package/src/build-api.ts +870 -847
  196. package/src/cell-formatting.ts +169 -169
  197. package/src/cell-render.ts +469 -469
  198. package/src/chart-export.ts +201 -201
  199. package/src/chart.ts +2296 -2296
  200. package/src/collaboration.test.ts +104 -104
  201. package/src/collaboration.ts +167 -167
  202. package/src/columns.ts +5 -0
  203. package/src/conditional-formatting.test.ts +104 -0
  204. package/src/conditional-formatting.ts +176 -19
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1111 -1111
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCarousel.svelte.ts +104 -0
  209. package/src/createCombobox.svelte.ts +179 -179
  210. package/src/createCommand.svelte.ts +125 -0
  211. package/src/createCountryInput.svelte.ts +157 -157
  212. package/src/createDropdownList.svelte.ts +162 -162
  213. package/src/createForm.svelte.ts +333 -0
  214. package/src/createGrid.svelte.ts +42 -42
  215. package/src/createGrid.test.ts +10 -10
  216. package/src/createGridState.svelte.ts +17 -17
  217. package/src/createMenu.svelte.ts +202 -0
  218. package/src/createOverlay.svelte.ts +101 -0
  219. package/src/createPagination.test.ts +65 -0
  220. package/src/createPagination.ts +84 -0
  221. package/src/createPopoverSelect.svelte.ts +206 -210
  222. package/src/createStepper.test.ts +62 -0
  223. package/src/createStepper.ts +61 -0
  224. package/src/createTooltip.svelte.ts +86 -0
  225. package/src/createTree.svelte.ts +319 -319
  226. package/src/datetime/timezone.test.ts +69 -0
  227. package/src/datetime/timezone.ts +134 -0
  228. package/src/dock-context.ts +47 -0
  229. package/src/dock-manager-model.test.ts +275 -0
  230. package/src/dock-manager-model.ts +593 -0
  231. package/src/dock-model.test.ts +163 -0
  232. package/src/dock-model.ts +368 -0
  233. package/src/dock-popout.ts +94 -0
  234. package/src/editing.test.ts +859 -859
  235. package/src/editing.ts +675 -675
  236. package/src/editor-contract.ts +2 -0
  237. package/src/export-data-api.test.ts +126 -126
  238. package/src/export-format.test.ts +107 -107
  239. package/src/export-format.ts +598 -598
  240. package/src/filter-operators.test.ts +59 -0
  241. package/src/filter-operators.ts +35 -2
  242. package/src/filtering/excel-filters.test.ts +86 -1
  243. package/src/filtering/excel-filters.ts +65 -0
  244. package/src/flex-render.ts +3 -3
  245. package/src/form-field.ts +87 -0
  246. package/src/index.ts +623 -469
  247. package/src/keyboard.test.ts +59 -59
  248. package/src/keyboard.ts +97 -97
  249. package/src/list-nav.test.ts +49 -0
  250. package/src/list-nav.ts +29 -0
  251. package/src/list-option.ts +2 -0
  252. package/src/menu-item.ts +21 -0
  253. package/src/menus.test.ts +48 -0
  254. package/src/menus.ts +639 -582
  255. package/src/merge-objects.ts +48 -48
  256. package/src/overlays.test.ts +90 -90
  257. package/src/panel-resize.ts +52 -0
  258. package/src/popover.bounds.test.ts +93 -0
  259. package/src/popover.ts +51 -7
  260. package/src/recurrence.test.ts +88 -1
  261. package/src/recurrence.ts +166 -15
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag.ts +10 -0
  264. package/src/scheduler-ical.test.ts +68 -0
  265. package/src/scheduler-ical.ts +180 -0
  266. package/src/scheduler-model.test.ts +546 -0
  267. package/src/scheduler-model.ts +853 -0
  268. package/src/scheduler-view.svelte.ts +33 -0
  269. package/src/selection.test.ts +754 -754
  270. package/src/selection.ts +600 -600
  271. package/src/server-data-source.test.ts +289 -289
  272. package/src/server-data-source.ts +413 -413
  273. package/src/sparkline.test.ts +68 -68
  274. package/src/sparkline.ts +169 -169
  275. package/src/spreadsheet.test.ts +489 -489
  276. package/src/spreadsheet.ts +312 -304
  277. package/src/static-functions.ts +11 -11
  278. package/src/subscribe.ts +38 -38
  279. package/src/svgrid-wrapper.types.ts +501 -501
  280. package/src/svgrid.behavior.test.ts +706 -706
  281. package/src/svgrid.charting.test.ts +527 -527
  282. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  283. package/src/svgrid.features.test.ts +157 -157
  284. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  285. package/src/svgrid.wrapper.test.ts +40 -40
  286. package/src/test-setup.ts +62 -62
  287. package/src/themes/index.ts +159 -159
  288. package/src/ui-app.types.ts +3 -0
  289. package/src/virtual.test.ts +6 -4
  290. package/src/virtualization/column-virtualizer.test.ts +27 -27
  291. package/src/virtualization/column-virtualizer.ts +30 -30
  292. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  293. package/src/virtualization/types.ts +30 -30
  294. package/src/virtualization/virtualizer.test.ts +47 -47
  295. package/src/virtualization/virtualizer.ts +296 -296
@@ -1,48 +1,48 @@
1
- /**
2
- * Merges objects together while keeping their getters alive.
3
- * Taken from SolidJS:
4
- * https://github.com/solidjs/solid/blob/24abc825c0996fd2bc8c1de1491efe9a7e743aff/packages/solid/src/server/rendering.ts#L82-L115
5
- */
6
- export function mergeObjects<T>(source: T): T
7
- export function mergeObjects<T, U>(source: T, source1: U): T & U
8
- export function mergeObjects<T, U, V>(
9
- source: T,
10
- source1: U,
11
- source2: V,
12
- ): T & U & V
13
- export function mergeObjects<T, U, V, W>(
14
- source: T,
15
- source1: U,
16
- source2: V,
17
- source3: W,
18
- ): T & U & V & W
19
- export function mergeObjects(...sources: any): any {
20
- const target = {}
21
-
22
- for (let source of sources) {
23
- if (typeof source === 'function') source = source()
24
-
25
- if (source) {
26
- const descriptors = Object.getOwnPropertyDescriptors(source)
27
-
28
- for (const key in descriptors) {
29
- if (key in target) continue
30
-
31
- Object.defineProperty(target, key, {
32
- enumerable: true,
33
- get() {
34
- for (let i = sources.length - 1; i >= 0; i--) {
35
- let scopedSource = sources[i]
36
- if (typeof scopedSource === 'function') scopedSource = scopedSource()
37
-
38
- const value = (scopedSource || {})[key]
39
- if (value !== undefined) return value
40
- }
41
- },
42
- })
43
- }
44
- }
45
- }
46
-
47
- return target
48
- }
1
+ /**
2
+ * Merges objects together while keeping their getters alive.
3
+ * Taken from SolidJS:
4
+ * https://github.com/solidjs/solid/blob/24abc825c0996fd2bc8c1de1491efe9a7e743aff/packages/solid/src/server/rendering.ts#L82-L115
5
+ */
6
+ export function mergeObjects<T>(source: T): T
7
+ export function mergeObjects<T, U>(source: T, source1: U): T & U
8
+ export function mergeObjects<T, U, V>(
9
+ source: T,
10
+ source1: U,
11
+ source2: V,
12
+ ): T & U & V
13
+ export function mergeObjects<T, U, V, W>(
14
+ source: T,
15
+ source1: U,
16
+ source2: V,
17
+ source3: W,
18
+ ): T & U & V & W
19
+ export function mergeObjects(...sources: any): any {
20
+ const target = {}
21
+
22
+ for (let source of sources) {
23
+ if (typeof source === 'function') source = source()
24
+
25
+ if (source) {
26
+ const descriptors = Object.getOwnPropertyDescriptors(source)
27
+
28
+ for (const key in descriptors) {
29
+ if (key in target) continue
30
+
31
+ Object.defineProperty(target, key, {
32
+ enumerable: true,
33
+ get() {
34
+ for (let i = sources.length - 1; i >= 0; i--) {
35
+ let scopedSource = sources[i]
36
+ if (typeof scopedSource === 'function') scopedSource = scopedSource()
37
+
38
+ const value = (scopedSource || {})[key]
39
+ if (value !== undefined) return value
40
+ }
41
+ },
42
+ })
43
+ }
44
+ }
45
+ }
46
+
47
+ return target
48
+ }
@@ -1,90 +1,90 @@
1
- /**
2
- * Component tests for the overlay trio: SvModal (dialog semantics, Escape /
3
- * backdrop close), SvPopover (click open + outside close) and SvTooltip
4
- * (focus-triggered, aria-describedby wiring).
5
- */
6
- import { describe, expect, it, vi, afterEach } from 'vitest'
7
- import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
8
- import SvModal from './SvModal.svelte'
9
- import SvPopover from './SvPopover.svelte'
10
- import SvTooltip from './SvTooltip.svelte'
11
-
12
- const body = createRawSnippet(() => ({ render: () => `<p class="body">hello</p>` }))
13
- const anchorBtn = createRawSnippet(() => ({ render: () => `<button class="anchor">open</button>` }))
14
-
15
- function mountC(Comp: any, props: Record<string, unknown>) {
16
- const target = document.createElement('div')
17
- document.body.appendChild(target)
18
- const app = mount(Comp, { target, props: props as any })
19
- flushSync()
20
- return { target, destroy: () => { unmount(app); target.remove() } }
21
- }
22
- afterEach(() => {
23
- document.querySelectorAll('.sv-modal__backdrop, .sv-pop__panel, .sv-tip').forEach((n) => n.remove())
24
- })
25
-
26
- describe('SvModal', () => {
27
- it('renders a labelled modal dialog when open', () => {
28
- const { destroy } = mountC(SvModal, { open: true, title: 'Edit', children: body })
29
- try {
30
- const dialog = document.querySelector<HTMLElement>('.sv-modal')!
31
- expect(dialog.getAttribute('role')).toBe('dialog')
32
- expect(dialog.getAttribute('aria-modal')).toBe('true')
33
- const titleId = dialog.getAttribute('aria-labelledby')!
34
- expect(document.getElementById(titleId)!.textContent).toBe('Edit')
35
- expect(document.querySelector('.sv-modal .body')!.textContent).toBe('hello')
36
- } finally { destroy() }
37
- })
38
-
39
- it('Escape and the close button both fire onClose', () => {
40
- let closed = 0
41
- const { destroy } = mountC(SvModal, { open: true, title: 'X', onClose: () => (closed++), children: body })
42
- try {
43
- document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
44
- flushSync()
45
- expect(closed).toBe(1)
46
- } finally { destroy() }
47
- })
48
- })
49
-
50
- describe('SvPopover', () => {
51
- it('opens the panel on anchor click and closes on outside pointerdown', () => {
52
- const { target, destroy } = mountC(SvPopover, { anchor: anchorBtn, children: body })
53
- try {
54
- expect(document.querySelector('.sv-pop__panel')).toBeNull()
55
- target.querySelector<HTMLElement>('.anchor')!.click()
56
- flushSync()
57
- const panel = document.querySelector<HTMLElement>('.sv-pop__panel')!
58
- expect(panel).not.toBeNull()
59
- expect(panel.getAttribute('role')).toBe('dialog')
60
- // Outside click closes.
61
- document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
62
- flushSync()
63
- expect(document.querySelector('.sv-pop__panel')).toBeNull()
64
- } finally { destroy() }
65
- })
66
- })
67
-
68
- describe('SvTooltip', () => {
69
- it('shows after the delay on focus and wires aria-describedby', () => {
70
- vi.useFakeTimers()
71
- try {
72
- const child = createRawSnippet(() => ({ render: () => `<button class="t">i</button>` }))
73
- const { target, destroy } = mountC(SvTooltip, { text: 'Tip text', delay: 200, children: child })
74
- const anchor = target.querySelector<HTMLElement>('.sv-tip__anchor')!
75
- anchor.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
76
- vi.advanceTimersByTime(210)
77
- flushSync()
78
- const tip = document.querySelector<HTMLElement>('.sv-tip')!
79
- expect(tip).not.toBeNull()
80
- expect(tip.getAttribute('role')).toBe('tooltip')
81
- expect(anchor.getAttribute('aria-describedby')).toBe(tip.id)
82
- // The wrapper span is never itself focusable, so AT users only ever land
83
- // on the real interactive child below - it must carry the description
84
- // too, or it's never announced on focus (see SvTooltip.svelte effect).
85
- const focusEl = target.querySelector<HTMLElement>('.t')!
86
- expect(focusEl.getAttribute('aria-describedby')).toBe(tip.id)
87
- destroy()
88
- } finally { vi.useRealTimers() }
89
- })
90
- })
1
+ /**
2
+ * Component tests for the overlay trio: SvModal (dialog semantics, Escape /
3
+ * backdrop close), SvPopover (click open + outside close) and SvTooltip
4
+ * (focus-triggered, aria-describedby wiring).
5
+ */
6
+ import { describe, expect, it, vi, afterEach } from 'vitest'
7
+ import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
8
+ import SvModal from './SvModal.svelte'
9
+ import SvPopover from './SvPopover.svelte'
10
+ import SvTooltip from './SvTooltip.svelte'
11
+
12
+ const body = createRawSnippet(() => ({ render: () => `<p class="body">hello</p>` }))
13
+ const anchorBtn = createRawSnippet(() => ({ render: () => `<button class="anchor">open</button>` }))
14
+
15
+ function mountC(Comp: any, props: Record<string, unknown>) {
16
+ const target = document.createElement('div')
17
+ document.body.appendChild(target)
18
+ const app = mount(Comp, { target, props: props as any })
19
+ flushSync()
20
+ return { target, destroy: () => { unmount(app); target.remove() } }
21
+ }
22
+ afterEach(() => {
23
+ document.querySelectorAll('.sv-modal__backdrop, .sv-pop__panel, .sv-tip').forEach((n) => n.remove())
24
+ })
25
+
26
+ describe('SvModal', () => {
27
+ it('renders a labelled modal dialog when open', () => {
28
+ const { destroy } = mountC(SvModal, { open: true, title: 'Edit', children: body })
29
+ try {
30
+ const dialog = document.querySelector<HTMLElement>('.sv-modal')!
31
+ expect(dialog.getAttribute('role')).toBe('dialog')
32
+ expect(dialog.getAttribute('aria-modal')).toBe('true')
33
+ const titleId = dialog.getAttribute('aria-labelledby')!
34
+ expect(document.getElementById(titleId)!.textContent).toBe('Edit')
35
+ expect(document.querySelector('.sv-modal .body')!.textContent).toBe('hello')
36
+ } finally { destroy() }
37
+ })
38
+
39
+ it('Escape and the close button both fire onClose', () => {
40
+ let closed = 0
41
+ const { destroy } = mountC(SvModal, { open: true, title: 'X', onClose: () => (closed++), children: body })
42
+ try {
43
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
44
+ flushSync()
45
+ expect(closed).toBe(1)
46
+ } finally { destroy() }
47
+ })
48
+ })
49
+
50
+ describe('SvPopover', () => {
51
+ it('opens the panel on anchor click and closes on outside pointerdown', () => {
52
+ const { target, destroy } = mountC(SvPopover, { anchor: anchorBtn, children: body })
53
+ try {
54
+ expect(document.querySelector('.sv-pop__panel')).toBeNull()
55
+ target.querySelector<HTMLElement>('.anchor')!.click()
56
+ flushSync()
57
+ const panel = document.querySelector<HTMLElement>('.sv-pop__panel')!
58
+ expect(panel).not.toBeNull()
59
+ expect(panel.getAttribute('role')).toBe('dialog')
60
+ // Outside click closes.
61
+ document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
62
+ flushSync()
63
+ expect(document.querySelector('.sv-pop__panel')).toBeNull()
64
+ } finally { destroy() }
65
+ })
66
+ })
67
+
68
+ describe('SvTooltip', () => {
69
+ it('shows after the delay on focus and wires aria-describedby', () => {
70
+ vi.useFakeTimers()
71
+ try {
72
+ const child = createRawSnippet(() => ({ render: () => `<button class="t">i</button>` }))
73
+ const { target, destroy } = mountC(SvTooltip, { text: 'Tip text', delay: 200, children: child })
74
+ const anchor = target.querySelector<HTMLElement>('.sv-tip__anchor')!
75
+ anchor.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
76
+ vi.advanceTimersByTime(210)
77
+ flushSync()
78
+ const tip = document.querySelector<HTMLElement>('.sv-tip')!
79
+ expect(tip).not.toBeNull()
80
+ expect(tip.getAttribute('role')).toBe('tooltip')
81
+ expect(anchor.getAttribute('aria-describedby')).toBe(tip.id)
82
+ // The wrapper span is never itself focusable, so AT users only ever land
83
+ // on the real interactive child below - it must carry the description
84
+ // too, or it's never announced on focus (see SvTooltip.svelte effect).
85
+ const focusEl = target.querySelector<HTMLElement>('.t')!
86
+ expect(focusEl.getAttribute('aria-describedby')).toBe(tip.id)
87
+ destroy()
88
+ } finally { vi.useRealTimers() }
89
+ })
90
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * panel-resize - the drag math behind a dropdown panel's bottom resize grip
3
+ * (the "····" handle). Rune-free and DOM-only so every popover select can reuse
4
+ * it: on the grip's `pointerdown` it tracks the pointer and reports a clamped
5
+ * height as it moves, releasing its listeners on `pointerup`.
6
+ *
7
+ * The grip is only meaningful when the panel opens DOWNWARD (it grows toward the
8
+ * bottom of the screen); components hide it on an upward flip, where there is no
9
+ * room below to grow into.
10
+ */
11
+
12
+ export type PanelResizeOptions = {
13
+ /** Panel height (px) at the moment the drag starts. */
14
+ startHeight: number
15
+ /** Smallest height the panel may shrink to. Default 80. */
16
+ min?: number
17
+ /** Largest height the panel may grow to - the live viewport ceiling. */
18
+ max: () => number
19
+ /** Called with the new clamped height on each pointer move. */
20
+ onHeight: (height: number) => void
21
+ }
22
+
23
+ /**
24
+ * Begin a vertical resize from a grip `pointerdown`. Returns nothing; the drag
25
+ * ends itself on `pointerup`. Safe to call from an `onpointerdown` handler.
26
+ */
27
+ export function startPanelResize(event: PointerEvent, opts: PanelResizeOptions): void {
28
+ // Ignore secondary buttons; only a primary drag resizes.
29
+ if (event.button !== 0) return
30
+ event.preventDefault()
31
+ event.stopPropagation()
32
+ const startY = event.clientY
33
+ const min = opts.min ?? 80
34
+
35
+ const clamp = (h: number) => Math.max(min, Math.min(opts.max(), Math.round(h)))
36
+ const onMove = (e: PointerEvent) => {
37
+ opts.onHeight(clamp(opts.startHeight + (e.clientY - startY)))
38
+ }
39
+ const onUp = () => {
40
+ window.removeEventListener('pointermove', onMove)
41
+ window.removeEventListener('pointerup', onUp)
42
+ window.removeEventListener('pointercancel', onUp)
43
+ document.body.style.userSelect = ''
44
+ document.body.style.cursor = ''
45
+ }
46
+ // Suppress text selection + keep the resize cursor for the whole drag.
47
+ document.body.style.userSelect = 'none'
48
+ document.body.style.cursor = 'ns-resize'
49
+ window.addEventListener('pointermove', onMove)
50
+ window.addEventListener('pointerup', onUp)
51
+ window.addEventListener('pointercancel', onUp)
52
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Bounds detection for anchored popover panels: flip up/down by available room,
3
+ * cap the panel height to the viewport, and never let an upward flip start
4
+ * off-screen. Pairs with the drag math in panel-resize.
5
+ */
6
+ import { describe, expect, it, beforeAll } from 'vitest'
7
+ import { anchoredRect } from './popover'
8
+
9
+ const VIEWPORT_H = 800
10
+ const VIEWPORT_W = 1200
11
+
12
+ beforeAll(() => {
13
+ Object.defineProperty(window, 'innerHeight', { value: VIEWPORT_H, configurable: true })
14
+ Object.defineProperty(window, 'innerWidth', { value: VIEWPORT_W, configurable: true })
15
+ })
16
+
17
+ // Minimal DOMRect stand-in - anchoredRect only reads top/bottom/left/width.
18
+ const rect = (top: number, height: number, left = 100, width = 200) =>
19
+ ({ top, bottom: top + height, left, width, right: left + width, height, x: left, y: top, toJSON() {} }) as DOMRect
20
+
21
+ describe('anchoredRect - flip direction', () => {
22
+ it('opens downward with room below', () => {
23
+ const r = anchoredRect(rect(100, 32), { estimatedHeight: 280 })
24
+ expect(r.openUpward).toBe(false)
25
+ expect(r.top).toBe(132 + 2) // trigger bottom + gap
26
+ })
27
+
28
+ it('flips upward when room below is short and there is more above', () => {
29
+ // Trigger near the bottom: 60px below, ~740px above.
30
+ const r = anchoredRect(rect(VIEWPORT_H - 92, 32), { estimatedHeight: 280 })
31
+ expect(r.openUpward).toBe(true)
32
+ // Panel sits above the trigger; its top must stay on-screen.
33
+ expect(r.top).toBeGreaterThanOrEqual(8)
34
+ expect(r.top).toBeLessThan(VIEWPORT_H - 92)
35
+ })
36
+ })
37
+
38
+ describe('anchoredRect - height bounds', () => {
39
+ it('caps maxHeight to the room below (never overflows the viewport)', () => {
40
+ // Short viewport so both sides are smaller than the 280 estimate; trigger in
41
+ // the middle with slightly more room below keeps it opening downward.
42
+ Object.defineProperty(window, 'innerHeight', { value: 260, configurable: true })
43
+ try {
44
+ const r = anchoredRect(rect(110, 32), { estimatedHeight: 280 })
45
+ // room below = 260 - 142 - gap(2) - margin(8) = 108
46
+ expect(r.openUpward).toBe(false)
47
+ expect(r.maxHeight).toBeLessThanOrEqual(108)
48
+ expect(r.maxHeight).toBeGreaterThan(0)
49
+ // availHeight is the hard ceiling and is >= the comfortable maxHeight.
50
+ expect(r.availHeight).toBeGreaterThanOrEqual(r.maxHeight)
51
+ } finally {
52
+ Object.defineProperty(window, 'innerHeight', { value: VIEWPORT_H, configurable: true })
53
+ }
54
+ })
55
+
56
+ it('maxHeight is the estimate when there is ample room', () => {
57
+ const r = anchoredRect(rect(100, 32), { estimatedHeight: 200 })
58
+ expect(r.maxHeight).toBe(200)
59
+ expect(r.availHeight).toBeGreaterThanOrEqual(200)
60
+ })
61
+
62
+ it('never returns a maxHeight below the minHeight floor', () => {
63
+ // Trigger pinned to the very bottom: almost no room below, lots above -> flips.
64
+ const r = anchoredRect(rect(VIEWPORT_H - 20, 20), { estimatedHeight: 300, minHeight: 96 })
65
+ expect(r.maxHeight).toBeGreaterThanOrEqual(96)
66
+ })
67
+ })
68
+
69
+ describe('anchoredRect - bottom anchor (natural-height upward panels)', () => {
70
+ it('returns a bottom offset that pins the panel just above the trigger', () => {
71
+ // Trigger top at 500, gap 2 -> panel bottom edge at 498 -> bottom offset
72
+ // from the viewport bottom = 800 - 500 + 2 = 302. Independent of any height
73
+ // estimate, so a bottom-anchored panel grows to its real content height.
74
+ const r = anchoredRect(rect(500, 32), { estimatedHeight: 999 })
75
+ expect(r.bottom).toBe(VIEWPORT_H - 500 + 2)
76
+ })
77
+
78
+ it('bottom offset does not depend on the (possibly wrong) height estimate', () => {
79
+ const trigger = rect(600, 30)
80
+ const a = anchoredRect(trigger, { estimatedHeight: 40 })
81
+ const b = anchoredRect(trigger, { estimatedHeight: 500 })
82
+ expect(a.bottom).toBe(b.bottom)
83
+ })
84
+ })
85
+
86
+ describe('anchoredRect - horizontal', () => {
87
+ it('clamps a wide panel within the viewport', () => {
88
+ const r = anchoredRect(rect(100, 32, VIEWPORT_W - 50, 40), { estimatedHeight: 100, minWidth: 300 })
89
+ expect(r.width).toBe(300)
90
+ expect(r.left + r.width).toBeLessThanOrEqual(VIEWPORT_W)
91
+ expect(r.left).toBeGreaterThanOrEqual(4)
92
+ })
93
+ })
package/src/popover.ts CHANGED
@@ -68,7 +68,31 @@ export function popIn(node: HTMLElement, param: { up?: boolean; duration?: numbe
68
68
  return {}
69
69
  }
70
70
 
71
- export type AnchoredRect = { top: number; left: number; width: number; openUpward: boolean }
71
+ export type AnchoredRect = {
72
+ top: number
73
+ left: number
74
+ width: number
75
+ openUpward: boolean
76
+ /**
77
+ * Comfortable panel height: the estimate, capped to the room on the chosen
78
+ * side so the panel never leaves the viewport. Apply as the panel's
79
+ * `max-height` and long content scrolls inside instead of overflowing.
80
+ */
81
+ maxHeight: number
82
+ /**
83
+ * Absolute height the panel may occupy on the chosen side (room minus the
84
+ * viewport margin). The hard ceiling a user resize must clamp to.
85
+ */
86
+ availHeight: number
87
+ /**
88
+ * For an upward-flipped panel: distance from the viewport bottom to where the
89
+ * panel's bottom edge sits (just above the trigger). Bottom-anchoring an
90
+ * upward panel with `style:bottom` lets it grow to its natural content height
91
+ * instead of being positioned from a (possibly wrong) height estimate.
92
+ * Undefined until `anchoredRect` computes it.
93
+ */
94
+ bottom?: number
95
+ }
72
96
 
73
97
  export type AnchorOptions = {
74
98
  /** Estimated panel height, used to decide whether to flip upward. */
@@ -79,19 +103,30 @@ export type AnchorOptions = {
79
103
  minWidth?: number
80
104
  /** Keep the panel within the viewport horizontally. Default true. */
81
105
  clampHorizontal?: boolean
106
+ /** Viewport edge kept clear top/bottom so a panel never touches it. Default 8. */
107
+ viewportMargin?: number
108
+ /** Floor for `maxHeight`/`availHeight` so a cramped panel stays usable. Default 96. */
109
+ minHeight?: number
82
110
  }
83
111
 
84
112
  /**
85
113
  * Compute a `position: fixed` rect anchored to `triggerRect`, flipping above the
86
- * trigger when there isn't room below and there's more room above. Mirrors the
87
- * behavior SvGridDropdown shipped with, generalized with min-width + horizontal
88
- * clamping for wider panels (date/time popovers).
114
+ * trigger when there isn't room below and there's more room above. Generalized
115
+ * with min-width + horizontal clamping for wider panels (date/time popovers),
116
+ * and with vertical bounds detection: `maxHeight` caps the panel to the room on
117
+ * the chosen side (minus a viewport margin) so a long list near a screen edge
118
+ * scrolls internally rather than overflowing, and an upward flip is clamped so
119
+ * its top never leaves the viewport.
89
120
  */
90
121
  export function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): AnchoredRect {
91
122
  const gap = opts.gap ?? 2
92
- const spaceBelow = window.innerHeight - triggerRect.bottom
93
- const spaceAbove = triggerRect.top
123
+ const margin = opts.viewportMargin ?? 8
124
+ const minH = opts.minHeight ?? 96
125
+ const spaceBelow = window.innerHeight - triggerRect.bottom - gap - margin
126
+ const spaceAbove = triggerRect.top - gap - margin
94
127
  const openUpward = spaceBelow < opts.estimatedHeight && spaceAbove > spaceBelow
128
+ const availHeight = Math.max(minH, Math.floor(openUpward ? spaceAbove : spaceBelow))
129
+ const maxHeight = Math.min(opts.estimatedHeight, availHeight)
95
130
  const width = Math.max(triggerRect.width, opts.minWidth ?? 0)
96
131
  let left = triggerRect.left
97
132
  if (opts.clampHorizontal !== false) {
@@ -99,9 +134,18 @@ export function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): Anchore
99
134
  left = Math.max(4, Math.min(left, maxLeft))
100
135
  }
101
136
  return {
102
- top: openUpward ? triggerRect.top - opts.estimatedHeight - gap : triggerRect.bottom + gap,
137
+ // Upward: pin by the comfortable height and clamp to the top margin so the
138
+ // panel can never start off-screen. Downward: sit just below the trigger.
139
+ top: openUpward
140
+ ? Math.max(margin, triggerRect.top - gap - maxHeight)
141
+ : triggerRect.bottom + gap,
103
142
  left,
104
143
  width,
105
144
  openUpward,
145
+ maxHeight,
146
+ availHeight,
147
+ // Bottom-anchor value for upward panels: viewport bottom -> panel bottom
148
+ // edge (which sits `gap` above the trigger top).
149
+ bottom: Math.round(window.innerHeight - triggerRect.top + gap),
106
150
  }
107
151
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { matchesRecurrence, expandRecurrence, type RecurrenceRule } from './recurrence'
2
+ import { matchesRecurrence, expandRecurrence, describeRecurrence, type RecurrenceRule } from './recurrence'
3
3
 
4
4
  // Fixed reference dates (local time). 2026-07-01 is a Wednesday.
5
5
  const D = (s: string) => new Date(`${s}T00:00:00`)
@@ -82,10 +82,97 @@ describe('matchesRecurrence - bounds + combining', () => {
82
82
  })
83
83
  })
84
84
 
85
+ describe('matchesRecurrence - positional weekday-of-month', () => {
86
+ it('the 1st Tuesday of the month', () => {
87
+ const r: RecurrenceRule = { freq: 'monthly', weekdays: [2], weekOfMonth: 1 }
88
+ expect(matchesRecurrence(D('2026-07-07'), r)).toBe(true) // 1st Tue of Jul
89
+ expect(matchesRecurrence(D('2026-07-14'), r)).toBe(false) // 2nd Tue
90
+ expect(matchesRecurrence(D('2026-08-04'), r)).toBe(true) // 1st Tue of Aug
91
+ })
92
+
93
+ it('the last Friday of the month', () => {
94
+ const r: RecurrenceRule = { freq: 'monthly', weekdays: [5], weekOfMonth: -1 }
95
+ expect(matchesRecurrence(D('2026-07-31'), r)).toBe(true) // last Fri of Jul
96
+ expect(matchesRecurrence(D('2026-07-24'), r)).toBe(false) // 4th but not last
97
+ expect(matchesRecurrence(D('2026-08-28'), r)).toBe(true) // last Fri of Aug
98
+ })
99
+
100
+ it('yearly positional: the 2nd Monday of November', () => {
101
+ const r: RecurrenceRule = { freq: 'yearly', month: 10, weekdays: [1], weekOfMonth: 2 }
102
+ expect(matchesRecurrence(D('2026-11-09'), r)).toBe(true) // 2nd Mon of Nov 2026
103
+ expect(matchesRecurrence(D('2026-11-16'), r)).toBe(false) // 3rd Mon
104
+ expect(matchesRecurrence(D('2026-10-12'), r)).toBe(false) // wrong month
105
+ })
106
+ })
107
+
108
+ describe('matchesRecurrence - last day of month + count', () => {
109
+ it('the last day of the month (day: -1) across month lengths', () => {
110
+ const r: RecurrenceRule = { freq: 'monthly', day: -1 }
111
+ expect(matchesRecurrence(D('2026-07-31'), r)).toBe(true) // 31-day month
112
+ expect(matchesRecurrence(D('2026-02-28'), r)).toBe(true) // 28-day Feb
113
+ expect(matchesRecurrence(D('2026-02-27'), r)).toBe(false)
114
+ expect(matchesRecurrence(D('2026-06-30'), r)).toBe(true) // 30-day month
115
+ })
116
+
117
+ it('ends after N occurrences (count)', () => {
118
+ // 3 weekly Mondays from Jul 6: Jul 6, 13, 20 - then stop.
119
+ const r: RecurrenceRule = { freq: 'weekly', weekdays: [1], from: D('2026-07-06'), count: 3 }
120
+ expect(matchesRecurrence(D('2026-07-06'), r)).toBe(true)
121
+ expect(matchesRecurrence(D('2026-07-20'), r)).toBe(true) // 3rd
122
+ expect(matchesRecurrence(D('2026-07-27'), r)).toBe(false) // 4th - past the count
123
+ })
124
+ })
125
+
85
126
  describe('expandRecurrence', () => {
86
127
  it('lists the matching dates in a range', () => {
87
128
  const r: RecurrenceRule = { freq: 'weekly', weekdays: [1] } // Mondays
88
129
  const dates = expandRecurrence(r, D('2026-07-01'), D('2026-07-31')).map((d) => d.getDate())
89
130
  expect(dates).toEqual([6, 13, 20, 27])
90
131
  })
132
+
133
+ it('stops after `count` occurrences even in a wider window', () => {
134
+ const r: RecurrenceRule = { freq: 'weekly', weekdays: [1], from: D('2026-07-06'), count: 3 }
135
+ const dates = expandRecurrence(r, D('2026-07-01'), D('2026-08-31')).map((d) => d.getDate())
136
+ expect(dates).toEqual([6, 13, 20]) // exactly 3, not every Monday
137
+ })
138
+ })
139
+
140
+ describe('describeRecurrence - human-readable summaries', () => {
141
+ it('empty / falsy rules read as the never label (default blank)', () => {
142
+ expect(describeRecurrence(null)).toBe('')
143
+ expect(describeRecurrence([])).toBe('')
144
+ expect(describeRecurrence(undefined, { never: 'Does not repeat' })).toBe('Does not repeat')
145
+ })
146
+
147
+ it('daily + weekly', () => {
148
+ expect(describeRecurrence({ freq: 'daily' })).toBe('Daily')
149
+ expect(describeRecurrence({ freq: 'daily', interval: 2 })).toBe('Every 2 days')
150
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [1, 2, 3, 4, 5] })).toBe('Weekly on weekdays')
151
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [0, 6] })).toBe('Weekly on weekends')
152
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [1, 3] })).toBe('Weekly on Mon, Wed')
153
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [5], interval: 2 })).toBe('Every 2 weeks on Fri')
154
+ })
155
+
156
+ it('monthly + yearly, positional + last day', () => {
157
+ expect(describeRecurrence({ freq: 'monthly', day: 1 })).toBe('Monthly on day 1')
158
+ expect(describeRecurrence({ freq: 'monthly', day: -1 })).toBe('Monthly on the last day')
159
+ expect(describeRecurrence({ freq: 'monthly', weekdays: [5], weekOfMonth: -1 })).toBe('Monthly on the last Fri')
160
+ expect(describeRecurrence({ freq: 'yearly', month: 10, weekdays: [4], weekOfMonth: 4 })).toBe(
161
+ 'Yearly on the fourth Thu of Nov',
162
+ )
163
+ expect(describeRecurrence({ freq: 'yearly', month: 11, day: 25 })).toBe('Yearly on day 25 of Dec')
164
+ })
165
+
166
+ it('appends the end condition (count / until)', () => {
167
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [1], count: 8 })).toBe('Weekly on Mon, 8 times')
168
+ expect(describeRecurrence({ freq: 'daily', until: D('2026-12-31') })).toBe('Daily until Dec 31, 2026')
169
+ })
170
+
171
+ it('joins a list of rules', () => {
172
+ const rules: RecurrenceRule[] = [
173
+ { freq: 'weekly', weekdays: [1] },
174
+ { freq: 'monthly', day: 1 },
175
+ ]
176
+ expect(describeRecurrence(rules)).toBe('Weekly on Mon; Monthly on day 1')
177
+ })
91
178
  })