@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.
Files changed (223) hide show
  1. package/dist/index.js +49 -126
  2. package/dist/nuxt.js +0 -1
  3. package/package.json +38 -36
  4. package/src/attribute-contract.test.ts +151 -0
  5. package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
  6. package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
  7. package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
  8. package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
  9. package/src/components/atoms/Button/Button.stories.ts +290 -0
  10. package/src/components/atoms/Button/Button.test.ts +406 -0
  11. package/src/components/atoms/Card/Card.stories.ts +45 -0
  12. package/src/components/atoms/Card/Card.test.ts +46 -0
  13. package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
  14. package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
  15. package/src/components/atoms/Chip/Chip.stories.ts +274 -0
  16. package/src/components/atoms/Chip/Chip.test.ts +230 -0
  17. package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
  18. package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
  19. package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
  20. package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
  21. package/src/components/atoms/Icon/Icon.stories.ts +63 -0
  22. package/src/components/atoms/Icon/Icon.test.ts +89 -0
  23. package/src/components/atoms/Loader/Loader.stories.ts +36 -0
  24. package/src/components/atoms/Loader/Loader.test.ts +40 -0
  25. package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
  26. package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
  27. package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
  28. package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
  29. package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
  30. package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
  31. package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
  32. package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
  33. package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
  34. package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
  35. package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
  36. package/src/components/atoms/Slider/Slider.stories.ts +151 -0
  37. package/src/components/atoms/Slider/Slider.test.ts +191 -0
  38. package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
  39. package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
  40. package/src/components/atoms/Switch/Switch.stories.ts +91 -0
  41. package/src/components/atoms/Switch/Switch.test.ts +183 -0
  42. package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
  43. package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
  44. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
  45. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
  46. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
  47. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
  48. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
  49. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
  50. package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
  51. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
  52. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
  53. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
  54. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
  55. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
  56. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
  57. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
  58. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
  59. package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
  60. package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
  61. package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
  62. package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
  63. package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +563 -0
  64. package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +689 -0
  65. package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +793 -0
  66. package/src/components/editor/molecules/EditorColor/gradientClass.test.ts +303 -0
  67. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.stories.ts +72 -0
  68. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.test.ts +124 -0
  69. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.stories.ts +46 -0
  70. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.test.ts +97 -0
  71. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.stories.ts +248 -0
  72. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.test.ts +288 -0
  73. package/src/components/editor/molecules/EditorGap/EditorGap.stories.ts +129 -0
  74. package/src/components/editor/molecules/EditorGap/EditorGap.test.ts +253 -0
  75. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.stories.ts +68 -0
  76. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.test.ts +150 -0
  77. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.stories.ts +71 -0
  78. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.test.ts +161 -0
  79. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.stories.ts +204 -0
  80. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.test.ts +238 -0
  81. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.stories.ts +257 -0
  82. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.test.ts +286 -0
  83. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.stories.ts +135 -0
  84. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.test.ts +230 -0
  85. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.stories.ts +154 -0
  86. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.test.ts +112 -0
  87. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.stories.ts +206 -0
  88. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.test.ts +332 -0
  89. package/src/components/editor/molecules/EditorPadding/EditorPadding.stories.ts +374 -0
  90. package/src/components/editor/molecules/EditorPadding/EditorPadding.test.ts +712 -0
  91. package/src/components/editor/molecules/EditorRadius/EditorRadius.stories.ts +64 -0
  92. package/src/components/editor/molecules/EditorRadius/EditorRadius.test.ts +137 -0
  93. package/src/components/editor/molecules/EditorShadow/EditorShadow.stories.ts +103 -0
  94. package/src/components/editor/molecules/EditorShadow/EditorShadow.test.ts +168 -0
  95. package/src/components/editor/molecules/EditorSize/EditorSize.stories.ts +84 -0
  96. package/src/components/editor/molecules/EditorSize/EditorSize.test.ts +253 -0
  97. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.stories.ts +49 -0
  98. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.test.ts +43 -0
  99. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.stories.ts +69 -0
  100. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.test.ts +155 -0
  101. package/src/components/molecules/Announcement/Announcement.stories.ts +224 -0
  102. package/src/components/molecules/Announcement/Announcement.test.ts +161 -0
  103. package/src/components/molecules/ContactInfoCard/ContactInfoCard.stories.ts +99 -0
  104. package/src/components/molecules/ContactInfoCard/ContactInfoCard.test.ts +70 -0
  105. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.stories.ts +148 -0
  106. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.test.ts +271 -0
  107. package/src/components/molecules/DoughnutChart/DoughnutChart.stories.ts +45 -0
  108. package/src/components/molecules/DoughnutChart/DoughnutChart.test.ts +205 -0
  109. package/src/components/molecules/InfoBlock/InfoBlock.stories.ts +84 -0
  110. package/src/components/molecules/InfoBlock/InfoBlock.test.ts +101 -0
  111. package/src/components/molecules/InputField/InputField.stories.ts +117 -0
  112. package/src/components/molecules/InputField/InputField.test.ts +138 -0
  113. package/src/components/molecules/InputSelect/InputSelect.stories.ts +116 -0
  114. package/src/components/molecules/InputSelect/InputSelect.test.ts +127 -0
  115. package/src/components/molecules/ModalFooter/ModalFooter.stories.ts +204 -0
  116. package/src/components/molecules/ModalFooter/ModalFooter.test.ts +276 -0
  117. package/src/components/molecules/PageCard/PageCard.stories.ts +100 -0
  118. package/src/components/molecules/PageCard/PageCard.test.ts +91 -0
  119. package/src/components/molecules/PageContainer/PageContainer.stories.ts +90 -0
  120. package/src/components/molecules/PageContainer/PageContainer.test.ts +50 -0
  121. package/src/components/molecules/PageMockup/PageMockup.stories.ts +38 -0
  122. package/src/components/molecules/PageMockup/PageMockup.test.ts +20 -0
  123. package/src/components/molecules/PageTypeTile/PageTypeTile.stories.ts +63 -0
  124. package/src/components/molecules/PageTypeTile/PageTypeTile.test.ts +41 -0
  125. package/src/components/molecules/Pagination/Pagination.stories.ts +72 -0
  126. package/src/components/molecules/Pagination/Pagination.test.ts +130 -0
  127. package/src/components/molecules/PickerGroup/PickerGroup.stories.ts +90 -0
  128. package/src/components/molecules/PickerGroup/PickerGroup.test.ts +145 -0
  129. package/src/components/molecules/PickerList/PickerList.stories.ts +436 -0
  130. package/src/components/molecules/PickerList/PickerList.test.ts +870 -0
  131. package/src/components/molecules/PlaceholderInput/PlaceholderInput.stories.ts +48 -0
  132. package/src/components/molecules/PlaceholderInput/PlaceholderInput.test.ts +107 -0
  133. package/src/components/molecules/PropertyCard/PropertyCard.stories.ts +46 -0
  134. package/src/components/molecules/PropertyCard/PropertyCard.test.ts +59 -0
  135. package/src/components/molecules/Select/Select.stories.ts +264 -0
  136. package/src/components/molecules/Select/Select.test.ts +315 -0
  137. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.stories.ts +84 -0
  138. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.test.ts +103 -0
  139. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.stories.ts +11 -0
  140. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.test.ts +38 -0
  141. package/src/components/molecules/SocialIcons/SocialIcons.stories.ts +28 -0
  142. package/src/components/molecules/SocialIcons/SocialIcons.test.ts +58 -0
  143. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
  144. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +116 -0
  145. package/src/components/molecules/SocialMediaType/SocialMediaType.stories.ts +37 -0
  146. package/src/components/molecules/SocialMediaType/SocialMediaType.test.ts +126 -0
  147. package/src/components/molecules/Source/Source.stories.ts +35 -0
  148. package/src/components/molecules/Source/Source.test.ts +45 -0
  149. package/src/components/molecules/TabbedContent/TabbedContent.stories.ts +96 -0
  150. package/src/components/molecules/TabbedContent/TabbedContent.test.ts +109 -0
  151. package/src/components/molecules/Tabs/Tabs.stories.ts +230 -0
  152. package/src/components/molecules/Tabs/Tabs.test.ts +280 -0
  153. package/src/components/molecules/TemplateCard/TemplateCard.stories.ts +327 -0
  154. package/src/components/molecules/TemplateCard/TemplateCard.test.ts +339 -0
  155. package/src/components/molecules/Textarea/Textarea.stories.ts +216 -0
  156. package/src/components/molecules/Textarea/Textarea.test.ts +191 -0
  157. package/src/components/molecules/TimelineEvent/TimelineEvent.stories.ts +319 -0
  158. package/src/components/molecules/TimelineEvent/TimelineEvent.test.ts +224 -0
  159. package/src/components/molecules/TimelineLanes/TimelineLanes.stories.ts +29 -0
  160. package/src/components/molecules/TimelineLanes/TimelineLanes.test.ts +30 -0
  161. package/src/components/molecules/Toast/Toast.stories.ts +110 -0
  162. package/src/components/molecules/Toast/Toast.test.ts +103 -0
  163. package/src/components/molecules/ToggleGroup/ToggleGroup.stories.ts +297 -0
  164. package/src/components/molecules/ToggleGroup/ToggleGroup.test.ts +578 -0
  165. package/src/components/molecules/Tree/Tree.stories.ts +884 -0
  166. package/src/components/molecules/Tree/Tree.test.ts +1247 -0
  167. package/src/components/molecules/Tree/tree-drop.test.ts +223 -0
  168. package/src/components/organisms/Calendar/Calendar.stories.ts +108 -0
  169. package/src/components/organisms/Calendar/Calendar.test.ts +279 -0
  170. package/src/components/organisms/Calendar/_CalendarDayView.test.ts +104 -0
  171. package/src/components/organisms/Calendar/_CalendarHeader.test.ts +86 -0
  172. package/src/components/organisms/Calendar/_CalendarMonthView.test.ts +52 -0
  173. package/src/components/organisms/Calendar/_CalendarTimePicker.test.ts +89 -0
  174. package/src/components/organisms/Calendar/_CalendarYearView.test.ts +56 -0
  175. package/src/components/organisms/ColorPicker/ColorPicker.stories.ts +229 -0
  176. package/src/components/organisms/ColorPicker/ColorPicker.test.ts +638 -0
  177. package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
  178. package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
  179. package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
  180. package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
  181. package/src/components/organisms/Combobox/Combobox.vue +1 -1
  182. package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
  183. package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
  184. package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
  185. package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
  186. package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
  187. package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
  188. package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
  189. package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
  190. package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
  191. package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
  192. package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
  193. package/src/components/organisms/Header/Header.stories.ts +75 -0
  194. package/src/components/organisms/Header/Header.test.ts +233 -0
  195. package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
  196. package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
  197. package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
  198. package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
  199. package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
  200. package/src/components/organisms/Modal/Modal.stories.ts +350 -0
  201. package/src/components/organisms/Modal/Modal.test.ts +275 -0
  202. package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
  203. package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
  204. package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
  205. package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
  206. package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
  207. package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
  208. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
  209. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  210. package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
  211. package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
  212. package/src/components/organisms/Table/Table.stories.ts +332 -0
  213. package/src/components/organisms/Table/Table.test.ts +320 -0
  214. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
  215. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
  216. package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
  217. package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
  218. package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
  219. package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
  220. package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
  221. package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
  222. package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
  223. package/src/components/organisms/VideoPlayer/VideoPlayer.test.ts +439 -0
