@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,512 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { defineComponent, ref, nextTick } from 'vue'
4
+ import Combobox from './Combobox.vue'
5
+
6
+ vi.mock('@floating-ui/vue', async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import('@floating-ui/vue')>()
8
+ return {
9
+ ...actual,
10
+ useFloating: () => ({
11
+ floatingStyles: ref({}),
12
+ placement: ref('bottom-start'),
13
+ isPositioned: ref(true),
14
+ }),
15
+ autoUpdate: () => () => {},
16
+ }
17
+ })
18
+
19
+ const stubs = {
20
+ NexxtIcon: { template: '<span :data-name="name" />', props: ['name'] },
21
+ Teleport: { template: '<div><slot /></div>' },
22
+ }
23
+
24
+ const STRING_OPTIONS = ['Amsterdam', 'Rotterdam', 'Utrecht', 'Den Haag']
25
+ const OBJECT_OPTIONS = [
26
+ { label: 'Amsterdam', value: 'AMS' },
27
+ { label: 'Rotterdam', value: 'RTM' },
28
+ { label: 'Utrecht', value: 'UTC' },
29
+ ]
30
+
31
+ const mountCombobox = (props = {}, modelValue: (string | number)[] = []) =>
32
+ mount(Combobox, {
33
+ props: {
34
+ options: STRING_OPTIONS,
35
+ 'onUpdate:modelValue': (v: (string | number)[]) => wrapper.setProps({ modelValue: v }),
36
+ modelValue,
37
+ ...props,
38
+ },
39
+ global: { stubs },
40
+ attachTo: document.body,
41
+ })
42
+
43
+ let wrapper: ReturnType<typeof mountCombobox>
44
+
45
+ const open = async (w = wrapper) => {
46
+ await w.find('button').trigger('click')
47
+ await nextTick()
48
+ }
49
+
50
+ describe('Combobox', () => {
51
+ describe('rendering', () => {
52
+ it('renders the trigger button', () => {
53
+ wrapper = mountCombobox()
54
+ expect(wrapper.find('button').exists()).toBe(true)
55
+ })
56
+
57
+ it('renders slot content in the trigger', () => {
58
+ wrapper = mount(Combobox, {
59
+ props: { options: STRING_OPTIONS },
60
+ slots: { default: 'Selecteer stad' },
61
+ global: { stubs },
62
+ }) as typeof wrapper
63
+ expect(wrapper.text()).toContain('Selecteer stad')
64
+ })
65
+
66
+ it('does not show the dropdown by default', () => {
67
+ wrapper = mountCombobox()
68
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
69
+ })
70
+
71
+ it('shows the dropdown after clicking the trigger', async () => {
72
+ wrapper = mountCombobox()
73
+ await open()
74
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
75
+ })
76
+
77
+ it('renders all options in the dropdown', async () => {
78
+ wrapper = mountCombobox()
79
+ await open()
80
+ const options = wrapper.findAll('[role="option"]')
81
+ expect(options).toHaveLength(STRING_OPTIONS.length)
82
+ })
83
+
84
+ it('renders object options using their label', async () => {
85
+ wrapper = mountCombobox({ options: OBJECT_OPTIONS })
86
+ await open()
87
+ expect(wrapper.text()).toContain('Amsterdam')
88
+ expect(wrapper.text()).toContain('Rotterdam')
89
+ })
90
+ })
91
+
92
+ describe('open / close', () => {
93
+ it('closes the dropdown when the trigger is clicked again', async () => {
94
+ wrapper = mountCombobox()
95
+ await open()
96
+ await wrapper.find('button').trigger('click')
97
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
98
+ })
99
+
100
+ it('closes the dropdown on Escape key', async () => {
101
+ wrapper = mountCombobox()
102
+ await open()
103
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
104
+ await nextTick()
105
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
106
+ })
107
+
108
+ it('closes the dropdown on click outside', async () => {
109
+ wrapper = mountCombobox()
110
+ await open()
111
+ document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
112
+ await nextTick()
113
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
114
+ })
115
+
116
+ it('does not close on click inside', async () => {
117
+ wrapper = mountCombobox()
118
+ await open()
119
+ await wrapper.find('[role="listbox"]').trigger('mousedown')
120
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
121
+ })
122
+ })
123
+
124
+ describe('search / filtering', () => {
125
+ it('filters options based on search input', async () => {
126
+ wrapper = mountCombobox()
127
+ await open()
128
+ await wrapper.find('input').setValue('Amst')
129
+ await nextTick()
130
+ const options = wrapper.findAll('[role="option"]')
131
+ expect(options).toHaveLength(1)
132
+ expect(options[0].text()).toContain('Amsterdam')
133
+ })
134
+
135
+ it('shows all options when search is cleared', async () => {
136
+ wrapper = mountCombobox()
137
+ await open()
138
+ await wrapper.find('input').setValue('dam')
139
+ await wrapper.find('input').setValue('')
140
+ await nextTick()
141
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(STRING_OPTIONS.length)
142
+ })
143
+
144
+ it('shows empty state when no options match', async () => {
145
+ wrapper = mountCombobox()
146
+ await open()
147
+ await wrapper.find('input').setValue('zzz')
148
+ await nextTick()
149
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(0)
150
+ })
151
+
152
+ it('is case-insensitive', async () => {
153
+ wrapper = mountCombobox()
154
+ await open()
155
+ await wrapper.find('input').setValue('AMSTERDAM')
156
+ await nextTick()
157
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
158
+ })
159
+ })
160
+
161
+ describe('selecting options', () => {
162
+ it('emits modelValue with selected option when an option is clicked', async () => {
163
+ wrapper = mountCombobox()
164
+ await open()
165
+ await wrapper.find('[role="option"]').trigger('click')
166
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['Amsterdam']])
167
+ })
168
+
169
+ it('removes a selected option from the list', async () => {
170
+ wrapper = mountCombobox({}, ['Amsterdam'])
171
+ await open()
172
+ const options = wrapper.findAll('[role="option"]')
173
+ expect(options.map((o) => o.text())).not.toContain('Amsterdam')
174
+ })
175
+
176
+ it('clears search after selecting an option', async () => {
177
+ wrapper = mountCombobox()
178
+ await open()
179
+ await wrapper.find('input').setValue('dam')
180
+ await wrapper.find('[role="option"]').trigger('click')
181
+ await nextTick()
182
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
183
+ })
184
+
185
+ it('accumulates multiple selections', async () => {
186
+ const selected: (string | number)[] = []
187
+ wrapper = mount(Combobox, {
188
+ props: {
189
+ options: STRING_OPTIONS,
190
+ modelValue: selected,
191
+ 'onUpdate:modelValue': (v: (string | number)[]) => {
192
+ selected.push(...v.filter((x) => !selected.includes(x)))
193
+ wrapper.setProps({ modelValue: [...selected] })
194
+ },
195
+ },
196
+ global: { stubs },
197
+ attachTo: document.body,
198
+ }) as typeof wrapper
199
+ await open()
200
+ await wrapper.findAll('[role="option"]')[0].trigger('click')
201
+ await nextTick()
202
+ await wrapper.findAll('[role="option"]')[0].trigger('click')
203
+ await nextTick()
204
+ expect(selected).toHaveLength(2)
205
+ })
206
+ })
207
+
208
+ // Both in one host, so one app: an array default shared between instances would show here.
209
+ it('keeps the selection of an uncontrolled instance to itself', async () => {
210
+ const host = mount(
211
+ defineComponent({
212
+ components: { Combobox },
213
+ setup: () => ({ options: STRING_OPTIONS }),
214
+ template: '<div><Combobox :options="options" /><Combobox :options="options" /></div>',
215
+ }),
216
+ { global: { stubs }, attachTo: document.body },
217
+ )
218
+ const [first, second] = host.findAllComponents(Combobox)
219
+
220
+ await open(first)
221
+ await first.find('[role="option"]').trigger('click')
222
+ await open(first)
223
+ await open(second)
224
+
225
+ expect(first.emitted('update:modelValue')).toEqual([[['Amsterdam']]])
226
+ expect(second.emitted('update:modelValue')).toBeUndefined()
227
+ expect(second.findAll('[role="option"]')).toHaveLength(STRING_OPTIONS.length)
228
+ host.unmount()
229
+ })
230
+
231
+ describe('chip removal', () => {
232
+ it('shows chips for selected values', async () => {
233
+ wrapper = mountCombobox({}, ['Amsterdam', 'Rotterdam'])
234
+ await open()
235
+ expect(wrapper.text()).toContain('Amsterdam')
236
+ expect(wrapper.text()).toContain('Rotterdam')
237
+ })
238
+
239
+ it('removes a value when its chip is clicked', async () => {
240
+ wrapper = mountCombobox({}, ['Amsterdam', 'Rotterdam'])
241
+ await open()
242
+ const chipButtons = wrapper.findAll('button[aria-label]')
243
+ await chipButtons[0].trigger('click')
244
+ const emitted = wrapper.emitted('update:modelValue')
245
+ expect(emitted).toBeTruthy()
246
+ const lastEmit = emitted![emitted!.length - 1][0] as (string | number)[]
247
+ expect(lastEmit).not.toContain('Amsterdam')
248
+ })
249
+
250
+ it('chip buttons have descriptive aria-labels', async () => {
251
+ wrapper = mountCombobox({}, ['Amsterdam'])
252
+ await open()
253
+ const chipButton = wrapper.find('button[aria-label]')
254
+ expect(chipButton.attributes('aria-label')).toContain('Amsterdam')
255
+ })
256
+ })
257
+
258
+ describe('allowNew', () => {
259
+ it('shows add hint when search has no matching options', async () => {
260
+ wrapper = mountCombobox({ allowNew: true })
261
+ await open()
262
+ await wrapper.find('input').setValue('Groningen')
263
+ await nextTick()
264
+ expect(wrapper.text()).toContain('Groningen')
265
+ expect(wrapper.find('[data-name="plus"]').exists()).toBe(true)
266
+ })
267
+
268
+ it('does not show add hint when allowNew is false', async () => {
269
+ wrapper = mountCombobox()
270
+ await open()
271
+ await wrapper.find('input').setValue('Groningen')
272
+ await nextTick()
273
+ expect(wrapper.find('[data-name="plus"]').exists()).toBe(false)
274
+ })
275
+
276
+ it('does not show add hint when search matches an existing option label', async () => {
277
+ wrapper = mountCombobox({ allowNew: true })
278
+ await open()
279
+ await wrapper.find('input').setValue('Amsterdam')
280
+ await nextTick()
281
+ expect(wrapper.find('[data-name="plus"]').exists()).toBe(false)
282
+ })
283
+
284
+ it('does not show add hint when search matches a custom item already added', async () => {
285
+ wrapper = mountCombobox({ allowNew: true })
286
+ await open()
287
+ await wrapper.find('input').setValue('Groningen')
288
+ await wrapper.find('[data-name="plus"]').trigger('click')
289
+ await nextTick()
290
+ await wrapper.find('input').setValue('Groningen')
291
+ await nextTick()
292
+ expect(wrapper.find('[data-name="plus"]').exists()).toBe(false)
293
+ })
294
+
295
+ it('adds a new custom item when clicking the add hint', async () => {
296
+ wrapper = mountCombobox({ allowNew: true })
297
+ await open()
298
+ await wrapper.find('input').setValue('Groningen')
299
+ await nextTick()
300
+ await wrapper.find('[data-name="plus"]').trigger('click')
301
+ const emitted = wrapper.emitted('update:modelValue')
302
+ expect(emitted).toBeTruthy()
303
+ expect((emitted![emitted!.length - 1][0] as string[]).includes('Groningen')).toBe(true)
304
+ })
305
+
306
+ it('selects the existing option instead of adding a custom item when label matches exactly', async () => {
307
+ wrapper = mountCombobox({ allowNew: true })
308
+ await open()
309
+ await wrapper.find('input').setValue('Amsterdam')
310
+ await nextTick()
311
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
312
+ await nextTick()
313
+ // Should select the existing option (emits it), not show an add hint
314
+ const emitted = wrapper.emitted('update:modelValue')
315
+ expect(emitted).toBeTruthy()
316
+ expect((emitted![0][0] as string[]).includes('Amsterdam')).toBe(true)
317
+ expect(wrapper.find('[data-name="plus"]').exists()).toBe(false)
318
+ })
319
+
320
+ it('does not add the same custom item twice via Enter', async () => {
321
+ wrapper = mountCombobox({ allowNew: true })
322
+ await open()
323
+ await wrapper.find('input').setValue('Groningen')
324
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
325
+ await nextTick()
326
+ await wrapper.setProps({ modelValue: ['Groningen'] })
327
+ await wrapper.find('input').setValue('Groningen')
328
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
329
+ await nextTick()
330
+ const emitted = wrapper.emitted('update:modelValue')
331
+ const lastEmit = emitted![emitted!.length - 1][0] as string[]
332
+ expect(lastEmit.filter((v) => v === 'Groningen')).toHaveLength(1)
333
+ })
334
+
335
+ it('custom items are shown with default chip variant, existing options with ghost', async () => {
336
+ wrapper = mountCombobox({ allowNew: true }, ['Groningen', 'Amsterdam'])
337
+ await open()
338
+ // Verify both chips are rendered — variant checking done via chip wrapper attributes
339
+ expect(wrapper.text()).toContain('Groningen')
340
+ expect(wrapper.text()).toContain('Amsterdam')
341
+ })
342
+ })
343
+
344
+ describe('enter key behaviour', () => {
345
+ it('selects the single remaining option on Enter without allowNew', async () => {
346
+ wrapper = mountCombobox()
347
+ await open()
348
+ await wrapper.find('input').setValue('Amst')
349
+ await nextTick()
350
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
351
+ await nextTick()
352
+ const emitted = wrapper.emitted('update:modelValue')
353
+ expect(emitted).toBeTruthy()
354
+ expect((emitted![0][0] as string[]).includes('Amsterdam')).toBe(true)
355
+ })
356
+
357
+ it('selects the single remaining option on Enter even when allowNew is true', async () => {
358
+ wrapper = mountCombobox({ allowNew: true })
359
+ await open()
360
+ await wrapper.find('input').setValue('Amst')
361
+ await nextTick()
362
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
363
+ await nextTick()
364
+ const emitted = wrapper.emitted('update:modelValue')
365
+ expect(emitted).toBeTruthy()
366
+ expect((emitted![0][0] as string[]).includes('Amsterdam')).toBe(true)
367
+ })
368
+
369
+ it('adds new item on Enter when multiple options exist but none match exactly', async () => {
370
+ wrapper = mountCombobox({ allowNew: true })
371
+ await open()
372
+ await wrapper.find('input').setValue('Groningen')
373
+ await nextTick()
374
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
375
+ await nextTick()
376
+ const emitted = wrapper.emitted('update:modelValue')
377
+ expect(emitted).toBeTruthy()
378
+ expect((emitted![0][0] as string[]).includes('Groningen')).toBe(true)
379
+ })
380
+ })
381
+
382
+ describe('accessibility', () => {
383
+ it('trigger has aria-haspopup="listbox"', () => {
384
+ wrapper = mountCombobox()
385
+ expect(wrapper.find('button').attributes('aria-haspopup')).toBe('listbox')
386
+ })
387
+
388
+ it('trigger has aria-expanded="false" when closed', () => {
389
+ wrapper = mountCombobox()
390
+ expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
391
+ })
392
+
393
+ it('trigger has aria-expanded="true" when open', async () => {
394
+ wrapper = mountCombobox()
395
+ await open()
396
+ expect(wrapper.find('button').attributes('aria-expanded')).toBe('true')
397
+ })
398
+
399
+ it('listbox has aria-multiselectable="true"', async () => {
400
+ wrapper = mountCombobox()
401
+ await open()
402
+ expect(wrapper.find('[role="listbox"]').attributes('aria-multiselectable')).toBe('true')
403
+ })
404
+
405
+ it('options have aria-selected="false"', async () => {
406
+ wrapper = mountCombobox()
407
+ await open()
408
+ wrapper.findAll('[role="option"]').forEach((o) => {
409
+ expect(o.attributes('aria-selected')).toBe('false')
410
+ })
411
+ })
412
+ })
413
+
414
+ describe('singleSelect', () => {
415
+ it('keeps selected options visible in the list', async () => {
416
+ wrapper = mountCombobox({ singleSelect: true }, ['Amsterdam'])
417
+ await open()
418
+ expect(wrapper.findAll('[role="option"]').map((o) => o.text())).toContain('Amsterdam')
419
+ })
420
+
421
+ it('emits a single-item array when an option is clicked', async () => {
422
+ wrapper = mountCombobox({ singleSelect: true })
423
+ await open()
424
+ await wrapper.find('[role="option"]').trigger('click')
425
+ const emitted = wrapper.emitted('update:modelValue')
426
+ expect(emitted).toBeTruthy()
427
+ expect((emitted![0][0] as (string | number)[]).length).toBe(1)
428
+ })
429
+
430
+ it('replaces the current selection when a different option is clicked', async () => {
431
+ wrapper = mountCombobox({ singleSelect: true }, ['Amsterdam'])
432
+ await open()
433
+ const rotterdam = wrapper
434
+ .findAll('[role="option"]')
435
+ .find((o) => o.text().includes('Rotterdam'))!
436
+ await rotterdam.trigger('click')
437
+ const emitted = wrapper.emitted('update:modelValue')
438
+ const last = emitted![emitted!.length - 1][0] as (string | number)[]
439
+ expect(last).toEqual(['Rotterdam'])
440
+ })
441
+
442
+ it('deselects when the currently selected option is clicked again', async () => {
443
+ wrapper = mountCombobox({ singleSelect: true }, ['Amsterdam'])
444
+ await open()
445
+ const amsterdam = wrapper
446
+ .findAll('[role="option"]')
447
+ .find((o) => o.text().includes('Amsterdam'))!
448
+ await amsterdam.trigger('click')
449
+ const emitted = wrapper.emitted('update:modelValue')
450
+ const last = emitted![emitted!.length - 1][0] as (string | number)[]
451
+ expect(last).toHaveLength(0)
452
+ })
453
+
454
+ it('shows a check icon on the selected option', async () => {
455
+ wrapper = mountCombobox({ singleSelect: true }, ['Amsterdam'])
456
+ await open()
457
+ const amsterdam = wrapper
458
+ .findAll('[role="option"]')
459
+ .find((o) => o.text().includes('Amsterdam'))!
460
+ expect(amsterdam.find('[data-name="check"]').exists()).toBe(true)
461
+ })
462
+
463
+ it('does not show a check icon on unselected options', async () => {
464
+ wrapper = mountCombobox({ singleSelect: true }, ['Amsterdam'])
465
+ await open()
466
+ const rotterdam = wrapper
467
+ .findAll('[role="option"]')
468
+ .find((o) => o.text().includes('Rotterdam'))!
469
+ expect(rotterdam.find('[data-name="check"]').exists()).toBe(false)
470
+ })
471
+
472
+ it('closes the panel after selecting an option', async () => {
473
+ wrapper = mountCombobox({ singleSelect: true })
474
+ await open()
475
+ await wrapper.find('[role="option"]').trigger('click')
476
+ await nextTick()
477
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
478
+ })
479
+
480
+ it('hides chips by default', async () => {
481
+ wrapper = mountCombobox({ singleSelect: true }, ['Amsterdam'])
482
+ await open()
483
+ expect(wrapper.find('button[aria-label]').exists()).toBe(false)
484
+ })
485
+ })
486
+
487
+ describe('showChips', () => {
488
+ it('shows chips by default in multi-select', async () => {
489
+ wrapper = mountCombobox({}, ['Amsterdam'])
490
+ await open()
491
+ expect(wrapper.find('button[aria-label]').exists()).toBe(true)
492
+ })
493
+
494
+ it('hides chips when showChips is false', async () => {
495
+ wrapper = mountCombobox({ showChips: false }, ['Amsterdam'])
496
+ await open()
497
+ expect(wrapper.find('button[aria-label]').exists()).toBe(false)
498
+ })
499
+ })
500
+
501
+ describe('fullWidth', () => {
502
+ it('does not apply w-full to the trigger by default', () => {
503
+ wrapper = mountCombobox()
504
+ expect(wrapper.find('button').classes()).not.toContain('w-full')
505
+ })
506
+
507
+ it('applies w-full to the trigger when fullWidth is true', () => {
508
+ wrapper = mountCombobox({ fullWidth: true })
509
+ expect(wrapper.find('button').classes()).toContain('w-full')
510
+ })
511
+ })
512
+ })
@@ -32,7 +32,7 @@ const {
32
32
  singleSelect = false,
33
33
  } = props
