@nexxtmove/ui 2.6.0 → 2.8.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 (229) hide show
  1. package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +7 -5
  2. package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +4 -1
  3. package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +3 -1
  4. package/dist/index.js +95 -172
  5. package/dist/nuxt.js +0 -1
  6. package/package.json +38 -36
  7. package/src/attribute-contract.test.ts +151 -0
  8. package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
  9. package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
  10. package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
  11. package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
  12. package/src/components/atoms/Button/Button.stories.ts +290 -0
  13. package/src/components/atoms/Button/Button.test.ts +406 -0
  14. package/src/components/atoms/Card/Card.stories.ts +45 -0
  15. package/src/components/atoms/Card/Card.test.ts +46 -0
  16. package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
  17. package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
  18. package/src/components/atoms/Chip/Chip.stories.ts +274 -0
  19. package/src/components/atoms/Chip/Chip.test.ts +230 -0
  20. package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
  21. package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
  22. package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
  23. package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
  24. package/src/components/atoms/Icon/Icon.stories.ts +63 -0
  25. package/src/components/atoms/Icon/Icon.test.ts +89 -0
  26. package/src/components/atoms/Loader/Loader.stories.ts +36 -0
  27. package/src/components/atoms/Loader/Loader.test.ts +40 -0
  28. package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
  29. package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
  30. package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
  31. package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
  32. package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
  33. package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
  34. package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
  35. package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
  36. package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
  37. package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
  38. package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
  39. package/src/components/atoms/Slider/Slider.stories.ts +151 -0
  40. package/src/components/atoms/Slider/Slider.test.ts +191 -0
  41. package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
  42. package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
  43. package/src/components/atoms/Switch/Switch.stories.ts +91 -0
  44. package/src/components/atoms/Switch/Switch.test.ts +183 -0
  45. package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
  46. package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
  47. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
  48. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
  49. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
  50. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
  51. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
  52. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
  53. package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
  54. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
  55. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
  56. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
  57. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
  58. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
  59. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
  60. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
  61. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
  62. package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
  63. package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
  64. package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
  65. package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
  66. package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +633 -0
  67. package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +776 -0
  68. package/src/components/editor/molecules/EditorColor/EditorColor.vue +7 -5
  69. package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +832 -0
  70. package/src/components/editor/molecules/EditorColor/_ColorField.vue +20 -15
  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 +696 -0
  182. package/src/components/organisms/ColorPicker/ColorPicker.vue +12 -3
  183. package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
  184. package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
  185. package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
  186. package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
  187. package/src/components/organisms/Combobox/Combobox.vue +1 -1
  188. package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
  189. package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
  190. package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
  191. package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
  192. package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
  193. package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
  194. package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
  195. package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
  196. package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
  197. package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
  198. package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
  199. package/src/components/organisms/Header/Header.stories.ts +75 -0
  200. package/src/components/organisms/Header/Header.test.ts +233 -0
  201. package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
  202. package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
  203. package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
  204. package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
  205. package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
  206. package/src/components/organisms/Modal/Modal.stories.ts +350 -0
  207. package/src/components/organisms/Modal/Modal.test.ts +275 -0
  208. package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
  209. package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
  210. package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
  211. package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
  212. package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
  213. package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
  214. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
  215. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  216. package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
  217. package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
  218. package/src/components/organisms/Table/Table.stories.ts +332 -0
  219. package/src/components/organisms/Table/Table.test.ts +320 -0
  220. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
  221. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
  222. package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
  223. package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
  224. package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
  225. package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
  226. package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
  227. package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
  228. package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
  229. package/src/components/organisms/VideoPlayer/VideoPlayer.test.ts +439 -0
