@nexxtmove/ui 2.6.0 → 2.7.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 (223) hide show
  1. package/dist/index.js +49 -126
  2. package/dist/nuxt.js +0 -1
  3. package/package.json +38 -36
  4. package/src/attribute-contract.test.ts +151 -0
  5. package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
  6. package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
  7. package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
  8. package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
  9. package/src/components/atoms/Button/Button.stories.ts +290 -0
  10. package/src/components/atoms/Button/Button.test.ts +406 -0
  11. package/src/components/atoms/Card/Card.stories.ts +45 -0
  12. package/src/components/atoms/Card/Card.test.ts +46 -0
  13. package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
  14. package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
  15. package/src/components/atoms/Chip/Chip.stories.ts +274 -0
  16. package/src/components/atoms/Chip/Chip.test.ts +230 -0
  17. package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
  18. package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
  19. package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
  20. package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
  21. package/src/components/atoms/Icon/Icon.stories.ts +63 -0
  22. package/src/components/atoms/Icon/Icon.test.ts +89 -0
  23. package/src/components/atoms/Loader/Loader.stories.ts +36 -0
  24. package/src/components/atoms/Loader/Loader.test.ts +40 -0
  25. package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
  26. package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
  27. package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
  28. package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
  29. package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
  30. package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
  31. package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
  32. package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
  33. package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
  34. package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
  35. package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
  36. package/src/components/atoms/Slider/Slider.stories.ts +151 -0
  37. package/src/components/atoms/Slider/Slider.test.ts +191 -0
  38. package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
  39. package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
  40. package/src/components/atoms/Switch/Switch.stories.ts +91 -0
  41. package/src/components/atoms/Switch/Switch.test.ts +183 -0
  42. package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
  43. package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
  44. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
  45. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
  46. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
  47. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
  48. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
  49. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
  50. package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
  51. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
  52. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
  53. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
  54. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
  55. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
  56. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
  57. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
  58. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
  59. package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
  60. package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
  61. package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
  62. package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
  63. package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +563 -0
  64. package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +689 -0
  65. package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +793 -0
  66. package/src/components/editor/molecules/EditorColor/gradientClass.test.ts +303 -0
  67. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.stories.ts +72 -0
  68. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.test.ts +124 -0
  69. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.stories.ts +46 -0
  70. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.test.ts +97 -0
  71. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.stories.ts +248 -0
  72. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.test.ts +288 -0
  73. package/src/components/editor/molecules/EditorGap/EditorGap.stories.ts +129 -0
  74. package/src/components/editor/molecules/EditorGap/EditorGap.test.ts +253 -0
  75. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.stories.ts +68 -0
  76. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.test.ts +150 -0
  77. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.stories.ts +71 -0
  78. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.test.ts +161 -0
  79. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.stories.ts +204 -0
  80. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.test.ts +238 -0
  81. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.stories.ts +257 -0
  82. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.test.ts +286 -0
  83. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.stories.ts +135 -0
  84. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.test.ts +230 -0
  85. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.stories.ts +154 -0
  86. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.test.ts +112 -0
  87. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.stories.ts +206 -0
  88. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.test.ts +332 -0
  89. package/src/components/editor/molecules/EditorPadding/EditorPadding.stories.ts +374 -0
  90. package/src/components/editor/molecules/EditorPadding/EditorPadding.test.ts +712 -0
  91. package/src/components/editor/molecules/EditorRadius/EditorRadius.stories.ts +64 -0
  92. package/src/components/editor/molecules/EditorRadius/EditorRadius.test.ts +137 -0
  93. package/src/components/editor/molecules/EditorShadow/EditorShadow.stories.ts +103 -0
  94. package/src/components/editor/molecules/EditorShadow/EditorShadow.test.ts +168 -0
  95. package/src/components/editor/molecules/EditorSize/EditorSize.stories.ts +84 -0
  96. package/src/components/editor/molecules/EditorSize/EditorSize.test.ts +253 -0
  97. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.stories.ts +49 -0
  98. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.test.ts +43 -0
  99. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.stories.ts +69 -0
  100. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.test.ts +155 -0
  101. package/src/components/molecules/Announcement/Announcement.stories.ts +224 -0
  102. package/src/components/molecules/Announcement/Announcement.test.ts +161 -0
  103. package/src/components/molecules/ContactInfoCard/ContactInfoCard.stories.ts +99 -0
  104. package/src/components/molecules/ContactInfoCard/ContactInfoCard.test.ts +70 -0
  105. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.stories.ts +148 -0
  106. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.test.ts +271 -0
  107. package/src/components/molecules/DoughnutChart/DoughnutChart.stories.ts +45 -0
  108. package/src/components/molecules/DoughnutChart/DoughnutChart.test.ts +205 -0
  109. package/src/components/molecules/InfoBlock/InfoBlock.stories.ts +84 -0
  110. package/src/components/molecules/InfoBlock/InfoBlock.test.ts +101 -0
  111. package/src/components/molecules/InputField/InputField.stories.ts +117 -0
  112. package/src/components/molecules/InputField/InputField.test.ts +138 -0
  113. package/src/components/molecules/InputSelect/InputSelect.stories.ts +116 -0
  114. package/src/components/molecules/InputSelect/InputSelect.test.ts +127 -0
  115. package/src/components/molecules/ModalFooter/ModalFooter.stories.ts +204 -0
  116. package/src/components/molecules/ModalFooter/ModalFooter.test.ts +276 -0
  117. package/src/components/molecules/PageCard/PageCard.stories.ts +100 -0
  118. package/src/components/molecules/PageCard/PageCard.test.ts +91 -0
  119. package/src/components/molecules/PageContainer/PageContainer.stories.ts +90 -0
  120. package/src/components/molecules/PageContainer/PageContainer.test.ts +50 -0
  121. package/src/components/molecules/PageMockup/PageMockup.stories.ts +38 -0
  122. package/src/components/molecules/PageMockup/PageMockup.test.ts +20 -0
  123. package/src/components/molecules/PageTypeTile/PageTypeTile.stories.ts +63 -0
  124. package/src/components/molecules/PageTypeTile/PageTypeTile.test.ts +41 -0
  125. package/src/components/molecules/Pagination/Pagination.stories.ts +72 -0
  126. package/src/components/molecules/Pagination/Pagination.test.ts +130 -0
  127. package/src/components/molecules/PickerGroup/PickerGroup.stories.ts +90 -0
  128. package/src/components/molecules/PickerGroup/PickerGroup.test.ts +145 -0
  129. package/src/components/molecules/PickerList/PickerList.stories.ts +436 -0
  130. package/src/components/molecules/PickerList/PickerList.test.ts +870 -0
  131. package/src/components/molecules/PlaceholderInput/PlaceholderInput.stories.ts +48 -0
  132. package/src/components/molecules/PlaceholderInput/PlaceholderInput.test.ts +107 -0
  133. package/src/components/molecules/PropertyCard/PropertyCard.stories.ts +46 -0
  134. package/src/components/molecules/PropertyCard/PropertyCard.test.ts +59 -0
  135. package/src/components/molecules/Select/Select.stories.ts +264 -0
  136. package/src/components/molecules/Select/Select.test.ts +315 -0
  137. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.stories.ts +84 -0
  138. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.test.ts +103 -0
  139. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.stories.ts +11 -0
  140. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.test.ts +38 -0
  141. package/src/components/molecules/SocialIcons/SocialIcons.stories.ts +28 -0
  142. package/src/components/molecules/SocialIcons/SocialIcons.test.ts +58 -0
  143. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
  144. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +116 -0
  145. package/src/components/molecules/SocialMediaType/SocialMediaType.stories.ts +37 -0
  146. package/src/components/molecules/SocialMediaType/SocialMediaType.test.ts +126 -0
  147. package/src/components/molecules/Source/Source.stories.ts +35 -0
  148. package/src/components/molecules/Source/Source.test.ts +45 -0
  149. package/src/components/molecules/TabbedContent/TabbedContent.stories.ts +96 -0
  150. package/src/components/molecules/TabbedContent/TabbedContent.test.ts +109 -0
  151. package/src/components/molecules/Tabs/Tabs.stories.ts +230 -0
  152. package/src/components/molecules/Tabs/Tabs.test.ts +280 -0
  153. package/src/components/molecules/TemplateCard/TemplateCard.stories.ts +327 -0
  154. package/src/components/molecules/TemplateCard/TemplateCard.test.ts +339 -0
  155. package/src/components/molecules/Textarea/Textarea.stories.ts +216 -0
  156. package/src/components/molecules/Textarea/Textarea.test.ts +191 -0
  157. package/src/components/molecules/TimelineEvent/TimelineEvent.stories.ts +319 -0
  158. package/src/components/molecules/TimelineEvent/TimelineEvent.test.ts +224 -0
  159. package/src/components/molecules/TimelineLanes/TimelineLanes.stories.ts +29 -0
  160. package/src/components/molecules/TimelineLanes/TimelineLanes.test.ts +30 -0
  161. package/src/components/molecules/Toast/Toast.stories.ts +110 -0
  162. package/src/components/molecules/Toast/Toast.test.ts +103 -0
  163. package/src/components/molecules/ToggleGroup/ToggleGroup.stories.ts +297 -0
  164. package/src/components/molecules/ToggleGroup/ToggleGroup.test.ts +578 -0
  165. package/src/components/molecules/Tree/Tree.stories.ts +884 -0
  166. package/src/components/molecules/Tree/Tree.test.ts +1247 -0
  167. package/src/components/molecules/Tree/tree-drop.test.ts +223 -0
  168. package/src/components/organisms/Calendar/Calendar.stories.ts +108 -0
  169. package/src/components/organisms/Calendar/Calendar.test.ts +279 -0
  170. package/src/components/organisms/Calendar/_CalendarDayView.test.ts +104 -0
  171. package/src/components/organisms/Calendar/_CalendarHeader.test.ts +86 -0
  172. package/src/components/organisms/Calendar/_CalendarMonthView.test.ts +52 -0
  173. package/src/components/organisms/Calendar/_CalendarTimePicker.test.ts +89 -0
  174. package/src/components/organisms/Calendar/_CalendarYearView.test.ts +56 -0
  175. package/src/components/organisms/ColorPicker/ColorPicker.stories.ts +229 -0
  176. package/src/components/organisms/ColorPicker/ColorPicker.test.ts +638 -0
  177. package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
  178. package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
  179. package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
  180. package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
  181. package/src/components/organisms/Combobox/Combobox.vue +1 -1
  182. package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
  183. package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
  184. package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
  185. package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
  186. package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
  187. package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
  188. package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
  189. package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
  190. package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
  191. package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
  192. package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
  193. package/src/components/organisms/Header/Header.stories.ts +75 -0
  194. package/src/components/organisms/Header/Header.test.ts +233 -0
  195. package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
  196. package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
  197. package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
  198. package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
  199. package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
  200. package/src/components/organisms/Modal/Modal.stories.ts +350 -0
  201. package/src/components/organisms/Modal/Modal.test.ts +275 -0
  202. package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
  203. package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
  204. package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
  205. package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
  206. package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
  207. package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
  208. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
  209. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  210. package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
  211. package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
  212. package/src/components/organisms/Table/Table.stories.ts +332 -0
  213. package/src/components/organisms/Table/Table.test.ts +320 -0
  214. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
  215. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
  216. package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
  217. package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
  218. package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
  219. package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
  220. package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
  221. package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
  222. package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
  223. package/src/components/organisms/VideoPlayer/VideoPlayer.test.ts +439 -0
