@nexxtmove/ui 2.5.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 (228) hide show
  1. package/dist/components/editor/molecules/EditorBorder/EditorBorder.vue.d.ts +12 -0
  2. package/dist/components/editor/molecules/EditorColor/_ThemeRoleSwatches.vue.d.ts +16 -0
  3. package/dist/index.js +1094 -1135
  4. package/dist/nuxt.js +0 -1
  5. package/package.json +38 -36
  6. package/src/attribute-contract.test.ts +151 -0
  7. package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
  8. package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
  9. package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
  10. package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
  11. package/src/components/atoms/Button/Button.stories.ts +290 -0
  12. package/src/components/atoms/Button/Button.test.ts +406 -0
  13. package/src/components/atoms/Card/Card.stories.ts +45 -0
  14. package/src/components/atoms/Card/Card.test.ts +46 -0
  15. package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
  16. package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
  17. package/src/components/atoms/Chip/Chip.stories.ts +274 -0
  18. package/src/components/atoms/Chip/Chip.test.ts +230 -0
  19. package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
  20. package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
  21. package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
  22. package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
  23. package/src/components/atoms/Icon/Icon.stories.ts +63 -0
  24. package/src/components/atoms/Icon/Icon.test.ts +89 -0
  25. package/src/components/atoms/Loader/Loader.stories.ts +36 -0
  26. package/src/components/atoms/Loader/Loader.test.ts +40 -0
  27. package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
  28. package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
  29. package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
  30. package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
  31. package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
  32. package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
  33. package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
  34. package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
  35. package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
  36. package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
  37. package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
  38. package/src/components/atoms/Slider/Slider.stories.ts +151 -0
  39. package/src/components/atoms/Slider/Slider.test.ts +191 -0
  40. package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
  41. package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
  42. package/src/components/atoms/Switch/Switch.stories.ts +91 -0
  43. package/src/components/atoms/Switch/Switch.test.ts +183 -0
  44. package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
  45. package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
  46. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
  47. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
  48. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
  49. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
  50. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
  51. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
  52. package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
  53. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
  54. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
  55. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
  56. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
  57. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
  58. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
  59. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
  60. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
  61. package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
  62. package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
  63. package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
  64. package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
  65. package/src/components/editor/molecules/EditorBorder/EditorBorder.vue +58 -2
  66. package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +563 -0
  67. package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +689 -0
  68. package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +793 -0
  69. package/src/components/editor/molecules/EditorColor/_ColorField.vue +7 -29
  70. package/src/components/editor/molecules/EditorColor/_ThemeRoleSwatches.vue +57 -0
  71. package/src/components/editor/molecules/EditorColor/gradientClass.test.ts +303 -0
  72. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.stories.ts +72 -0
  73. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.test.ts +124 -0
  74. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.stories.ts +46 -0
  75. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.test.ts +97 -0
  76. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.stories.ts +248 -0
  77. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.test.ts +288 -0
  78. package/src/components/editor/molecules/EditorGap/EditorGap.stories.ts +129 -0
  79. package/src/components/editor/molecules/EditorGap/EditorGap.test.ts +253 -0
  80. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.stories.ts +68 -0
  81. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.test.ts +150 -0
  82. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.stories.ts +71 -0
  83. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.test.ts +161 -0
  84. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.stories.ts +204 -0
  85. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.test.ts +238 -0
  86. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.stories.ts +257 -0
  87. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.test.ts +286 -0
  88. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.stories.ts +135 -0
  89. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.test.ts +230 -0
  90. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.stories.ts +154 -0
  91. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.test.ts +112 -0
  92. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.stories.ts +206 -0
  93. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.test.ts +332 -0
  94. package/src/components/editor/molecules/EditorPadding/EditorPadding.stories.ts +374 -0
  95. package/src/components/editor/molecules/EditorPadding/EditorPadding.test.ts +712 -0
  96. package/src/components/editor/molecules/EditorRadius/EditorRadius.stories.ts +64 -0
  97. package/src/components/editor/molecules/EditorRadius/EditorRadius.test.ts +137 -0
  98. package/src/components/editor/molecules/EditorShadow/EditorShadow.stories.ts +103 -0
  99. package/src/components/editor/molecules/EditorShadow/EditorShadow.test.ts +168 -0
  100. package/src/components/editor/molecules/EditorSize/EditorSize.stories.ts +84 -0
  101. package/src/components/editor/molecules/EditorSize/EditorSize.test.ts +253 -0
  102. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.stories.ts +49 -0
  103. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.test.ts +43 -0
  104. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.stories.ts +69 -0
  105. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.test.ts +155 -0
  106. package/src/components/molecules/Announcement/Announcement.stories.ts +224 -0
  107. package/src/components/molecules/Announcement/Announcement.test.ts +161 -0
  108. package/src/components/molecules/ContactInfoCard/ContactInfoCard.stories.ts +99 -0
  109. package/src/components/molecules/ContactInfoCard/ContactInfoCard.test.ts +70 -0
  110. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.stories.ts +148 -0
  111. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.test.ts +271 -0
  112. package/src/components/molecules/DoughnutChart/DoughnutChart.stories.ts +45 -0
  113. package/src/components/molecules/DoughnutChart/DoughnutChart.test.ts +205 -0
  114. package/src/components/molecules/InfoBlock/InfoBlock.stories.ts +84 -0
  115. package/src/components/molecules/InfoBlock/InfoBlock.test.ts +101 -0
  116. package/src/components/molecules/InputField/InputField.stories.ts +117 -0
  117. package/src/components/molecules/InputField/InputField.test.ts +138 -0
  118. package/src/components/molecules/InputSelect/InputSelect.stories.ts +116 -0
  119. package/src/components/molecules/InputSelect/InputSelect.test.ts +127 -0
  120. package/src/components/molecules/ModalFooter/ModalFooter.stories.ts +204 -0
  121. package/src/components/molecules/ModalFooter/ModalFooter.test.ts +276 -0
  122. package/src/components/molecules/PageCard/PageCard.stories.ts +100 -0
  123. package/src/components/molecules/PageCard/PageCard.test.ts +91 -0
  124. package/src/components/molecules/PageContainer/PageContainer.stories.ts +90 -0
  125. package/src/components/molecules/PageContainer/PageContainer.test.ts +50 -0
  126. package/src/components/molecules/PageMockup/PageMockup.stories.ts +38 -0
  127. package/src/components/molecules/PageMockup/PageMockup.test.ts +20 -0
  128. package/src/components/molecules/PageTypeTile/PageTypeTile.stories.ts +63 -0
  129. package/src/components/molecules/PageTypeTile/PageTypeTile.test.ts +41 -0
  130. package/src/components/molecules/Pagination/Pagination.stories.ts +72 -0
  131. package/src/components/molecules/Pagination/Pagination.test.ts +130 -0
  132. package/src/components/molecules/PickerGroup/PickerGroup.stories.ts +90 -0
  133. package/src/components/molecules/PickerGroup/PickerGroup.test.ts +145 -0
  134. package/src/components/molecules/PickerList/PickerList.stories.ts +436 -0
  135. package/src/components/molecules/PickerList/PickerList.test.ts +870 -0
  136. package/src/components/molecules/PlaceholderInput/PlaceholderInput.stories.ts +48 -0
  137. package/src/components/molecules/PlaceholderInput/PlaceholderInput.test.ts +107 -0
  138. package/src/components/molecules/PropertyCard/PropertyCard.stories.ts +46 -0
  139. package/src/components/molecules/PropertyCard/PropertyCard.test.ts +59 -0
  140. package/src/components/molecules/Select/Select.stories.ts +264 -0
  141. package/src/components/molecules/Select/Select.test.ts +315 -0
  142. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.stories.ts +84 -0
  143. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.test.ts +103 -0
  144. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.stories.ts +11 -0
  145. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.test.ts +38 -0
  146. package/src/components/molecules/SocialIcons/SocialIcons.stories.ts +28 -0
  147. package/src/components/molecules/SocialIcons/SocialIcons.test.ts +58 -0
  148. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
  149. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +116 -0
  150. package/src/components/molecules/SocialMediaType/SocialMediaType.stories.ts +37 -0
  151. package/src/components/molecules/SocialMediaType/SocialMediaType.test.ts +126 -0
  152. package/src/components/molecules/Source/Source.stories.ts +35 -0
  153. package/src/components/molecules/Source/Source.test.ts +45 -0
  154. package/src/components/molecules/TabbedContent/TabbedContent.stories.ts +96 -0
  155. package/src/components/molecules/TabbedContent/TabbedContent.test.ts +109 -0
  156. package/src/components/molecules/Tabs/Tabs.stories.ts +230 -0
  157. package/src/components/molecules/Tabs/Tabs.test.ts +280 -0
  158. package/src/components/molecules/TemplateCard/TemplateCard.stories.ts +327 -0
  159. package/src/components/molecules/TemplateCard/TemplateCard.test.ts +339 -0
  160. package/src/components/molecules/Textarea/Textarea.stories.ts +216 -0
  161. package/src/components/molecules/Textarea/Textarea.test.ts +191 -0
  162. package/src/components/molecules/TimelineEvent/TimelineEvent.stories.ts +319 -0
  163. package/src/components/molecules/TimelineEvent/TimelineEvent.test.ts +224 -0
  164. package/src/components/molecules/TimelineLanes/TimelineLanes.stories.ts +29 -0
  165. package/src/components/molecules/TimelineLanes/TimelineLanes.test.ts +30 -0
  166. package/src/components/molecules/Toast/Toast.stories.ts +110 -0
  167. package/src/components/molecules/Toast/Toast.test.ts +103 -0
  168. package/src/components/molecules/ToggleGroup/ToggleGroup.stories.ts +297 -0
  169. package/src/components/molecules/ToggleGroup/ToggleGroup.test.ts +578 -0
  170. package/src/components/molecules/Tree/Tree.stories.ts +884 -0
  171. package/src/components/molecules/Tree/Tree.test.ts +1247 -0
  172. package/src/components/molecules/Tree/tree-drop.test.ts +223 -0
  173. package/src/components/organisms/Calendar/Calendar.stories.ts +108 -0
  174. package/src/components/organisms/Calendar/Calendar.test.ts +279 -0
  175. package/src/components/organisms/Calendar/_CalendarDayView.test.ts +104 -0
  176. package/src/components/organisms/Calendar/_CalendarHeader.test.ts +86 -0
  177. package/src/components/organisms/Calendar/_CalendarMonthView.test.ts +52 -0
  178. package/src/components/organisms/Calendar/_CalendarTimePicker.test.ts +89 -0
  179. package/src/components/organisms/Calendar/_CalendarYearView.test.ts +56 -0
  180. package/src/components/organisms/ColorPicker/ColorPicker.stories.ts +229 -0
  181. package/src/components/organisms/ColorPicker/ColorPicker.test.ts +638 -0
  182. package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
  183. package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
  184. package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
  185. package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
  186. package/src/components/organisms/Combobox/Combobox.vue +1 -1
  187. package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
  188. package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
  189. package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
  190. package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
  191. package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
  192. package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
  193. package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
  194. package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
  195. package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
  196. package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
  197. package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
  198. package/src/components/organisms/Header/Header.stories.ts +75 -0
  199. package/src/components/organisms/Header/Header.test.ts +233 -0
  200. package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
  201. package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
  202. package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
  203. package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
  204. package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
  205. package/src/components/organisms/Modal/Modal.stories.ts +350 -0
  206. package/src/components/organisms/Modal/Modal.test.ts +275 -0
  207. package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
  208. package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
  209. package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
  210. package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
  211. package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
  212. package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
  213. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
  214. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  215. package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
  216. package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
  217. package/src/components/organisms/Table/Table.stories.ts +332 -0
  218. package/src/components/organisms/Table/Table.test.ts +320 -0
  219. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
  220. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
  221. package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
  222. package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
  223. package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
  224. package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
  225. package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
  226. package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
  227. package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
  228. package/src/components/organisms/VideoPlayer/VideoPlayer.test.ts +439 -0