34
34
 
35
- const selected = defineModel<(string | number)[]>({ default: [] })
35
+ const selected = defineModel<(string | number)[]>({ default: () => [] })
36
36
 
37
37
  const panelRef = ref<InstanceType<typeof ComboboxPanel> | null>(null)
38
38
 
@@ -0,0 +1,158 @@
1
+ import { ref } from 'vue'
2
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
3
+ import ComboboxPanel from './ComboboxPanel.vue'
4
+ import type { NexxtComboboxGroup } from './combobox-types'
5
+
6
+ const CITIES = [
7
+ { label: 'Almere', value: 'ALM' },
8
+ { label: 'Amersfoort', value: 'AMF' },
9
+ { label: 'Amsterdam', value: 'AMS' },
10
+ { label: 'Apeldoorn', value: 'APD' },
11
+ { label: 'Arnhem', value: 'ARN' },
12
+ { label: 'Breda', value: 'BRD' },
13
+ { label: 'Den Haag', value: 'DHG' },
14
+ { label: 'Eindhoven', value: 'EHV' },
15
+ { label: 'Groningen', value: 'GRQ' },
16
+ { label: 'Haarlem', value: 'HAA' },
17
+ { label: 'Leeuwarden', value: 'LWR' },
18
+ { label: 'Rotterdam', value: 'RTM' },
19
+ { label: 'Utrecht', value: 'UTC' },
20
+ { label: 'Zwolle', value: 'ZWO' },
21
+ ]
22
+
23
+ export default {
24
+ title: 'Components/Organisms/ComboboxPanel',
25
+ component: ComboboxPanel,
26
+ decorators: [
27
+ () => ({
28
+ template:
29
+ '<div style="padding: 16px; display: flex; gap: 24px; align-items: flex-start;"><story /></div>',
30
+ }),
31
+ ],
32
+ } satisfies Meta<typeof ComboboxPanel>
33
+
34
+ type Story = StoryObj<typeof ComboboxPanel>
35
+
36
+ export const StringOptions: Story = {
37
+ render: (args) => ({
38
+ components: { ComboboxPanel },
39
+ setup() {
40
+ const selected = ref<string[]>([])
41
+ return { args, selected }
42
+ },
43
+ template: `<ComboboxPanel v-bind="args" v-model="selected" />`,
44
+ }),
45
+ args: {
46
+ placeholder: 'Zoeken...',
47
+ options: CITIES.map((city) => city.label),
48
+ },
49
+ }
50
+
51
+ export const AllowNew: Story = {
52
+ render: (args) => ({
53
+ components: { ComboboxPanel },
54
+ setup() {
55
+ const selected = ref<string[]>([])
56
+ return { args, selected }
57
+ },
58
+ template: `<ComboboxPanel v-bind="args" v-model="selected" />`,
59
+ }),
60
+ args: {
61
+ placeholder: 'Zoeken of toevoegen...',
62
+ options: CITIES.map((city) => city.label),
63
+ allowNew: true,
64
+ },
65
+ }
66
+
67
+ export const ObjectOptions: Story = {
68
+ render: (args) => ({
69
+ components: { ComboboxPanel },
70
+ setup() {
71
+ const selected = ref<(string | number)[]>([])
72
+ return { args, selected }
73
+ },
74
+ template: `<ComboboxPanel v-bind="args" v-model="selected" />`,
75
+ }),
76
+ args: {
77
+ placeholder: 'Selecteer een stad...',
78
+ options: CITIES,
79
+ },
80
+ }
81
+
82
+ export const Floating: Story = {
83
+ name: 'Floating (standalone)',
84
+ render: (args) => ({
85
+ components: { ComboboxPanel },
86
+ setup() {
87
+ const selected = ref<string[]>([])
88
+ return { args, selected }
89
+ },
90
+ template: `<ComboboxPanel v-bind="args" v-model="selected" />`,
91
+ }),
92
+ decorators: [
93
+ () => ({
94
+ template: '<div style="padding: 16px; min-height: 300px;"><story /></div>',
95
+ }),
96
+ ],
97
+ args: {
98
+ placeholder: 'Zoeken...',
99
+ options: CITIES.map((city) => city.label),
100
+ floating: true,
101
+ },
102
+ }
103
+
104
+ export const NonFloating: Story = {
105
+ name: 'Non-floating (inside dropdown)',
106
+ render: (args) => ({
107
+ components: { ComboboxPanel },
108
+ setup() {
109
+ const selected = ref<string[]>([])
110
+ return { args, selected }
111
+ },
112
+ template: `<ComboboxPanel v-bind="args" v-model="selected" />`,
113
+ }),
114
+ args: {
115
+ placeholder: 'Zoeken...',
116
+ options: CITIES.map((city) => city.label),
117
+ floating: false,
118
+ },
119
+ }
120
+
121
+ const PLACEHOLDER_GROUPS: NexxtComboboxGroup[] = [
122
+ { label: 'Contact', options: ['Voornaam', 'Achternaam', 'E-mailadres', 'Telefoonnummer'] },
123
+ { label: 'Woning', options: ['Adres', 'Postcode', 'Woonplaats', 'Vraagprijs'] },
124
+ { label: 'Makelaar', options: ['Kantoornaam', 'Naam makelaar'] },
125
+ ]
126
+
127
+ export const GroupedOptions: Story = {
128
+ render: (args) => ({
129
+ components: { ComboboxPanel },
130
+ setup() {
131
+ const selected = ref<(string | number)[]>([])
132
+ return { args, selected }
133
+ },
134
+ template: `<ComboboxPanel v-bind="args" v-model="selected" />`,
135
+ }),
136
+ args: {
137
+ placeholder: 'Zoek een placeholder...',
138
+ options: PLACEHOLDER_GROUPS,
139
+ floating: false,
140
+ },
141
+ }
142
+
143
+ export const GroupedSingleSelect: Story = {
144
+ render: (args) => ({
145
+ components: { ComboboxPanel },
146
+ setup() {
147
+ const selected = ref<(string | number)[]>(['Postcode'])
148
+ return { args, selected }
149
+ },
150
+ template: `<ComboboxPanel v-bind="args" v-model="selected" />`,
151
+ }),
152
+ args: {
153
+ placeholder: 'Zoek een placeholder...',
154
+ options: PLACEHOLDER_GROUPS,
155
+ floating: false,
156
+ singleSelect: true,
157
+ },
158
+ }