@@ -0,0 +1,1247 @@
1
+ import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
2
+ import { h, nextTick } from 'vue'
3
+ import { flushPromises, mount } from '@vue/test-utils'
4
+ import Tree, { type NexxtTreeNode } from './Tree.vue'
5
+
6
+ const NODES: NexxtTreeNode[] = [
7
+ {
8
+ id: 'page',
9
+ label: 'Welkomstpagina',
10
+ icon: 'file',
11
+ children: [
12
+ { id: 'image', label: 'Afbeelding 1', icon: 'image' },
13
+ {
14
+ id: 'group',
15
+ label: 'Groep 1',
16
+ icon: 'object-group',
17
+ children: [
18
+ { id: 'title', label: 'Titel', icon: 'heading' },
19
+ { id: 'text', label: 'Tekst', icon: 'font' },
20
+ ],
21
+ },
22
+ ],
23
+ },
24
+ ]
25
+
26
+ const labels = (wrapper: ReturnType<typeof mount>) =>
27
+ wrapper.findAll('[role="treeitem"]').map((row) => row.text())
28
+
29
+ const dragEvent = (type: string, properties: Partial<DragEvent> = {}) => {
30
+ const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent
31
+
32
+ Object.entries(properties).forEach(([key, value]) => {
33
+ Object.defineProperty(event, key, { configurable: true, value })
34
+ })
35
+
36
+ return event
37
+ }
38
+ const pointerEvent = (type: string, properties: Partial<PointerEvent> = {}) => {
39
+ const event = new Event(type, { bubbles: true, cancelable: true }) as PointerEvent
40
+
41
+ Object.entries(properties).forEach(([key, value]) => {
42
+ Object.defineProperty(event, key, { configurable: true, value })
43
+ })
44
+
45
+ return event
46
+ }
47
+
48
+ const dataTransfer = (): DataTransfer =>
49
+ ({
50
+ dropEffect: 'none',
51
+ effectAllowed: 'all',
52
+ files: [] as unknown as FileList,
53
+ items: [] as unknown as DataTransferItemList,
54
+ types: [],
55
+ clearData: vi.fn(),
56
+ getData: vi.fn(),
57
+ setData: vi.fn(),
58
+ setDragImage: vi.fn(),
59
+ }) as unknown as DataTransfer
60
+ describe('Tree', () => {
61
+ it('renders every node as a tree item with its level', () => {
62
+ const wrapper = mount(Tree, { props: { nodes: NODES, label: 'Pagina structuur' } })
63
+
64
+ expect(wrapper.attributes('role')).toBe('tree')
65
+ expect(wrapper.attributes('aria-label')).toBe('Pagina structuur')
66
+ expect(labels(wrapper)).toEqual(['Welkomstpagina', 'Afbeelding 1', 'Groep 1', 'Titel', 'Tekst'])
67
+ expect(wrapper.find('[data-tree-id="title"]').attributes('aria-level')).toBe('3')
68
+ expect(wrapper.find('[data-tree-id="group"]').attributes('aria-expanded')).toBe('true')
69
+ expect(wrapper.find('[data-tree-id="image"]').attributes('aria-expanded')).toBeUndefined()
70
+ })
71
+
72
+ it('hides the children of a collapsed node', () => {
73
+ const wrapper = mount(Tree, {
74
+ props: { nodes: NODES, label: 'Structuur', collapsed: ['group'] },
75
+ })
76
+
77
+ expect(labels(wrapper)).toEqual(['Welkomstpagina', 'Afbeelding 1', 'Groep 1'])
78
+ expect(wrapper.find('[data-tree-id="group"]').attributes('aria-expanded')).toBe('false')
79
+ })
80
+
81
+ it('selects a row on click and marks it', async () => {
82
+ const wrapper = mount(Tree, { props: { nodes: NODES, label: 'Structuur' } })
83
+
84
+ await wrapper.find('[data-tree-id="image"]').trigger('click')
85
+
86
+ expect(wrapper.emitted('update:selected')).toEqual([['image']])
87
+ })
88
+
89
+ describe('drag and drop', () => {
90
+ // jsdom lays nothing out: every element in the row list gets a 28px row and a 2px gap, in
91
+ // document order, which is what the hit-test reads. Leaving elements take no space.
92
+ const PITCH = 30
93
+ const native = {
94
+ offsetTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop')!,
95
+ offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!,
96
+ }
97
+
98
+ beforeEach(() => {
99
+ Object.defineProperty(HTMLElement.prototype, 'offsetTop', {
100
+ configurable: true,
101
+ get(this: HTMLElement) {
102
+ const flow = Array.from(this.parentElement?.children ?? []).filter(
103
+ (element) => !element.classList.contains('absolute'),
104
+ )
105
+
106
+ return Math.max(0, flow.indexOf(this)) * PITCH
107
+ },
108
+ })
109
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
110
+ configurable: true,
111
+ get: () => PITCH - 2,
112
+ })
113
+ })
114
+
115
+ afterEach(() => {
116
+ Object.defineProperty(HTMLElement.prototype, 'offsetTop', native.offsetTop)
117
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', native.offsetHeight)
118
+ vi.useRealTimers()
119
+ })
120
+
121
+ const mountDraggable = (props: Record<string, unknown> = {}) => {
122
+ const wrapper = mount(Tree, {
123
+ props: { nodes: NODES, label: 'Structuur', draggable: true, ...props },
124
+ attachTo: document.body,
125
+ })
126
+
127
+ vi.spyOn(wrapper.element as HTMLElement, 'getBoundingClientRect').mockReturnValue({
128
+ top: 0,
129
+ left: 0,
130
+ right: 200,
131
+ bottom: 1000,
132
+ } as DOMRect)
133
+
134
+ return wrapper
135
+ }
136
+
137
+ type Wrapper = ReturnType<typeof mountDraggable>
138
+
139
+ const rowOf = (wrapper: Wrapper, id: string) =>
140
+ wrapper.find(`[data-tree-id="${id}"]`).element as HTMLElement
141
+ const ghost = (wrapper: Wrapper) => wrapper.find('[data-tree-ghost]')
142
+ /**
143
+ * The ghost row's indent for `depth`, in the tree's own step: one level is the difference
144
+ * between the padding of `image` (level 2) and `page` (level 1), so a new indent breaks nothing.
145
+ */
146
+ const indentAt = (wrapper: Wrapper, depth: number) => {
147
+ const padding = (id: string) => parseFloat(rowOf(wrapper, id).style.paddingLeft)
148
+
149
+ return `${depth * (padding('image') - padding('page'))}px`
150
+ }
151
+ const ghostIndent = (wrapper: Wrapper) =>
152
+ (ghost(wrapper).element.firstElementChild as HTMLElement).style.marginLeft
153
+ /** The ids in the row list in document order, with the ghost row as `+`. */
154
+ const order = (wrapper: Wrapper) =>
155
+ Array.from(rowOf(wrapper, 'page').parentElement!.children).map((element) =>
156
+ 'treeGhost' in (element as HTMLElement).dataset
157
+ ? '+'
158
+ : (element as HTMLElement).dataset.treeId,
159
+ )
160
+
161
+ /** Starts a native drag of `id`; the returned function moves it to `y` within the tree. */
162
+ const dragNative = (wrapper: Wrapper, id: string) => {
163
+ const transfer = dataTransfer()
164
+ rowOf(wrapper, id).dispatchEvent(dragEvent('dragstart', { dataTransfer: transfer }))
165
+
166
+ const over = async (y: number, target: Element = wrapper.element) => {
167
+ const event = dragEvent('dragover', { clientX: 10, clientY: y, dataTransfer: transfer })
168
+ target.dispatchEvent(event)
169
+ await nextTick()
170
+
171
+ return event
172
+ }
173
+ const drop = async () => {
174
+ const event = dragEvent('drop', { dataTransfer: transfer })
175
+ ;(ghost(wrapper).exists() ? ghost(wrapper).element : wrapper.element).dispatchEvent(event)
176
+ rowOf(wrapper, id).dispatchEvent(dragEvent('dragend', { dataTransfer: transfer }))
177
+ await nextTick()
178
+ }
179
+
180
+ return { over, drop }
181
+ }
182
+
183
+ const touch = (type: string, x: number, y: number) =>
184
+ pointerEvent(type, { pointerId: 1, pointerType: 'touch', clientX: x, clientY: y })
185
+
186
+ it('emits a move for a spot before a sibling', async () => {
187
+ const wrapper = mountDraggable()
188
+ const drag = dragNative(wrapper, 'text')
189
+
190
+ // the top half of `title`
191
+ const over = await drag.over(3 * PITCH + 5)
192
+ await drag.drop()
193
+
194
+ expect(over.defaultPrevented).toBe(true)
195
+ expect(wrapper.emitted('move')).toEqual([[{ id: 'text', parentId: 'group', index: 0 }]])
196
+ wrapper.unmount()
197
+ })
198
+
199
+ it('emits a move for a spot after a sibling', async () => {
200
+ const wrapper = mountDraggable()
201
+ const drag = dragNative(wrapper, 'title')
202
+
203
+ await drag.over(4 * PITCH + 20)
204
+ await drag.drop()
205
+
206
+ expect(wrapper.emitted('move')).toEqual([[{ id: 'title', parentId: 'group', index: 2 }]])
207
+ wrapper.unmount()
208
+ })
209
+
210
+ it('lands a drop inside a group as its first child', async () => {
211
+ const wrapper = mountDraggable()
212
+ const drag = dragNative(wrapper, 'image')
213
+
214
+ // the middle of `group`, which has two children
215
+ await drag.over(2 * PITCH + 14)
216
+ await drag.drop()
217
+
218
+ expect(wrapper.emitted('move')).toEqual([[{ id: 'image', parentId: 'group', index: 0 }]])
219
+ wrapper.unmount()
220
+ })
221
+
222
+ it('treats the spot a node already has as no target', async () => {
223
+ const wrapper = mountDraggable()
224
+ const drag = dragNative(wrapper, 'title')
225
+
226
+ // before `text` is right after `title`, where it already is
227
+ const over = await drag.over(4 * PITCH + 5)
228
+
229
+ expect(over.defaultPrevented).toBe(false)
230
+ expect(ghost(wrapper).exists()).toBe(false)
231
+
232
+ await drag.drop()
233
+
234
+ expect(wrapper.emitted('move')).toBeUndefined()
235
+ wrapper.unmount()
236
+ })
237
+
238
+ it('does not allow a node to move onto itself or into its own descendant', async () => {
239
+ const wrapper = mountDraggable()
240
+ const drag = dragNative(wrapper, 'group')
241
+
242
+ for (const y of [2 * PITCH + 14, 3 * PITCH + 5, 4 * PITCH + 20]) {
243
+ const over = await drag.over(y)
244
+
245
+ expect(over.defaultPrevented).toBe(false)
246
+ expect(ghost(wrapper).exists()).toBe(false)
247
+ }
248
+
249
+ await drag.drop()
250
+ expect(wrapper.emitted('move')).toBeUndefined()
251
+ wrapper.unmount()
252
+ })
253
+
254
+ it('shows one ghost row, out of the accessibility tree, where the node will land', async () => {
255
+ const wrapper = mountDraggable()
256
+ const drag = dragNative(wrapper, 'image')
257
+ const before = wrapper
258
+ .findAll('[role="treeitem"]')
259
+ .map((row) => [row.attributes('aria-posinset'), row.attributes('aria-setsize')])
260
+
261
+ await drag.over(3 * PITCH + 5)
262
+
263
+ expect(order(wrapper)).toEqual(['page', 'image', 'group', '+', 'title', 'text'])
264
+ expect(wrapper.findAll('[data-tree-ghost]')).toHaveLength(1)
265
+
266
+ const element = ghost(wrapper)
267
+ expect(element.attributes('aria-hidden')).toBe('true')
268
+ expect(element.attributes('role')).toBeUndefined()
269
+ expect(element.attributes('tabindex')).toBeUndefined()
270
+ expect(element.attributes('data-tree-id')).toBeUndefined()
271
+ expect(element.text()).toBe('Afbeelding 1')
272
+ // indented as deep as the children of `group`
273
+ expect(ghostIndent(wrapper)).toBe(indentAt(wrapper, 2))
274
+ expect(element.element.firstElementChild!.classList).toContain(
275
+ 'motion-reduce:transition-none',
276
+ )
277
+
278
+ // the treeitems are exactly what they were without a drag
279
+ expect(
280
+ wrapper
281
+ .findAll('[role="treeitem"]')
282
+ .map((row) => [row.attributes('aria-posinset'), row.attributes('aria-setsize')]),
283
+ ).toEqual(before)
284
+
285
+ await drag.drop()
286
+ expect(ghost(wrapper).exists()).toBe(false)
287
+ wrapper.unmount()
288
+ })
289
+
290
+ it('puts the ghost row under an open group for inside, and after its subtree for after', async () => {
291
+ const wrapper = mountDraggable()
292
+ const drag = dragNative(wrapper, 'image')
293
+
294
+ await drag.over(2 * PITCH + 14)
295
+ expect(order(wrapper)).toEqual(['page', 'image', 'group', '+', 'title', 'text'])
296
+
297
+ // the bottom of `group`: it lands after the group, so after everything shown inside it
298
+ await drag.over(2 * PITCH + 26)
299
+ expect(order(wrapper)).toEqual(['page', 'image', 'group', 'title', 'text', '+'])
300
+ expect(ghostIndent(wrapper)).toBe(indentAt(wrapper, 1))
301
+
302
+ await drag.drop()
303
+ expect(wrapper.emitted('move')).toEqual([[{ id: 'image', parentId: 'page', index: 2 }]])
304
+ wrapper.unmount()
305
+ })
306
+
307
+ it('leaves the ghost row alone while the pointer rests, on a row, a gap or the ghost', async () => {
308
+ const wrapper = mountDraggable()
309
+ const drag = dragNative(wrapper, 'image')
310
+
311
+ // after `title`: the ghost row goes in between `title` and `text`
312
+ await drag.over(3 * PITCH + 20)
313
+ // let the source dim first: that is a change of its own, and not the ghost row's
314
+ await new Promise((resolve) => setTimeout(resolve))
315
+ const placed = ghost(wrapper).element
316
+ expect(order(wrapper)).toEqual(['page', 'image', 'group', 'title', '+', 'text'])
317
+
318
+ const mutations: MutationRecord[] = []
319
+ const observer = new MutationObserver((records) => mutations.push(...records))
320
+ observer.observe(wrapper.element, { subtree: true, childList: true, attributes: true })
321
+
322
+ // the same point over and over, the gap under `title`, the ghost row itself, and the top
323
+ // of `text` that now sits below the ghost: before `text` is the very same spot
324
+ for (const y of [
325
+ 3 * PITCH + 20,
326
+ 3 * PITCH + 20,
327
+ 3 * PITCH + 29,
328
+ 4 * PITCH + 10,
329
+ 5 * PITCH + 5,
330
+ ]) {
331
+ await drag.over(y)
332
+ }
333
+ await flushPromises()
334
+ observer.disconnect()
335
+
336
+ expect(mutations).toEqual([])
337
+ expect(ghost(wrapper).element).toBe(placed)
338
+
339
+ await drag.drop()
340
+ expect(wrapper.emitted('move')).toEqual([[{ id: 'image', parentId: 'group', index: 1 }]])
341
+ wrapper.unmount()
342
+ })
343
+
344
+ it('marks a collapsed group itself instead of showing a ghost row under it', async () => {
345
+ const wrapper = mountDraggable({ collapsed: ['group'] })
346
+ const drag = dragNative(wrapper, 'image')
347
+
348
+ await drag.over(2 * PITCH + 14)
349
+
350
+ const group = wrapper.find('[data-tree-id="group"]')
351
+ expect(ghost(wrapper).exists()).toBe(false)
352
+ expect(group.attributes('data-tree-drop')).toBe('inside')
353
+ expect(group.classes()).toEqual(
354
+ expect.arrayContaining(['outline-dashed', 'bg-cornflower-blue-50']),
355
+ )
356
+ expect(group.attributes('aria-expanded')).toBe('false')
357
+
358
+ await drag.drop()
359
+
360
+ expect(wrapper.emitted('update:collapsed')).toBeUndefined()
361
+ expect(wrapper.emitted('move')).toEqual([[{ id: 'image', parentId: 'group', index: 0 }]])
362
+ expect(group.attributes('data-tree-drop')).toBeUndefined()
363
+ wrapper.unmount()
364
+ })
365
+
366
+ it('dims the dragged rows only once the drag image is taken, and turns hover off', async () => {
367
+ vi.useFakeTimers()
368
+ const wrapper = mountDraggable()
369
+ dragNative(wrapper, 'group')
370
+ await nextTick()
371
+
372
+ // still at full strength while the browser takes the drag image
373
+ expect(rowOf(wrapper, 'group').classList).not.toContain('opacity-40')
374
+
375
+ vi.runAllTimers()
376
+ await nextTick()
377
+
378
+ const dimmed = ['page', 'image', 'group', 'title', 'text'].filter((id) =>
379
+ rowOf(wrapper, id).classList.contains('opacity-40'),
380
+ )
381
+ expect(dimmed).toEqual(['group', 'title', 'text'])
382
+ expect(rowOf(wrapper, 'image').classList).not.toContain('hover:bg-gray-100')
383
+
384
+ rowOf(wrapper, 'group').dispatchEvent(dragEvent('dragend'))
385
+ await nextTick()
386
+
387
+ expect(rowOf(wrapper, 'group').classList).not.toContain('opacity-40')
388
+ expect(rowOf(wrapper, 'image').classList).toContain('hover:bg-gray-100')
389
+ wrapper.unmount()
390
+ })
391
+
392
+ it('clears the spot once the pointer leaves the tree, and drops nothing there', async () => {
393
+ const wrapper = mountDraggable()
394
+ const outside = document.createElement('div')
395
+ document.body.append(outside)
396
+ const drag = dragNative(wrapper, 'image')
397
+
398
+ await drag.over(3 * PITCH + 5)
399
+ expect(ghost(wrapper).exists()).toBe(true)
400
+
401
+ await drag.over(0, outside)
402
+ expect(ghost(wrapper).exists()).toBe(false)
403
+
404
+ // Escape and a drop outside the tree both end in a `dragend` without a `drop`
405
+ rowOf(wrapper, 'image').dispatchEvent(dragEvent('dragend'))
406
+ await nextTick()
407
+
408
+ expect(wrapper.emitted('move')).toBeUndefined()
409
+ outside.remove()
410
+ wrapper.unmount()
411
+ })
412
+
413
+ it('emits a move from a touch pointer', async () => {
414
+ const wrapper = mountDraggable()
415
+
416
+ rowOf(wrapper, 'title').dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
417
+ const move = touch('pointermove', 10, 4 * PITCH + 20)
418
+ wrapper.element.dispatchEvent(move)
419
+ await nextTick()
420
+
421
+ expect(order(wrapper)).toEqual(['page', 'image', 'group', 'title', 'text', '+'])
422
+ // no drag image to keep at full strength: dimmed at once
423
+ expect(rowOf(wrapper, 'title').classList).toContain('opacity-40')
424
+
425
+ wrapper.element.dispatchEvent(touch('pointerup', 10, 4 * PITCH + 20))
426
+ await nextTick()
427
+
428
+ expect(move.defaultPrevented).toBe(true)
429
+ expect(wrapper.emitted('move')).toEqual([[{ id: 'title', parentId: 'group', index: 2 }]])
430
+ expect(ghost(wrapper).exists()).toBe(false)
431
+ wrapper.unmount()
432
+ })
433
+
434
+ it('cancels a touch drag on Escape and ignores the click that follows', async () => {
435
+ const wrapper = mountDraggable()
436
+
437
+ rowOf(wrapper, 'title').dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
438
+ wrapper.element.dispatchEvent(touch('pointermove', 10, 4 * PITCH + 20))
439
+ await nextTick()
440
+ expect(ghost(wrapper).exists()).toBe(true)
441
+
442
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
443
+ await nextTick()
444
+ expect(ghost(wrapper).exists()).toBe(false)
445
+ expect(rowOf(wrapper, 'title').classList).not.toContain('opacity-40')
446
+
447
+ wrapper.element.dispatchEvent(touch('pointerup', 10, 4 * PITCH + 20))
448
+ rowOf(wrapper, 'title').click()
449
+
450
+ expect(wrapper.emitted('move')).toBeUndefined()
451
+ expect(wrapper.emitted('update:selected')).toBeUndefined()
452
+ wrapper.unmount()
453
+ })
454
+
455
+ it('drops nothing when a touch drag is let go outside the tree', async () => {
456
+ const wrapper = mountDraggable()
457
+
458
+ rowOf(wrapper, 'title').dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
459
+ wrapper.element.dispatchEvent(touch('pointermove', 10, 4 * PITCH + 20))
460
+ await nextTick()
461
+
462
+ // pointer capture keeps the events coming from the row, wherever the finger goes
463
+ wrapper.element.dispatchEvent(touch('pointermove', 300, 4 * PITCH + 20))
464
+ await nextTick()
465
+ expect(ghost(wrapper).exists()).toBe(false)
466
+
467
+ wrapper.element.dispatchEvent(touch('pointerup', 300, 4 * PITCH + 20))
468
+
469
+ expect(wrapper.emitted('move')).toBeUndefined()
470
+ wrapper.unmount()
471
+ })
472
+
473
+ it('ends a native drag on Escape without a move: a dragend and no drop', async () => {
474
+ const wrapper = mountDraggable()
475
+ const drag = dragNative(wrapper, 'image')
476
+
477
+ await drag.over(3 * PITCH + 5)
478
+ expect(ghost(wrapper).exists()).toBe(true)
479
+
480
+ rowOf(wrapper, 'image').dispatchEvent(dragEvent('dragend'))
481
+ await nextTick()
482
+
483
+ expect(ghost(wrapper).exists()).toBe(false)
484
+ expect(wrapper.emitted('move')).toBeUndefined()
485
+ wrapper.unmount()
486
+ })
487
+
488
+ describe('canDrop', () => {
489
+ it('turns a spot down: no ghost row, no drop and a `none` drop effect', async () => {
490
+ const canDrop = vi.fn(({ parentId }: { parentId: string | null }) => parentId !== 'group')
491
+ const wrapper = mountDraggable({ canDrop })
492
+ const drag = dragNative(wrapper, 'image')
493
+
494
+ // before `title`, inside `group`
495
+ const refused = await drag.over(3 * PITCH + 5)
496
+
497
+ expect(canDrop).toHaveBeenLastCalledWith({ id: 'image', parentId: 'group', index: 0 })
498
+ expect(refused.defaultPrevented).toBe(false)
499
+ expect(refused.dataTransfer!.dropEffect).toBe('none')
500
+ expect(ghost(wrapper).exists()).toBe(false)
501
+
502
+ await drag.drop()
503
+ expect(wrapper.emitted('move')).toBeUndefined()
504
+ wrapper.unmount()
505
+ })
506
+
507
+ it('only turns down the spots it refuses', async () => {
508
+ const canDrop = ({ parentId }: { parentId: string | null }) => parentId !== 'group'
509
+ const wrapper = mountDraggable({ canDrop })
510
+ const drag = dragNative(wrapper, 'image')
511
+
512
+ // the bottom of `group`: after it, inside `page`
513
+ const allowed = await drag.over(2 * PITCH + 26)
514
+
515
+ expect(allowed.defaultPrevented).toBe(true)
516
+ expect(allowed.dataTransfer!.dropEffect).toBe('move')
517
+ expect(ghost(wrapper).exists()).toBe(true)
518
+
519
+ await drag.drop()
520
+ expect(wrapper.emitted('move')).toEqual([[{ id: 'image', parentId: 'page', index: 2 }]])
521
+ wrapper.unmount()
522
+ })
523
+
524
+ it('does not mark a collapsed group it refuses', async () => {
525
+ const wrapper = mountDraggable({ collapsed: ['group'], canDrop: () => false })
526
+ const drag = dragNative(wrapper, 'image')
527
+
528
+ await drag.over(2 * PITCH + 14)
529
+
530
+ const group = wrapper.find('[data-tree-id="group"]')
531
+ expect(group.attributes('data-tree-drop')).toBeUndefined()
532
+ expect(group.classes()).not.toContain('outline-dashed')
533
+ wrapper.unmount()
534
+ })
535
+
536
+ it('turns a spot down for a touch drag too', async () => {
537
+ const wrapper = mountDraggable({ canDrop: () => false })
538
+
539
+ rowOf(wrapper, 'title').dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
540
+ wrapper.element.dispatchEvent(touch('pointermove', 10, 4 * PITCH + 20))
541
+ await nextTick()
542
+ expect(ghost(wrapper).exists()).toBe(false)
543
+
544
+ wrapper.element.dispatchEvent(touch('pointerup', 10, 4 * PITCH + 20))
545
+ expect(wrapper.emitted('move')).toBeUndefined()
546
+ wrapper.unmount()
547
+ })
548
+ })
549
+
550
+ it('offers the end of the last row’s level in the empty space below the rows', async () => {
551
+ const wrapper = mountDraggable()
552
+ const drag = dragNative(wrapper, 'image')
553
+
554
+ // the tree runs to 1000px, the rows end at 150
555
+ const over = await drag.over(600)
556
+
557
+ expect(over.defaultPrevented).toBe(true)
558
+ expect(order(wrapper)).toEqual(['page', 'image', 'group', 'title', 'text', '+'])
559
+ expect(ghostIndent(wrapper)).toBe(indentAt(wrapper, 2))
560
+
561
+ await drag.drop()
562
+ expect(wrapper.emitted('move')).toEqual([[{ id: 'image', parentId: 'group', index: 2 }]])
563
+ wrapper.unmount()
564
+ })
565
+
566
+ it('leaves that empty space to canDrop as well', async () => {
567
+ const wrapper = mountDraggable({ canDrop: () => false })
568
+ const drag = dragNative(wrapper, 'image')
569
+
570
+ expect((await drag.over(600)).defaultPrevented).toBe(false)
571
+ expect(ghost(wrapper).exists()).toBe(false)
572
+ wrapper.unmount()
573
+ })
574
+
575
+ it('lets the next click through after a pointercancel', async () => {
576
+ const wrapper = mountDraggable()
577
+
578
+ rowOf(wrapper, 'title').dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
579
+ wrapper.element.dispatchEvent(touch('pointermove', 10, 4 * PITCH + 20))
580
+ await nextTick()
581
+ wrapper.element.dispatchEvent(touch('pointercancel', 10, 4 * PITCH + 20))
582
+ await nextTick()
583
+
584
+ expect(ghost(wrapper).exists()).toBe(false)
585
+ expect(wrapper.emitted('move')).toBeUndefined()
586
+
587
+ rowOf(wrapper, 'image').click()
588
+ expect(wrapper.emitted('update:selected')).toEqual([['image']])
589
+ wrapper.unmount()
590
+ })
591
+
592
+ it('does not cost the next click when a drag ends without one', async () => {
593
+ const wrapper = mountDraggable()
594
+
595
+ rowOf(wrapper, 'title').dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
596
+ wrapper.element.dispatchEvent(touch('pointermove', 10, 4 * PITCH + 20))
597
+ wrapper.element.dispatchEvent(touch('pointerup', 10, 4 * PITCH + 20))
598
+ await nextTick()
599
+
600
+ // no click came after the lift; a new press starts a new gesture
601
+ rowOf(wrapper, 'image').dispatchEvent(pointerEvent('pointerdown', { pointerType: 'mouse' }))
602
+ rowOf(wrapper, 'image').click()
603
+
604
+ expect(wrapper.emitted('update:selected')).toEqual([['image']])
605
+ wrapper.unmount()
606
+ })
607
+
608
+ it('keeps Escape during a drag from the page, so a dialog around it stays open', async () => {
609
+ const wrapper = mountDraggable()
610
+ const page = vi.fn()
611
+ document.addEventListener('keydown', page)
612
+
613
+ const escape = () => {
614
+ const event = new KeyboardEvent('keydown', {
615
+ key: 'Escape',
616
+ bubbles: true,
617
+ cancelable: true,
618
+ })
619
+ document.body.dispatchEvent(event)
620
+
621
+ return event
622
+ }
623
+
624
+ // touch
625
+ rowOf(wrapper, 'title').dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
626
+ wrapper.element.dispatchEvent(touch('pointermove', 10, 4 * PITCH + 20))
627
+ expect(escape().defaultPrevented).toBe(true)
628
+ wrapper.element.dispatchEvent(touch('pointerup', 10, 4 * PITCH + 20))
629
+
630
+ // native
631
+ dragNative(wrapper, 'image')
632
+ expect(escape().defaultPrevented).toBe(true)
633
+ rowOf(wrapper, 'image').dispatchEvent(dragEvent('dragend'))
634
+
635
+ expect(page).not.toHaveBeenCalled()
636
+
637
+ // without a drag it is the page's again
638
+ escape()
639
+ expect(page).toHaveBeenCalledTimes(1)
640
+
641
+ document.removeEventListener('keydown', page)
642
+ wrapper.unmount()
643
+ })
644
+
645
+ describe('node actions', () => {
646
+ /** A button and a link in every row's actions: one the browser does not drag, one it does. */
647
+ const mountWithActions = () => {
648
+ const wrapper = mount(Tree, {
649
+ props: { nodes: NODES, label: 'Structuur', draggable: true },
650
+ slots: {
651
+ 'node-actions': ({ node, tabindex }: { node: NexxtTreeNode; tabindex: 0 | -1 }) => [
652
+ h('button', { type: 'button', tabindex, 'data-button': node.id }, 'Verwijder'),
653
+ h('a', { href: '#', tabindex, 'data-link': node.id }, 'Open'),
654
+ ],
655
+ },
656
+ attachTo: document.body,
657
+ })
658
+
659
+ vi.spyOn(wrapper.element as HTMLElement, 'getBoundingClientRect').mockReturnValue({
660
+ top: 0,
661
+ left: 0,
662
+ right: 200,
663
+ bottom: 1000,
664
+ } as DOMRect)
665
+
666
+ return wrapper
667
+ }
668
+
669
+ const mouseDown = (element: Element) =>
670
+ element.dispatchEvent(pointerEvent('pointerdown', { pointerType: 'mouse' }))
671
+
672
+ it('starts no mouse drag from a button or a link in them', async () => {
673
+ const wrapper = mountWithActions()
674
+
675
+ for (const selector of ['[data-button="image"]', '[data-link="image"]']) {
676
+ const element = wrapper.find(selector).element
677
+ mouseDown(element)
678
+
679
+ // from a button the browser reports the row as the target; a link drags itself
680
+ const start = dragEvent('dragstart', { dataTransfer: dataTransfer() })
681
+ ;(selector.startsWith('[data-button') ? rowOf(wrapper, 'image') : element).dispatchEvent(
682
+ start,
683
+ )
684
+ const over = dragEvent('dragover', { clientX: 10, clientY: 3 * PITCH + 5 })
685
+ wrapper.element.dispatchEvent(over)
686
+ await nextTick()
687
+
688
+ expect(start.defaultPrevented).toBe(true)
689
+ expect(over.defaultPrevented).toBe(false)
690
+ expect(ghost(wrapper).exists()).toBe(false)
691
+ }
692
+
693
+ expect(wrapper.emitted('move')).toBeUndefined()
694
+ wrapper.unmount()
695
+ })
696
+
697
+ it('refuses a dragstart from inside the row that is not the row itself', () => {
698
+ const wrapper = mountWithActions()
699
+ // pressed on the row, but the link is what the browser drags
700
+ mouseDown(rowOf(wrapper, 'image'))
701
+ const start = dragEvent('dragstart', { dataTransfer: dataTransfer() })
702
+ wrapper.find('[data-link="image"]').element.dispatchEvent(start)
703
+
704
+ expect(start.defaultPrevented).toBe(true)
705
+ wrapper.unmount()
706
+ })
707
+
708
+ it('starts no touch drag from a button or a link in them', async () => {
709
+ const wrapper = mountWithActions()
710
+
711
+ for (const selector of ['[data-button="title"]', '[data-link="title"]']) {
712
+ wrapper.find(selector).element.dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
713
+ wrapper.element.dispatchEvent(touch('pointermove', 10, 4 * PITCH + 20))
714
+ await nextTick()
715
+ wrapper.element.dispatchEvent(touch('pointerup', 10, 4 * PITCH + 20))
716
+
717
+ expect(ghost(wrapper).exists()).toBe(false)
718
+ expect(rowOf(wrapper, 'title').classList).not.toContain('opacity-40')
719
+ }
720
+
721
+ expect(wrapper.emitted('move')).toBeUndefined()
722
+ wrapper.unmount()
723
+ })
724
+
725
+ it('still runs a click on an action without selecting the row', async () => {
726
+ const wrapper = mountWithActions()
727
+ const onClick = vi.fn()
728
+ const button = wrapper.find('[data-button="image"]').element as HTMLElement
729
+ button.addEventListener('click', onClick)
730
+
731
+ mouseDown(button)
732
+ button.click()
733
+
734
+ expect(onClick).toHaveBeenCalledTimes(1)
735
+ expect(wrapper.emitted('update:selected')).toBeUndefined()
736
+ wrapper.unmount()
737
+ })
738
+
739
+ it('still drags the row itself after a press in the actions', async () => {
740
+ const wrapper = mountWithActions()
741
+
742
+ mouseDown(wrapper.find('[data-button="image"]').element)
743
+ mouseDown(rowOf(wrapper, 'image'))
744
+ const start = dragEvent('dragstart', { dataTransfer: dataTransfer() })
745
+ rowOf(wrapper, 'image').dispatchEvent(start)
746
+
747
+ expect(start.defaultPrevented).toBe(false)
748
+ rowOf(wrapper, 'image').dispatchEvent(dragEvent('dragend'))
749
+ wrapper.unmount()
750
+ })
751
+ })
752
+
753
+ it('lets go of Escape once the tree unmounts in the middle of a drag', async () => {
754
+ const escape = () => {
755
+ const event = new KeyboardEvent('keydown', {
756
+ key: 'Escape',
757
+ bubbles: true,
758
+ cancelable: true,
759
+ })
760
+ document.body.dispatchEvent(event)
761
+
762
+ return event
763
+ }
764
+
765
+ const native = mountDraggable()
766
+ const drag = dragNative(native, 'image')
767
+ await drag.over(3 * PITCH + 5)
768
+ native.unmount()
769
+ expect(escape().defaultPrevented).toBe(false)
770
+
771
+ const viaTouch = mountDraggable()
772
+ rowOf(viaTouch, 'title').dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
773
+ viaTouch.element.dispatchEvent(touch('pointermove', 10, 4 * PITCH + 20))
774
+ viaTouch.unmount()
775
+ expect(escape().defaultPrevented).toBe(false)
776
+ })
777
+
778
+ it('does not drag a node with `draggable: false`', async () => {
779
+ const nodes = structuredClone(NODES)
780
+ nodes[0]!.children![1]!.children![0]!.draggable = false
781
+ const wrapper = mountDraggable({ nodes })
782
+ const title = rowOf(wrapper, 'title')
783
+
784
+ expect(title.getAttribute('draggable')).toBe('false')
785
+ expect(title.classList).toContain('cursor-pointer')
786
+
787
+ // native: the browser starts no drag on it, and a stray dragstart starts none here
788
+ const drag = dragNative(wrapper, 'title')
789
+ expect((await drag.over(4 * PITCH + 20)).defaultPrevented).toBe(false)
790
+
791
+ // touch
792
+ title.dispatchEvent(touch('pointerdown', 10, 3 * PITCH + 10))
793
+ wrapper.element.dispatchEvent(touch('pointermove', 10, 4 * PITCH + 20))
794
+ await nextTick()
795
+ wrapper.element.dispatchEvent(touch('pointerup', 10, 4 * PITCH + 20))
796
+
797
+ expect(ghost(wrapper).exists()).toBe(false)
798
+ expect(wrapper.emitted('move')).toBeUndefined()
799
+ wrapper.unmount()
800
+ })
801
+ })
802
+
803
+ it('marks the selected row', () => {
804
+ const wrapper = mount(Tree, { props: { nodes: NODES, label: 'Structuur', selected: 'title' } })
805
+ const row = wrapper.find('[data-tree-id="title"]')
806
+
807
+ expect(row.attributes('aria-selected')).toBe('true')
808
+ expect(row.attributes('tabindex')).toBe('0')
809
+ expect(wrapper.findAll('[aria-selected="true"]')).toHaveLength(1)
810
+ })
811
+
812
+ it('fades the highlight out while nothing is selected', async () => {
813
+ const wrapper = mount(Tree, { props: { nodes: NODES, label: 'Structuur' } })
814
+ const highlight = wrapper.find('[data-tree-highlight]')
815
+
816
+ expect(highlight.classes()).toContain('opacity-0')
817
+
818
+ await wrapper.setProps({ selected: 'title' })
819
+
820
+ expect(highlight.classes()).not.toContain('opacity-0')
821
+ })
822
+
823
+ it('hides the highlight when the selected row is collapsed away', async () => {
824
+ const wrapper = mount(Tree, {
825
+ props: { nodes: NODES, label: 'Structuur', selected: 'title', collapsed: ['group'] },
826
+ })
827
+
828
+ expect(wrapper.find('[data-tree-highlight]').classes()).toContain('opacity-0')
829
+ })
830
+
831
+ it('borrows the row transition when a fold moves the highlight', async () => {
832
+ const wrapper = mount(Tree, {
833
+ props: { nodes: NODES, label: 'Structuur', selected: 'title' },
834
+ attachTo: document.body,
835
+ })
836
+
837
+ // the highlight only takes its transition classes once it has been positioned
838
+ await flushPromises()
839
+
840
+ const highlight = () => wrapper.find('[data-tree-highlight]').classes()
841
+
842
+ // a new selection arrives on the row it was clicked on
843
+ await wrapper.setProps({ selected: 'image' })
844
+ expect(highlight()).toContain('ease-out-strong')
845
+
846
+ // folding drags it along with a row that slides on the `move` transition, so it has to
847
+ // match that curve or the two visibly come apart on the way
848
+ await wrapper.setProps({ collapsed: ['group'] })
849
+ expect(highlight()).toContain('ease-in-out-strong')
850
+
851
+ wrapper.unmount()
852
+ })
853
+
854
+ it('opts out of every animation under reduced motion', async () => {
855
+ const wrapper = mount(Tree, {
856
+ props: { nodes: NODES, label: 'Structuur', selected: 'title' },
857
+ attachTo: document.body,
858
+ })
859
+
860
+ // the highlight only takes its transition classes once it has been positioned
861
+ await flushPromises()
862
+
863
+ const optedOut = (selector: string) =>
864
+ wrapper.find(selector).classes().includes('motion-reduce:transition-none')
865
+
866
+ expect(optedOut('[data-tree-highlight]')).toBe(true)
867
+ expect(optedOut('[data-tree-id="title"]')).toBe(true)
868
+ expect(optedOut('[data-tree-id="group"] button i')).toBe(true)
869
+
870
+ wrapper.unmount()
871
+ })
872
+
873
+ it('toggles with the chevron without selecting', async () => {
874
+ const wrapper = mount(Tree, { props: { nodes: NODES, label: 'Structuur' } })
875
+
876
+ await wrapper.find('[data-tree-id="group"] button').trigger('click')
877
+
878
+ expect(wrapper.emitted('update:collapsed')).toEqual([[['group']]])
879
+ expect(wrapper.emitted('update:selected')).toBeUndefined()
880
+ })
881
+
882
+ it('never lets the chevron take focus', async () => {
883
+ const wrapper = mount(Tree, {
884
+ props: { nodes: NODES, label: 'Structuur' },
885
+ attachTo: document.body,
886
+ })
887
+ const chevron = wrapper.find('[data-tree-id="group"] button')
888
+ const mousedown = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
889
+
890
+ chevron.element.dispatchEvent(mousedown)
891
+ await chevron.trigger('click')
892
+
893
+ // the default of a mousedown is what focuses a button, and it sits in `aria-hidden`
894
+ expect(mousedown.defaultPrevented).toBe(true)
895
+ expect(document.activeElement).not.toBe(chevron.element)
896
+
897
+ wrapper.unmount()
898
+ })
899
+
900
+ it('hands focus to the folding row when the focused row folds away', async () => {
901
+ const wrapper = mount(Tree, {
902
+ props: {
903
+ nodes: NODES,
904
+ label: 'Structuur',
905
+ 'onUpdate:collapsed': (value: string[]) => wrapper.setProps({ collapsed: value }),
906
+ },
907
+ attachTo: document.body,
908
+ })
909
+
910
+ ;(wrapper.find('[data-tree-id="title"]').element as HTMLElement).focus()
911
+ await wrapper.find('[data-tree-id="group"] button').trigger('click')
912
+ await flushPromises()
913
+
914
+ expect(document.activeElement?.getAttribute('data-tree-id')).toBe('group')
915
+
916
+ wrapper.unmount()
917
+ })
918
+
919
+ it('keeps a single tab stop', () => {
920
+ const wrapper = mount(Tree, { props: { nodes: NODES, label: 'Structuur' } })
921
+ const stops = wrapper
922
+ .findAll('[role="treeitem"]')
923
+ .filter((row) => row.attributes('tabindex') === '0')
924
+
925
+ expect(stops).toHaveLength(1)
926
+ expect(stops[0]!.text()).toBe('Welkomstpagina')
927
+ })
928
+
929
+ it('collapses the focused node with ArrowLeft', async () => {
930
+ const wrapper = mount(Tree, { props: { nodes: NODES, label: 'Structuur', selected: 'group' } })
931
+
932
+ await wrapper.trigger('keydown', { key: 'ArrowLeft' })
933
+
934
+ expect(wrapper.emitted('update:collapsed')).toEqual([[['group']]])
935
+ })
936
+
937
+ it('moves focus with the arrow keys and selects with Enter', async () => {
938
+ const wrapper = mount(Tree, {
939
+ props: { nodes: NODES, label: 'Structuur', selected: 'group' },
940
+ attachTo: document.body,
941
+ })
942
+
943
+ await wrapper.trigger('keydown', { key: 'ArrowDown' })
944
+ await wrapper.vm.$nextTick()
945
+ expect(document.activeElement?.getAttribute('data-tree-id')).toBe('title')
946
+
947
+ await wrapper.trigger('keydown', { key: 'Enter' })
948
+ expect(wrapper.emitted('update:selected')).toEqual([['title']])
949
+
950
+ wrapper.unmount()
951
+ })
952
+ it('names each tree item by its label alone', () => {
953
+ const wrapper = mount(Tree, { props: { nodes: NODES, label: 'Structuur' } })
954
+
955
+ wrapper.findAll('[role="treeitem"]').forEach((row) => {
956
+ const label = wrapper.find(`[id="${row.attributes('aria-labelledby')}"]`)
957
+
958
+ expect(label.exists()).toBe(true)
959
+ expect(row.element.contains(label.element)).toBe(true)
960
+ expect(label.text()).toBe(row.text())
961
+ })
962
+ })
963
+
964
+ describe('node actions', () => {
965
+ const received: { id: string; tabindex: 0 | -1 }[] = []
966
+
967
+ const mountWithActions = (props: Record<string, unknown> = {}) => {
968
+ received.length = 0
969
+
970
+ return mount(Tree, {
971
+ props: { nodes: NODES, label: 'Structuur', ...props },
972
+ slots: {
973
+ 'node-actions': ({ node, tabindex }: { node: NexxtTreeNode; tabindex: 0 | -1 }) => {
974
+ received.push({ id: node.id, tabindex })
975
+
976
+ return h('button', { type: 'button', tabindex, 'data-action': node.id }, 'Verwijder')
977
+ },
978
+ },
979
+ attachTo: document.body,
980
+ })
981
+ }
982
+
983
+ it('renders no container without the slot', () => {
984
+ const wrapper = mount(Tree, { props: { nodes: NODES, label: 'Structuur' } })
985
+
986
+ expect(wrapper.find('[data-tree-id="page"]').element.lastElementChild?.tagName).toBe('SPAN')
987
+ })
988
+
989
+ it('gives only the selected row its actions, and makes the others inert', async () => {
990
+ const wrapper = mountWithActions({ selected: 'group' })
991
+ /** The rows whose actions can be seen, focused and read. */
992
+ const reachable = () =>
993
+ wrapper
994
+ .findAll('[data-tree-actions]')
995
+ .filter((actions) => actions.attributes('inert') === undefined)
996
+ .map((actions) => actions.element.closest<HTMLElement>('[data-tree-id]')!.dataset.treeId)
997
+ const roomFor = () =>
998
+ wrapper
999
+ .findAll('[data-tree-actions]')
1000
+ .filter((actions) => actions.classes('grid-cols-[minmax(0,1fr)]'))
1001
+ .map((actions) => actions.element.closest<HTMLElement>('[data-tree-id]')!.dataset.treeId)
1002
+
1003
+ expect(reachable()).toEqual(['group'])
1004
+ expect(roomFor()).toEqual(['group'])
1005
+
1006
+ await wrapper.setProps({ selected: 'title' })
1007
+ expect(reachable()).toEqual(['title'])
1008
+ expect(roomFor()).toEqual(['title'])
1009
+
1010
+ await wrapper.setProps({ selected: null })
1011
+ expect(reachable()).toEqual([])
1012
+ expect(roomFor()).toEqual([])
1013
+
1014
+ wrapper.unmount()
1015
+ })
1016
+
1017
+ it('lets the label run to the row’s end when no actions sit beside it', () => {
1018
+ const wrapper = mountWithActions()
1019
+ const label = wrapper.find('[data-tree-id="page"] [data-tree-label]')
1020
+ const actions = wrapper.find('[data-tree-id="page"] [data-tree-actions]')
1021
+
1022
+ // the label is the row's flexible part, and the closed track gives back the gap before it
1023
+ expect(label.classes()).toEqual(expect.arrayContaining(['min-w-0', 'flex-1', 'truncate']))
1024
+ expect(actions.classes()).toEqual(
1025
+ expect.arrayContaining(['grid-cols-[minmax(0,0fr)]', '-ml-1.5']),
1026
+ )
1027
+
1028
+ wrapper.unmount()
1029
+ })
1030
+
1031
+ it('hands every row its node and only the tab stop a tabindex of 0', () => {
1032
+ const wrapper = mountWithActions({ selected: 'group' })
1033
+
1034
+ expect(received.map((item) => item.id)).toEqual(['page', 'image', 'group', 'title', 'text'])
1035
+ expect(received.filter((item) => item.tabindex === 0)).toEqual([{ id: 'group', tabindex: 0 }])
1036
+ expect(wrapper.find('[data-action="group"]').attributes('tabindex')).toBe('0')
1037
+ expect(wrapper.find('[data-action="title"]').attributes('tabindex')).toBe('-1')
1038
+
1039
+ wrapper.unmount()
1040
+ })
1041
+
1042
+ it('keeps a click on an action away from the row', async () => {
1043
+ const wrapper = mountWithActions()
1044
+
1045
+ await wrapper.find('[data-action="group"]').trigger('click')
1046
+
1047
+ expect(wrapper.emitted('update:selected')).toBeUndefined()
1048
+ expect(wrapper.emitted('update:collapsed')).toBeUndefined()
1049
+
1050
+ wrapper.unmount()
1051
+ })
1052
+
1053
+ it('keeps keys on an action away from the tree', async () => {
1054
+ const wrapper = mountWithActions()
1055
+ const action = wrapper.find('[data-action="page"]')
1056
+
1057
+ for (const key of ['Enter', ' ', 'ArrowDown', 'ArrowLeft']) {
1058
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
1059
+ action.element.dispatchEvent(event)
1060
+
1061
+ // the tree would have handled it and cancelled the default, eating the native click
1062
+ expect(event.defaultPrevented).toBe(false)
1063
+ }
1064
+
1065
+ expect(wrapper.emitted('update:selected')).toBeUndefined()
1066
+ expect(wrapper.emitted('update:collapsed')).toBeUndefined()
1067
+
1068
+ wrapper.unmount()
1069
+ })
1070
+
1071
+ it('leaves the name of the row to its label', () => {
1072
+ const wrapper = mountWithActions()
1073
+ const row = wrapper.find('[data-tree-id="page"]')
1074
+
1075
+ expect(wrapper.find(`[id="${row.attributes('aria-labelledby')}"]`).text()).toBe(
1076
+ 'Welkomstpagina',
1077
+ )
1078
+
1079
+ wrapper.unmount()
1080
+ })
1081
+ })
1082
+
1083
+ describe('focusNode', () => {
1084
+ // jsdom implements neither; the scroll is what these tests watch
1085
+ const scrollIntoView = vi.fn()
1086
+
1087
+ beforeEach(() => {
1088
+ scrollIntoView.mockClear()
1089
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
1090
+ configurable: true,
1091
+ value: scrollIntoView,
1092
+ })
1093
+ vi.stubGlobal('matchMedia', () => ({ matches: false }))
1094
+ })
1095
+
1096
+ afterEach(() => {
1097
+ delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView
1098
+ vi.unstubAllGlobals()
1099
+ })
1100
+
1101
+ const scrolledRows = () =>
1102
+ scrollIntoView.mock.contexts.map((row) => (row as HTMLElement).dataset.treeId)
1103
+
1104
+ /** Test utils stub the TransitionGroup; the real one is what runs the unfold hooks. */
1105
+ const mountUnfolding = (collapsed: string[]) => {
1106
+ const wrapper = mount(Tree, {
1107
+ props: {
1108
+ nodes: NODES,
1109
+ label: 'Structuur',
1110
+ collapsed,
1111
+ 'onUpdate:collapsed': (value: string[]) => wrapper.setProps({ collapsed: value }),
1112
+ },
1113
+ global: { stubs: { TransitionGroup: false } },
1114
+ attachTo: document.body,
1115
+ })
1116
+
1117
+ return wrapper
1118
+ }
1119
+
1120
+ const landed = (wrapper: ReturnType<typeof mount>, id: string) =>
1121
+ vi.waitFor(() =>
1122
+ expect(
1123
+ (wrapper.find(`[data-tree-id="${id}"]`).element as HTMLElement).style.pointerEvents,
1124
+ ).toBe(''),
1125
+ )
1126
+
1127
+ it('scrolls a visible row to the nearest edge instead of letting focus centre it', async () => {
1128
+ const focus = vi.spyOn(HTMLElement.prototype, 'focus')
1129
+ const wrapper = mount(Tree, {
1130
+ props: { nodes: NODES, label: 'Structuur' },
1131
+ attachTo: document.body,
1132
+ })
1133
+
1134
+ await wrapper.vm.focusNode('text')
1135
+
1136
+ expect(focus).toHaveBeenLastCalledWith({ preventScroll: true })
1137
+ expect(scrollIntoView).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' })
1138
+ expect(scrolledRows()).toEqual(['text'])
1139
+
1140
+ focus.mockRestore()
1141
+ wrapper.unmount()
1142
+ })
1143
+
1144
+ it('holds the scroll of an unfolding row until it has landed', async () => {
1145
+ const wrapper = mountUnfolding(['group'])
1146
+
1147
+ await expect(wrapper.vm.focusNode('title')).resolves.toBe(true)
1148
+
1149
+ // focused at once, but still stacked on its parent: no scroll to that spot
1150
+ expect(document.activeElement?.getAttribute('data-tree-id')).toBe('title')
1151
+ expect(scrollIntoView).not.toHaveBeenCalled()
1152
+
1153
+ await landed(wrapper, 'title')
1154
+
1155
+ expect(scrollIntoView).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' })
1156
+ expect(scrolledRows()).toEqual(['title'])
1157
+
1158
+ wrapper.unmount()
1159
+ })
1160
+
1161
+ it('lets the last of two quick calls win', async () => {
1162
+ const wrapper = mountUnfolding(['group'])
1163
+
1164
+ const results = await Promise.all([
1165
+ wrapper.vm.focusNode('title'),
1166
+ wrapper.vm.focusNode('image'),
1167
+ ])
1168
+ await landed(wrapper, 'title')
1169
+
1170
+ expect(results).toEqual([true, true])
1171
+ expect(document.activeElement?.getAttribute('data-tree-id')).toBe('image')
1172
+ const stops = wrapper
1173
+ .findAll('[role="treeitem"]')
1174
+ .filter((row) => row.attributes('tabindex') === '0')
1175
+ expect(stops.map((row) => row.attributes('data-tree-id'))).toEqual(['image'])
1176
+ // the first row landing later does not pull the panel back to it
1177
+ expect(scrolledRows()).toEqual(['image'])
1178
+
1179
+ wrapper.unmount()
1180
+ })
1181
+
1182
+ it('focuses a visible row and makes it the only tab stop', async () => {
1183
+ const wrapper = mount(Tree, {
1184
+ props: { nodes: NODES, label: 'Structuur' },
1185
+ attachTo: document.body,
1186
+ })
1187
+
1188
+ await expect(wrapper.vm.focusNode('image')).resolves.toBe(true)
1189
+
1190
+ expect(document.activeElement?.getAttribute('data-tree-id')).toBe('image')
1191
+ const stops = wrapper
1192
+ .findAll('[role="treeitem"]')
1193
+ .filter((row) => row.attributes('tabindex') === '0')
1194
+ expect(stops.map((row) => row.attributes('data-tree-id'))).toEqual(['image'])
1195
+
1196
+ wrapper.unmount()
1197
+ })
1198
+
1199
+ it('unfolds the collapsed ancestors of a hidden row before focusing it', async () => {
1200
+ const wrapper = mount(Tree, {
1201
+ props: {
1202
+ nodes: NODES,
1203
+ label: 'Structuur',
1204
+ collapsed: ['page', 'group', 'other'],
1205
+ 'onUpdate:collapsed': (value: string[]) => wrapper.setProps({ collapsed: value }),
1206
+ },
1207
+ attachTo: document.body,
1208
+ })
1209
+
1210
+ await expect(wrapper.vm.focusNode('title')).resolves.toBe(true)
1211
+
1212
+ // only the ancestors open up; an unrelated fold is left as it was
1213
+ expect(wrapper.emitted('update:collapsed')).toEqual([[['other']]])
1214
+ expect(document.activeElement?.getAttribute('data-tree-id')).toBe('title')
1215
+
1216
+ wrapper.unmount()
1217
+ })
1218
+
1219
+ it('returns false for an unknown id and changes nothing', async () => {
1220
+ const wrapper = mount(Tree, {
1221
+ props: { nodes: NODES, label: 'Structuur', collapsed: ['group'] },
1222
+ attachTo: document.body,
1223
+ })
1224
+
1225
+ await expect(wrapper.vm.focusNode('missing')).resolves.toBe(false)
1226
+
1227
+ expect(wrapper.emitted('update:collapsed')).toBeUndefined()
1228
+ expect(document.activeElement).toBe(document.body)
1229
+
1230
+ wrapper.unmount()
1231
+ })
1232
+
1233
+ it('leaves the selection alone', async () => {
1234
+ const wrapper = mount(Tree, {
1235
+ props: { nodes: NODES, label: 'Structuur', selected: 'group' },
1236
+ attachTo: document.body,
1237
+ })
1238
+
1239
+ await wrapper.vm.focusNode('text')
1240
+
1241
+ expect(wrapper.emitted('update:selected')).toBeUndefined()
1242
+ expect(wrapper.find('[data-tree-id="group"]').attributes('aria-selected')).toBe('true')
1243
+
1244
+ wrapper.unmount()
1245
+ })
1246
+ })
1247
+ })