@@ -0,0 +1,864 @@
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
2
+ import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
3
+ import { ref, nextTick, type Ref } from 'vue'
4
+ import FloatingPanel from './FloatingPanel.vue'
5
+
6
+ // The options of the last useFloating() call, so the middleware can be inspected.
7
+ const floating = vi.hoisted(() => ({ lastOptions: undefined as unknown }))
8
+
9
+ vi.mock('@floating-ui/vue', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('@floating-ui/vue')>()
11
+ return {
12
+ ...actual,
13
+ useFloating: (_reference: unknown, _floating: unknown, options: unknown) => {
14
+ floating.lastOptions = options
15
+ return {
16
+ floatingStyles: ref({}),
17
+ placement: ref('bottom-start'),
18
+ isPositioned: ref(true),
19
+ }
20
+ },
21
+ autoUpdate: () => () => {},
22
+ }
23
+ })
24
+
25
+ const mountPanel = (props = {}, slots = {}) =>
26
+ mount(FloatingPanel, {
27
+ props,
28
+ slots: {
29
+ trigger: `<template #trigger="{ toggle, isOpen }">
30
+ <button class="trigger" :aria-expanded="isOpen" @click="toggle">Toggle</button>
31
+ </template>`,
32
+ content: `<template #content="{ close }">
33
+ <div class="panel">
34
+ <span>Panel content</span>
35
+ <button class="close-btn" @click="close">Close</button>
36
+ </div>
37
+ </template>`,
38
+ ...slots,
39
+ },
40
+ attachTo: document.body,
41
+ })
42
+
43
+ let wrapper: VueWrapper
44
+
45
+ afterEach(() => {
46
+ wrapper?.unmount()
47
+ })
48
+
49
+ const open = async (w = wrapper) => {
50
+ await w.find('.trigger').trigger('click')
51
+ // flushPromises lets computePosition resolve so isPositioned becomes true,
52
+ // which gates the content rendering in the two-level template.
53
+ await flushPromises()
54
+ await nextTick()
55
+ }
56
+
57
+ describe('FloatingPanel', () => {
58
+ describe('rendering', () => {
59
+ it('renders the trigger slot', () => {
60
+ wrapper = mountPanel()
61
+ expect(wrapper.find('.trigger').exists()).toBe(true)
62
+ })
63
+
64
+ it('does not render content by default', () => {
65
+ wrapper = mountPanel()
66
+ expect(wrapper.find('.panel').exists()).toBe(false)
67
+ })
68
+
69
+ it('renders content after trigger click', async () => {
70
+ wrapper = mountPanel()
71
+ await open()
72
+ expect(wrapper.find('.panel').exists()).toBe(true)
73
+ })
74
+ })
75
+
76
+ describe('open / close', () => {
77
+ it('opens on trigger click', async () => {
78
+ wrapper = mountPanel()
79
+ await open()
80
+ expect(wrapper.find('.panel').exists()).toBe(true)
81
+ })
82
+
83
+ it('closes on second trigger click', async () => {
84
+ wrapper = mountPanel()
85
+ await open()
86
+ await wrapper.find('.trigger').trigger('click')
87
+ await nextTick()
88
+ expect(wrapper.find('.panel').exists()).toBe(false)
89
+ })
90
+
91
+ it('closes when clicking outside', async () => {
92
+ wrapper = mountPanel()
93
+ await open()
94
+ document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
95
+ await nextTick()
96
+ expect(wrapper.find('.panel').exists()).toBe(false)
97
+ })
98
+
99
+ it('does not close when clicking the trigger', async () => {
100
+ wrapper = mountPanel()
101
+ await open()
102
+ wrapper.find('.trigger').element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
103
+ await nextTick()
104
+ expect(wrapper.find('.panel').exists()).toBe(true)
105
+ })
106
+
107
+ it('does not close when clicking inside the content', async () => {
108
+ wrapper = mountPanel()
109
+ await open()
110
+ wrapper.find('.panel').element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
111
+ await nextTick()
112
+ expect(wrapper.find('.panel').exists()).toBe(true)
113
+ })
114
+
115
+ it('closes on Escape key', async () => {
116
+ wrapper = mountPanel()
117
+ await open()
118
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
119
+ await nextTick()
120
+ expect(wrapper.find('.panel').exists()).toBe(false)
121
+ })
122
+
123
+ it('does not close on Escape when already closed', async () => {
124
+ wrapper = mountPanel()
125
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
126
+ await nextTick()
127
+ expect(wrapper.find('.panel').exists()).toBe(false)
128
+ })
129
+
130
+ it('closes via close slot prop from content', async () => {
131
+ wrapper = mountPanel()
132
+ await open()
133
+ await wrapper.find('.close-btn').trigger('click')
134
+ await nextTick()
135
+ expect(wrapper.find('.panel').exists()).toBe(false)
136
+ })
137
+ })
138
+
139
+ describe('trigger slot props', () => {
140
+ it('passes isOpen=false to trigger when closed', () => {
141
+ wrapper = mountPanel()
142
+ expect(wrapper.find('.trigger').attributes('aria-expanded')).toBe('false')
143
+ })
144
+
145
+ it('passes isOpen=true to trigger when open', async () => {
146
+ wrapper = mountPanel()
147
+ await open()
148
+ expect(wrapper.find('.trigger').attributes('aria-expanded')).toBe('true')
149
+ })
150
+ })
151
+
152
+ describe('emits', () => {
153
+ it('emits open when panel opens', async () => {
154
+ wrapper = mountPanel()
155
+ await open()
156
+ expect(wrapper.emitted('open')).toHaveLength(1)
157
+ })
158
+
159
+ it('emits close when panel closes via trigger', async () => {
160
+ wrapper = mountPanel()
161
+ await open()
162
+ await wrapper.find('.trigger').trigger('click')
163
+ await nextTick()
164
+ expect(wrapper.emitted('close')).toHaveLength(1)
165
+ })
166
+
167
+ it('emits close when panel closes via Escape', async () => {
168
+ wrapper = mountPanel()
169
+ await open()
170
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
171
+ await nextTick()
172
+ expect(wrapper.emitted('close')).toHaveLength(1)
173
+ })
174
+
175
+ it('emits close when panel closes via click outside', async () => {
176
+ wrapper = mountPanel()
177
+ await open()
178
+ document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
179
+ await nextTick()
180
+ expect(wrapper.emitted('close')).toHaveLength(1)
181
+ })
182
+
183
+ it('does not emit close more than once per close action', async () => {
184
+ wrapper = mountPanel()
185
+ await open()
186
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
187
+ await nextTick()
188
+ expect(wrapper.emitted('close')).toHaveLength(1)
189
+ })
190
+ })
191
+
192
+ describe('expose', () => {
193
+ it('exposes isOpen ref', () => {
194
+ wrapper = mountPanel()
195
+ expect((wrapper.vm as typeof wrapper.vm & { isOpen: boolean }).isOpen).toBe(false)
196
+ })
197
+
198
+ it('exposes open() method', async () => {
199
+ wrapper = mountPanel()
200
+ ;(wrapper.vm as typeof wrapper.vm & { open: () => void }).open()
201
+ await flushPromises()
202
+ expect(wrapper.find('.panel').exists()).toBe(true)
203
+ })
204
+
205
+ it('exposes close() method', async () => {
206
+ wrapper = mountPanel()
207
+ await open()
208
+ ;(wrapper.vm as typeof wrapper.vm & { close: () => void }).close()
209
+ await nextTick()
210
+ expect(wrapper.find('.panel').exists()).toBe(false)
211
+ })
212
+
213
+ it('exposes toggle() method', async () => {
214
+ wrapper = mountPanel()
215
+ ;(wrapper.vm as typeof wrapper.vm & { toggle: () => void }).toggle()
216
+ await flushPromises()
217
+ expect(wrapper.find('.panel').exists()).toBe(true)
218
+ ;(wrapper.vm as typeof wrapper.vm & { toggle: () => void }).toggle()
219
+ await nextTick()
220
+ expect(wrapper.find('.panel').exists()).toBe(false)
221
+ })
222
+ })
223
+
224
+ describe('placement prop', () => {
225
+ it('defaults to bottom-start', () => {
226
+ wrapper = mountPanel()
227
+ expect((wrapper.props() as { placement: string }).placement).toBe('bottom-start')
228
+ })
229
+
230
+ it('accepts any valid Floating UI placement', () => {
231
+ wrapper = mountPanel({ placement: 'top-end' })
232
+ expect((wrapper.props() as { placement: string }).placement).toBe('top-end')
233
+ })
234
+ })
235
+ })
236
+
237
+ // Utility classes are assembled from fragments at runtime: a complete class written
238
+ // as a literal in a file under src/components/ is generated by Tailwind's @source
239
+ // scan, so a missing class would still resolve. See CONTRIBUTING.md.
240
+ const tw = (...parts: string[]) => parts.join('-')
241
+
242
+ // The element the content slot's own root sits inside. Works whether `surface`
243
+ // decorates the existing inner div or introduces a new wrapper around it.
244
+ const contentBox = (w: VueWrapper) => w.find('.panel').element.parentElement as HTMLElement
245
+
246
+ describe('FloatingPanel surface prop', () => {
247
+ it('leaves the content undecorated by default', async () => {
248
+ wrapper = mountPanel()
249
+ await open()
250
+
251
+ expect(contentBox(wrapper).getAttribute('class')).toBeNull()
252
+ })
253
+
254
+ it('adds no extra element around the content by default', async () => {
255
+ wrapper = mountPanel()
256
+ await open()
257
+
258
+ // .panel -> the existing v-if div -> the Transition. Nothing in between.
259
+ expect(contentBox(wrapper).parentElement?.tagName).toBe('TRANSITION-STUB')
260
+ })
261
+
262
+ it('renders identical markup for the default and an explicit surface=false', async () => {
263
+ wrapper = mountPanel()
264
+ await open()
265
+ const byDefault = contentBox(wrapper).outerHTML
266
+ wrapper.unmount()
267
+
268
+ wrapper = mountPanel({ surface: false })
269
+ await open()
270
+
271
+ expect(contentBox(wrapper).outerHTML).toBe(byDefault)
272
+ })
273
+
274
+ it('wraps the content in a bordered white card when surface is true', async () => {
275
+ wrapper = mountPanel({ surface: true })
276
+ await open()
277
+
278
+ const box = contentBox(wrapper)
279
+ expect(box.classList.contains(tw('rounded', 'xl'))).toBe(true)
280
+ expect(box.classList.contains('border')).toBe(true)
281
+ expect(box.classList.contains(tw('border', 'gray', '200'))).toBe(true)
282
+ expect(box.classList.contains(tw('bg', 'white'))).toBe(true)
283
+ expect(box.classList.contains(tw('shadow', 'sm'))).toBe(true)
284
+ // Clipping belongs with the radius. Four of the app's panels scroll or clip
285
+ // their own content; once the radius moves to this wrapper, clipping on an
286
+ // inner element has nothing left to clip against, so content would paint
287
+ // over the rounded corners.
288
+ expect(box.classList.contains(tw('overflow', 'hidden'))).toBe(true)
289
+ })
290
+
291
+ it('puts the surface inside the transition, not around it', async () => {
292
+ wrapper = mountPanel({ surface: true })
293
+ await open()
294
+
295
+ // one extra element compared to the default: .panel -> surface -> v-if div
296
+ expect(contentBox(wrapper).parentElement?.tagName).toBe('DIV')
297
+ expect(contentBox(wrapper).parentElement?.parentElement?.tagName).toBe('TRANSITION-STUB')
298
+ })
299
+
300
+ it('still renders the content and its close binding when surface is true', async () => {
301
+ wrapper = mountPanel({ surface: true })
302
+ await open()
303
+ expect(wrapper.text()).toContain('Panel content')
304
+
305
+ await wrapper.find('.close-btn').trigger('click')
306
+ await nextTick()
307
+ expect(wrapper.find('.panel').exists()).toBe(false)
308
+ })
309
+
310
+ it('does not decorate the trigger when surface is true', async () => {
311
+ wrapper = mountPanel({ surface: true })
312
+ await open()
313
+
314
+ const trigger = wrapper.find('.trigger').element.parentElement as HTMLElement
315
+ expect(trigger.classList.contains(tw('bg', 'white'))).toBe(false)
316
+ })
317
+ })
318
+
319
+ type MiddlewareEntry = { name: string; options?: { apply?: (state: unknown) => void } }
320
+
321
+ const middlewareNames = () =>
322
+ (floating.lastOptions as { middleware: Ref<MiddlewareEntry[]> }).middleware.value.map(
323
+ (m) => m.name,
324
+ )
325
+
326
+ const fitHeightApply = () => {
327
+ const { middleware } = floating.lastOptions as { middleware: Ref<MiddlewareEntry[]> }
328
+ return middleware.value.at(-1)!.options!.apply!
329
+ }
330
+
331
+ const panelContainer = () => document.querySelector('.panel')?.closest('.z-50') ?? null
332
+
333
+ describe('FloatingPanel default render', () => {
334
+ it('uses the same middleware as before when no new prop is set', () => {
335
+ wrapper = mountPanel()
336
+ expect(middlewareNames()).toEqual(['offset', 'flip', 'shift'])
337
+ })
338
+
339
+ it('adds one size() for matchWidth, one for fitHeight, and two for both', () => {
340
+ wrapper = mountPanel({ matchWidth: true })
341
+ expect(middlewareNames()).toEqual(['offset', 'flip', 'shift', 'size'])
342
+ wrapper.unmount()
343
+
344
+ wrapper = mountPanel({ fitHeight: true })
345
+ expect(middlewareNames()).toEqual(['offset', 'flip', 'shift', 'size'])
346
+ wrapper.unmount()
347
+
348
+ wrapper = mountPanel({ matchWidth: true, fitHeight: true })
349
+ expect(middlewareNames()).toEqual(['offset', 'flip', 'shift', 'size', 'size'])
350
+ })
351
+
352
+ it('renders identical markup with the new props explicitly off, open and closed', async () => {
353
+ const render = async (props: object) => {
354
+ wrapper = mountPanel(props)
355
+ const closed = wrapper.html()
356
+ await open()
357
+ const opened = wrapper.html()
358
+ wrapper.unmount()
359
+ return { closed, opened }
360
+ }
361
+
362
+ const byDefault = await render({})
363
+ for (const props of [{ teleport: false }, { fitHeight: false }, { surface: false }]) {
364
+ expect(await render(props)).toEqual(byDefault)
365
+ }
366
+ expect(byDefault.opened).not.toContain('teleport start')
367
+ expect(byDefault.opened).not.toContain('data-focus-guard')
368
+ })
369
+
370
+ // html() drops empty comments, so check the raw nodes: an inactive guard must not leave a
371
+ // `<!---->` placeholder in the trigger or the panel.
372
+ it('leaves no placeholder comment next to the trigger or in the panel', async () => {
373
+ const emptyComments = () =>
374
+ [...document.body.querySelectorAll('*')].flatMap((el) =>
375
+ [...el.childNodes].filter((n) => n.nodeType === Node.COMMENT_NODE && !n.nodeValue),
376
+ )
377
+
378
+ wrapper = mountPanel()
379
+ const trigger = wrapper.find('.trigger').element
380
+ expect(trigger.parentElement!.innerHTML).toBe(trigger.outerHTML)
381
+ expect(emptyComments()).toHaveLength(0)
382
+
383
+ await open()
384
+ expect(trigger.parentElement!.innerHTML).toBe(trigger.outerHTML)
385
+ expect(emptyComments()).toHaveLength(0)
386
+ })
387
+ })
388
+
389
+ describe('FloatingPanel fitHeight prop', () => {
390
+ const VIEWPORT = 800
391
+ const reference = { getBoundingClientRect: () => ({ top: 100, bottom: 140 }) }
392
+
393
+ const mountFit = (props = {}) =>
394
+ mountPanel(
395
+ { fitHeight: true, ...props },
396
+ {
397
+ content: `<template #content="{ availableHeight }">
398
+ <div class="panel"><span class="height">{{ String(availableHeight) }}</span></div>
399
+ </template>`,
400
+ },
401
+ )
402
+
403
+ const run = async (placement: string, rect = reference) => {
404
+ const element = document.createElement('div')
405
+ fitHeightApply()({ elements: { reference: rect, floating: element }, placement })
406
+ await nextTick()
407
+ return {
408
+ variable: element.style.getPropertyValue('--floating-panel-available-height'),
409
+ slot: wrapper.find('.height').text(),
410
+ }
411
+ }
412
+
413
+ beforeEach(() => {
414
+ Object.defineProperty(document.documentElement, 'clientHeight', {
415
+ configurable: true,
416
+ value: VIEWPORT,
417
+ })
418
+ })
419
+
420
+ afterEach(() => {
421
+ delete (document.documentElement as { clientHeight?: number }).clientHeight
422
+ })
423
+
424
+ it('measures the roomiest vertical side, minus offset and viewport margin', async () => {
425
+ wrapper = mountFit()
426
+ await open()
427
+
428
+ // below: 800 - 140 = 660 is roomier than 100 above; minus offset 8 and margin 8
429
+ expect(await run('bottom-start')).toEqual({ variable: '644px', slot: '644' })
430
+ // independent of the side flip() picked
431
+ expect(await run('top')).toEqual({ variable: '644px', slot: '644' })
432
+ // a trigger low on the page: above (600) is the roomiest side, even for a bottom placement
433
+ expect(
434
+ await run('bottom', { getBoundingClientRect: () => ({ top: 600, bottom: 640 }) }),
435
+ ).toEqual({ variable: '584px', slot: '584' })
436
+ })
437
+
438
+ it('uses the full viewport minus both margins for side placements', async () => {
439
+ wrapper = mountFit()
440
+ await open()
441
+ expect(await run('right-start')).toEqual({ variable: '784px', slot: '784' })
442
+ })
443
+
444
+ it('subtracts the surface border, for both surfaces', async () => {
445
+ wrapper = mountFit({ surface: true })
446
+ await open()
447
+ expect(await run('bottom')).toEqual({ variable: '642px', slot: '642' })
448
+ wrapper.unmount()
449
+
450
+ wrapper = mountFit({ surface: 'raised' })
451
+ await open()
452
+ expect(await run('right')).toEqual({ variable: '782px', slot: '782' })
453
+ })
454
+
455
+ it('floors fractions and never goes below 0', async () => {
456
+ wrapper = mountFit()
457
+ await open()
458
+ // 800 - 139.6 - 16 = 644.4
459
+ expect(
460
+ await run('bottom', { getBoundingClientRect: () => ({ top: 100, bottom: 139.6 }) }),
461
+ ).toEqual({ variable: '644px', slot: '644' })
462
+ expect(
463
+ await run('bottom', { getBoundingClientRect: () => ({ top: 4, bottom: VIEWPORT - 4 }) }),
464
+ ).toEqual({ variable: '0px', slot: '0' })
465
+ })
466
+
467
+ it('passes null and sets no variable without fitHeight', async () => {
468
+ wrapper = mountFit({ fitHeight: false })
469
+ await open()
470
+ expect(wrapper.find('.height').text()).toBe('null')
471
+ expect(panelContainer()?.getAttribute('style') ?? '').not.toContain('available-height')
472
+ })
473
+ })
474
+
475
+ describe('FloatingPanel raised surface', () => {
476
+ it('uses the heavier border and shadow for surface="raised"', async () => {
477
+ wrapper = mountPanel({ surface: 'raised' })
478
+ await open()
479
+
480
+ const box = contentBox(wrapper)
481
+ expect(box.classList.contains(tw('border', 'gray', '300'))).toBe(true)
482
+ expect(box.classList.contains(tw('shadow', 'lg'))).toBe(true)
483
+ expect(box.classList.contains(tw('border', 'gray', '200'))).toBe(false)
484
+ expect(box.classList.contains(tw('shadow', 'sm'))).toBe(false)
485
+ expect(box.classList.contains(tw('overflow', 'hidden'))).toBe(true)
486
+ })
487
+
488
+ it('casts the bare attribute `surface` to the default surface', async () => {
489
+ wrapper = mount(
490
+ {
491
+ components: { FloatingPanel },
492
+ template: `<FloatingPanel surface>
493
+ <template #trigger="{ toggle }"><button class="trigger" @click="toggle">Toggle</button></template>
494
+ <template #content><div class="panel">Panel content</div></template>
495
+ </FloatingPanel>`,
496
+ },
497
+ { attachTo: document.body },
498
+ )
499
+ await open()
500
+
501
+ const box = contentBox(wrapper)
502
+ expect(box.classList.contains(tw('border', 'gray', '200'))).toBe(true)
503
+ expect(box.classList.contains(tw('shadow', 'sm'))).toBe(true)
504
+ })
505
+ })
506
+
507
+ describe('FloatingPanel reduced motion', () => {
508
+ const stubReducedMotion = (reduce: boolean) =>
509
+ vi.stubGlobal('matchMedia', (query: string) => ({
510
+ matches: reduce && query.includes('reduce'),
511
+ addEventListener: () => {},
512
+ removeEventListener: () => {},
513
+ }))
514
+
515
+ const mountWithTransition = () =>
516
+ mount(FloatingPanel, {
517
+ slots: {
518
+ trigger: `<template #trigger="{ toggle }"><button class="trigger" @click="toggle">Toggle</button></template>`,
519
+ content: `<template #content><div class="panel">Panel content</div></template>`,
520
+ },
521
+ global: { stubs: { transition: false } },
522
+ attachTo: document.body,
523
+ })
524
+
525
+ afterEach(() => {
526
+ vi.unstubAllGlobals()
527
+ })
528
+
529
+ it('removes the panel in the same patch when motion is reduced', async () => {
530
+ stubReducedMotion(true)
531
+ wrapper = mountWithTransition()
532
+ await open()
533
+ expect(panelContainer()).not.toBeNull()
534
+
535
+ await wrapper.find('.trigger').trigger('click')
536
+ await nextTick()
537
+ expect(document.querySelector('.z-50')).toBeNull()
538
+ })
539
+
540
+ it('keeps the panel for the leave transition otherwise', async () => {
541
+ stubReducedMotion(false)
542
+ wrapper = mountWithTransition()
543
+ await open()
544
+
545
+ await wrapper.find('.trigger').trigger('click')
546
+ await nextTick()
547
+ expect(document.querySelector('.z-50')).not.toBeNull()
548
+ })
549
+
550
+ it('switches transitions off under motion-reduce as a CSS fallback', async () => {
551
+ wrapper = mountPanel()
552
+ await open()
553
+ const stub = wrapper.find('transition-stub')
554
+ expect(stub.attributes('enteractiveclass')).toContain('motion-reduce:transition-none')
555
+ expect(stub.attributes('leaveactiveclass')).toContain('motion-reduce:transition-none')
556
+ })
557
+ })
558
+
559
+ describe('FloatingPanel teleport prop', () => {
560
+ const Host = (hostAttrs = '', onClose: () => void = () => {}) => ({
561
+ components: { FloatingPanel },
562
+ setup: () => ({ panelRef: ref<InstanceType<typeof FloatingPanel> | null>(null), onClose }),
563
+ template: `<div class="host" ${hostAttrs}>
564
+ <button class="before">Voor</button>
565
+ <FloatingPanel ref="panelRef" teleport @close="onClose">
566
+ <template #trigger="{ toggle }"><button class="trigger" @click="toggle">Toggle</button></template>
567
+ <template #content="{ teleported }">
568
+ <div class="panel" :data-teleported="teleported">
569
+ <button class="first">Eerste</button>
570
+ <button class="last">Laatste</button>
571
+ <div class="stops-escape" tabindex="-1" @keydown.esc.stop></div>
572
+ </div>
573
+ </template>
574
+ </FloatingPanel>
575
+ <button class="after">Na</button>
576
+ <button class="tail" @keydown.stop>Staart</button>
577
+ </div>`,
578
+ })
579
+
580
+ const guard = (edge: string) =>
581
+ document.querySelector<HTMLElement>(`[data-focus-guard="${edge}"]`)!
582
+ const focus = (selector: string) => document.querySelector<HTMLElement>(selector)!.focus()
583
+ const focused = () => document.activeElement?.className
584
+ const pressTab = () =>
585
+ document.activeElement!.dispatchEvent(
586
+ new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
587
+ )
588
+ const exposed = () =>
589
+ (wrapper.vm as unknown as { panelRef: { teleported: boolean } }).panelRef.teleported
590
+
591
+ it('renders the panel in <body>, not next to the trigger', async () => {
592
+ wrapper = mount(Host(), { attachTo: document.body })
593
+ await open()
594
+
595
+ const container = panelContainer()!
596
+ expect(container.parentElement).toBe(document.body)
597
+ expect(wrapper.find('.host').element.contains(container)).toBe(false)
598
+ })
599
+
600
+ it('stays in place inside a role="dialog"', async () => {
601
+ wrapper = mount(Host('role="dialog"'), { attachTo: document.body })
602
+ await open()
603
+
604
+ expect(wrapper.find('.host').element.contains(panelContainer())).toBe(true)
605
+ expect(document.querySelector('[data-focus-guard]')).toBeNull()
606
+ })
607
+
608
+ it('reports whether it teleported, as a slot prop and on the instance', async () => {
609
+ wrapper = mount(Host(), { attachTo: document.body })
610
+ expect(exposed()).toBe(false)
611
+ await open()
612
+ expect(document.querySelector('.panel')!.getAttribute('data-teleported')).toBe('true')
613
+ expect(exposed()).toBe(true)
614
+ wrapper.unmount()
615
+
616
+ wrapper = mount(Host('role="dialog"'), { attachTo: document.body })
617
+ await open()
618
+ expect(document.querySelector('.panel')!.getAttribute('data-teleported')).toBe('false')
619
+ expect(exposed()).toBe(false)
620
+ })
621
+
622
+ it('does not close on a mousedown inside the teleported panel', async () => {
623
+ wrapper = mount(Host(), { attachTo: document.body })
624
+ await open()
625
+
626
+ document.querySelector('.first')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
627
+ await nextTick()
628
+ expect(document.querySelector('.panel')).not.toBeNull()
629
+ })
630
+
631
+ it('places the bridge inside the trigger and the other guards around the content', async () => {
632
+ wrapper = mount(Host(), { attachTo: document.body })
633
+ await open()
634
+
635
+ const trigger = wrapper.find('.trigger').element.parentElement!
636
+ expect(trigger.lastElementChild).toBe(guard('bridge'))
637
+ expect(panelContainer()!.firstElementChild).toBe(guard('start'))
638
+ expect(panelContainer()!.lastElementChild).toBe(guard('end'))
639
+ for (const edge of ['bridge', 'start', 'end']) {
640
+ expect(guard(edge).getAttribute('tabindex')).toBe('0')
641
+ expect(guard(edge).hasAttribute('aria-hidden')).toBe(false)
642
+ }
643
+ })
644
+
645
+ it('leads Tab from the trigger into the panel', async () => {
646
+ wrapper = mount(Host(), { attachTo: document.body })
647
+ await open()
648
+ focus('.trigger')
649
+
650
+ guard('bridge').focus()
651
+ expect(focused()).toBe('first')
652
+ })
653
+
654
+ it('leads Shift+Tab from the element after the trigger back into the panel', async () => {
655
+ wrapper = mount(Host(), { attachTo: document.body })
656
+ await open()
657
+ focus('.after')
658
+
659
+ guard('bridge').focus()
660
+ expect(focused()).toBe('last')
661
+ })
662
+
663
+ it('leads Shift+Tab out of the panel back to the trigger', async () => {
664
+ wrapper = mount(Host(), { attachTo: document.body })
665
+ await open()
666
+ focus('.first')
667
+
668
+ guard('start').focus()
669
+ expect(focused()).toBe('trigger')
670
+ })
671
+
672
+ it('leads Tab out of the panel to the element after the trigger, and stays open', async () => {
673
+ wrapper = mount(Host(), { attachTo: document.body })
674
+ await open()
675
+ focus('.last')
676
+
677
+ guard('end').focus()
678
+ expect(focused()).toBe('after')
679
+ expect(document.querySelector('.panel')).not.toBeNull()
680
+ })
681
+
682
+ it('leads Shift+Tab from outside the page to the last tab stop before the panel', async () => {
683
+ wrapper = mount(Host(), { attachTo: document.body })
684
+ await open()
685
+ ;(document.activeElement as HTMLElement | null)?.blur()
686
+
687
+ // relatedTarget null: focus comes from the browser UI
688
+ guard('end').focus()
689
+ expect(focused()).toBe('tail')
690
+ })
691
+
692
+ it('skips the panel on Tab from the last tab stop before it, instead of looping', async () => {
693
+ vi.useFakeTimers()
694
+ wrapper = mount(Host(), { attachTo: document.body })
695
+ await open()
696
+ const container = panelContainer() as HTMLElement
697
+
698
+ focus('.tail')
699
+ pressTab()
700
+ expect(container.inert).toBe(true)
701
+ vi.runAllTimers()
702
+ expect(container.inert).toBe(false)
703
+ vi.useRealTimers()
704
+ })
705
+
706
+ it('still skips the panel when the focused element stops Tab from propagating', async () => {
707
+ vi.useFakeTimers()
708
+ wrapper = mount(Host(), { attachTo: document.body })
709
+ await open()
710
+ const container = panelContainer() as HTMLElement
711
+ // `.tail` has @keydown.stop: a bubble-phase listener on document would never see this Tab
712
+ let reachedDocument = false
713
+ const spy = () => (reachedDocument = true)
714
+ document.addEventListener('keydown', spy)
715
+
716
+ focus('.tail')
717
+ pressTab()
718
+ document.removeEventListener('keydown', spy)
719
+ expect(reachedDocument).toBe(false)
720
+ expect(container.inert).toBe(true)
721
+ vi.runAllTimers()
722
+ vi.useRealTimers()
723
+ })
724
+
725
+ it('stays open on an Escape that content stops from propagating', async () => {
726
+ wrapper = mount(Host(), { attachTo: document.body })
727
+ await open()
728
+
729
+ focus('.stops-escape')
730
+ document.activeElement!.dispatchEvent(
731
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
732
+ )
733
+ await flushPromises()
734
+ expect(document.querySelector('.panel')).not.toBeNull()
735
+ })
736
+
737
+ it('leaves the panel reachable on Tab from anywhere else', async () => {
738
+ wrapper = mount(Host(), { attachTo: document.body })
739
+ await open()
740
+ const container = panelContainer() as HTMLElement
741
+
742
+ for (const selector of ['.before', '.trigger', '.after', '.first']) {
743
+ focus(selector)
744
+ pressTab()
745
+ expect(container.inert).toBeFalsy()
746
+ }
747
+ })
748
+
749
+ it('puts focus back on the trigger when it closes with focus inside', async () => {
750
+ wrapper = mount(Host(), { attachTo: document.body })
751
+ await open()
752
+ focus('.first')
753
+
754
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
755
+ await flushPromises()
756
+ expect(focused()).toBe('trigger')
757
+ })
758
+
759
+ it('leaves focus alone when it closes with focus outside the panel', async () => {
760
+ wrapper = mount(Host(), { attachTo: document.body })
761
+ await open()
762
+ focus('.before')
763
+
764
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
765
+ await flushPromises()
766
+ expect(focused()).toBe('before')
767
+ })
768
+
769
+ it('lets a consumer that moves focus in @close have the last word', async () => {
770
+ wrapper = mount(
771
+ Host('', () => focus('.tail')),
772
+ { attachTo: document.body },
773
+ )
774
+ await open()
775
+ focus('.first')
776
+
777
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
778
+ await flushPromises()
779
+ expect(focused()).toBe('tail')
780
+ })
781
+
782
+ it('does not move focus on close when not teleported', async () => {
783
+ wrapper = mount(Host('role="dialog"'), { attachTo: document.body })
784
+ await open()
785
+ focus('.first')
786
+
787
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
788
+ await flushPromises()
789
+ expect(focused()).not.toBe('trigger')
790
+ })
791
+
792
+ it('removes the bridge when the panel closes', async () => {
793
+ wrapper = mount(Host(), { attachTo: document.body })
794
+ await open()
795
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
796
+ await flushPromises()
797
+
798
+ expect(document.querySelector('[data-focus-guard="bridge"]')).toBeNull()
799
+ })
800
+ })
801
+
802
+ describe('FloatingPanel unmount while open', () => {
803
+ const Teleported = {
804
+ components: { FloatingPanel },
805
+ template: `<FloatingPanel teleport surface>
806
+ <template #trigger="{ toggle }"><button class="trigger" @click="toggle">Toggle</button></template>
807
+ <template #content><div class="panel"><button>In het paneel</button></div></template>
808
+ </FloatingPanel>`,
809
+ }
810
+
811
+ const leftovers = () => document.body.querySelectorAll('.panel, .z-50, [data-focus-guard]').length
812
+
813
+ const trackListeners = () => {
814
+ const live = new Map<string, number>()
815
+ const add = vi.spyOn(document, 'addEventListener')
816
+ const remove = vi.spyOn(document, 'removeEventListener')
817
+ return () => {
818
+ for (const [type] of add.mock.calls) live.set(type, (live.get(type) ?? 0) + 1)
819
+ for (const [type] of remove.mock.calls) live.set(type, (live.get(type) ?? 0) - 1)
820
+ add.mockRestore()
821
+ remove.mockRestore()
822
+ return [...live].filter(([, count]) => count !== 0)
823
+ }
824
+ }
825
+
826
+ afterEach(() => {
827
+ vi.unstubAllGlobals()
828
+ })
829
+
830
+ it('leaves nothing in <body> and no document listeners when unmounted open', async () => {
831
+ const listeners = trackListeners()
832
+ wrapper = mount(Teleported, { attachTo: document.body })
833
+ await open()
834
+ expect(leftovers()).toBeGreaterThan(0)
835
+
836
+ wrapper.unmount()
837
+ expect(leftovers()).toBe(0)
838
+ expect(listeners()).toEqual([])
839
+ })
840
+
841
+ it('leaves nothing behind when unmounted during the leave transition', async () => {
842
+ const listeners = trackListeners()
843
+ let mediaListeners = 0
844
+ vi.stubGlobal('matchMedia', () => ({
845
+ matches: false,
846
+ addEventListener: () => mediaListeners++,
847
+ removeEventListener: () => mediaListeners--,
848
+ }))
849
+ wrapper = mount(Teleported, {
850
+ attachTo: document.body,
851
+ global: { stubs: { transition: false } },
852
+ })
853
+ await open()
854
+ await wrapper.find('.trigger').trigger('click')
855
+ await nextTick()
856
+ expect(leftovers()).toBeGreaterThan(0) // still leaving
857
+
858
+ wrapper.unmount()
859
+ await flushPromises()
860
+ expect(leftovers()).toBe(0)
861
+ expect(listeners()).toEqual([])
862
+ expect(mediaListeners).toBe(0)
863
+ })
864
+ })