@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,73 +1,97 @@
1
- /**
2
- * Component tests for SvMenu: the trigger opens a portalled menu, items/separators
3
- * render with menu semantics, choosing a leaf fires onSelect + closes, and a
4
- * submenu opens on pointer-enter.
5
- */
6
- import { describe, expect, it, afterEach } from 'vitest'
7
- import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
8
- import SvMenu, { type MenuItem } from './SvMenu.svelte'
9
-
10
- const anchor = createRawSnippet(() => ({ render: () => `<button class="trigger">Actions</button>` }))
11
-
12
- function mountMenu(props: Record<string, unknown>) {
13
- const target = document.createElement('div')
14
- document.body.appendChild(target)
15
- const app = mount(SvMenu, { target, props: { anchor, ...props } as any })
16
- flushSync()
17
- return { target, destroy: () => { unmount(app); target.remove() } }
18
- }
19
- const menu = () => document.querySelector<HTMLElement>('.sv-menu')
20
- afterEach(() => document.querySelectorAll('.sv-menu').forEach((n) => n.remove()))
21
-
22
- describe('SvMenu', () => {
23
- const items: MenuItem[] = [
24
- { label: 'Cut', shortcut: 'Ctrl+X', onSelect: () => {} },
25
- { label: 'Copy', shortcut: 'Ctrl+C' },
26
- { separator: true },
27
- { label: 'Share', children: [{ label: 'Email' }, { label: 'Link' }] },
28
- { label: 'Delete', disabled: true },
29
- ]
30
-
31
- it('opens on trigger click with menu/menuitem roles + a separator', () => {
32
- const { target, destroy } = mountMenu({ items })
33
- try {
34
- expect(menu()).toBeNull()
35
- target.querySelector<HTMLElement>('.trigger')!.click()
36
- flushSync()
37
- const m = menu()!
38
- expect(m.querySelector('[role="menu"]')).not.toBeNull()
39
- expect(m.querySelectorAll('[role="menuitem"]').length).toBe(4) // 4 items, separator excluded
40
- expect(m.querySelector('[role="separator"]')).not.toBeNull()
41
- expect(m.querySelector('[aria-disabled="true"]')).not.toBeNull()
42
- } finally { destroy() }
43
- })
44
-
45
- it('choosing a leaf fires onSelect and closes', () => {
46
- let chosen: MenuItem | undefined
47
- const { target, destroy } = mountMenu({ items, onSelect: (i: MenuItem) => (chosen = i) })
48
- try {
49
- target.querySelector<HTMLElement>('.trigger')!.click()
50
- flushSync()
51
- const copy = [...menu()!.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((b) => b.textContent?.includes('Copy'))!
52
- copy.click()
53
- flushSync()
54
- expect(chosen?.label).toBe('Copy')
55
- expect(menu()).toBeNull()
56
- } finally { destroy() }
57
- })
58
-
59
- it('opens a submenu on pointer-enter of a parent item', () => {
60
- const { target, destroy } = mountMenu({ items })
61
- try {
62
- target.querySelector<HTMLElement>('.trigger')!.click()
63
- flushSync()
64
- const share = [...menu()!.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((b) => b.textContent?.includes('Share'))!
65
- expect(share.getAttribute('aria-haspopup')).toBe('menu')
66
- share.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
67
- flushSync()
68
- expect(menu()!.querySelector('.sv-menu__flyout')).not.toBeNull()
69
- const sub = [...menu()!.querySelectorAll('[role="menuitem"]')].map((b) => b.textContent)
70
- expect(sub.some((t) => t?.includes('Email'))).toBe(true)
71
- } finally { destroy() }
72
- })
73
- })
1
+ /**
2
+ * Component tests for SvMenu: the trigger opens a portalled menu, items/separators
3
+ * render with menu semantics, choosing a leaf fires onSelect + closes, and a
4
+ * submenu opens on pointer-enter.
5
+ */
6
+ import { describe, expect, it, afterEach } from 'vitest'
7
+ import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
8
+ import SvMenu, { type MenuItem } from './SvMenu.svelte'
9
+
10
+ const anchor = createRawSnippet(() => ({ render: () => `<button class="trigger">Actions</button>` }))
11
+
12
+ function mountMenu(props: Record<string, unknown>) {
13
+ const target = document.createElement('div')
14
+ document.body.appendChild(target)
15
+ const app = mount(SvMenu, { target, props: { anchor, ...props } as any })
16
+ flushSync()
17
+ return { target, destroy: () => { unmount(app); target.remove() } }
18
+ }
19
+ const menu = () => document.querySelector<HTMLElement>('.sv-menu')
20
+ afterEach(() => document.querySelectorAll('.sv-menu').forEach((n) => n.remove()))
21
+
22
+ describe('SvMenu', () => {
23
+ const items: MenuItem[] = [
24
+ { label: 'Cut', shortcut: 'Ctrl+X', onSelect: () => {} },
25
+ { label: 'Copy', shortcut: 'Ctrl+C' },
26
+ { separator: true },
27
+ { label: 'Share', children: [{ label: 'Email' }, { label: 'Link' }] },
28
+ { label: 'Delete', disabled: true },
29
+ ]
30
+
31
+ it('opens on trigger click with menu/menuitem roles + a separator', () => {
32
+ const { target, destroy } = mountMenu({ items })
33
+ try {
34
+ expect(menu()).toBeNull()
35
+ target.querySelector<HTMLElement>('.trigger')!.click()
36
+ flushSync()
37
+ const m = menu()!
38
+ expect(m.querySelector('[role="menu"]')).not.toBeNull()
39
+ expect(m.querySelectorAll('[role="menuitem"]').length).toBe(4) // 4 items, separator excluded
40
+ expect(m.querySelector('[role="separator"]')).not.toBeNull()
41
+ expect(m.querySelector('[aria-disabled="true"]')).not.toBeNull()
42
+ } finally { destroy() }
43
+ })
44
+
45
+ it('choosing a leaf fires onSelect and closes', () => {
46
+ let chosen: MenuItem | undefined
47
+ const { target, destroy } = mountMenu({ items, onSelect: (i: MenuItem) => (chosen = i) })
48
+ try {
49
+ target.querySelector<HTMLElement>('.trigger')!.click()
50
+ flushSync()
51
+ const copy = [...menu()!.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((b) => b.textContent?.includes('Copy'))!
52
+ copy.click()
53
+ flushSync()
54
+ expect(chosen?.label).toBe('Copy')
55
+ expect(menu()).toBeNull()
56
+ } finally { destroy() }
57
+ })
58
+
59
+ it('ArrowDown moves the roving tab stop and Enter activates via keyboard', () => {
60
+ let chosen: MenuItem | undefined
61
+ const { target, destroy } = mountMenu({ items, onSelect: (i: MenuItem) => (chosen = i) })
62
+ try {
63
+ target.querySelector<HTMLElement>('.trigger')!.click()
64
+ flushSync()
65
+ const btns = () => [...menu()!.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
66
+ const cut = btns().find((b) => b.textContent?.includes('Cut'))!
67
+ // From nothing focused, ArrowDown lands on the first item, then the next.
68
+ cut.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
69
+ flushSync()
70
+ cut.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
71
+ flushSync()
72
+ const copy = btns().find((b) => b.textContent?.includes('Copy'))!
73
+ expect(copy.tabIndex).toBe(0)
74
+ expect(cut.tabIndex).toBe(-1)
75
+ // Enter on the focused item activates it and closes the menu.
76
+ copy.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
77
+ flushSync()
78
+ expect(chosen?.label).toBe('Copy')
79
+ expect(menu()).toBeNull()
80
+ } finally { destroy() }
81
+ })
82
+
83
+ it('opens a submenu on pointer-enter of a parent item', () => {
84
+ const { target, destroy } = mountMenu({ items })
85
+ try {
86
+ target.querySelector<HTMLElement>('.trigger')!.click()
87
+ flushSync()
88
+ const share = [...menu()!.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((b) => b.textContent?.includes('Share'))!
89
+ expect(share.getAttribute('aria-haspopup')).toBe('menu')
90
+ share.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
91
+ flushSync()
92
+ expect(menu()!.querySelector('.sv-menu__flyout')).not.toBeNull()
93
+ const sub = [...menu()!.querySelectorAll('[role="menuitem"]')].map((b) => b.textContent)
94
+ expect(sub.some((t) => t?.includes('Email'))).toBe(true)
95
+ } finally { destroy() }
96
+ })
97
+ })
@@ -1,165 +1,117 @@
1
- <script lang="ts" module>
2
- import type { Snippet } from 'svelte'
3
- export type MenuItem = {
4
- /** Item text. Omit + set `separator` for a divider. */
5
- label?: string
6
- separator?: boolean
7
- icon?: Snippet
8
- /** Right-aligned hint, e.g. a keyboard shortcut. */
9
- shortcut?: string
10
- disabled?: boolean
11
- /** Nested submenu items (renders a flyout). */
12
- children?: MenuItem[]
13
- onSelect?: () => void
14
- }
15
- </script>
16
-
17
- <script lang="ts">
18
- /**
19
- * SvMenuList - the recursive menu surface behind SvMenu: a WAI-ARIA `menu` with
20
- * roving focus, submenus (hover + ArrowRight), separators, icons and shortcuts.
21
- * Rendered inside a portalled panel by SvMenu; it self-imports for submenus.
22
- */
23
- import Self from './SvMenuList.svelte'
24
- import { type EditorDir } from './editor-contract'
25
-
26
- let {
27
- items,
28
- onclose,
29
- onselect,
30
- submenu = false,
31
- oncollapse,
32
- dir,
33
- }: {
34
- items: ReadonlyArray<MenuItem>
35
- /** Close the WHOLE menu (a leaf was chosen, Escape, or outside-click). */
36
- onclose: () => void
37
- /** Report the chosen leaf item. */
38
- onselect: (item: MenuItem) => void
39
- submenu?: boolean
40
- /** Collapse just THIS submenu level (ArrowLeft, or ArrowRight under `rtl`)
41
- * and refocus the item that opened it. Only meaningful when `submenu` is
42
- * true; the root list has nowhere to collapse into. */
43
- oncollapse?: () => void
44
- /** Text direction; under `rtl` the ArrowRight/ArrowLeft submenu open/close
45
- * keys swap (see createTree.svelte.ts for the reference pattern). */
46
- dir?: EditorDir
47
- } = $props()
48
-
49
- // Indexes of real (non-separator) items, for roving focus.
50
- const focusable = $derived(items.map((it, i) => (it.separator || it.disabled ? -1 : i)).filter((i) => i >= 0))
51
- let active = $state(-1)
52
- let openSub = $state(-1)
53
- let listEl = $state<HTMLDivElement | null>(null)
54
-
55
- function focusItem(i: number) {
56
- active = i
57
- queueMicrotask(() => listEl?.querySelector<HTMLElement>(`[data-mi="${i}"]`)?.focus())
58
- }
59
- function move(delta: number) {
60
- if (!focusable.length) return
61
- const pos = focusable.indexOf(active)
62
- const next = focusable[(pos + delta + focusable.length) % focusable.length]
63
- if (next != null) { openSub = -1; focusItem(next) }
64
- }
65
- function choose(item: MenuItem) {
66
- if (item.disabled || item.separator) return
67
- if (item.children?.length) { openSub = items.indexOf(item); return }
68
- item.onSelect?.()
69
- onselect(item)
70
- onclose()
71
- }
72
- function onKeydown(e: KeyboardEvent, i: number) {
73
- const item = items[i]
74
- const rtl = dir === 'rtl'
75
- // Open this item's submenu (opposite key under rtl).
76
- const openThis = () => { if (item?.children?.length) { e.preventDefault(); openSub = i } }
77
- // Collapse just THIS list back into its parent item (opposite key under
78
- // rtl) - `oncollapse` is local to this nesting level, unlike `onclose`.
79
- const collapseThis = () => { if (submenu) { e.preventDefault(); oncollapse?.() } }
80
- switch (e.key) {
81
- case 'ArrowDown': e.preventDefault(); move(1); break
82
- case 'ArrowUp': e.preventDefault(); move(-1); break
83
- case 'Home': e.preventDefault(); focusItem(focusable[0] ?? 0); break
84
- case 'End': e.preventDefault(); focusItem(focusable.at(-1) ?? 0); break
85
- case 'ArrowRight': (rtl ? collapseThis : openThis)(); break
86
- case 'ArrowLeft': (rtl ? openThis : collapseThis)(); break
87
- case 'Enter':
88
- case ' ': e.preventDefault(); if (item) choose(item); break
89
- case 'Escape': e.preventDefault(); onclose(); break
90
- // Tab leaves the menu entirely (WAI-ARIA menu button pattern): close the
91
- // whole tree rather than trapping focus inside a portalled panel.
92
- case 'Tab': onclose(); break
93
- }
94
- }
95
- </script>
96
-
97
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
98
- <div bind:this={listEl} class="sv-menu__list" class:is-submenu={submenu} role="menu">
99
- {#each items as item, i (i)}
100
- {#if item.separator}
101
- <div class="sv-menu__sep" role="separator"></div>
102
- {:else}
103
- <div class="sv-menu__itemwrap">
104
- <button
105
- type="button"
106
- class="sv-menu__item"
107
- class:is-active={active === i}
108
- data-mi={i}
109
- role="menuitem"
110
- aria-haspopup={item.children?.length ? 'menu' : undefined}
111
- aria-expanded={item.children?.length ? openSub === i : undefined}
112
- aria-disabled={item.disabled || undefined}
113
- disabled={item.disabled}
114
- tabindex={active === i || (active === -1 && i === focusable[0]) ? 0 : -1}
115
- onpointerenter={() => { if (!item.disabled) { active = i; openSub = item.children?.length ? i : -1 } }}
116
- onkeydown={(e) => onKeydown(e, i)}
117
- onclick={() => choose(item)}
118
- >
119
- <span class="sv-menu__icon">{#if item.icon}{@render item.icon()}{/if}</span>
120
- <span class="sv-menu__label">{item.label}</span>
121
- {#if item.shortcut}<span class="sv-menu__shortcut">{item.shortcut}</span>{/if}
122
- {#if item.children?.length}<svg class="sv-menu__chev" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>{/if}
123
- </button>
124
- {#if item.children?.length && openSub === i}
125
- <div class="sv-menu__flyout">
126
- <Self
127
- items={item.children}
128
- {onclose}
129
- {onselect}
130
- oncollapse={() => { openSub = -1; focusItem(i) }}
131
- {dir}
132
- submenu
133
- />
134
- </div>
135
- {/if}
136
- </div>
137
- {/if}
138
- {/each}
139
- </div>
140
-
141
- <style>
142
- .sv-menu__list { display: flex; flex-direction: column; min-width: 180px; padding: 4px; }
143
- .sv-menu__sep { height: 1px; background: var(--sg-border, #e2e8f0); margin: 4px 6px; }
144
- .sv-menu__itemwrap { position: relative; }
145
- .sv-menu__item {
146
- display: flex; align-items: center; gap: 9px; width: 100%; box-sizing: border-box;
147
- padding: 7px 10px; background: none; border: 0; border-radius: 6px; cursor: pointer;
148
- font: inherit; font-size: 13px; color: var(--sg-fg, #0f172a); text-align: start;
149
- transition: background 0.1s;
150
- }
151
- .sv-menu__item.is-active:not([disabled]), .sv-menu__item:hover:not([disabled]) { background: var(--sg-row-hover-bg, #f1f5f9); }
152
- .sv-menu__item:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: -2px; }
153
- .sv-menu__item[disabled] { opacity: 0.45; cursor: default; }
154
- .sv-menu__icon { display: inline-flex; width: 16px; flex: none; color: var(--sg-muted, #64748b); }
155
- .sv-menu__icon:empty { width: 0; }
156
- .sv-menu__label { flex: 1; white-space: nowrap; }
157
- .sv-menu__shortcut { color: var(--sg-muted, #94a3b8); font-size: 11.5px; margin-inline-start: 12px; }
158
- .sv-menu__chev { color: var(--sg-muted, #94a3b8); flex: none; }
159
- .sv-menu[dir='rtl'] .sv-menu__chev, .sv-menu__list.is-submenu .sv-menu__chev { transform: scaleX(-1); }
160
- .sv-menu__flyout {
161
- position: absolute; top: -5px; inset-inline-start: 100%; z-index: 1;
162
- background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0);
163
- border-radius: 10px; box-shadow: 0 16px 40px -12px rgba(15,23,42,0.32); margin-inline-start: 2px;
164
- }
165
- </style>
1
+ <script lang="ts" module>
2
+ // MenuItem lives in a plain `.ts` module (breaks a Vite dev import cycle); it is
3
+ // re-exported here so existing `import { type MenuItem } from './SvMenuList.svelte'`
4
+ // sites keep working.
5
+ export type { MenuItem } from './menu-item'
6
+ </script>
7
+
8
+ <script lang="ts">
9
+ /**
10
+ * SvMenuList - the recursive menu surface behind SvMenu: a WAI-ARIA `menu` with
11
+ * roving focus, submenus (hover + ArrowRight), separators, icons and shortcuts.
12
+ * Rendered inside a portalled panel by SvMenu; it self-imports for submenus.
13
+ */
14
+ import Self from './SvMenuList.svelte'
15
+ import { type EditorDir } from './editor-contract'
16
+ import type { MenuItem } from './menu-item'
17
+ import { createMenu } from './createMenu.svelte'
18
+
19
+ let {
20
+ items,
21
+ onclose,
22
+ onselect,
23
+ submenu = false,
24
+ oncollapse,
25
+ dir,
26
+ }: {
27
+ items: ReadonlyArray<MenuItem>
28
+ /** Close the WHOLE menu (a leaf was chosen, Escape, or outside-click). */
29
+ onclose: () => void
30
+ /** Report the chosen leaf item. */
31
+ onselect: (item: MenuItem) => void
32
+ submenu?: boolean
33
+ /** Collapse just THIS submenu level (ArrowLeft, or ArrowRight under `rtl`)
34
+ * and refocus the item that opened it. Only meaningful when `submenu` is
35
+ * true; the root list has nowhere to collapse into. */
36
+ oncollapse?: () => void
37
+ /** Text direction; under `rtl` the ArrowRight/ArrowLeft submenu open/close
38
+ * keys swap (see createTree.svelte.ts for the reference pattern). */
39
+ dir?: EditorDir
40
+ } = $props()
41
+
42
+ let listEl = $state<HTMLDivElement | null>(null)
43
+
44
+ // Roving focus, submenu open/collapse and the full keyboard contract live in
45
+ // the shared headless core; this component only renders + drives real DOM
46
+ // focus (the core stays DOM-free). See createMenu.svelte.ts.
47
+ const menu = createMenu({
48
+ items: () => items,
49
+ onSelect: (item) => onselect(item),
50
+ onClose: () => onclose(),
51
+ submenu: () => submenu,
52
+ onCollapse: () => oncollapse?.(),
53
+ dir: () => dir,
54
+ focusItem: (i) => queueMicrotask(() => listEl?.querySelector<HTMLElement>(`[data-mi="${i}"]`)?.focus()),
55
+ })
56
+ </script>
57
+
58
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
59
+ <div bind:this={listEl} class="sv-menu__list" class:is-submenu={submenu} {...menu.listProps()}>
60
+ {#each items as item, i (i)}
61
+ {#if item.separator}
62
+ <div class="sv-menu__sep" role="separator"></div>
63
+ {:else}
64
+ <div class="sv-menu__itemwrap">
65
+ <button
66
+ type="button"
67
+ class="sv-menu__item"
68
+ class:is-active={menu.isActive(i)}
69
+ {...menu.itemProps(i)}
70
+ >
71
+ <span class="sv-menu__icon">{#if item.icon}{@render item.icon()}{/if}</span>
72
+ <span class="sv-menu__label">{item.label}</span>
73
+ {#if item.shortcut}<span class="sv-menu__shortcut">{item.shortcut}</span>{/if}
74
+ {#if item.children?.length}<svg class="sv-menu__chev" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>{/if}
75
+ </button>
76
+ {#if item.children?.length && menu.isSubOpen(i)}
77
+ <div class="sv-menu__flyout">
78
+ <Self
79
+ items={item.children}
80
+ {onclose}
81
+ {onselect}
82
+ oncollapse={() => menu.focus(i)}
83
+ {dir}
84
+ submenu
85
+ />
86
+ </div>
87
+ {/if}
88
+ </div>
89
+ {/if}
90
+ {/each}
91
+ </div>
92
+
93
+ <style>
94
+ .sv-menu__list { display: flex; flex-direction: column; min-width: 180px; padding: 4px; }
95
+ .sv-menu__sep { height: 1px; background: var(--sg-border, #e2e8f0); margin: 4px 6px; }
96
+ .sv-menu__itemwrap { position: relative; }
97
+ .sv-menu__item {
98
+ display: flex; align-items: center; gap: 9px; width: 100%; box-sizing: border-box;
99
+ padding: 7px 10px; background: none; border: 0; border-radius: 6px; cursor: pointer;
100
+ font: inherit; font-size: 13px; color: var(--sg-fg, #0f172a); text-align: start;
101
+ transition: background 0.1s;
102
+ }
103
+ .sv-menu__item.is-active:not([disabled]), .sv-menu__item:hover:not([disabled]) { background: var(--sg-row-hover-bg, #f1f5f9); }
104
+ .sv-menu__item:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: -2px; }
105
+ .sv-menu__item[disabled] { opacity: 0.45; cursor: default; }
106
+ .sv-menu__icon { display: inline-flex; width: 16px; flex: none; color: var(--sg-muted, #64748b); }
107
+ .sv-menu__icon:empty { width: 0; }
108
+ .sv-menu__label { flex: 1; white-space: nowrap; }
109
+ .sv-menu__shortcut { color: var(--sg-muted, #94a3b8); font-size: 11.5px; margin-inline-start: 12px; }
110
+ .sv-menu__chev { color: var(--sg-muted, #94a3b8); flex: none; }
111
+ .sv-menu[dir='rtl'] .sv-menu__chev, .sv-menu__list.is-submenu .sv-menu__chev { transform: scaleX(-1); }
112
+ .sv-menu__flyout {
113
+ position: absolute; top: -5px; inset-inline-start: 100%; z-index: 1;
114
+ background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0);
115
+ border-radius: 10px; box-shadow: 0 16px 40px -12px rgba(15,23,42,0.32); margin-inline-start: 2px;
116
+ }
117
+ </style>
@@ -15,9 +15,7 @@
15
15
  import type { Snippet } from 'svelte'
16
16
  import { portalToBody } from './popover'
17
17
  import { nextEditorId } from './editor-contract'
18
- import { createFocusTrap } from './a11y/focus-trap'
19
- import { lockScroll } from './a11y/scroll-lock'
20
- import { createDismissableLayer } from './a11y/dismissable'
18
+ import { createOverlay } from './createOverlay.svelte'
21
19
 
22
20
  type Props = {
23
21
  open?: boolean
@@ -55,28 +53,16 @@
55
53
 
56
54
  function close() { open = false; onClose?.() }
57
55
 
58
- // Reset transient drag/resize each time it opens, then hand focus, scroll-lock
59
- // and Escape/backdrop dismissal to the shared a11y primitives (one tested
60
- // implementation, and nested overlays close top-first via the layer stack).
61
- $effect(() => {
62
- if (!open || !dialogEl) return
63
- drag = { x: 0, y: 0 }
64
- box = null
65
- const trap = createFocusTrap(dialogEl)
66
- trap.activate()
67
- const unlockScroll = lockScroll()
68
- const layer = createDismissableLayer({
69
- element: () => dialogEl,
70
- onDismiss: (reason) => {
71
- if (reason === 'escape' ? closeOnEsc : closeOnBackdrop) close()
72
- },
73
- })
74
- layer.activate()
75
- return () => {
76
- layer.release()
77
- unlockScroll()
78
- trap.release()
79
- }
56
+ // Focus trap + scroll lock + Escape/backdrop dismissal come from the shared
57
+ // headless overlay core; this component resets its transient drag/resize on
58
+ // open and renders the panel. See createOverlay.svelte.ts.
59
+ const overlay = createOverlay({
60
+ open: () => open,
61
+ getDialog: () => dialogEl,
62
+ onClose: close,
63
+ closeOnEsc: () => closeOnEsc,
64
+ closeOnBackdrop: () => closeOnBackdrop,
65
+ onOpen: () => { drag = { x: 0, y: 0 }; box = null },
80
66
  })
81
67
 
82
68
  // --- Drag (header) ---------------------------------------------------------
@@ -130,10 +116,7 @@
130
116
  bind:this={dialogEl}
131
117
  class="sv-modal sv-modal--{size}"
132
118
  class:is-resized={!!box}
133
- role="dialog"
134
- aria-modal="true"
135
- aria-labelledby={title ? titleId : undefined}
136
- tabindex="-1"
119
+ {...overlay.dialogProps({ labelledBy: title ? titleId : undefined })}
137
120
  style:transform={`translate(calc(-50% + ${drag.x}px), calc(-50% + ${drag.y}px))`}
138
121
  style:width={box ? `${box.w}px` : undefined}
139
122
  style:height={box ? `${box.h}px` : undefined}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Component tests for SvModal: dialog semantics, scroll lock, and Escape /
3
+ * backdrop dismissal - all inherited from the shared createOverlay core (which
4
+ * SvDrawer shares), plus the modal's title wiring and closeOnEsc opt-out.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { mount, unmount, flushSync } from 'svelte'
8
+ import SvModal from './SvModal.svelte'
9
+
10
+ function mountModal(props: Record<string, unknown>) {
11
+ const target = document.createElement('div')
12
+ document.body.appendChild(target)
13
+ const app = mount(SvModal, { target, props: props as any })
14
+ flushSync()
15
+ return { target, destroy: () => { unmount(app); target.remove() } }
16
+ }
17
+
18
+ const dialog = () => document.body.querySelector<HTMLElement>('.sv-modal')
19
+
20
+ describe('SvModal', () => {
21
+ it('renders nothing when closed', () => {
22
+ const { destroy } = mountModal({ open: false })
23
+ try {
24
+ expect(dialog()).toBeNull()
25
+ } finally { destroy() }
26
+ })
27
+
28
+ it('renders a modal dialog with the title wired to aria-labelledby', () => {
29
+ const { destroy } = mountModal({ open: true, title: 'Edit row' })
30
+ try {
31
+ const el = dialog()!
32
+ expect(el.getAttribute('role')).toBe('dialog')
33
+ expect(el.getAttribute('aria-modal')).toBe('true')
34
+ expect(el.getAttribute('tabindex')).toBe('-1')
35
+ const labelledby = el.getAttribute('aria-labelledby')
36
+ expect(labelledby).toBeTruthy()
37
+ expect(document.getElementById(labelledby!)?.textContent).toBe('Edit row')
38
+ } finally { destroy() }
39
+ })
40
+
41
+ it('locks body scroll while open and restores it on close', () => {
42
+ document.body.style.overflow = 'auto'
43
+ const { destroy } = mountModal({ open: true })
44
+ try {
45
+ expect(document.body.style.overflow).toBe('hidden')
46
+ } finally {
47
+ destroy()
48
+ flushSync()
49
+ expect(document.body.style.overflow).toBe('auto')
50
+ }
51
+ })
52
+
53
+ it('closes on Escape and fires onClose', () => {
54
+ let closed = 0
55
+ const { destroy } = mountModal({ open: true, onClose: () => closed++ })
56
+ try {
57
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
58
+ flushSync()
59
+ expect(closed).toBe(1)
60
+ expect(dialog()).toBeNull()
61
+ } finally { destroy() }
62
+ })
63
+
64
+ it('does not close on Escape when closeOnEsc is false', () => {
65
+ const { destroy } = mountModal({ open: true, closeOnEsc: false })
66
+ try {
67
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
68
+ flushSync()
69
+ expect(dialog()).not.toBeNull()
70
+ } finally { destroy() }
71
+ })
72
+
73
+ it('dismisses on outside (backdrop) pointerdown', () => {
74
+ const { destroy } = mountModal({ open: true })
75
+ try {
76
+ const backdrop = document.body.querySelector<HTMLElement>('.sv-modal__backdrop')!
77
+ backdrop.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
78
+ flushSync()
79
+ expect(dialog()).toBeNull()
80
+ } finally { destroy() }
81
+ })
82
+ })
@@ -178,6 +178,7 @@
178
178
  style:top={`${ms.rect.top}px`}
179
179
  style:left={`${ms.rect.left}px`}
180
180
  style:min-width={`${ms.rect.width}px`}
181
+ style:max-height={`${ms.rect.maxHeight}px`}
181
182
  onkeydown={ms.onPanelKeydown}
182
183
  >
183
184
  {#if searchable}