@@ -0,0 +1,884 @@
1
+ import { createApp, ref, type Ref } from 'vue'
2
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
3
+ import { expect, fn, userEvent, waitFor, within } from 'storybook/test'
4
+ import NexxtIcon from '../../atoms/Icon/Icon.vue'
5
+ import Tree, { type NexxtTreeMove, type NexxtTreeNode } from './Tree.vue'
6
+
7
+ export default {
8
+ title: 'Components/Molecules/Tree',
9
+ component: Tree,
10
+ args: { draggable: false },
11
+ } satisfies Meta<typeof Tree>
12
+
13
+ type Story = StoryObj<typeof Tree>
14
+
15
+ /**
16
+ * Motion only goes wrong mid-flight: once everything has landed the tree looks the same whether
17
+ * it slid there or jumped. So the folding plays sample every frame and assert on the worst one.
18
+ * `overlap` is two readable rows on the same pixels — doubled labels; `drift` is the highlight
19
+ * running away from the row it belongs to; `tops` is the travel of a single row.
20
+ */
21
+ const watchMotion = (canvasElement: HTMLElement) => {
22
+ const highlight = canvasElement.querySelector<HTMLElement>('[data-tree-highlight]')!
23
+ const tops: Record<string, number[]> = {}
24
+ let overlap = 0
25
+ let drift = 0
26
+ let sampling = true
27
+
28
+ const sample = () => {
29
+ // a row on its way out is hidden, and one on its way in is still fading up: neither is read
30
+ const onScreen = Array.from(canvasElement.querySelectorAll<HTMLElement>('[data-tree-id]'))
31
+ .map((row) => ({ row, style: getComputedStyle(row), rect: row.getBoundingClientRect() }))
32
+ .filter((item) => item.style.visibility !== 'hidden')
33
+
34
+ const readable = onScreen
35
+ .filter((item) => Number(item.style.opacity) > 0.5)
36
+ .map((item) => item.rect)
37
+ .sort((a, b) => a.top - b.top)
38
+
39
+ readable.forEach((rect, index) => {
40
+ const next = readable[index + 1]
41
+ if (next) overlap = Math.max(overlap, rect.bottom - next.top)
42
+ })
43
+
44
+ onScreen.forEach(({ row, rect }) => {
45
+ ;(tops[row.dataset.treeId!] ??= []).push(rect.top)
46
+
47
+ if (row.getAttribute('aria-selected') === 'true') {
48
+ drift = Math.max(drift, Math.abs(highlight.getBoundingClientRect().top - rect.top))
49
+ }
50
+ })
51
+
52
+ if (sampling) requestAnimationFrame(sample)
53
+ }
54
+
55
+ requestAnimationFrame(sample)
56
+
57
+ return {
58
+ stop: () => {
59
+ sampling = false
60
+ },
61
+ overlap: () => overlap,
62
+ drift: () => drift,
63
+ tops: (id: string) => tops[id] ?? [],
64
+ }
65
+ }
66
+
67
+ /**
68
+ * A label appears the moment a row is inserted and disappears once the rows above it have landed,
69
+ * so neither marks the end of the fold. Nothing else does either: waiting it out is the only way
70
+ * to be sure the sampling covers all 200ms of it.
71
+ */
72
+ const foldFinished = () => new Promise((resolve) => setTimeout(resolve, 400))
73
+
74
+ /**
75
+ * One page, `Over ons`, with leaves and groups mixed on every level and nesting four levels deep:
76
+ * `Over ons` › `Team` › `Medewerkers` › `Naam`. `Groep 2` is empty, so there is a group to drop
77
+ * into without children; the page opts out with `draggable: false` and stays put.
78
+ */
79
+ const NODES: NexxtTreeNode[] = [
80
+ {
81
+ id: 'about',
82
+ label: 'Over ons',
83
+ icon: 'file',
84
+ draggable: false,
85
+ children: [
86
+ { id: 'story', label: 'Ons verhaal', icon: 'font' },
87
+ {
88
+ id: 'team',
89
+ label: 'Team',
90
+ icon: 'draw-square',
91
+ children: [
92
+ { id: 'team-photo', label: 'Teamfoto', icon: 'image' },
93
+ {
94
+ id: 'members',
95
+ label: 'Medewerkers',
96
+ icon: 'users',
97
+ children: [
98
+ { id: 'member-name', label: 'Naam', icon: 'heading' },
99
+ { id: 'member-role', label: 'Functie', icon: 'font' },
100
+ ],
101
+ },
102
+ ],
103
+ },
104
+ { id: 'empty-group', label: 'Groep 2', icon: 'draw-square', children: [] },
105
+ {
106
+ id: 'group',
107
+ label: 'Groep 1',
108
+ icon: 'draw-square',
109
+ children: [
110
+ { id: 'title', label: 'Titel', icon: 'heading' },
111
+ { id: 'text-1', label: 'Tekst', icon: 'font' },
112
+ ],
113
+ },
114
+ ],
115
+ },
116
+ ]
117
+
118
+ const findNode = (nodes: NexxtTreeNode[], id: string): NexxtTreeNode | null => {
119
+ for (const node of nodes) {
120
+ if (node.id === id) return node
121
+
122
+ const found = node.children ? findNode(node.children, id) : null
123
+ if (found) return found
124
+ }
125
+
126
+ return null
127
+ }
128
+
129
+ const removeNode = (nodes: NexxtTreeNode[], id: string): NexxtTreeNode | null => {
130
+ const index = nodes.findIndex((node) => node.id === id)
131
+ if (index !== -1) return nodes.splice(index, 1)[0] ?? null
132
+
133
+ for (const node of nodes) {
134
+ if (node.children) {
135
+ const removed = removeNode(node.children, id)
136
+ if (removed) return removed
137
+ }
138
+ }
139
+
140
+ return null
141
+ }
142
+
143
+ /** `index` counts the siblings as they are before the move, the moved node among them. */
144
+ const moveNode = (nodes: NexxtTreeNode[], move: NexxtTreeMove) => {
145
+ const parent = move.parentId ? findNode(nodes, move.parentId) : null
146
+ const target = parent ? (parent.children ??= []) : nodes
147
+ const from = target.findIndex((node) => node.id === move.id)
148
+
149
+ const node = removeNode(nodes, move.id)
150
+ if (!node) return
151
+
152
+ const index = from !== -1 && from < move.index ? move.index - 1 : move.index
153
+ target.splice(Math.min(index, target.length), 0, node)
154
+ }
155
+
156
+ /**
157
+ * A private copy of `source` that `@move` reorders, so turning on `draggable` in the controls
158
+ * works in every story without a drag leaking into the shared fixtures.
159
+ */
160
+ const movableNodes = (source: NexxtTreeNode[]) => {
161
+ const nodes = ref<NexxtTreeNode[]>(structuredClone(source))
162
+
163
+ return { nodes, move: (event: NexxtTreeMove) => moveNode(nodes.value, event) }
164
+ }
165
+
166
+ export const Default: Story = {
167
+ render: (args) => ({
168
+ components: { Tree },
169
+ setup() {
170
+ const selected = ref<string | null>('group')
171
+ const collapsed = ref<string[]>([])
172
+
173
+ return { args, ...movableNodes(NODES), selected, collapsed }
174
+ },
175
+ template: `
176
+ <div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
177
+ <Tree v-model:selected="selected" v-model:collapsed="collapsed" :nodes="nodes" :draggable="args.draggable" @move="move" label="Pagina structuur" />
178
+ </div>
179
+ `,
180
+ }),
181
+ play: async ({ canvasElement }) => {
182
+ const canvas = within(canvasElement)
183
+ const highlight = canvasElement.querySelector<HTMLElement>('[data-tree-highlight]')!
184
+ const selectedRow = canvasElement.querySelector<HTMLElement>('[data-tree-id="group"]')!
185
+
186
+ // On first load the highlight is simply there: nothing animates into place. The story itself
187
+ // mounted too long ago to watch, so a throwaway copy is mounted and listened in on.
188
+ const host = document.createElement('div')
189
+ const started: string[] = []
190
+ host.addEventListener('transitionstart', (event) => {
191
+ started.push((event as TransitionEvent).propertyName)
192
+ })
193
+
194
+ canvasElement.append(host)
195
+ const app = createApp(Tree, { nodes: NODES, label: 'Mount probe', selected: 'group' })
196
+ app.mount(host)
197
+
198
+ await new Promise((resolve) => setTimeout(resolve, 500))
199
+
200
+ app.unmount()
201
+ host.remove()
202
+ await expect(started).toEqual([])
203
+
204
+ const offset = highlight.getBoundingClientRect().top - selectedRow.getBoundingClientRect().top
205
+ await expect(Math.abs(offset)).toBeLessThan(2)
206
+
207
+ const before = getComputedStyle(highlight).transform
208
+ const row = canvasElement.querySelector<HTMLElement>('[data-tree-id="title"]')!
209
+
210
+ // Label and icon fade to the selected colour rather than snapping. Recorded up front:
211
+ // the 150ms transition is over well before an assertion could poll for it. The listener
212
+ // sits on the row and catches the icon through bubbling.
213
+ const fading: string[] = []
214
+ row.addEventListener('transitionstart', (event) => {
215
+ const target = event.target as HTMLElement
216
+ fading.push(`${target.tagName}:${(event as TransitionEvent).propertyName}`)
217
+ })
218
+
219
+ await userEvent.click(canvas.getByText('Titel'))
220
+
221
+ // a later selection does slide to the new row instead of jumping between backgrounds
222
+ await waitFor(() => expect(highlight.getAnimations()).not.toHaveLength(0), { timeout: 2000 })
223
+ await waitFor(() => expect(getComputedStyle(highlight).transform).not.toBe(before), {
224
+ timeout: 2000,
225
+ })
226
+
227
+ await waitFor(
228
+ () => {
229
+ expect(fading).toContain('DIV:color')
230
+ expect(fading).toContain('I:color')
231
+ },
232
+ { timeout: 2000 },
233
+ )
234
+ },
235
+ }
236
+
237
+ /** Fires a drag event the way the browser would, at `clientY` on the tree's left half. */
238
+ const dragAt = (target: Element, type: string, clientY: number, dataTransfer: DataTransfer) => {
239
+ const tree = target.closest('[role="tree"]')!.getBoundingClientRect()
240
+ const event = new DragEvent(type, {
241
+ bubbles: true,
242
+ cancelable: true,
243
+ clientX: tree.left + 40,
244
+ clientY,
245
+ dataTransfer,
246
+ })
247
+ target.dispatchEvent(event)
248
+
249
+ return event
250
+ }
251
+
252
+ /**
253
+ * Drag a row onto, above or below another to reorder the tree. A ghost row shows where it lands,
254
+ * at the depth it lands on; on a collapsed group the group row itself is marked. The play fires
255
+ * the drag events itself rather than driving a real pointer, which is not reliable in the
256
+ * combined `pnpm test` run: it checks the ghost row against the real layout, that a resting
257
+ * pointer leaves it alone, and that the dropped row lands without flying in from its old spot.
258
+ */
259
+ export const DragToReorder: Story = {
260
+ args: { draggable: true },
261
+ render: (args) => ({
262
+ components: { Tree },
263
+ setup() {
264
+ const selected = ref<string | null>('group')
265
+ const collapsed = ref<string[]>([])
266
+
267
+ return { args, ...movableNodes(NODES), selected, collapsed }
268
+ },
269
+ template: `
270
+ <div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
271
+ <Tree v-model:selected="selected" v-model:collapsed="collapsed" :nodes="nodes" :draggable="args.draggable" @move="move" label="Pagina structuur" />
272
+ </div>
273
+ `,
274
+ }),
275
+ // Picks its rows by position, not by id: the last row is dragged to before the second one
276
+ play: async ({ canvasElement, step }) => {
277
+ const tree = canvasElement.querySelector<HTMLElement>('[role="tree"]')!
278
+ const rowOf = (id: string) => tree.querySelector<HTMLElement>(`[data-tree-id="${id}"]`)!
279
+ const ghost = () => tree.querySelector<HTMLElement>('[data-tree-ghost]:not(.absolute)')
280
+ const ids = () =>
281
+ Array.from(tree.querySelectorAll<HTMLElement>('[role="treeitem"]')).map(
282
+ (row) => row.dataset.treeId!,
283
+ )
284
+ const settled = () => new Promise((resolve) => setTimeout(resolve, 400))
285
+
286
+ const treeitems = ids()
287
+ const [first, targetId] = treeitems
288
+ const sourceId = treeitems.at(-1)!
289
+ const target = () => rowOf(targetId!)
290
+ const source = () => rowOf(sourceId)
291
+ // the top quarter of the target row: the spot before it
292
+ const restY = target().getBoundingClientRect().top + 5
293
+
294
+ await step('cancelling leaves everything as it was', async () => {
295
+ const transfer = new DataTransfer()
296
+ dragAt(source(), 'dragstart', restY, transfer)
297
+ dragAt(target(), 'dragover', restY, transfer)
298
+ await waitFor(() => expect(ghost()).not.toBeNull())
299
+
300
+ // Escape, or letting go outside the tree: a `dragend` and no `drop`
301
+ dragAt(source(), 'dragend', restY, transfer)
302
+ await waitFor(() => expect(ghost()).toBeNull())
303
+ await expect(ids()).toEqual(treeitems)
304
+ await expect(source().classList).not.toContain('opacity-40')
305
+ })
306
+
307
+ const transfer = new DataTransfer()
308
+ dragAt(source(), 'dragstart', restY, transfer)
309
+ await waitFor(() => expect(source().classList).toContain('opacity-40'))
310
+ dragAt(target(), 'dragover', restY, transfer)
311
+ await settled()
312
+
313
+ await step('one ghost row, as tall as a row, at the depth it lands on', async () => {
314
+ const placed = ghost()!
315
+ const surface = placed.firstElementChild as HTMLElement
316
+
317
+ await expect(tree.querySelectorAll('[data-tree-ghost]')).toHaveLength(1)
318
+ await expect(ids()).toEqual(treeitems)
319
+ await expect(placed.nextElementSibling).toBe(target())
320
+ await expect(placed.offsetHeight).toBe(target().offsetHeight)
321
+ // indented like the row it lands next to: their contents start at the same x
322
+ const padding = (element: Element) => parseFloat(getComputedStyle(element).paddingLeft)
323
+ await expect(surface.getBoundingClientRect().left - tree.getBoundingClientRect().left).toBe(
324
+ padding(target()) - padding(surface),
325
+ )
326
+
327
+ // its icon lines up with the icon of the row it lands next to
328
+ const ghostIcon = placed.querySelector('[data-tree-ghost-content] > i')
329
+ const targetIcon = target().querySelector(':scope > i')
330
+ if (ghostIcon && targetIcon) {
331
+ const offset =
332
+ ghostIcon.getBoundingClientRect().left - targetIcon.getBoundingClientRect().left
333
+ await expect(Math.abs(offset)).toBeLessThan(0.5)
334
+ }
335
+ })
336
+
337
+ await step('a resting pointer changes nothing', async () => {
338
+ const placed = ghost()!
339
+ const mutations: MutationRecord[] = []
340
+ const observer = new MutationObserver((records) => mutations.push(...records))
341
+ observer.observe(tree, { subtree: true, childList: true, attributes: true })
342
+
343
+ // the browser repeats `dragover` while the pointer rests; the point is now on the ghost
344
+ for (let index = 0; index < 8; index++) {
345
+ const under = document.elementFromPoint(tree.getBoundingClientRect().left + 40, restY)!
346
+ dragAt(under, 'dragover', restY, transfer)
347
+ await new Promise((resolve) => setTimeout(resolve, 30))
348
+ }
349
+ observer.disconnect()
350
+
351
+ await expect(mutations).toEqual([])
352
+ await expect(ghost()).toBe(placed)
353
+ await expect(placed.getAnimations({ subtree: true })).toEqual([])
354
+ })
355
+
356
+ await step('the highlight stays on the selected row the ghost pushed down', async () => {
357
+ const highlight = tree.querySelector<HTMLElement>('[data-tree-highlight]')!
358
+ const selectedRow = tree.querySelector<HTMLElement>('[aria-selected="true"]')
359
+
360
+ if (selectedRow) {
361
+ const offset =
362
+ highlight.getBoundingClientRect().top - selectedRow.getBoundingClientRect().top
363
+ await expect(Math.abs(offset)).toBeLessThan(2)
364
+ }
365
+ })
366
+
367
+ await step('the dropped row takes the ghost row’s place without flying there', async () => {
368
+ dragAt(ghost()!, 'drop', restY, transfer)
369
+ dragAt(source(), 'dragend', restY, transfer)
370
+
371
+ await waitFor(() => expect(ids().slice(0, 3)).toEqual([first, sourceId, targetId]))
372
+ await new Promise((resolve) => requestAnimationFrame(resolve))
373
+
374
+ const flying = source()
375
+ .getAnimations()
376
+ .filter((animation) => (animation as CSSTransition).transitionProperty === 'transform')
377
+ await expect(flying).toEqual([])
378
+ await expect(ghost()).toBeNull()
379
+ await expect(source().classList).not.toContain('opacity-40')
380
+ })
381
+ },
382
+ }
383
+
384
+ /** Nothing lands next to the page: everything stays inside the one root. */
385
+ const insidePage = ({ parentId }: NexxtTreeMove) => parentId !== null
386
+
387
+ /**
388
+ * The tree as the app uses it: the page is the single root, and `canDrop` turns down every spot
389
+ * on the root level, so a block can never end up next to the page. A refused spot shows no ghost
390
+ * row and drops nothing. The tree is taller than its rows here: the empty space below the last
391
+ * row is the end of that row's level, not a dead zone.
392
+ */
393
+ export const PageAsRoot: Story = {
394
+ args: { draggable: true },
395
+ render: (args) => ({
396
+ components: { Tree },
397
+ setup() {
398
+ const selected = ref<string | null>('about')
399
+ const collapsed = ref<string[]>([])
400
+
401
+ return { args, ...movableNodes(NODES), selected, collapsed, canDrop: insidePage }
402
+ },
403
+ template: `
404
+ <div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
405
+ <Tree v-model:selected="selected" v-model:collapsed="collapsed" class="min-h-80" :nodes="nodes" :draggable="args.draggable" :can-drop="canDrop" @move="move" label="Pagina structuur" />
406
+ </div>
407
+ `,
408
+ }),
409
+ play: async ({ canvasElement, step }) => {
410
+ const tree = canvasElement.querySelector<HTMLElement>('[role="tree"]')!
411
+ const rowOf = (id: string) => tree.querySelector<HTMLElement>(`[data-tree-id="${id}"]`)!
412
+ const ghost = () => tree.querySelector<HTMLElement>('[data-tree-ghost]:not(.absolute)')
413
+ const ids = () =>
414
+ Array.from(tree.querySelectorAll<HTMLElement>('[role="treeitem"]')).map(
415
+ (row) => row.dataset.treeId!,
416
+ )
417
+ const transfer = new DataTransfer()
418
+ const page = rowOf('about').getBoundingClientRect()
419
+
420
+ dragAt(rowOf('story'), 'dragstart', page.top, transfer)
421
+
422
+ await step('the root level is turned down, above and below the page', async () => {
423
+ for (const y of [page.top + 2, page.bottom - 2]) {
424
+ const event = dragAt(rowOf('about'), 'dragover', y, transfer)
425
+
426
+ await expect(event.defaultPrevented).toBe(false)
427
+ await expect(transfer.dropEffect).toBe('none')
428
+ }
429
+ await new Promise((resolve) => setTimeout(resolve, 100))
430
+ await expect(ghost()).toBeNull()
431
+ })
432
+
433
+ await step('the empty space below the rows is the end of the last row’s level', async () => {
434
+ const last = ids().at(-1)!
435
+ const event = dragAt(tree, 'dragover', tree.getBoundingClientRect().bottom - 10, transfer)
436
+
437
+ await expect(event.defaultPrevented).toBe(true)
438
+ await waitFor(() => expect(ghost()).not.toBeNull())
439
+ await expect(ghost()!.previousElementSibling).toBe(rowOf(last))
440
+ })
441
+
442
+ await step('dropping there moves the block to the end of that level', async () => {
443
+ dragAt(ghost()!, 'drop', tree.getBoundingClientRect().bottom - 10, transfer)
444
+ dragAt(rowOf('story'), 'dragend', page.top, transfer)
445
+
446
+ await waitFor(() => expect(ids().at(-1)).toBe('story'))
447
+ await expect(rowOf('story').getAttribute('aria-level')).toBe('3')
448
+ await expect(ghost()).toBeNull()
449
+ })
450
+ },
451
+ }
452
+
453
+ /**
454
+ * Hovering anywhere on a row darkens its chevron; point at a row to see it. No play: a real-pointer
455
+ * hover is not reliable in the combined `pnpm test` run.
456
+ */
457
+ export const HoveringARow: Story = {
458
+ render: (args) => ({
459
+ components: { Tree },
460
+ setup() {
461
+ const selected = ref<string | null>(null)
462
+ const collapsed = ref<string[]>([])
463
+
464
+ return { args, ...movableNodes(NODES), selected, collapsed }
465
+ },
466
+ template: `
467
+ <div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
468
+ <Tree v-model:selected="selected" v-model:collapsed="collapsed" :nodes="nodes" :draggable="args.draggable" @move="move" label="Pagina structuur" />
469
+ </div>
470
+ `,
471
+ }),
472
+ }
473
+
474
+ export const CollapsingARow: Story = {
475
+ render: (args) => ({
476
+ components: { Tree },
477
+ setup() {
478
+ // A selection below the folding node: that row slides up and the highlight has to keep up
479
+ const selected = ref<string | null>('group')
480
+ const collapsed = ref<string[]>([])
481
+
482
+ return { args, ...movableNodes(NODES), selected, collapsed }
483
+ },
484
+ template: `
485
+ <div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
486
+ <Tree v-model:selected="selected" v-model:collapsed="collapsed" :nodes="nodes" :draggable="args.draggable" @move="move" label="Pagina structuur" />
487
+ </div>
488
+ `,
489
+ }),
490
+ play: async ({ canvasElement }) => {
491
+ const canvas = within(canvasElement)
492
+ const chevron = canvasElement.querySelector<HTMLElement>('[data-tree-id="team"] button')!
493
+ const motion = watchMotion(canvasElement)
494
+
495
+ await userEvent.click(chevron)
496
+
497
+ // the children are dropped once the rows above them have taken their place
498
+ await waitFor(() => expect(canvas.queryByText('Teamfoto')).toBeNull(), { timeout: 2000 })
499
+ await expect(canvas.getByText('Team')).toBeInTheDocument()
500
+ await foldFinished()
501
+
502
+ motion.stop()
503
+
504
+ // the highlight travels with the row it sits behind rather than racing it to the end
505
+ await expect(motion.drift()).toBeLessThan(4)
506
+ // and the row slides up over empty space, not over the labels that are leaving
507
+ await expect(motion.overlap()).toBeLessThan(1)
508
+ },
509
+ }
510
+
511
+ export const ExpandingARow: Story = {
512
+ render: (args) => ({
513
+ components: { Tree },
514
+ setup() {
515
+ const selected = ref<string | null>('group')
516
+ const collapsed = ref<string[]>(['team'])
517
+
518
+ return { args, ...movableNodes(NODES), selected, collapsed }
519
+ },
520
+ template: `
521
+ <div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
522
+ <Tree v-model:selected="selected" v-model:collapsed="collapsed" :nodes="nodes" :draggable="args.draggable" @move="move" label="Pagina structuur" />
523
+ </div>
524
+ `,
525
+ }),
526
+ play: async ({ canvasElement }) => {
527
+ const canvas = within(canvasElement)
528
+ const chevron = canvasElement.querySelector<HTMLElement>('[data-tree-id="team"] button')!
529
+ const motion = watchMotion(canvasElement)
530
+
531
+ await userEvent.click(chevron)
532
+ await waitFor(() => expect(canvas.getByText('Teamfoto')).toBeInTheDocument(), {
533
+ timeout: 2000,
534
+ })
535
+ await foldFinished()
536
+
537
+ motion.stop()
538
+
539
+ // Inserted at their final spot the children would be readable on top of the rows that are
540
+ // still sliding down to make room for them
541
+ await expect(motion.overlap()).toBeLessThan(1)
542
+
543
+ // they unfold from under their parent instead: one way down, never a jump back up
544
+ const unfolding = motion.tops('members')
545
+
546
+ await expect(unfolding.at(-1)! - unfolding[0]!).toBeGreaterThan(20)
547
+ unfolding.forEach((top, index) => {
548
+ if (index) expect(top).toBeGreaterThanOrEqual(unfolding[index - 1]! - 0.5)
549
+ })
550
+ },
551
+ }
552
+
553
+ export const Collapsed: Story = {
554
+ render: (args) => ({
555
+ components: { Tree },
556
+ setup() {
557
+ const selected = ref<string | null>(null)
558
+ const collapsed = ref<string[]>(['group'])
559
+
560
+ return { args, ...movableNodes(NODES), selected, collapsed }
561
+ },
562
+ template: `
563
+ <div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
564
+ <Tree v-model:selected="selected" v-model:collapsed="collapsed" :nodes="nodes" :draggable="args.draggable" @move="move" label="Pagina structuur" />
565
+ </div>
566
+ `,
567
+ }),
568
+ }
569
+
570
+ /** A long label next to the action shows that the label truncates before it runs under the button. */
571
+ const NODES_WITH_LONG_LABEL: NexxtTreeNode[] = NODES.map((page) => ({
572
+ ...page,
573
+ children: page.children?.map((node) =>
574
+ node.id === 'story' ? { ...node, label: 'Ons verhaal in een hele lange titel' } : node,
575
+ ),
576
+ }))
577
+
578
+ const onDelete = fn()
579
+
580
+ /**
581
+ * The selected row shows its action; every other row keeps it out of sight, out of the tab order
582
+ * and out of the accessibility tree. With the keyboard: Enter selects the row, Tab reaches its
583
+ * action.
584
+ */
585
+ export const WithNodeActions: Story = {
586
+ render: (args) => ({
587
+ components: { Tree, NexxtIcon },
588
+ setup() {
589
+ const selected = ref<string | null>(null)
590
+ const collapsed = ref<string[]>([])
591
+
592
+ return { args, ...movableNodes(NODES_WITH_LONG_LABEL), selected, collapsed, onDelete }
593
+ },
594
+ template: `
595
+ <div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
596
+ <Tree v-model:selected="selected" v-model:collapsed="collapsed" :nodes="nodes" :draggable="args.draggable" @move="move" label="Pagina structuur">
597
+ <template #node-actions="{ node, tabindex }">
598
+ <button
599
+ type="button"
600
+ class="flex size-6 cursor-pointer items-center justify-center rounded-md text-gray-700 transition-colors duration-150 hover:bg-gray-200 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-cornflower-blue-500 motion-reduce:transition-none"
601
+ :tabindex="tabindex"
602
+ :aria-label="\`Verwijder \${node.label}\`"
603
+ @click="onDelete(node)"
604
+ >
605
+ <NexxtIcon name="trash" />
606
+ </button>
607
+ </template>
608
+ </Tree>
609
+ </div>
610
+ `,
611
+ }),
612
+ play: async ({ canvasElement }) => {
613
+ const canvas = within(canvasElement)
614
+ const actionsOf = (id: string) =>
615
+ canvasElement.querySelector<HTMLElement>(
616
+ `[data-tree-id="${id}"] [data-tree-actions-content]`,
617
+ )!
618
+ const selectedRows = () => canvasElement.querySelectorAll('[aria-selected="true"]')
619
+
620
+ onDelete.mockClear()
621
+
622
+ // Tab lands on the tree's one tab stop; without a selection no row offers its action
623
+ await userEvent.tab()
624
+ const about = canvasElement.querySelector('[data-tree-id="about"]')
625
+ await expect(document.activeElement).toBe(about)
626
+ await expect(actionsOf('about').closest('[inert]')).not.toBeNull()
627
+ await expect(getComputedStyle(actionsOf('about')).opacity).toBe('0')
628
+
629
+ // Enter selects the row, which brings in its action and no other
630
+ await userEvent.keyboard('{Enter}')
631
+ await waitFor(
632
+ () => {
633
+ expect(getComputedStyle(actionsOf('about')).opacity).toBe('1')
634
+ expect(getComputedStyle(actionsOf('story')).opacity).toBe('0')
635
+ },
636
+ { timeout: 2000 },
637
+ )
638
+ await expect(actionsOf('about').closest('[inert]')).toBeNull()
639
+ await expect(actionsOf('story').closest('[inert]')).not.toBeNull()
640
+
641
+ // one extra stop: the action of the row you are on
642
+ await userEvent.tab()
643
+ const action = canvas.getByRole('button', { name: 'Verwijder Over ons' })
644
+ await expect(document.activeElement).toBe(action)
645
+
646
+ // Enter runs the action and nothing else: the selection stays where it was
647
+ await userEvent.keyboard('{Enter}')
648
+ await expect(onDelete).toHaveBeenCalledTimes(1)
649
+ await expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'about' }))
650
+ await expect(Array.from(selectedRows())).toEqual([about])
651
+
652
+ // the arrow keys belong to the tree, not to the action: focus stays put
653
+ await userEvent.keyboard('{ArrowDown}')
654
+ await expect(document.activeElement).toBe(action)
655
+
656
+ // the row is named by its label, not by its label plus the button inside it
657
+ await expect(canvas.getByRole('treeitem', { name: /^Over ons$/ })).toBeInTheDocument()
658
+
659
+ // Clicking the chevron never focuses it: it sits in `aria-hidden`, where Chromium blocks the
660
+ // focus with a console warning. Recorded on the way, as the row takes focus right after.
661
+ const chevron = canvasElement.querySelector<HTMLElement>('[data-tree-id="group"] button')!
662
+ const focused: Element[] = []
663
+ const record = (event: FocusEvent) => focused.push(event.target as Element)
664
+
665
+ canvasElement.addEventListener('focusin', record)
666
+ await userEvent.click(chevron)
667
+ canvasElement.removeEventListener('focusin', record)
668
+
669
+ await expect(focused).not.toContain(chevron)
670
+
671
+ // a 24px action does not make the rows any taller than a tree without actions
672
+ const host = document.createElement('div')
673
+ host.className = 'w-72 p-4'
674
+ canvasElement.append(host)
675
+ const app = createApp(Tree, { nodes: NODES_WITH_LONG_LABEL, label: 'Height probe' })
676
+ app.mount(host)
677
+
678
+ const plain = host.querySelector<HTMLElement>('[data-tree-id="about"]')!.offsetHeight
679
+ const withActions =
680
+ canvasElement.querySelector<HTMLElement>('[data-tree-id="about"]')!.offsetHeight
681
+
682
+ app.unmount()
683
+ host.remove()
684
+
685
+ await expect(withActions).toBe(plain)
686
+ },
687
+ }
688
+
689
+ /** Long enough to truncate in a narrow panel, on every level. */
690
+ const NODES_WITH_LONG_LABELS: NexxtTreeNode[] = [
691
+ {
692
+ id: 'page',
693
+ label: 'Welkomstpagina voor nieuwe bezoekers',
694
+ icon: 'file',
695
+ children: [
696
+ { id: 'image', label: 'Afbeelding van het kantoor aan de gracht', icon: 'image' },
697
+ {
698
+ id: 'group',
699
+ label: 'Een groep met een hele lange naam',
700
+ icon: 'object-group',
701
+ children: [
702
+ { id: 'title', label: 'Titel boven de introductietekst', icon: 'heading' },
703
+ { id: 'text', label: 'Kort', icon: 'font' },
704
+ ],
705
+ },
706
+ ],
707
+ },
708
+ ]
709
+
710
+ /**
711
+ * In a narrow panel every row that is not selected gives its label the whole row. The selected
712
+ * row has its actions: they slide in from its end and push its label in, and slide out again when
713
+ * the selection moves on. The rows around it never move.
714
+ */
715
+ export const NarrowPanelWithNodeActions: Story = {
716
+ render: (args) => ({
717
+ components: { Tree, NexxtIcon },
718
+ setup() {
719
+ const selected = ref<string | null>('group')
720
+ const collapsed = ref<string[]>([])
721
+
722
+ return { args, ...movableNodes(NODES_WITH_LONG_LABELS), selected, collapsed, onDelete }
723
+ },
724
+ template: `
725
+ <div class="w-56 rounded-xl border border-gray-200 bg-white p-2">
726
+ <Tree v-model:selected="selected" v-model:collapsed="collapsed" :nodes="nodes" :draggable="args.draggable" @move="move" label="Pagina structuur">
727
+ <template #node-actions="{ node, tabindex }">
728
+ <button
729
+ v-for="action in [{ icon: 'copy', label: 'Dupliceer' }, { icon: 'trash', label: 'Verwijder' }]"
730
+ :key="action.icon"
731
+ type="button"
732
+ class="flex size-6 cursor-pointer items-center justify-center rounded-md text-gray-700 transition-colors duration-150 hover:bg-gray-200 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-cornflower-blue-500 motion-reduce:transition-none"
733
+ :tabindex="tabindex"
734
+ :aria-label="\`\${action.label} \${node.label}\`"
735
+ @click="onDelete(node)"
736
+ >
737
+ <NexxtIcon :name="action.icon" />
738
+ </button>
739
+ </template>
740
+ </Tree>
741
+ </div>
742
+ `,
743
+ }),
744
+ args: { draggable: true },
745
+ play: async ({ canvasElement }) => {
746
+ const row = (id: string) => canvasElement.querySelector<HTMLElement>(`[data-tree-id="${id}"]`)!
747
+ const rect = (id: string, part: string) =>
748
+ row(id).querySelector<HTMLElement>(part)!.getBoundingClientRect()
749
+ /** Where the row's content ends: its right edge minus its padding. */
750
+ const contentEnd = (id: string) =>
751
+ row(id).getBoundingClientRect().right - parseFloat(getComputedStyle(row(id)).paddingRight)
752
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 400))
753
+
754
+ await settle()
755
+
756
+ // a row that is not selected gives its label the whole row, and has no actions to see
757
+ await expect(rect('image', '[data-tree-label]').right).toBeCloseTo(contentEnd('image'), 0)
758
+ await expect(
759
+ getComputedStyle(row('image').querySelector('[data-tree-actions-content]')!).opacity,
760
+ ).toBe('0')
761
+
762
+ // the selected row has its actions in the flow, after its label
763
+ const selectedActions = rect('group', '[data-tree-actions-content]')
764
+ await expect(rect('group', '[data-tree-label]').right).toBeLessThanOrEqual(selectedActions.left)
765
+ await expect(selectedActions.right).toBeCloseTo(contentEnd('group'), 0)
766
+
767
+ // selecting a row slides its actions in and pushes its label in; the other rows stay put
768
+ const others = ['page', 'title'].map((id) => rect(id, '[data-tree-label]').right)
769
+ const track = row('image').querySelector<HTMLElement>('[data-tree-actions]')!
770
+ const widths: number[] = []
771
+ let sampling = true
772
+ const sample = () => {
773
+ widths.push(track.getBoundingClientRect().width)
774
+ if (sampling) requestAnimationFrame(sample)
775
+ }
776
+ requestAnimationFrame(sample)
777
+ await userEvent.click(row('image').querySelector('[data-tree-label]')!)
778
+ await settle()
779
+ sampling = false
780
+ // it slid: on its way the track was neither shut nor fully open
781
+ const open = track.getBoundingClientRect().width
782
+ await expect(widths.some((width) => width > 1 && width < open - 1)).toBe(true)
783
+ await expect(rect('image', '[data-tree-label]').right).toBeLessThanOrEqual(
784
+ rect('image', '[data-tree-actions-content]').left,
785
+ )
786
+ await expect(['page', 'title'].map((id) => rect(id, '[data-tree-label]').right)).toEqual(others)
787
+ // and the row it left gives its label the whole row again
788
+ await expect(rect('group', '[data-tree-label]').right).toBeCloseTo(contentEnd('group'), 0)
789
+ // nothing slid out of the tree: the panel does not scroll sideways
790
+ const tree = canvasElement.querySelector<HTMLElement>('[role="tree"]')!
791
+ await expect(tree.scrollWidth).toBe(tree.clientWidth)
792
+ },
793
+ }
794
+
795
+ /** A copy of `nodes` without `id`, plus the id of the parent it was removed from. */
796
+ const withoutNode = (nodes: NexxtTreeNode[], id: string) => {
797
+ let removedFrom: string | null = null
798
+
799
+ const walk = (list: NexxtTreeNode[], parent: string | null): NexxtTreeNode[] =>
800
+ list
801
+ .filter((node) => {
802
+ if (node.id !== id) return true
803
+ removedFrom = parent
804
+ return false
805
+ })
806
+ .map((node) => (node.children ? { ...node, children: walk(node.children, node.id) } : node))
807
+
808
+ const remaining = walk(nodes, null)
809
+
810
+ return { nodes: remaining, parentId: removedFrom as string | null }
811
+ }
812
+
813
+ /**
814
+ * Removing the focused node takes its row, and the focus with it. `focusNode` hands focus to the
815
+ * parent instead, so a keyboard user carries on inside the tree rather than at the top of the page.
816
+ */
817
+ export const DeleteAndRefocus: Story = {
818
+ render: (args) => ({
819
+ components: { Tree, NexxtIcon },
820
+ setup() {
821
+ const { nodes, move } = movableNodes(NODES_WITH_LONG_LABEL)
822
+ const selected = ref<string | null>(null)
823
+ const collapsed = ref<string[]>([])
824
+ const tree: Ref<InstanceType<typeof Tree> | null> = ref(null)
825
+
826
+ const onDelete = (node: NexxtTreeNode) => {
827
+ const result = withoutNode(nodes.value, node.id)
828
+ nodes.value = result.nodes
829
+
830
+ if (result.parentId) tree.value?.focusNode(result.parentId)
831
+ }
832
+
833
+ return { args, nodes, move, selected, collapsed, tree, onDelete }
834
+ },
835
+ template: `
836
+ <div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
837
+ <Tree ref="tree" v-model:selected="selected" v-model:collapsed="collapsed" :nodes="nodes" :draggable="args.draggable" @move="move" label="Pagina structuur">
838
+ <template #node-actions="{ node, tabindex }">
839
+ <button
840
+ type="button"
841
+ class="flex size-6 cursor-pointer items-center justify-center rounded-md text-gray-700 transition-colors duration-150 hover:bg-gray-200 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-cornflower-blue-500 motion-reduce:transition-none"
842
+ :tabindex="tabindex"
843
+ :aria-label="\`Verwijder \${node.label}\`"
844
+ @click="onDelete(node)"
845
+ >
846
+ <NexxtIcon name="trash" />
847
+ </button>
848
+ </template>
849
+ </Tree>
850
+ </div>
851
+ `,
852
+ }),
853
+ play: async ({ canvasElement }) => {
854
+ const canvas = within(canvasElement)
855
+ const parent = canvasElement.querySelector<HTMLElement>('[data-tree-id="about"]')!
856
+
857
+ // onto the story row, select it, then onto its delete button
858
+ await userEvent.tab()
859
+ await userEvent.keyboard('{ArrowDown}')
860
+ await expect(document.activeElement).toBe(canvasElement.querySelector('[data-tree-id="story"]'))
861
+ await userEvent.keyboard('{Enter}')
862
+ await userEvent.tab()
863
+ await expect(document.activeElement).toBe(
864
+ canvas.getByRole('button', { name: 'Verwijder Ons verhaal in een hele lange titel' }),
865
+ )
866
+
867
+ await userEvent.keyboard('{Enter}')
868
+
869
+ // the row is gone and focus landed on its parent, now the tree's one tab stop
870
+ await waitFor(() => expect(document.activeElement).toBe(parent), { timeout: 2000 })
871
+ // the leaving row stays in the DOM for a beat, invisible, so it is looked for as a user sees it
872
+ await expect(
873
+ canvas.queryByRole('treeitem', { name: 'Ons verhaal in een hele lange titel' }),
874
+ ).toBeNull()
875
+ // the leaving row keeps its old tabindex until it is gone, hidden and so out of the tab order
876
+ await waitFor(
877
+ () =>
878
+ expect(
879
+ Array.from(canvasElement.querySelectorAll('[role="treeitem"][tabindex="0"]')),
880
+ ).toEqual([parent]),
881
+ { timeout: 2000 },
882
+ )
883
+ },
884
+ }