@@ -0,0 +1,793 @@
1
+ import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'
2
+ import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
3
+ import { nextTick, ref } from 'vue'
4
+ import NexxtEditorColor from './EditorColor.vue'
5
+ import type { EditorColorValue, NexxtEditorColorProps } from './EditorColor.vue'
6
+
7
+ beforeAll(() => {
8
+ global.ResizeObserver = class {
9
+ observe() {}
10
+ unobserve() {}
11
+ disconnect() {}
12
+ }
13
+ Object.defineProperty(document, 'fonts', {
14
+ value: { ready: Promise.resolve() },
15
+ configurable: true,
16
+ })
17
+ })
18
+
19
+ vi.mock('@floating-ui/vue', async (importOriginal) => {
20
+ const actual = await importOriginal<typeof import('@floating-ui/vue')>()
21
+ return {
22
+ ...actual,
23
+ useFloating: () => ({
24
+ floatingStyles: ref({}),
25
+ placement: ref('bottom-start'),
26
+ isPositioned: ref(true),
27
+ }),
28
+ autoUpdate: () => () => {},
29
+ }
30
+ })
31
+
32
+ const THEME_ROLES = [
33
+ { role: 'main' as const, label: 'Hoofdkleur', hex: '#1777ea' },
34
+ { role: 'text' as const, label: 'Tekst', hex: '#111111' },
35
+ ]
36
+
37
+ let wrapper: VueWrapper | undefined
38
+
39
+ afterEach(() => {
40
+ wrapper?.unmount()
41
+ wrapper = undefined
42
+ document.body.innerHTML = ''
43
+ })
44
+
45
+ /** Mounts with a working v-model and opens the panel. */
46
+ const setup = async (
47
+ modelValue: string,
48
+ props: Partial<NexxtEditorColorProps> = {},
49
+ ): Promise<{ w: VueWrapper; values: string[] }> => {
50
+ const values: string[] = []
51
+ const w = mount(NexxtEditorColor, {
52
+ attachTo: document.body,
53
+ props: {
54
+ modes: ['solid', 'gradient'],
55
+ themeRoles: THEME_ROLES,
56
+ ...props,
57
+ modelValue: modelValue as EditorColorValue,
58
+ 'onUpdate:modelValue': async (value: EditorColorValue) => {
59
+ values.push(value)
60
+ await w.setProps({ modelValue: value })
61
+ },
62
+ },
63
+ })
64
+ wrapper = w
65
+ await w.find('button[aria-haspopup]').trigger('click')
66
+ await flushPromises()
67
+ await nextTick()
68
+ return { w, values }
69
+ }
70
+
71
+ const settle = async () => {
72
+ await flushPromises()
73
+ await nextTick()
74
+ }
75
+
76
+ const button = (w: VueWrapper, text: string) => {
77
+ const found = w.findAll('button').find((candidate) => candidate.text() === text)
78
+ if (!found) throw new Error(`No button "${text}"`)
79
+ return found
80
+ }
81
+
82
+ const stopHandles = (w: VueWrapper) => w.findAll('[data-gradient-bar] [role="slider"]')
83
+
84
+ const key = async (w: VueWrapper, selector: string, keyName: string, init = {}) => {
85
+ await w.find(selector).trigger('keydown', { key: keyName, ...init })
86
+ await settle()
87
+ }
88
+
89
+ const rows = (w: VueWrapper) => w.findAll('[data-gradient-row]')
90
+
91
+ /** jsdom has no PointerEvent; a MouseEvent carries the same coordinates. */
92
+ const pointer = (target: Element, type: string, clientX: number, clientY: number) =>
93
+ target.dispatchEvent(new MouseEvent(type, { clientX, clientY, button: 0, bubbles: true }))
94
+
95
+ /** Gives the preview square a layout: 176px at the origin, as in the editor column. */
96
+ const mockSquare = (w: VueWrapper) =>
97
+ vi
98
+ .spyOn(w.find('[data-gradient-preview]').element, 'getBoundingClientRect')
99
+ .mockReturnValue(new DOMRect(0, 0, 176, 176))
100
+
101
+ const openPopover = async (w: VueWrapper, index: number) => {
102
+ const swatch = rows(w)[index]?.find('button[aria-label="Kleur kiezen"]')
103
+ await swatch?.trigger('pointerdown')
104
+ await swatch?.trigger('click')
105
+ await settle()
106
+ }
107
+
108
+ describe('NexxtEditorColor gradient', () => {
109
+ describe('opt-in', () => {
110
+ it('has no mode switch without gradient', async () => {
111
+ const { w } = await setup('bg-[#7f56d9]', { modes: ['solid'] })
112
+ expect(w.find('[role="toolbar"]').exists()).toBe(false)
113
+ })
114
+
115
+ it('ignores gradient for a text colour', async () => {
116
+ const { w } = await setup('text-[#7f56d9]', { property: 'text' })
117
+ expect(w.find('[role="toolbar"]').exists()).toBe(false)
118
+ })
119
+
120
+ it('shows the mode switch with gradient', async () => {
121
+ const { w } = await setup('bg-[#7f56d9]')
122
+ expect(w.find('[role="toolbar"]').attributes('aria-labelledby')).toBeTruthy()
123
+ expect(w.text()).toContain('Effen')
124
+ expect(w.text()).toContain('Verloop')
125
+ })
126
+ })
127
+
128
+ describe('describedby', () => {
129
+ it('sets no attribute without the prop', async () => {
130
+ const { w } = await setup('bg-[#7f56d9]', { modes: ['solid'], switchable: true })
131
+ expect(w.find('[aria-describedby]').exists()).toBe(false)
132
+ })
133
+
134
+ it('describes the trigger', async () => {
135
+ const { w } = await setup('bg-[#7f56d9]', { describedby: 'hint' })
136
+ expect(w.find('button[aria-haspopup]').attributes('aria-describedby')).toBe('hint')
137
+ })
138
+
139
+ it('describes the switch as well when switchable', async () => {
140
+ const { w } = await setup('bg-[#7f56d9]', { describedby: 'hint', switchable: true })
141
+ expect(w.find('button[aria-haspopup]').attributes('aria-describedby')).toBe('hint')
142
+ expect(w.find('[role="switch"]').attributes('aria-describedby')).toBe('hint')
143
+ })
144
+ })
145
+
146
+ describe('gradient only', () => {
147
+ it('has no Effen | Verloop switch and opens on the gradient', async () => {
148
+ const { w } = await setup('', { modes: ['gradient'] })
149
+ expect(w.findAll('button').some((b) => b.text() === 'Effen')).toBe(false)
150
+ expect(w.find('[data-gradient-bar]').exists()).toBe(true)
151
+ })
152
+
153
+ it('opens a solid colour as its fade, without writing until the first change', async () => {
154
+ const { w, values } = await setup('bg-[#7f56d9]', { modes: ['gradient'] })
155
+ expect(values).toEqual([])
156
+ // The trigger shows the stored colour, as the solid trigger does.
157
+ const trigger = w.find('button[aria-haspopup]')
158
+ expect(trigger.text()).toContain('#7f56d9')
159
+ expect(trigger.find('[aria-label="Geselecteerde kleur: #7f56d9"]').exists()).toBe(true)
160
+ expect(rows(w).map((row) => row.find('input[aria-label="Hex"]').element)).toEqual([
161
+ expect.objectContaining({ value: '#7f56d9' }),
162
+ expect.objectContaining({ value: '#7f56d900' }),
163
+ ])
164
+ expect(w.find('[data-angle-end]').attributes('aria-valuenow')).toBe('180')
165
+ await key(w, '[data-gradient-bar] [role="slider"]', 'ArrowRight')
166
+ expect(values).toEqual(['bg-linear-180 from-[#7f56d9] from-1% to-[#7f56d900]'])
167
+ })
168
+
169
+ it('opens a role as its fade, and an empty value on the default', async () => {
170
+ const { w, values } = await setup('bg-theme-main', { modes: ['gradient'] })
171
+ expect(w.find('button[aria-haspopup]').text()).toContain('Hoofdkleur')
172
+ await w.find('button[aria-label="Kleurstops omdraaien"]').trigger('click')
173
+ await settle()
174
+ expect(values).toEqual(['bg-linear-180 from-[#1777ea00] to-theme-main'])
175
+
176
+ const { w: empty, values: none } = await setup('', { modes: ['gradient'] })
177
+ expect(none).toEqual([])
178
+ expect(empty.find('button[aria-haspopup]').text()).toContain('Kies kleur...')
179
+ expect(empty.find('[aria-label="Geen kleur geselecteerd"]').exists()).toBe(true)
180
+ await key(empty, '[data-angle-end]', 'ArrowUp')
181
+ expect(none).toEqual(['bg-linear-181 from-[#00000000] to-[#00000099]'])
182
+ })
183
+
184
+ it('reads a stored gradient as usual', async () => {
185
+ const { w } = await setup('bg-radial from-[#000000] to-[#ffffff]', { modes: ['gradient'] })
186
+ expect(w.find('button[aria-haspopup]').text()).toContain('Radiaal verloop')
187
+ expect(w.find('[data-gradient-center]').exists()).toBe(true)
188
+ })
189
+
190
+ it('falls back to the solid colour for another property', async () => {
191
+ const { w } = await setup('text-[#7f56d9]', { modes: ['gradient'], property: 'text' })
192
+ expect(w.find('[data-gradient-bar]').exists()).toBe(false)
193
+ expect(w.find('input[placeholder="#000000"]').exists()).toBe(true)
194
+ })
195
+ })
196
+
197
+ describe('switching modes', () => {
198
+ it('turns a hex into a fade to the same hex at 00', async () => {
199
+ const { w, values } = await setup('bg-[#7f56d9]')
200
+ await button(w, 'Verloop').trigger('click')
201
+ await settle()
202
+ expect(values.at(-1)).toBe('bg-linear-180 from-[#7f56d9] to-[#7f56d900]')
203
+ })
204
+
205
+ it('turns a role into a fade from the role to its hex at 00', async () => {
206
+ const { w, values } = await setup('bg-theme-main')
207
+ await button(w, 'Verloop').trigger('click')
208
+ await settle()
209
+ expect(values.at(-1)).toBe('bg-linear-180 from-theme-main to-[#1777ea00]')
210
+ })
211
+
212
+ it('writes the default fade without a colour', async () => {
213
+ const { w, values } = await setup('')
214
+ await button(w, 'Verloop').trigger('click')
215
+ await settle()
216
+ expect(values.at(-1)).toBe('bg-linear-180 from-[#00000000] to-[#00000099]')
217
+ })
218
+
219
+ it('turns the selected stop into the solid colour, and back within the session', async () => {
220
+ const { w, values } = await setup('bg-linear-90 from-theme-main to-[#432e7380]', {
221
+ showAlpha: true,
222
+ })
223
+ await stopHandles(w)[1]?.trigger('focus')
224
+ await button(w, 'Effen').trigger('click')
225
+ await settle()
226
+ expect(values.at(-1)).toBe('bg-[#432e7380]')
227
+
228
+ await button(w, 'Verloop').trigger('click')
229
+ await settle()
230
+ expect(values.at(-1)).toBe('bg-linear-90 from-theme-main to-[#432e7380]')
231
+ })
232
+
233
+ it('drops the alpha of the stop without showAlpha', async () => {
234
+ const { w, values } = await setup('bg-linear-90 from-theme-main to-[#432e7380]')
235
+ await stopHandles(w)[1]?.trigger('focus')
236
+ await button(w, 'Effen').trigger('click')
237
+ await settle()
238
+ expect(values.at(-1)).toBe('bg-[#432e73]')
239
+ })
240
+
241
+ it('uses the clamped selection after a change from outside with fewer stops', async () => {
242
+ const { w, values } = await setup('bg-linear-90 from-[#000000] via-[#888888] to-[#ffffff]')
243
+ await stopHandles(w)[2]?.trigger('focus')
244
+ await settle()
245
+ // An undo in the consumer: back to two stops while the third was selected.
246
+ await w.setProps({ modelValue: 'bg-linear-90 from-[#111111] to-[#222222]' })
247
+ await settle()
248
+ expect(stopHandles(w)[1]?.attributes('data-selected')).toBeDefined()
249
+ await button(w, 'Effen').trigger('click')
250
+ await settle()
251
+ expect(values.at(-1)).toBe('bg-[#222222]')
252
+ })
253
+
254
+ it('writes nothing when switching away from a gradient it cannot show', async () => {
255
+ const { w, values } = await setup('bg-linear-90 from-theme-accent to-[#432e73]')
256
+ await button(w, 'Effen').trigger('click')
257
+ await settle()
258
+ expect(values).toEqual([])
259
+ })
260
+
261
+ it('turns a gradient into the solid colour of its selected stop when property leaves bg', async () => {
262
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#432e7380]', {
263
+ showAlpha: true,
264
+ })
265
+ await stopHandles(w)[1]?.trigger('focus')
266
+ await settle()
267
+ await w.setProps({ property: 'text' })
268
+ await settle()
269
+ expect(values.at(-1)).toBe('text-[#432e7380]')
270
+ expect(w.find('[role="toolbar"]').exists()).toBe(false)
271
+ })
272
+
273
+ it('drops the alpha on that rewrite without showAlpha, and keeps a role', async () => {
274
+ const { w, values } = await setup('bg-linear-90 from-[#00000080] to-[#ffffff]')
275
+ await w.setProps({ property: 'text' })
276
+ await settle()
277
+ expect(values.at(-1)).toBe('text-[#000000]')
278
+
279
+ const { w: roleField, values: roleValues } = await setup(
280
+ 'bg-linear-90 from-theme-main to-[#ffffff]',
281
+ )
282
+ await roleField.setProps({ property: 'text' })
283
+ await settle()
284
+ expect(roleValues.at(-1)).toBe('text-theme-main')
285
+ })
286
+
287
+ it('keeps the role of the selected stop', async () => {
288
+ const { w, values } = await setup('bg-linear-90 from-theme-main to-[#432e73]')
289
+ await button(w, 'Effen').trigger('click')
290
+ await settle()
291
+ expect(values.at(-1)).toBe('bg-theme-main')
292
+ })
293
+
294
+ it('shows the gradient type in the trigger', async () => {
295
+ const { w } = await setup('bg-radial from-[#000000] to-[#ffffff]')
296
+ const trigger = w.find('button[aria-haspopup]')
297
+ expect(trigger.text()).toContain('Radiaal verloop')
298
+ expect(trigger.find('[role="img"]').attributes('aria-label')).toBe(
299
+ 'Geselecteerd verloop: radiaal, 2 kleuren',
300
+ )
301
+ })
302
+
303
+ it('shows the placeholder for an unreadable gradient and writes nothing on open', async () => {
304
+ const { w, values } = await setup('bg-linear-90 from-theme-brand to-[#ffffff]')
305
+ const trigger = w.find('button[aria-haspopup]')
306
+ expect(trigger.text()).toContain('Kies kleur...')
307
+ const swatch = trigger.find('[role="img"]')
308
+ expect(swatch.attributes('aria-label')).toBe('Geen kleur geselecteerd')
309
+ expect(swatch.attributes('style') ?? '').not.toContain('background-image')
310
+ expect(stopHandles(w)).toHaveLength(2)
311
+ expect(values).toEqual([])
312
+ })
313
+ })
314
+
315
+ describe('stop handles', () => {
316
+ it('are sliders with a name and a value text', async () => {
317
+ const { w } = await setup('bg-linear-90 from-theme-main to-[#432e73cc] to-80%')
318
+ const [first, second] = stopHandles(w)
319
+ expect(first?.attributes()).toMatchObject({
320
+ role: 'slider',
321
+ 'aria-label': 'Kleurstop 1 van 2',
322
+ 'aria-valuenow': '0',
323
+ 'aria-valuetext': '0 %, Hoofdkleur',
324
+ })
325
+ expect(second?.attributes('aria-valuetext')).toBe('80 %, #432e73, dekking 80 %')
326
+ })
327
+
328
+ it('move with the arrows, Shift, Home and End', async () => {
329
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
330
+ const selector = '[data-gradient-bar] [role="slider"]'
331
+ await key(w, selector, 'ArrowRight')
332
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#000000] from-1% to-[#ffffff]')
333
+ await key(w, selector, 'ArrowUp', { shiftKey: true })
334
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#000000] from-11% to-[#ffffff]')
335
+ await key(w, selector, 'Home')
336
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#000000] to-[#ffffff]')
337
+ })
338
+
339
+ it('keep colour, selection and focus when one passes another', async () => {
340
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff] to-60%')
341
+ const first = stopHandles(w)[0]
342
+ ;(first?.element as HTMLElement).focus()
343
+ await first?.trigger('focus')
344
+ await first?.trigger('keydown', { key: 'End' })
345
+ await settle()
346
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#ffffff] from-60% to-[#000000]')
347
+ const handles = stopHandles(w)
348
+ expect(handles[1]?.attributes('aria-valuetext')).toBe('100 %, #000000')
349
+ expect(document.activeElement).toBe(handles[1]?.element)
350
+ expect(handles[1]?.attributes('data-selected')).toBeDefined()
351
+ })
352
+ })
353
+
354
+ describe('adding and removing', () => {
355
+ it('adds a stop halfway in the colour already there, and focuses its row', async () => {
356
+ const { w, values } = await setup('bg-linear-90 from-[#00000000] to-[#ff0000]')
357
+ await w.find('[data-gradient-add]').trigger('click')
358
+ await settle()
359
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#00000000] via-[#ff000080] to-[#ff0000]')
360
+ expect(document.activeElement?.getAttribute('aria-label')).toBe('Positie (%)')
361
+ expect(document.activeElement?.closest('[role="group"]')?.getAttribute('aria-label')).toBe(
362
+ 'Kleurstop 2 van 3',
363
+ )
364
+ })
365
+
366
+ it('gives a new stop the role when both ends share it', async () => {
367
+ const { w, values } = await setup('bg-linear-90 from-theme-main to-theme-main to-80%')
368
+ await w.find('[data-gradient-add]').trigger('click')
369
+ await settle()
370
+ expect(values.at(-1)).toBe(
371
+ 'bg-linear-90 from-theme-main via-theme-main via-40% to-theme-main to-80%',
372
+ )
373
+ })
374
+
375
+ it('adds a stop where the bar is clicked, and focuses its handle', async () => {
376
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
377
+ const bar = w.find('[data-gradient-bar]')
378
+ vi.spyOn(bar.element, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, 200, 20))
379
+ pointer(bar.element, 'pointerdown', 150, 10)
380
+ await settle()
381
+ expect(values.at(-1)).toMatch(
382
+ /^bg-linear-90 from-\[#000000\] via-\[#[0-9a-f]{6}\] via-25% to-\[#ffffff\]$/,
383
+ )
384
+ expect(document.activeElement?.getAttribute('aria-valuenow')).toBe('25')
385
+ })
386
+
387
+ it('marks add as unavailable at three stops, with the reason', async () => {
388
+ const { w, values } = await setup('bg-linear-90 from-[#000000] via-[#888888] to-[#ffffff]')
389
+ const add = w.find('[data-gradient-add]')
390
+ expect(add.attributes('aria-disabled')).toBe('true')
391
+ expect(add.attributes('aria-label')).toBe('Kleurstop toevoegen')
392
+ expect(add.text()).toBe('Toevoegen')
393
+ const reason = document.getElementById(add.attributes('aria-describedby') ?? '')
394
+ expect(reason?.textContent).toBe('Een verloop heeft maximaal 3 kleurstops.')
395
+ expect(w.text()).toContain('max. 3')
396
+ await add.trigger('click')
397
+ await settle()
398
+ expect(values).toEqual([])
399
+ })
400
+
401
+ it('removes with Delete on a handle, focusing the neighbour above', async () => {
402
+ const { w, values } = await setup('bg-linear-90 from-[#000000] via-[#888888] to-[#ffffff]')
403
+ const middle = stopHandles(w)[1]
404
+ await middle?.trigger('focus')
405
+ await middle?.trigger('keydown', { key: 'Delete' })
406
+ await settle()
407
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#000000] to-[#ffffff]')
408
+ expect(document.activeElement?.getAttribute('aria-label')).toBe('Positie (%)')
409
+ expect((document.activeElement as HTMLInputElement).value).toBe('0')
410
+
411
+ const removes = w.findAll('button[aria-label="Verwijderen"]')
412
+ expect(removes).toHaveLength(2)
413
+ removes.forEach((remove) => expect(remove.attributes('disabled')).toBeDefined())
414
+ await stopHandles(w)[0]?.trigger('keydown', { key: 'Backspace' })
415
+ await settle()
416
+ expect(values).toHaveLength(1)
417
+ })
418
+
419
+ it('removes the first stop from its row, keeping the others in place', async () => {
420
+ const { w, values } = await setup(
421
+ 'bg-linear-90 from-[#000000] via-[#888888] via-30% to-[#ffffff]',
422
+ )
423
+ await rows(w)[0]?.find('button[aria-label="Verwijderen"]').trigger('click')
424
+ await settle()
425
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#888888] from-30% to-[#ffffff]')
426
+ expect((document.activeElement as HTMLInputElement).value).toBe('30')
427
+ })
428
+ })
429
+
430
+ it('reverses positions and order, and writes nothing for a mirror image', async () => {
431
+ const { w, values } = await setup('bg-linear-45 from-[#000000] from-20% to-[#ffffff]')
432
+ const reverse = w.find('button[aria-label="Kleurstops omdraaien"]')
433
+ await reverse.trigger('click')
434
+ await settle()
435
+ expect(values.at(-1)).toBe('bg-linear-45 from-[#ffffff] to-[#000000] to-80%')
436
+
437
+ const { w: symmetric, values: none } = await setup(
438
+ 'bg-linear-45 from-[#000000] via-[#ffffff] to-[#000000]',
439
+ )
440
+ await symmetric.find('button[aria-label="Kleurstops omdraaien"]').trigger('click')
441
+ await settle()
442
+ expect(none).toEqual([])
443
+ })
444
+
445
+ describe('stop list', () => {
446
+ it('has a group per stop with position, colour, hex, opacity and remove', async () => {
447
+ const { w } = await setup('bg-linear-90 from-theme-main to-[#432e73cc] to-80%')
448
+ const [first, second] = rows(w)
449
+ expect(first?.attributes('aria-label')).toBe('Kleurstop 1 van 2')
450
+ expect(first?.find('input[aria-label="Hex"]').element).toHaveProperty('value', 'Hoofdkleur')
451
+ expect(first?.find('input[aria-label="Hex"]').attributes('readonly')).toBeDefined()
452
+ const names = second?.findAll('input, button').map((el) => el.attributes('aria-label'))
453
+ expect(names).toEqual(['Positie (%)', 'Kleur kiezen', 'Hex', 'Verwijderen'])
454
+ // The opacity shows in the hex, eight digits below 100 %, as in the picker.
455
+ expect(second?.find('input[aria-label="Hex"]').element).toHaveProperty('value', '#432e73cc')
456
+ })
457
+
458
+ it('moves a stop with its position field, keeping the focus through a re-sort', async () => {
459
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff] to-60%')
460
+ const input = rows(w)[0]?.find('input[aria-label="Positie (%)"]')
461
+ ;(input?.element as HTMLInputElement).focus()
462
+ await input?.setValue('80')
463
+ await input?.trigger('keydown', { key: 'Enter' })
464
+ await settle()
465
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#ffffff] from-60% to-[#000000] to-80%')
466
+ expect(document.activeElement).toBe(input?.element)
467
+ expect(rows(w)[1]?.find('input[aria-label="Positie (%)"]').element).toBe(input?.element)
468
+ })
469
+
470
+ it('keeps typing in a field when the value changes from outside', async () => {
471
+ const { w } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
472
+ const hex = rows(w)[0]?.find('input[aria-label="Hex"]')
473
+ ;(hex?.element as HTMLInputElement).focus()
474
+ await hex?.setValue('#12')
475
+ await w.setProps({ modelValue: 'bg-linear-90 from-[#ff0000] to-[#ffffff]' })
476
+ await settle()
477
+ expect(hex?.element).toHaveProperty('value', '#12')
478
+
479
+ // An unfocused field follows the new value.
480
+ const other = rows(w)[1]?.find('input[aria-label="Hex"]')
481
+ await w.setProps({ modelValue: 'bg-linear-90 from-[#ff0000] to-[#00ff00]' })
482
+ await settle()
483
+ expect(other?.element).toHaveProperty('value', '#00ff00')
484
+ })
485
+
486
+ it('reverts a position above 100 and Escape without writing', async () => {
487
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
488
+ const input = rows(w)[0]?.find('input[aria-label="Positie (%)"]')
489
+ await input?.setValue('140')
490
+ await input?.trigger('blur')
491
+ expect(values).toEqual([])
492
+ expect(input?.element).toHaveProperty('value', '0')
493
+ await input?.setValue('45')
494
+ await input?.trigger('keydown', { key: 'Escape' })
495
+ expect(input?.element).toHaveProperty('value', '0')
496
+ })
497
+
498
+ it('applies a six- or eight-digit hex; an invalid hex is marked, then reverted', async () => {
499
+ const { w, values } = await setup('bg-linear-90 from-[#00000080] to-[#ffffff]')
500
+ const row = rows(w)[0]
501
+ const hex = row?.find('input[aria-label="Hex"]')
502
+ expect(hex?.element).toHaveProperty('value', '#00000080')
503
+
504
+ await hex?.setValue('FF000040')
505
+ await hex?.trigger('keydown', { key: 'Enter' })
506
+ await settle()
507
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#ff000040] to-[#ffffff]')
508
+
509
+ // Six digits is fully opaque, and so is an explicit ff pair.
510
+ await hex?.setValue('#00ff00')
511
+ await hex?.trigger('blur')
512
+ await settle()
513
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#00ff00] to-[#ffffff]')
514
+ await hex?.setValue('#0000ffFF')
515
+ await hex?.trigger('blur')
516
+ await settle()
517
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#0000ff] to-[#ffffff]')
518
+
519
+ await hex?.setValue('#zzz')
520
+ await hex?.trigger('keydown', { key: 'Enter' })
521
+ expect(row?.find('.ring-brick-400').exists()).toBe(true)
522
+ await hex?.trigger('blur')
523
+ expect(hex?.element).toHaveProperty('value', '#0000ff')
524
+ expect(row?.find('.ring-brick-400').exists()).toBe(false)
525
+ })
526
+
527
+ it('has no opacity field and no hint in the panel itself', async () => {
528
+ const { w } = await setup('bg-linear-90 from-theme-main to-[#ffffff]')
529
+ expect(w.find('input[aria-label="Dekking (%)"]').exists()).toBe(false)
530
+ expect(w.text()).not.toContain('Huisstijlkleuren zijn altijd dekkend.')
531
+ })
532
+ })
533
+
534
+ describe('colour popover', () => {
535
+ it('opens the colour editor for the stop and closes alone on Escape', async () => {
536
+ const { w } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
537
+ const swatch = rows(w)[1]?.find('button[aria-label="Kleur kiezen"]')
538
+ expect(swatch?.attributes('aria-haspopup')).toBe('dialog')
539
+ await swatch?.trigger('pointerdown')
540
+ await swatch?.trigger('click')
541
+ await settle()
542
+ expect(swatch?.attributes('aria-expanded')).toBe('true')
543
+ const dialog = w.find('[role="dialog"]')
544
+ expect(dialog.attributes('aria-label')).toBe('Kleur van kleurstop 2')
545
+ expect(dialog.find('input[placeholder="#000000"]').attributes('aria-label')).toBe('Hex')
546
+ expect(dialog.text()).toContain('Dekking')
547
+ expect(dialog.text()).not.toContain('HEX')
548
+
549
+ await dialog.trigger('keydown', { key: 'Escape' })
550
+ await settle()
551
+ expect(w.find('[role="dialog"]').exists()).toBe(false)
552
+ expect(document.activeElement).toBe(swatch?.element)
553
+ expect(w.find('[data-gradient-bar]').exists()).toBe(true)
554
+ })
555
+
556
+ it('binds the stop to a role from the theme swatches in the popover', async () => {
557
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
558
+ await openPopover(w, 0)
559
+ await w.find('[role="dialog"] button[aria-label="Tekst"]').trigger('click')
560
+ await settle()
561
+ expect(values.at(-1)).toBe('bg-linear-90 from-theme-text to-[#ffffff]')
562
+ expect(w.find('[role="dialog"] button[aria-label="Tekst"]').attributes('aria-pressed')).toBe(
563
+ 'true',
564
+ )
565
+ const thumb = w.find('[role="dialog"] [aria-disabled="true"][role="slider"]')
566
+ expect(thumb.attributes('aria-valuenow')).toBe('100')
567
+ expect(
568
+ document.getElementById(thumb.attributes('aria-describedby') ?? '')?.textContent?.trim(),
569
+ ).toBe('Huisstijlkleuren zijn altijd dekkend.')
570
+ })
571
+
572
+ it('shows the role hint next to the locked opacity of a role stop only', async () => {
573
+ const { w } = await setup('bg-linear-90 from-theme-main to-[#ffffff]')
574
+ await openPopover(w, 0)
575
+ const dialog = w.find('[role="dialog"]')
576
+ const thumb = dialog.find('[aria-disabled="true"][role="slider"]')
577
+ const hint = dialog.find(`#${thumb.attributes('aria-describedby')}`)
578
+ expect(hint.text()).toBe('Huisstijlkleuren zijn altijd dekkend.')
579
+
580
+ await dialog.trigger('keydown', { key: 'Escape' })
581
+ await settle()
582
+ await openPopover(w, 1)
583
+ expect(w.find('[role="dialog"]').text()).not.toContain('Huisstijlkleuren')
584
+ expect(w.find('[role="dialog"] [aria-disabled="true"][role="slider"]').exists()).toBe(false)
585
+ })
586
+
587
+ it('offers the neighbouring stop first among the suggestions', async () => {
588
+ const { w } = await setup('bg-linear-90 from-[#111111] to-[#222222]', {
589
+ suggestions: ['#ff0000'],
590
+ })
591
+ await openPopover(w, 0)
592
+ const labels = w
593
+ .findAll('[role="dialog"] button[aria-label^="Kies kleur #"]')
594
+ .map((b) => b.attributes('aria-label'))
595
+ expect(labels).toEqual(['Kies kleur #222222', 'Kies kleur #ff0000'])
596
+ })
597
+ })
598
+
599
+ describe('angle', () => {
600
+ it('turns with the keyboard on the angle point, keeping the positions', async () => {
601
+ const { w, values } = await setup('bg-linear-355 from-[#000000] to-[#ffffff] to-80%')
602
+ const handle = '[data-angle-end]'
603
+ expect(w.find(handle).attributes('aria-valuetext')).toBe('355 graden')
604
+ await key(w, handle, 'ArrowUp', { shiftKey: true })
605
+ expect(values.at(-1)).toBe('bg-linear-10 from-[#000000] to-[#ffffff] to-80%')
606
+ await key(w, handle, 'ArrowLeft')
607
+ expect(values.at(-1)).toMatch(/^bg-linear-9 /)
608
+ await key(w, handle, 'Home')
609
+ expect(values.at(-1)).toMatch(/^bg-linear-0 /)
610
+ })
611
+
612
+ it('hides the other points from the keyboard', async () => {
613
+ const { w } = await setup('bg-linear-90 from-[#000000] via-[#888888] to-[#ffffff]')
614
+ const points = w.findAll('[data-stop-point]')
615
+ expect(points).toHaveLength(3)
616
+ expect(points.filter((p) => p.attributes('role') === 'slider')).toHaveLength(1)
617
+ points
618
+ .filter((p) => p.attributes('role') !== 'slider')
619
+ .forEach((p) => {
620
+ expect(p.attributes('aria-hidden')).toBe('true')
621
+ expect(p.attributes('tabindex')).toBe('-1')
622
+ })
623
+ })
624
+
625
+ it('sets the angle and both positions by dragging the to-point', async () => {
626
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
627
+ mockSquare(w)
628
+ const to = w.find('[data-angle-end]').element
629
+ pointer(to, 'pointerdown', 176, 88)
630
+ pointer(to, 'pointermove', 88, 0)
631
+ pointer(to, 'pointerup', 88, 0)
632
+ await settle()
633
+ expect(values.at(-1)).toBe('bg-linear-45 from-[#000000] from-25% to-[#ffffff] to-75%')
634
+ })
635
+
636
+ it('clamps a drag beyond the square onto its edge', async () => {
637
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
638
+ mockSquare(w)
639
+ const to = w.find('[data-angle-end]').element
640
+ pointer(to, 'pointerdown', 176, 88)
641
+ // far beyond the right edge: the point lands on (176, 88)
642
+ pointer(to, 'pointermove', 400, 88)
643
+ pointer(to, 'pointerup', 400, 88)
644
+ await settle()
645
+ expect(values.at(-1) ?? 'bg-linear-90 from-[#000000] to-[#ffffff]').toBe(
646
+ 'bg-linear-90 from-[#000000] to-[#ffffff]',
647
+ )
648
+ // and up past the top-right corner: the point lands on the corner (176, 0)
649
+ pointer(to, 'pointerdown', 176, 88)
650
+ pointer(to, 'pointermove', 500, -300)
651
+ pointer(to, 'pointerup', 500, -300)
652
+ await settle()
653
+ expect(values.at(-1)).toBe('bg-linear-63 from-[#000000] from-17% to-[#ffffff]')
654
+ w.findAll('[data-stop-point]').forEach((point) => {
655
+ const style = (point.element as HTMLElement).style
656
+ expect(parseFloat(style.left)).toBeGreaterThanOrEqual(0)
657
+ expect(parseFloat(style.left)).toBeLessThanOrEqual(100)
658
+ expect(parseFloat(style.top)).toBeGreaterThanOrEqual(0)
659
+ expect(parseFloat(style.top)).toBeLessThanOrEqual(100)
660
+ })
661
+ })
662
+
663
+ it('draws the handles of 0 % and 100 % on the edge at 22°', async () => {
664
+ const { w } = await setup('bg-linear-22 from-[#000000] to-[#ffffff]')
665
+ const [from, to] = w.findAll('[data-stop-point]').map((point) => {
666
+ const style = (point.element as HTMLElement).style
667
+ return { x: parseFloat(style.left), y: parseFloat(style.top) }
668
+ })
669
+ expect(to?.y).toBeCloseTo(0)
670
+ expect(from?.y).toBeCloseTo(100)
671
+ for (const point of [from, to]) {
672
+ expect(point?.x).toBeGreaterThanOrEqual(0)
673
+ expect(point?.x).toBeLessThanOrEqual(100)
674
+ }
675
+ })
676
+
677
+ it('ends a drag when the pointer loses its capture', async () => {
678
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
679
+ mockSquare(w)
680
+ const to = w.find('[data-angle-end]').element
681
+ pointer(to, 'pointerdown', 176, 88)
682
+ to.dispatchEvent(new MouseEvent('lostpointercapture', { bubbles: true }))
683
+ pointer(to, 'pointermove', 88, 0)
684
+ await settle()
685
+ expect(values).toEqual([])
686
+ })
687
+
688
+ it('puts the selected point where the zone is clicked without a drag', async () => {
689
+ const { w, values } = await setup('bg-linear-90 from-[#000000] to-[#ffffff]')
690
+ mockSquare(w)
691
+ const zone = w.find('[data-gradient-zone]').element
692
+ pointer(zone, 'pointerdown', 132, 88)
693
+ pointer(zone, 'pointerup', 132, 88)
694
+ await settle()
695
+ // The from-stop is selected; its point lands three quarters along the line.
696
+ expect(values.at(-1)).toBe('bg-linear-90 from-[#000000] from-75% to-[#ffffff]')
697
+ })
698
+ })
699
+
700
+ describe('radial', () => {
701
+ it('switches type, moves the centre and remembers the angle', async () => {
702
+ const { w, values } = await setup('bg-linear-45 from-[#000000] to-[#ffffff]')
703
+ await button(w, 'Radiaal').trigger('click')
704
+ await settle()
705
+ expect(values.at(-1)).toBe('bg-radial from-[#000000] to-[#ffffff]')
706
+
707
+ const handle = w.find('[role="slider"][aria-label="Middelpunt"]')
708
+ await handle.trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
709
+ await handle.trigger('keydown', { key: 'ArrowUp' })
710
+ await settle()
711
+ expect(values.at(-1)).toBe('bg-radial-[at_40%_49%] from-[#000000] to-[#ffffff]')
712
+ expect(handle.attributes('aria-valuetext')).toBe('40 % van links, 49 % van boven')
713
+
714
+ await button(w, 'Lineair').trigger('click')
715
+ await settle()
716
+ expect(values.at(-1)).toMatch(/^bg-linear-45 /)
717
+ })
718
+
719
+ it('reads a centre at 0 or 100 clamped to 1–99, writing nothing', async () => {
720
+ const { w, values } = await setup('bg-radial-[at_0%_100%] from-[#000000] to-[#ffffff]')
721
+ const center = w.find('[data-gradient-center]')
722
+ expect(center.attributes('aria-valuetext')).toBe('1 % van links, 99 % van boven')
723
+ const style = (center.element as HTMLElement).style
724
+ expect([style.left, style.top]).toEqual(['1%', '99%'])
725
+ expect(values).toEqual([])
726
+ // The first change writes the clamped centre.
727
+ await key(w, '[data-gradient-center]', 'ArrowRight')
728
+ expect(values).toEqual(['bg-radial-[at_2%_99%] from-[#000000] to-[#ffffff]'])
729
+ })
730
+
731
+ it('clamps the centre to 1–99', async () => {
732
+ const { w, values } = await setup('bg-radial-[at_2%_50%] from-[#000000] to-[#ffffff]')
733
+ await key(w, '[data-gradient-center]', 'ArrowLeft', { shiftKey: true })
734
+ expect(values.at(-1)).toBe('bg-radial-[at_1%_50%] from-[#000000] to-[#ffffff]')
735
+ })
736
+
737
+ /** Where a point is drawn, in px of the 176px square. */
738
+ const pointAt = (w: VueWrapper, selector: string) => {
739
+ const style = (w.find(selector).element as HTMLElement).style
740
+ return { x: (parseFloat(style.left) / 100) * 176, y: (parseFloat(style.top) / 100) * 176 }
741
+ }
742
+ const expectInside = (point: { x: number; y: number }) => {
743
+ expect(point.x).toBeGreaterThanOrEqual(-0.01)
744
+ expect(point.y).toBeGreaterThanOrEqual(-0.01)
745
+ expect(point.x).toBeLessThanOrEqual(176.01)
746
+ expect(point.y).toBeLessThanOrEqual(176.01)
747
+ }
748
+
749
+ it('keeps the end point inside the square after dragging via off the corner ray', async () => {
750
+ const { w } = await setup(
751
+ 'bg-radial-[at_30%_70%] from-[#ff0000] via-[#00ff00] via-40% to-[#0000ff]',
752
+ )
753
+ mockSquare(w)
754
+ const via = w.findAll('[data-stop-point]')[1]?.element as HTMLElement
755
+ pointer(via, 'pointerdown', 80, 90)
756
+ pointer(via, 'pointermove', 60, 100)
757
+ pointer(via, 'pointerup', 60, 100)
758
+ await settle()
759
+ const to = w.findAll('[data-stop-point]')[2]?.attributes('data-stop-point')
760
+ expectInside(pointAt(w, `[data-stop-point="${to}"]`))
761
+ })
762
+
763
+ it('drops a kept ray that no longer fits after the centre changes from outside', async () => {
764
+ const { w, values } = await setup('bg-radial-[at_30%_70%] from-[#ff0000] to-[#0000ff]')
765
+ mockSquare(w)
766
+ const to = w.findAll('[data-stop-point]')[1]?.element as HTMLElement
767
+ pointer(to, 'pointerdown', 176, 0)
768
+ pointer(to, 'pointermove', 20, 170)
769
+ pointer(to, 'pointerup', 20, 170)
770
+ await settle()
771
+ const dragged = values.at(-1) ?? ''
772
+ expect(dragged).toMatch(/to-\d+%$/)
773
+ await w.setProps({ modelValue: dragged.replace('at_30%_70%', 'at_80%_80%') })
774
+ await settle()
775
+ w.findAll('[data-stop-point]').forEach((point) => {
776
+ const id = point.attributes('data-stop-point')
777
+ expectInside(pointAt(w, `[data-stop-point="${id}"]`))
778
+ })
779
+ })
780
+
781
+ it('sets a stop position by its distance from the centre', async () => {
782
+ const { w, values } = await setup('bg-radial from-[#000000] to-[#ffffff]')
783
+ mockSquare(w)
784
+ const to = w.findAll('[data-stop-point]')[1]?.element as HTMLElement
785
+ pointer(to, 'pointerdown', 176, 176)
786
+ // half the radius (√2·88) to the right of the centre
787
+ pointer(to, 'pointermove', 88 + 62.2, 88)
788
+ pointer(to, 'pointerup', 88 + 62.2, 88)
789
+ await settle()
790
+ expect(values.at(-1)).toBe('bg-radial from-[#000000] to-[#ffffff] to-50%')
791
+ })
792
+ })
793
+ })