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