@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,677 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { expect, screen, userEvent, waitFor, within } from 'storybook/test'
3
+ import { ref, onMounted } from 'vue'
4
+ import FloatingPanel from './FloatingPanel.vue'
5
+ import NexxtButton from '../Button/Button.vue'
6
+ import NexxtModal from '../../organisms/Modal/Modal.vue'
7
+
8
+ export default {
9
+ title: 'Components/Atoms/Floating Panel',
10
+ component: FloatingPanel,
11
+ decorators: [
12
+ () => ({
13
+ // Room for an open panel above or below a centred trigger, also inline on the Docs page.
14
+ template:
15
+ '<div style="min-height: 400px; padding: 48px; display: flex; align-items: center; justify-content: center;"><story /></div>',
16
+ }),
17
+ ],
18
+ argTypes: {
19
+ placement: {
20
+ control: 'select',
21
+ options: [
22
+ 'bottom-start',
23
+ 'bottom',
24
+ 'bottom-end',
25
+ 'top-start',
26
+ 'top',
27
+ 'top-end',
28
+ 'left-start',
29
+ 'left',
30
+ 'left-end',
31
+ 'right-start',
32
+ 'right',
33
+ 'right-end',
34
+ ],
35
+ },
36
+ offsetDistance: { control: 'number' },
37
+ },
38
+ } satisfies Meta<typeof FloatingPanel>
39
+
40
+ type Story = StoryObj<typeof FloatingPanel>
41
+
42
+ /**
43
+ * The panel scales and fades in from its trigger. Under `prefers-reduced-motion: reduce` it
44
+ * appears and disappears without animation; to see that here, use DevTools → Rendering →
45
+ * "Emulate CSS media feature prefers-reduced-motion".
46
+ */
47
+ export const Default: Story = {
48
+ render: (args) => ({
49
+ components: { FloatingPanel, NexxtButton },
50
+ setup() {
51
+ return { args }
52
+ },
53
+ template: `
54
+ <FloatingPanel v-bind="args">
55
+ <template #trigger="{ isOpen, toggle }">
56
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">
57
+ {{ isOpen ? 'Close' : 'Open panel' }}
58
+ </NexxtButton>
59
+ </template>
60
+ <template #content>
61
+ <div style="background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); min-width: 200px;">
62
+ <p style="margin: 0; font-size: 14px; color: #374151;">Floating panel content</p>
63
+ </div>
64
+ </template>
65
+ </FloatingPanel>
66
+ `,
67
+ }),
68
+ args: {
69
+ placement: 'bottom-start',
70
+ },
71
+ }
72
+
73
+ export const AllPlacements: Story = {
74
+ render: () => ({
75
+ components: { FloatingPanel, NexxtButton },
76
+ setup() {
77
+ const placements = [
78
+ 'bottom-start',
79
+ 'bottom',
80
+ 'bottom-end',
81
+ 'top-start',
82
+ 'top',
83
+ 'top-end',
84
+ 'right-start',
85
+ 'right',
86
+ 'left-start',
87
+ 'left',
88
+ ] as const
89
+ return { placements }
90
+ },
91
+ template: `
92
+ <div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; max-width: 560px;">
93
+ <FloatingPanel v-for="p in placements" :key="p" :placement="p">
94
+ <template #trigger="{ toggle, isOpen }">
95
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">
96
+ {{ p }}
97
+ </NexxtButton>
98
+ </template>
99
+ <template #content>
100
+ <div style="background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); font-size: 13px; color: #374151;">
101
+ Placed: {{ p }}
102
+ </div>
103
+ </template>
104
+ </FloatingPanel>
105
+ </div>
106
+ `,
107
+ }),
108
+ }
109
+
110
+ export const InsideOverflowHidden: Story = {
111
+ decorators: [
112
+ () => ({
113
+ // The panel is meant to stick out of the dashed box; the padding keeps it inside the story.
114
+ template: `
115
+ <div style="padding-bottom: 160px;">
116
+ <div style="overflow: hidden; border: 2px dashed #f87171; border-radius: 8px; padding: 32px; display: flex; align-items: center; justify-content: center; min-height: 200px;">
117
+ <p style="position: absolute; top: 8px; left: 12px; font-size: 11px; color: #f87171; margin: 0;">overflow: hidden container: panel escapes via position: fixed</p>
118
+ <story />
119
+ </div>
120
+ </div>
121
+ `,
122
+ }),
123
+ ],
124
+ render: (args) => ({
125
+ components: { FloatingPanel, NexxtButton },
126
+ setup() {
127
+ return { args }
128
+ },
129
+ template: `
130
+ <FloatingPanel v-bind="args">
131
+ <template #trigger="{ toggle, isOpen }">
132
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">
133
+ Open (escapes overflow)
134
+ </NexxtButton>
135
+ </template>
136
+ <template #content>
137
+ <div style="background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 280px;">
138
+ <p style="margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #111827;">Escaped via position: fixed</p>
139
+ <p style="margin: 0 0 12px; font-size: 13px; color: #6b7280;">This panel is rendered next to its trigger, but <code>position: fixed</code> takes it out of the container's clipping.</p>
140
+ <p style="margin: 0; font-size: 13px; color: #6b7280;">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt.</p>
141
+ </div>
142
+ </template>
143
+ </FloatingPanel>
144
+ `,
145
+ }),
146
+ args: {
147
+ placement: 'bottom-start',
148
+ },
149
+ }
150
+
151
+ export const WithInputFocus: Story = {
152
+ render: (args) => ({
153
+ components: { FloatingPanel },
154
+ setup() {
155
+ return { args }
156
+ },
157
+ template: `
158
+ <FloatingPanel v-bind="args">
159
+ <template #trigger="{ isOpen, open }">
160
+ <input
161
+ type="text"
162
+ placeholder="Focus me to open the panel"
163
+ :aria-expanded="isOpen"
164
+ @focus="open"
165
+ style="border: 1px solid #d1d5db; border-radius: 6px; padding: 8px 12px; font-size: 14px; outline: none; width: 240px; box-sizing: border-box;"
166
+ />
167
+ </template>
168
+ <template #content>
169
+ <div style="background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); min-width: 240px;">
170
+ <p style="margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #111827;">Suggestions</p>
171
+ <ul style="margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px;">
172
+ <li style="font-size: 14px; color: #374151; padding: 6px 8px; border-radius: 4px; cursor: pointer; background: #f9fafb;">Option A</li>
173
+ <li style="font-size: 14px; color: #374151; padding: 6px 8px; border-radius: 4px; cursor: pointer;">Option B</li>
174
+ <li style="font-size: 14px; color: #374151; padding: 6px 8px; border-radius: 4px; cursor: pointer;">Option C</li>
175
+ </ul>
176
+ </div>
177
+ </template>
178
+ </FloatingPanel>
179
+ `,
180
+ }),
181
+ args: {
182
+ placement: 'bottom-start',
183
+ },
184
+ }
185
+
186
+ export const WithCloseSlotProp: Story = {
187
+ render: (args) => ({
188
+ components: { FloatingPanel, NexxtButton },
189
+ setup() {
190
+ const log = ref<string[]>([])
191
+ return { args, log }
192
+ },
193
+ template: `
194
+ <div style="display: flex; flex-direction: column; align-items: center; gap: 12px;">
195
+ <FloatingPanel v-bind="args" @open="log.push('opened')" @close="log.push('closed')">
196
+ <template #trigger="{ toggle, isOpen }">
197
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">
198
+ Toggle
199
+ </NexxtButton>
200
+ </template>
201
+ <template #content="{ close }">
202
+ <div style="background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: flex; flex-direction: column; gap: 8px; min-width: 180px;">
203
+ <p style="margin: 0; font-size: 14px; color: #374151;">Panel is open</p>
204
+ <NexxtButton variant="danger" @click="close">Close from inside</NexxtButton>
205
+ </div>
206
+ </template>
207
+ </FloatingPanel>
208
+ <ul style="font-size: 12px; color: #6b7280; list-style: none; padding: 0; margin: 0;">
209
+ <li v-for="(entry, i) in log" :key="i">{{ entry }}</li>
210
+ </ul>
211
+ </div>
212
+ `,
213
+ }),
214
+ args: {
215
+ placement: 'bottom-start',
216
+ },
217
+ }
218
+
219
+ /**
220
+ * `surface` (default `false`) wraps the content slot in the standard panel surface
221
+ * — rounded corners, a light border, a white background and a small shadow — so a
222
+ * consumer no longer has to hand-roll one per call site.
223
+ *
224
+ * Both panels below render the exact same unstyled content slot. The left one is
225
+ * the bare default: with nothing behind it, the content sits directly on the page.
226
+ * The right one has `surface` on. Both open on mount so the difference is visible
227
+ * without interacting.
228
+ */
229
+ export const Surface: Story = {
230
+ render: () => ({
231
+ components: { FloatingPanel, NexxtButton },
232
+ setup() {
233
+ const bare = ref<InstanceType<typeof FloatingPanel> | null>(null)
234
+ const surfaced = ref<InstanceType<typeof FloatingPanel> | null>(null)
235
+ onMounted(() => {
236
+ bare.value?.open()
237
+ surfaced.value?.open()
238
+ })
239
+ return { bare, surfaced }
240
+ },
241
+ template: `
242
+ <div style="display: flex; gap: 160px; align-items: flex-start;">
243
+ <div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
244
+ <span style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #374151;">surface: false (default)</span>
245
+ <FloatingPanel ref="bare" placement="bottom">
246
+ <template #trigger="{ toggle, isOpen }">
247
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">Bare</NexxtButton>
248
+ </template>
249
+ <template #content>
250
+ <div style="width: 180px;">
251
+ <p style="margin: 0 0 4px; font-weight: 600;">Unstyled content</p>
252
+ <p style="margin: 0;">No wrapper — the slot is placed as-is.</p>
253
+ </div>
254
+ </template>
255
+ </FloatingPanel>
256
+ </div>
257
+
258
+ <div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
259
+ <span style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #374151;">surface: true</span>
260
+ <FloatingPanel ref="surfaced" placement="bottom" surface>
261
+ <template #trigger="{ toggle, isOpen }">
262
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">Surface</NexxtButton>
263
+ </template>
264
+ <template #content>
265
+ <div style="width: 180px;">
266
+ <p style="margin: 0 0 4px; font-weight: 600;">Unstyled content</p>
267
+ <p style="margin: 0;">Same slot, wrapped by the component.</p>
268
+ </div>
269
+ </template>
270
+ </FloatingPanel>
271
+ </div>
272
+ </div>
273
+ `,
274
+ }),
275
+ }
276
+
277
+ /**
278
+ * `surface` on its own, opened by hand — the everyday case a consumer writes now
279
+ * that the panel supplies its own background.
280
+ */
281
+ export const SurfaceInteractive: Story = {
282
+ render: (args) => ({
283
+ components: { FloatingPanel, NexxtButton },
284
+ setup() {
285
+ return { args }
286
+ },
287
+ template: `
288
+ <FloatingPanel v-bind="args" surface>
289
+ <template #trigger="{ isOpen, toggle }">
290
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">
291
+ {{ isOpen ? 'Close' : 'Open panel' }}
292
+ </NexxtButton>
293
+ </template>
294
+ <template #content>
295
+ <div style="padding: 16px; min-width: 220px; max-width: 280px;">
296
+ <p style="margin: 0 0 8px; font-weight: 600;">Surface panel</p>
297
+ <p style="margin: 0;">The border, radius, white background and shadow come from the component.</p>
298
+ </div>
299
+ </template>
300
+ </FloatingPanel>
301
+ `,
302
+ }),
303
+ args: {
304
+ placement: 'bottom-start',
305
+ },
306
+ }
307
+
308
+ /**
309
+ * `surface` over busy page content, which is the case it exists for: the panel has
310
+ * to stay legible when something is scrolling underneath it. The bare default is
311
+ * shown over the same content for comparison.
312
+ */
313
+ export const SurfaceOverContent: Story = {
314
+ render: () => ({
315
+ components: { FloatingPanel, NexxtButton },
316
+ setup() {
317
+ const bare = ref<InstanceType<typeof FloatingPanel> | null>(null)
318
+ const surfaced = ref<InstanceType<typeof FloatingPanel> | null>(null)
319
+ onMounted(() => {
320
+ bare.value?.open()
321
+ surfaced.value?.open()
322
+ })
323
+ return { bare, surfaced }
324
+ },
325
+ template: `
326
+ <div style="position: relative; width: 620px;">
327
+ <p style="position: absolute; inset: 60px 0 auto 0; margin: 0; font-size: 13px; line-height: 22px; color: #4b5563;">
328
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
329
+ incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
330
+ nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
331
+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore.
332
+ </p>
333
+ <div style="display: flex; gap: 200px; position: relative;">
334
+ <FloatingPanel ref="bare" placement="bottom">
335
+ <template #trigger="{ toggle, isOpen }">
336
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">Bare</NexxtButton>
337
+ </template>
338
+ <template #content>
339
+ <div style="width: 170px;">Content with nothing behind it.</div>
340
+ </template>
341
+ </FloatingPanel>
342
+
343
+ <FloatingPanel ref="surfaced" placement="bottom" surface>
344
+ <template #trigger="{ toggle, isOpen }">
345
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">Surface</NexxtButton>
346
+ </template>
347
+ <template #content>
348
+ <div style="width: 170px; padding: 12px;">Content on its own surface.</div>
349
+ </template>
350
+ </FloatingPanel>
351
+ </div>
352
+ </div>
353
+ `,
354
+ }),
355
+ }
356
+
357
+ /**
358
+ * `surface="raised"` is the surface for large panels that cover a stretch of white page or
359
+ * canvas, such as a picker: a gray-300 border and `shadow-lg`, so the panel reads as a layer
360
+ * above the page. `surface` (`true`) stays the surface for dropdowns, selects, menus and
361
+ * suggestions. Rule of thumb: if the panel covers content the user was just looking at, raise it.
362
+ */
363
+ export const Raised: Story = {
364
+ render: () => ({
365
+ components: { FloatingPanel, NexxtButton },
366
+ setup() {
367
+ const standard = ref<InstanceType<typeof FloatingPanel> | null>(null)
368
+ const raised = ref<InstanceType<typeof FloatingPanel> | null>(null)
369
+ onMounted(() => {
370
+ standard.value?.open()
371
+ raised.value?.open()
372
+ })
373
+ return { standard, raised }
374
+ },
375
+ template: `
376
+ <div style="position: relative; width: 100%; background: white; display: grid; grid-template-columns: repeat(auto-fit, 482px); justify-content: center; column-gap: 48px;">
377
+ <div aria-hidden="true" style="position: absolute; inset: 48px 0 0 0; overflow: hidden; font-size: 13px; line-height: 22px; color: #4b5563;">
378
+ <p v-for="n in 8" :key="n" style="margin: 0 0 8px;">
379
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
380
+ incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
381
+ exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure
382
+ dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
383
+ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt.
384
+ </p>
385
+ </div>
386
+ <div style="min-height: 340px;">
387
+ <FloatingPanel ref="standard" placement="bottom-start" surface>
388
+ <template #trigger="{ toggle, isOpen }">
389
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">surface</NexxtButton>
390
+ </template>
391
+ <template #content>
392
+ <div data-testid="content-surface" style="width: 480px; height: 280px; padding: 16px;">
393
+ Small-panel surface over a large area.
394
+ </div>
395
+ </template>
396
+ </FloatingPanel>
397
+ </div>
398
+ <div style="min-height: 340px;">
399
+ <FloatingPanel ref="raised" placement="bottom-start" surface="raised">
400
+ <template #trigger="{ toggle, isOpen }">
401
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">surface="raised"</NexxtButton>
402
+ </template>
403
+ <template #content>
404
+ <div data-testid="content-raised" style="width: 480px; height: 280px; padding: 16px;">
405
+ Raised surface: reads as a layer above the page.
406
+ </div>
407
+ </template>
408
+ </FloatingPanel>
409
+ </div>
410
+ </div>
411
+ `,
412
+ }),
413
+ play: async ({ canvasElement }) => {
414
+ const canvas = within(canvasElement)
415
+ const standard = (await canvas.findByTestId('content-surface')).parentElement!
416
+ const raised = (await canvas.findByTestId('content-raised')).parentElement!
417
+
418
+ // Resolve the token through an inline style, so the expected value does not depend on the
419
+ // same Tailwind scan that produced the classes under test.
420
+ const probe = document.createElement('div')
421
+ probe.style.color = 'var(--color-gray-300)'
422
+ document.body.append(probe)
423
+ const gray300 = getComputedStyle(probe).color
424
+ probe.remove()
425
+ await expect(
426
+ getComputedStyle(document.documentElement).getPropertyValue('--color-gray-300').trim(),
427
+ ).not.toBe('')
428
+
429
+ await expect(getComputedStyle(raised).borderTopColor).toBe(gray300)
430
+ await expect(getComputedStyle(raised).boxShadow).not.toBe('none')
431
+ await expect(getComputedStyle(raised).boxShadow).not.toBe(getComputedStyle(standard).boxShadow)
432
+ await expect(getComputedStyle(standard).borderTopColor).not.toBe(gray300)
433
+ },
434
+ }
435
+
436
+ /**
437
+ * `fitHeight` hands the content the room on the roomiest side of the trigger, as the
438
+ * `availableHeight` slot prop and as `--floating-panel-available-height`. The panel sets no
439
+ * height itself; the content decides. Here the trigger sits at the bottom of the viewport, so the
440
+ * list is capped to the room above it and the panel flips up.
441
+ */
442
+ export const FitHeight: Story = {
443
+ parameters: { layout: 'fullscreen' },
444
+ decorators: [
445
+ () => ({
446
+ // The meta decorator adds 48px padding on each side; together they fill the viewport.
447
+ template:
448
+ '<div style="height: calc(100dvh - 96px); display: flex; align-items: end;"><story /></div>',
449
+ }),
450
+ ],
451
+ render: () => ({
452
+ components: { FloatingPanel, NexxtButton },
453
+ setup() {
454
+ const rows = Array.from({ length: 60 }, (_, i) => `Row ${i + 1}`)
455
+ return { rows }
456
+ },
457
+ template: `
458
+ <FloatingPanel placement="bottom-start" surface fit-height>
459
+ <template #trigger="{ toggle, isOpen }">
460
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">Open long list</NexxtButton>
461
+ </template>
462
+ <template #content="{ availableHeight }">
463
+ <ul
464
+ data-testid="fit-list"
465
+ :data-available-height="availableHeight"
466
+ :style="{ maxHeight: availableHeight + 'px' }"
467
+ class="overflow-y-auto"
468
+ style="margin: 0; padding: 8px 16px; list-style: none; width: 220px;"
469
+ >
470
+ <li v-for="row in rows" :key="row" style="padding: 4px 0;">{{ row }}</li>
471
+ </ul>
472
+ </template>
473
+ </FloatingPanel>
474
+ `,
475
+ }),
476
+ play: async ({ canvasElement }) => {
477
+ const canvas = within(canvasElement)
478
+ const trigger = canvas.getByRole('button', { name: 'Open long list' })
479
+ await userEvent.click(trigger)
480
+ const list = await canvas.findByTestId('fit-list')
481
+
482
+ const triggerRect = trigger.getBoundingClientRect()
483
+ const viewport = document.documentElement.clientHeight
484
+ const expected =
485
+ Math.floor(Math.max(triggerRect.top, viewport - triggerRect.bottom)) - 8 - 8 - 2
486
+ await expect(Number(list.dataset.availableHeight)).toBe(expected)
487
+
488
+ // Measure the positioning element, which is never transformed by the enter transition.
489
+ const panel = list.closest('.z-50')!
490
+ await waitFor(() => {
491
+ const rect = panel.getBoundingClientRect()
492
+ // the list uses all the room it gets: 8px from the edge, less than a pixel of rounding
493
+ expect(rect.top).toBeGreaterThanOrEqual(8)
494
+ expect(rect.top).toBeLessThan(9)
495
+ expect(rect.bottom).toBeLessThanOrEqual(triggerRect.top)
496
+ })
497
+ },
498
+ }
499
+
500
+ /**
501
+ * Two sibling wrappers with the same `z-index`: the first holds the trigger, the second overlaps
502
+ * the area where the panel opens. Left, without `teleport`, the panel lives in the first
503
+ * wrapper's stacking context, so the later sibling paints over it. Right, with `teleport`, the
504
+ * panel renders at the end of `<body>` and stays on top.
505
+ */
506
+ export const StackingContext: Story = {
507
+ render: () => ({
508
+ components: { FloatingPanel, NexxtButton },
509
+ setup() {
510
+ // `ref` inside v-for collects every instance into this array
511
+ const panels = ref<InstanceType<typeof FloatingPanel>[]>([])
512
+ onMounted(() => panels.value.forEach((panel) => panel.open()))
513
+ const columns = [
514
+ { key: 'in-place', label: 'teleport: false', teleport: false },
515
+ { key: 'teleported', label: 'teleport: true', teleport: true },
516
+ ]
517
+ return { panels, columns }
518
+ },
519
+ template: `
520
+ <div style="display: flex; gap: 32px; align-items: flex-start;">
521
+ <div v-for="column in columns" :key="column.key" :data-column="column.key" style="width: 180px;">
522
+ <p style="margin: 0 0 8px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #374151;">{{ column.label }}</p>
523
+ <div class="relative z-10">
524
+ <FloatingPanel ref="panels" :teleport="column.teleport" surface>
525
+ <template #trigger="{ toggle, isOpen }">
526
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">Open</NexxtButton>
527
+ </template>
528
+ <template #content>
529
+ <div :data-testid="'stack-panel-' + column.key" style="width: 160px; height: 96px; padding: 12px;">Panel</div>
530
+ </template>
531
+ </FloatingPanel>
532
+ </div>
533
+ <div data-sibling class="relative z-10" style="margin-top: 24px; height: 120px; background: #fde68a; border-radius: 8px; padding: 8px; font-size: 12px; color: #1f2937;">
534
+ Sibling, same z-index
535
+ </div>
536
+ </div>
537
+ </div>
538
+ `,
539
+ }),
540
+ play: async ({ canvasElement }) => {
541
+ const topAt = async (key: string) => {
542
+ const column = canvasElement.querySelector(`[data-column="${key}"]`)!
543
+ const sibling = column.querySelector('[data-sibling]')!
544
+ // Teleported content is outside canvasElement, so search the whole document.
545
+ const panel = await within(document.body).findByTestId(`stack-panel-${key}`)
546
+ // Wait for the enter transition: during the scale-in the rect is still growing.
547
+ await new Promise((resolve) => setTimeout(resolve, 300))
548
+ const a = panel.getBoundingClientRect()
549
+ const b = sibling.getBoundingClientRect()
550
+ const x = (Math.max(a.left, b.left) + Math.min(a.right, b.right)) / 2
551
+ const y = (Math.max(a.top, b.top) + Math.min(a.bottom, b.bottom)) / 2
552
+ await expect(Math.min(a.bottom, b.bottom)).toBeGreaterThan(Math.max(a.top, b.top))
553
+ return { hit: document.elementFromPoint(x, y), panel }
554
+ }
555
+
556
+ const left = await topAt('in-place')
557
+ await expect(left.hit?.closest('[data-sibling]')).not.toBeNull()
558
+
559
+ const right = await topAt('teleported')
560
+ await expect(right.panel.contains(right.hit)).toBe(true)
561
+ },
562
+ }
563
+
564
+ /**
565
+ * With `teleport`, the panel sits at the end of `<body>`, yet Tab and Shift+Tab walk the page as
566
+ * if it still followed the trigger: three invisible focus guards hand focus on. Tab from the
567
+ * trigger enters the panel, Tab from its last control continues after the trigger, and Shift+Tab
568
+ * walks the same way back. Tab from the last tab stop of the page skips the panel and leaves the
569
+ * document, as it would without teleport. Closing with focus inside puts focus back on the
570
+ * trigger.
571
+ */
572
+ export const TeleportKeyboard: Story = {
573
+ render: () => ({
574
+ components: { FloatingPanel, NexxtButton },
575
+ template: `
576
+ <div style="display: flex; gap: 12px; align-items: center;">
577
+ <NexxtButton variant="secondary">Voor</NexxtButton>
578
+ <FloatingPanel teleport surface>
579
+ <template #trigger="{ toggle, isOpen }">
580
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">Trigger</NexxtButton>
581
+ </template>
582
+ <template #content>
583
+ <div style="display: flex; gap: 8px; padding: 12px;">
584
+ <NexxtButton variant="secondary">Knop 1</NexxtButton>
585
+ <NexxtButton variant="secondary">Knop 2</NexxtButton>
586
+ </div>
587
+ </template>
588
+ </FloatingPanel>
589
+ <NexxtButton variant="secondary">Na</NexxtButton>
590
+ </div>
591
+ `,
592
+ }),
593
+ play: async ({ canvasElement }) => {
594
+ const canvas = within(canvasElement)
595
+ const trigger = canvas.getByRole('button', { name: 'Trigger' })
596
+ await userEvent.click(trigger)
597
+ const first = await screen.findByRole('button', { name: 'Knop 1' })
598
+ const second = screen.getByRole('button', { name: 'Knop 2' })
599
+ const after = canvas.getByRole('button', { name: 'Na' })
600
+ await expect(canvasElement.contains(first)).toBe(false)
601
+ await expect(trigger).toHaveFocus()
602
+
603
+ for (const next of [first, second, after]) {
604
+ await userEvent.tab()
605
+ await expect(next).toHaveFocus()
606
+ }
607
+ for (const previous of [second, first, trigger]) {
608
+ await userEvent.tab({ shift: true })
609
+ await expect(previous).toHaveFocus()
610
+ }
611
+
612
+ // Escape from inside: focus goes back to the trigger instead of dropping to <body>
613
+ await userEvent.tab()
614
+ await expect(first).toHaveFocus()
615
+ await userEvent.keyboard('{Escape}')
616
+ await waitFor(() => expect(first).not.toBeInTheDocument())
617
+ await expect(trigger).toHaveFocus()
618
+ },
619
+ }
620
+
621
+ /**
622
+ * Inside a `role="dialog"` such as `NexxtModal`, `teleport` is ignored. Reka makes everything
623
+ * outside the dialog `pointer-events: none` and traps focus inside it, so a panel in `<body>`
624
+ * could not be clicked, a click on it would land on the overlay and close the modal, and focus
625
+ * would be pulled back into the dialog. The panel stays next to its trigger instead;
626
+ * `position: fixed` already escapes the dialog's overflow.
627
+ */
628
+ export const TeleportInModal: Story = {
629
+ render: () => ({
630
+ components: { FloatingPanel, NexxtButton, NexxtModal },
631
+ setup() {
632
+ const open = ref(false)
633
+ const query = ref('')
634
+ return { open, query }
635
+ },
636
+ template: `
637
+ <NexxtButton @click="open = true">Open modal</NexxtButton>
638
+ <NexxtModal v-model="open" title="Modal with a floating panel">
639
+ <p style="margin: 0 0 12px; font-size: 13px; color: #4b5563;">
640
+ This panel sets <code>teleport</code>, but inside a dialog it is ignored: the panel stays
641
+ next to its trigger.
642
+ </p>
643
+ <FloatingPanel teleport surface>
644
+ <template #trigger="{ toggle, isOpen }">
645
+ <NexxtButton variant="secondary" :aria-expanded="isOpen" @click="toggle">Open panel</NexxtButton>
646
+ </template>
647
+ <template #content>
648
+ <div style="padding: 12px; width: 240px;">
649
+ <label style="display: flex; flex-direction: column; gap: 4px; font-size: 13px;">
650
+ Search
651
+ <input v-model="query" type="text" style="border: 1px solid #d1d5db; border-radius: 6px; padding: 6px 8px;" />
652
+ </label>
653
+ </div>
654
+ </template>
655
+ </FloatingPanel>
656
+ </NexxtModal>
657
+ `,
658
+ }),
659
+ play: async ({ canvasElement }) => {
660
+ // Closed on mount, so the Docs page is not covered by the scrim; the play opens it.
661
+ await userEvent.click(within(canvasElement).getByRole('button', { name: 'Open modal' }))
662
+ const dialog = await screen.findByRole('dialog')
663
+ const modal = within(dialog)
664
+ await userEvent.click(modal.getByRole('button', { name: 'Open panel' }))
665
+ const input = await modal.findByRole('textbox', { name: 'Search' })
666
+ await expect(dialog.contains(input)).toBe(true)
667
+
668
+ await userEvent.click(input)
669
+ await userEvent.type(input, 'abc')
670
+ await expect(input).toHaveFocus()
671
+ await expect(input).toHaveValue('abc')
672
+
673
+ await userEvent.click(input.closest('div')!)
674
+ await expect(screen.getByRole('dialog')).toBe(dialog)
675
+ await expect(input).toBeInTheDocument()
676
+ },
677
+ }