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