@nexxtmove/ui 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/editor/molecules/EditorBorder/EditorBorder.vue.d.ts +12 -0
- package/dist/components/editor/molecules/EditorColor/_ThemeRoleSwatches.vue.d.ts +16 -0
- package/dist/index.js +1094 -1135
- package/dist/nuxt.js +0 -1
- package/package.json +38 -36
- package/src/attribute-contract.test.ts +151 -0
- package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
- package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
- package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
- package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
- package/src/components/atoms/Button/Button.stories.ts +290 -0
- package/src/components/atoms/Button/Button.test.ts +406 -0
- package/src/components/atoms/Card/Card.stories.ts +45 -0
- package/src/components/atoms/Card/Card.test.ts +46 -0
- package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
- package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
- package/src/components/atoms/Chip/Chip.stories.ts +274 -0
- package/src/components/atoms/Chip/Chip.test.ts +230 -0
- package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
- package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
- package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
- package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
- package/src/components/atoms/Icon/Icon.stories.ts +63 -0
- package/src/components/atoms/Icon/Icon.test.ts +89 -0
- package/src/components/atoms/Loader/Loader.stories.ts +36 -0
- package/src/components/atoms/Loader/Loader.test.ts +40 -0
- package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
- package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
- package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
- package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
- package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
- package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
- package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
- package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
- package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
- package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
- package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
- package/src/components/atoms/Slider/Slider.stories.ts +151 -0
- package/src/components/atoms/Slider/Slider.test.ts +191 -0
- package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
- package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
- package/src/components/atoms/Switch/Switch.stories.ts +91 -0
- package/src/components/atoms/Switch/Switch.test.ts +183 -0
- package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
- package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
- package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
- package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
- package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
- package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
- package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
- package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
- package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
- package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
- package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
- package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
- package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
- package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
- package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
- package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
- package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
- package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
- package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
- package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
- package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
- package/src/components/editor/molecules/EditorBorder/EditorBorder.vue +58 -2
- package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +563 -0
- package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +689 -0
- package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +793 -0
- package/src/components/editor/molecules/EditorColor/_ColorField.vue +7 -29
- package/src/components/editor/molecules/EditorColor/_ThemeRoleSwatches.vue +57 -0
- package/src/components/editor/molecules/EditorColor/gradientClass.test.ts +303 -0
- package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.stories.ts +72 -0
- package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.test.ts +124 -0
- package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.stories.ts +46 -0
- package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.test.ts +97 -0
- package/src/components/editor/molecules/EditorFontSize/EditorFontSize.stories.ts +248 -0
- package/src/components/editor/molecules/EditorFontSize/EditorFontSize.test.ts +288 -0
- package/src/components/editor/molecules/EditorGap/EditorGap.stories.ts +129 -0
- package/src/components/editor/molecules/EditorGap/EditorGap.test.ts +253 -0
- package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.stories.ts +68 -0
- package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.test.ts +150 -0
- package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.stories.ts +71 -0
- package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.test.ts +161 -0
- package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.stories.ts +204 -0
- package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.test.ts +238 -0
- package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.stories.ts +257 -0
- package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.test.ts +286 -0
- package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.stories.ts +135 -0
- package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.test.ts +230 -0
- package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.stories.ts +154 -0
- package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.test.ts +112 -0
- package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.stories.ts +206 -0
- package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.test.ts +332 -0
- package/src/components/editor/molecules/EditorPadding/EditorPadding.stories.ts +374 -0
- package/src/components/editor/molecules/EditorPadding/EditorPadding.test.ts +712 -0
- package/src/components/editor/molecules/EditorRadius/EditorRadius.stories.ts +64 -0
- package/src/components/editor/molecules/EditorRadius/EditorRadius.test.ts +137 -0
- package/src/components/editor/molecules/EditorShadow/EditorShadow.stories.ts +103 -0
- package/src/components/editor/molecules/EditorShadow/EditorShadow.test.ts +168 -0
- package/src/components/editor/molecules/EditorSize/EditorSize.stories.ts +84 -0
- package/src/components/editor/molecules/EditorSize/EditorSize.test.ts +253 -0
- package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.stories.ts +49 -0
- package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.test.ts +43 -0
- package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.stories.ts +69 -0
- package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.test.ts +155 -0
- package/src/components/molecules/Announcement/Announcement.stories.ts +224 -0
- package/src/components/molecules/Announcement/Announcement.test.ts +161 -0
- package/src/components/molecules/ContactInfoCard/ContactInfoCard.stories.ts +99 -0
- package/src/components/molecules/ContactInfoCard/ContactInfoCard.test.ts +70 -0
- package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.stories.ts +148 -0
- package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.test.ts +271 -0
- package/src/components/molecules/DoughnutChart/DoughnutChart.stories.ts +45 -0
- package/src/components/molecules/DoughnutChart/DoughnutChart.test.ts +205 -0
- package/src/components/molecules/InfoBlock/InfoBlock.stories.ts +84 -0
- package/src/components/molecules/InfoBlock/InfoBlock.test.ts +101 -0
- package/src/components/molecules/InputField/InputField.stories.ts +117 -0
- package/src/components/molecules/InputField/InputField.test.ts +138 -0
- package/src/components/molecules/InputSelect/InputSelect.stories.ts +116 -0
- package/src/components/molecules/InputSelect/InputSelect.test.ts +127 -0
- package/src/components/molecules/ModalFooter/ModalFooter.stories.ts +204 -0
- package/src/components/molecules/ModalFooter/ModalFooter.test.ts +276 -0
- package/src/components/molecules/PageCard/PageCard.stories.ts +100 -0
- package/src/components/molecules/PageCard/PageCard.test.ts +91 -0
- package/src/components/molecules/PageContainer/PageContainer.stories.ts +90 -0
- package/src/components/molecules/PageContainer/PageContainer.test.ts +50 -0
- package/src/components/molecules/PageMockup/PageMockup.stories.ts +38 -0
- package/src/components/molecules/PageMockup/PageMockup.test.ts +20 -0
- package/src/components/molecules/PageTypeTile/PageTypeTile.stories.ts +63 -0
- package/src/components/molecules/PageTypeTile/PageTypeTile.test.ts +41 -0
- package/src/components/molecules/Pagination/Pagination.stories.ts +72 -0
- package/src/components/molecules/Pagination/Pagination.test.ts +130 -0
- package/src/components/molecules/PickerGroup/PickerGroup.stories.ts +90 -0
- package/src/components/molecules/PickerGroup/PickerGroup.test.ts +145 -0
- package/src/components/molecules/PickerList/PickerList.stories.ts +436 -0
- package/src/components/molecules/PickerList/PickerList.test.ts +870 -0
- package/src/components/molecules/PlaceholderInput/PlaceholderInput.stories.ts +48 -0
- package/src/components/molecules/PlaceholderInput/PlaceholderInput.test.ts +107 -0
- package/src/components/molecules/PropertyCard/PropertyCard.stories.ts +46 -0
- package/src/components/molecules/PropertyCard/PropertyCard.test.ts +59 -0
- package/src/components/molecules/Select/Select.stories.ts +264 -0
- package/src/components/molecules/Select/Select.test.ts +315 -0
- package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.stories.ts +84 -0
- package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.test.ts +103 -0
- package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.stories.ts +11 -0
- package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.test.ts +38 -0
- package/src/components/molecules/SocialIcons/SocialIcons.stories.ts +28 -0
- package/src/components/molecules/SocialIcons/SocialIcons.test.ts +58 -0
- package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
- package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +116 -0
- package/src/components/molecules/SocialMediaType/SocialMediaType.stories.ts +37 -0
- package/src/components/molecules/SocialMediaType/SocialMediaType.test.ts +126 -0
- package/src/components/molecules/Source/Source.stories.ts +35 -0
- package/src/components/molecules/Source/Source.test.ts +45 -0
- package/src/components/molecules/TabbedContent/TabbedContent.stories.ts +96 -0
- package/src/components/molecules/TabbedContent/TabbedContent.test.ts +109 -0
- package/src/components/molecules/Tabs/Tabs.stories.ts +230 -0
- package/src/components/molecules/Tabs/Tabs.test.ts +280 -0
- package/src/components/molecules/TemplateCard/TemplateCard.stories.ts +327 -0
- package/src/components/molecules/TemplateCard/TemplateCard.test.ts +339 -0
- package/src/components/molecules/Textarea/Textarea.stories.ts +216 -0
- package/src/components/molecules/Textarea/Textarea.test.ts +191 -0
- package/src/components/molecules/TimelineEvent/TimelineEvent.stories.ts +319 -0
- package/src/components/molecules/TimelineEvent/TimelineEvent.test.ts +224 -0
- package/src/components/molecules/TimelineLanes/TimelineLanes.stories.ts +29 -0
- package/src/components/molecules/TimelineLanes/TimelineLanes.test.ts +30 -0
- package/src/components/molecules/Toast/Toast.stories.ts +110 -0
- package/src/components/molecules/Toast/Toast.test.ts +103 -0
- package/src/components/molecules/ToggleGroup/ToggleGroup.stories.ts +297 -0
- package/src/components/molecules/ToggleGroup/ToggleGroup.test.ts +578 -0
- package/src/components/molecules/Tree/Tree.stories.ts +884 -0
- package/src/components/molecules/Tree/Tree.test.ts +1247 -0
- package/src/components/molecules/Tree/tree-drop.test.ts +223 -0
- package/src/components/organisms/Calendar/Calendar.stories.ts +108 -0
- package/src/components/organisms/Calendar/Calendar.test.ts +279 -0
- package/src/components/organisms/Calendar/_CalendarDayView.test.ts +104 -0
- package/src/components/organisms/Calendar/_CalendarHeader.test.ts +86 -0
- package/src/components/organisms/Calendar/_CalendarMonthView.test.ts +52 -0
- package/src/components/organisms/Calendar/_CalendarTimePicker.test.ts +89 -0
- package/src/components/organisms/Calendar/_CalendarYearView.test.ts +56 -0
- package/src/components/organisms/ColorPicker/ColorPicker.stories.ts +229 -0
- package/src/components/organisms/ColorPicker/ColorPicker.test.ts +638 -0
- package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
- package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
- package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
- package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
- package/src/components/organisms/Combobox/Combobox.vue +1 -1
- package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
- package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
- package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
- package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
- package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
- package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
- package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
- package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
- package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
- package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
- package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
- package/src/components/organisms/Header/Header.stories.ts +75 -0
- package/src/components/organisms/Header/Header.test.ts +233 -0
- package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
- package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
- package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
- package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
- package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
- package/src/components/organisms/Modal/Modal.stories.ts +350 -0
- package/src/components/organisms/Modal/Modal.test.ts +275 -0
- package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
- package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
- package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
- package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
- package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
- package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
- package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
- package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
- package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
- package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
- package/src/components/organisms/Table/Table.stories.ts +332 -0
- package/src/components/organisms/Table/Table.test.ts +320 -0
- package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
- package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
- package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
- package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
- package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
- package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
- package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
- package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
- package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
- 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
|
+
})
|