@nexxtmove/ui 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/dist/components/editor/molecules/EditorBorder/EditorBorder.vue.d.ts +12 -0
  2. package/dist/components/editor/molecules/EditorColor/_ThemeRoleSwatches.vue.d.ts +16 -0
  3. package/dist/index.js +1094 -1135
  4. package/dist/nuxt.js +0 -1
  5. package/package.json +38 -36
  6. package/src/attribute-contract.test.ts +151 -0
  7. package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
  8. package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
  9. package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
  10. package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
  11. package/src/components/atoms/Button/Button.stories.ts +290 -0
  12. package/src/components/atoms/Button/Button.test.ts +406 -0
  13. package/src/components/atoms/Card/Card.stories.ts +45 -0
  14. package/src/components/atoms/Card/Card.test.ts +46 -0
  15. package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
  16. package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
  17. package/src/components/atoms/Chip/Chip.stories.ts +274 -0
  18. package/src/components/atoms/Chip/Chip.test.ts +230 -0
  19. package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
  20. package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
  21. package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
  22. package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
  23. package/src/components/atoms/Icon/Icon.stories.ts +63 -0
  24. package/src/components/atoms/Icon/Icon.test.ts +89 -0
  25. package/src/components/atoms/Loader/Loader.stories.ts +36 -0
  26. package/src/components/atoms/Loader/Loader.test.ts +40 -0
  27. package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
  28. package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
  29. package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
  30. package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
  31. package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
  32. package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
  33. package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
  34. package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
  35. package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
  36. package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
  37. package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
  38. package/src/components/atoms/Slider/Slider.stories.ts +151 -0
  39. package/src/components/atoms/Slider/Slider.test.ts +191 -0
  40. package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
  41. package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
  42. package/src/components/atoms/Switch/Switch.stories.ts +91 -0
  43. package/src/components/atoms/Switch/Switch.test.ts +183 -0
  44. package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
  45. package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
  46. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
  47. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
  48. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
  49. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
  50. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
  51. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
  52. package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
  53. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
  54. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
  55. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
  56. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
  57. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
  58. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
  59. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
  60. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
  61. package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
  62. package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
  63. package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
  64. package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
  65. package/src/components/editor/molecules/EditorBorder/EditorBorder.vue +58 -2
  66. package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +563 -0
  67. package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +689 -0
  68. package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +793 -0
  69. package/src/components/editor/molecules/EditorColor/_ColorField.vue +7 -29
  70. package/src/components/editor/molecules/EditorColor/_ThemeRoleSwatches.vue +57 -0
  71. package/src/components/editor/molecules/EditorColor/gradientClass.test.ts +303 -0
  72. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.stories.ts +72 -0
  73. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.test.ts +124 -0
  74. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.stories.ts +46 -0
  75. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.test.ts +97 -0
  76. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.stories.ts +248 -0
  77. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.test.ts +288 -0
  78. package/src/components/editor/molecules/EditorGap/EditorGap.stories.ts +129 -0
  79. package/src/components/editor/molecules/EditorGap/EditorGap.test.ts +253 -0
  80. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.stories.ts +68 -0
  81. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.test.ts +150 -0
  82. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.stories.ts +71 -0
  83. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.test.ts +161 -0
  84. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.stories.ts +204 -0
  85. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.test.ts +238 -0
  86. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.stories.ts +257 -0
  87. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.test.ts +286 -0
  88. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.stories.ts +135 -0
  89. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.test.ts +230 -0
  90. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.stories.ts +154 -0
  91. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.test.ts +112 -0
  92. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.stories.ts +206 -0
  93. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.test.ts +332 -0
  94. package/src/components/editor/molecules/EditorPadding/EditorPadding.stories.ts +374 -0
  95. package/src/components/editor/molecules/EditorPadding/EditorPadding.test.ts +712 -0
  96. package/src/components/editor/molecules/EditorRadius/EditorRadius.stories.ts +64 -0
  97. package/src/components/editor/molecules/EditorRadius/EditorRadius.test.ts +137 -0
  98. package/src/components/editor/molecules/EditorShadow/EditorShadow.stories.ts +103 -0
  99. package/src/components/editor/molecules/EditorShadow/EditorShadow.test.ts +168 -0
  100. package/src/components/editor/molecules/EditorSize/EditorSize.stories.ts +84 -0
  101. package/src/components/editor/molecules/EditorSize/EditorSize.test.ts +253 -0
  102. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.stories.ts +49 -0
  103. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.test.ts +43 -0
  104. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.stories.ts +69 -0
  105. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.test.ts +155 -0
  106. package/src/components/molecules/Announcement/Announcement.stories.ts +224 -0
  107. package/src/components/molecules/Announcement/Announcement.test.ts +161 -0
  108. package/src/components/molecules/ContactInfoCard/ContactInfoCard.stories.ts +99 -0
  109. package/src/components/molecules/ContactInfoCard/ContactInfoCard.test.ts +70 -0
  110. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.stories.ts +148 -0
  111. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.test.ts +271 -0
  112. package/src/components/molecules/DoughnutChart/DoughnutChart.stories.ts +45 -0
  113. package/src/components/molecules/DoughnutChart/DoughnutChart.test.ts +205 -0
  114. package/src/components/molecules/InfoBlock/InfoBlock.stories.ts +84 -0
  115. package/src/components/molecules/InfoBlock/InfoBlock.test.ts +101 -0
  116. package/src/components/molecules/InputField/InputField.stories.ts +117 -0
  117. package/src/components/molecules/InputField/InputField.test.ts +138 -0
  118. package/src/components/molecules/InputSelect/InputSelect.stories.ts +116 -0
  119. package/src/components/molecules/InputSelect/InputSelect.test.ts +127 -0
  120. package/src/components/molecules/ModalFooter/ModalFooter.stories.ts +204 -0
  121. package/src/components/molecules/ModalFooter/ModalFooter.test.ts +276 -0
  122. package/src/components/molecules/PageCard/PageCard.stories.ts +100 -0
  123. package/src/components/molecules/PageCard/PageCard.test.ts +91 -0
  124. package/src/components/molecules/PageContainer/PageContainer.stories.ts +90 -0
  125. package/src/components/molecules/PageContainer/PageContainer.test.ts +50 -0
  126. package/src/components/molecules/PageMockup/PageMockup.stories.ts +38 -0
  127. package/src/components/molecules/PageMockup/PageMockup.test.ts +20 -0
  128. package/src/components/molecules/PageTypeTile/PageTypeTile.stories.ts +63 -0
  129. package/src/components/molecules/PageTypeTile/PageTypeTile.test.ts +41 -0
  130. package/src/components/molecules/Pagination/Pagination.stories.ts +72 -0
  131. package/src/components/molecules/Pagination/Pagination.test.ts +130 -0
  132. package/src/components/molecules/PickerGroup/PickerGroup.stories.ts +90 -0
  133. package/src/components/molecules/PickerGroup/PickerGroup.test.ts +145 -0
  134. package/src/components/molecules/PickerList/PickerList.stories.ts +436 -0
  135. package/src/components/molecules/PickerList/PickerList.test.ts +870 -0
  136. package/src/components/molecules/PlaceholderInput/PlaceholderInput.stories.ts +48 -0
  137. package/src/components/molecules/PlaceholderInput/PlaceholderInput.test.ts +107 -0
  138. package/src/components/molecules/PropertyCard/PropertyCard.stories.ts +46 -0
  139. package/src/components/molecules/PropertyCard/PropertyCard.test.ts +59 -0
  140. package/src/components/molecules/Select/Select.stories.ts +264 -0
  141. package/src/components/molecules/Select/Select.test.ts +315 -0
  142. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.stories.ts +84 -0
  143. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.test.ts +103 -0
  144. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.stories.ts +11 -0
  145. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.test.ts +38 -0
  146. package/src/components/molecules/SocialIcons/SocialIcons.stories.ts +28 -0
  147. package/src/components/molecules/SocialIcons/SocialIcons.test.ts +58 -0
  148. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
  149. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +116 -0
  150. package/src/components/molecules/SocialMediaType/SocialMediaType.stories.ts +37 -0
  151. package/src/components/molecules/SocialMediaType/SocialMediaType.test.ts +126 -0
  152. package/src/components/molecules/Source/Source.stories.ts +35 -0
  153. package/src/components/molecules/Source/Source.test.ts +45 -0
  154. package/src/components/molecules/TabbedContent/TabbedContent.stories.ts +96 -0
  155. package/src/components/molecules/TabbedContent/TabbedContent.test.ts +109 -0
  156. package/src/components/molecules/Tabs/Tabs.stories.ts +230 -0
  157. package/src/components/molecules/Tabs/Tabs.test.ts +280 -0
  158. package/src/components/molecules/TemplateCard/TemplateCard.stories.ts +327 -0
  159. package/src/components/molecules/TemplateCard/TemplateCard.test.ts +339 -0
  160. package/src/components/molecules/Textarea/Textarea.stories.ts +216 -0
  161. package/src/components/molecules/Textarea/Textarea.test.ts +191 -0
  162. package/src/components/molecules/TimelineEvent/TimelineEvent.stories.ts +319 -0
  163. package/src/components/molecules/TimelineEvent/TimelineEvent.test.ts +224 -0
  164. package/src/components/molecules/TimelineLanes/TimelineLanes.stories.ts +29 -0
  165. package/src/components/molecules/TimelineLanes/TimelineLanes.test.ts +30 -0
  166. package/src/components/molecules/Toast/Toast.stories.ts +110 -0
  167. package/src/components/molecules/Toast/Toast.test.ts +103 -0
  168. package/src/components/molecules/ToggleGroup/ToggleGroup.stories.ts +297 -0
  169. package/src/components/molecules/ToggleGroup/ToggleGroup.test.ts +578 -0
  170. package/src/components/molecules/Tree/Tree.stories.ts +884 -0
  171. package/src/components/molecules/Tree/Tree.test.ts +1247 -0
  172. package/src/components/molecules/Tree/tree-drop.test.ts +223 -0
  173. package/src/components/organisms/Calendar/Calendar.stories.ts +108 -0
  174. package/src/components/organisms/Calendar/Calendar.test.ts +279 -0
  175. package/src/components/organisms/Calendar/_CalendarDayView.test.ts +104 -0
  176. package/src/components/organisms/Calendar/_CalendarHeader.test.ts +86 -0
  177. package/src/components/organisms/Calendar/_CalendarMonthView.test.ts +52 -0
  178. package/src/components/organisms/Calendar/_CalendarTimePicker.test.ts +89 -0
  179. package/src/components/organisms/Calendar/_CalendarYearView.test.ts +56 -0
  180. package/src/components/organisms/ColorPicker/ColorPicker.stories.ts +229 -0
  181. package/src/components/organisms/ColorPicker/ColorPicker.test.ts +638 -0
  182. package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
  183. package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
  184. package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
  185. package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
  186. package/src/components/organisms/Combobox/Combobox.vue +1 -1
  187. package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
  188. package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
  189. package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
  190. package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
  191. package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
  192. package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
  193. package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
  194. package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
  195. package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
  196. package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
  197. package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
  198. package/src/components/organisms/Header/Header.stories.ts +75 -0
  199. package/src/components/organisms/Header/Header.test.ts +233 -0
  200. package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
  201. package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
  202. package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
  203. package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
  204. package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
  205. package/src/components/organisms/Modal/Modal.stories.ts +350 -0
  206. package/src/components/organisms/Modal/Modal.test.ts +275 -0
  207. package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
  208. package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
  209. package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
  210. package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
  211. package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
  212. package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
  213. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
  214. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  215. package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
  216. package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
  217. package/src/components/organisms/Table/Table.stories.ts +332 -0
  218. package/src/components/organisms/Table/Table.test.ts +320 -0
  219. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
  220. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
  221. package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
  222. package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
  223. package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
  224. package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
  225. package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
  226. package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
  227. package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
  228. package/src/components/organisms/VideoPlayer/VideoPlayer.test.ts +439 -0
@@ -0,0 +1,524 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { defineComponent, nextTick } from 'vue'
4
+ import ComboboxPanel from './ComboboxPanel.vue'
5
+ import type { NexxtComboboxGroup } from './combobox-types'
6
+
7
+ const stubs = {
8
+ NexxtIcon: { template: '<span :data-name="name" />', props: ['name'] },
9
+ }
10
+
11
+ const STRING_OPTIONS = ['Amsterdam', 'Rotterdam', 'Utrecht', 'Den Haag']
12
+ const OBJECT_OPTIONS = [
13
+ { label: 'Amsterdam', value: 'AMS' },
14
+ { label: 'Rotterdam', value: 'RTM' },
15
+ { label: 'Utrecht', value: 'UTC' },
16
+ ]
17
+
18
+ const mountPanel = (props = {}, modelValue: (string | number)[] = []) =>
19
+ mount(ComboboxPanel, {
20
+ props: {
21
+ options: STRING_OPTIONS,
22
+ 'onUpdate:modelValue': (v: (string | number)[]) => wrapper.setProps({ modelValue: v }),
23
+ modelValue,
24
+ ...props,
25
+ },
26
+ global: { stubs },
27
+ attachTo: document.body,
28
+ })
29
+
30
+ let wrapper: ReturnType<typeof mountPanel>
31
+
32
+ describe('ComboboxPanel', () => {
33
+ describe('rendering', () => {
34
+ it('renders the search input', () => {
35
+ wrapper = mountPanel()
36
+ expect(wrapper.find('input').exists()).toBe(true)
37
+ })
38
+
39
+ // Same attribute set as before groups existed: nothing internal may leak onto an option
40
+ it.each([true, false])(
41
+ 'renders options with only role, aria-selected and class (floating: %s)',
42
+ async (floating) => {
43
+ wrapper = mountPanel({ floating, singleSelect: true }, ['Amsterdam'])
44
+ if (floating) await wrapper.find('input').trigger('focus')
45
+ const options = wrapper.findAll('[role="option"]')
46
+ expect(options).toHaveLength(STRING_OPTIONS.length)
47
+ for (const option of options) {
48
+ expect(option.element.getAttributeNames().sort()).toEqual([
49
+ 'aria-selected',
50
+ 'class',
51
+ 'role',
52
+ ])
53
+ }
54
+ },
55
+ )
56
+
57
+ it('renders all options', () => {
58
+ wrapper = mountPanel()
59
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(STRING_OPTIONS.length)
60
+ })
61
+
62
+ it('renders object options using their label', () => {
63
+ wrapper = mountPanel({ options: OBJECT_OPTIONS })
64
+ expect(wrapper.text()).toContain('Amsterdam')
65
+ expect(wrapper.text()).toContain('Rotterdam')
66
+ })
67
+
68
+ it('uses a custom placeholder', () => {
69
+ wrapper = mountPanel({ placeholder: 'Type here...' })
70
+ expect(wrapper.find('input').attributes('placeholder')).toBe('Type here...')
71
+ })
72
+ })
73
+
74
+ describe('search / filtering', () => {
75
+ it('filters options based on search input', async () => {
76
+ wrapper = mountPanel()
77
+ await wrapper.find('input').setValue('Amst')
78
+ await nextTick()
79
+ const options = wrapper.findAll('[role="option"]')
80
+ expect(options).toHaveLength(1)
81
+ expect(options[0].text()).toContain('Amsterdam')
82
+ })
83
+
84
+ it('shows all options when search is cleared', async () => {
85
+ wrapper = mountPanel()
86
+ await wrapper.find('input').setValue('dam')
87
+ await wrapper.find('input').setValue('')
88
+ await nextTick()
89
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(STRING_OPTIONS.length)
90
+ })
91
+
92
+ it('is case-insensitive', async () => {
93
+ wrapper = mountPanel()
94
+ await wrapper.find('input').setValue('AMSTERDAM')
95
+ await nextTick()
96
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
97
+ })
98
+ })
99
+
100
+ describe('selecting options', () => {
101
+ it('emits modelValue when an option is clicked', async () => {
102
+ wrapper = mountPanel()
103
+ await wrapper.find('[role="option"]').trigger('click')
104
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['Amsterdam']])
105
+ })
106
+
107
+ it('removes a selected option from the list', async () => {
108
+ wrapper = mountPanel({}, ['Amsterdam'])
109
+ const options = wrapper.findAll('[role="option"]')
110
+ expect(options.map((o) => o.text())).not.toContain('Amsterdam')
111
+ })
112
+
113
+ it('clears search after selecting an option', async () => {
114
+ wrapper = mountPanel()
115
+ await wrapper.find('input').setValue('dam')
116
+ await wrapper.find('[role="option"]').trigger('click')
117
+ await nextTick()
118
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
119
+ })
120
+ })
121
+
122
+ // Both in one host, so one app: an array default shared between instances would show here.
123
+ it('keeps the selection of an uncontrolled instance to itself', async () => {
124
+ const host = mount(
125
+ defineComponent({
126
+ components: { ComboboxPanel },
127
+ setup: () => ({ options: STRING_OPTIONS }),
128
+ template:
129
+ '<div><ComboboxPanel :options="options" /><ComboboxPanel :options="options" /></div>',
130
+ }),
131
+ { global: { stubs }, attachTo: document.body },
132
+ )
133
+ const [first, second] = host.findAllComponents(ComboboxPanel)
134
+
135
+ await first.find('[role="option"]').trigger('click')
136
+
137
+ expect(first.emitted('update:modelValue')).toEqual([[['Amsterdam']]])
138
+ expect(first.findAll('[role="option"]').map((o) => o.text())).not.toContain('Amsterdam')
139
+ expect(second.emitted('update:modelValue')).toBeUndefined()
140
+ expect(second.findAll('[role="option"]')).toHaveLength(STRING_OPTIONS.length)
141
+ host.unmount()
142
+ })
143
+
144
+ describe('chip removal', () => {
145
+ it('shows chips for selected values', async () => {
146
+ wrapper = mountPanel({}, ['Amsterdam', 'Rotterdam'])
147
+ expect(wrapper.text()).toContain('Amsterdam')
148
+ expect(wrapper.text()).toContain('Rotterdam')
149
+ })
150
+
151
+ it('removes a value when its chip is clicked', async () => {
152
+ wrapper = mountPanel({}, ['Amsterdam', 'Rotterdam'])
153
+ await wrapper.find('button[aria-label]').trigger('click')
154
+ const emitted = wrapper.emitted('update:modelValue')
155
+ expect(emitted).toBeTruthy()
156
+ const lastEmit = emitted![emitted!.length - 1][0] as (string | number)[]
157
+ expect(lastEmit).not.toContain('Amsterdam')
158
+ })
159
+
160
+ it('chip buttons have descriptive aria-labels', () => {
161
+ wrapper = mountPanel({}, ['Amsterdam'])
162
+ expect(wrapper.find('button[aria-label]').attributes('aria-label')).toContain('Amsterdam')
163
+ })
164
+ })
165
+
166
+ describe('allowNew', () => {
167
+ it('shows add hint when search has no matching options', async () => {
168
+ wrapper = mountPanel({ allowNew: true })
169
+ await wrapper.find('input').setValue('Groningen')
170
+ await nextTick()
171
+ expect(wrapper.find('[data-name="plus"]').exists()).toBe(true)
172
+ })
173
+
174
+ it('does not show add hint when allowNew is false', async () => {
175
+ wrapper = mountPanel()
176
+ await wrapper.find('input').setValue('Groningen')
177
+ await nextTick()
178
+ expect(wrapper.find('[data-name="plus"]').exists()).toBe(false)
179
+ })
180
+
181
+ it('does not show add hint when search matches an existing option label', async () => {
182
+ wrapper = mountPanel({ allowNew: true })
183
+ await wrapper.find('input').setValue('Amsterdam')
184
+ await nextTick()
185
+ expect(wrapper.find('[data-name="plus"]').exists()).toBe(false)
186
+ })
187
+
188
+ it('does not show add hint when search matches a custom item already added', async () => {
189
+ wrapper = mountPanel({ allowNew: true })
190
+ await wrapper.find('input').setValue('Groningen')
191
+ await wrapper.find('[data-name="plus"]').trigger('click')
192
+ await nextTick()
193
+ await wrapper.find('input').setValue('Groningen')
194
+ await nextTick()
195
+ expect(wrapper.find('[data-name="plus"]').exists()).toBe(false)
196
+ })
197
+
198
+ it('adds a new custom item when clicking the add hint', async () => {
199
+ wrapper = mountPanel({ allowNew: true })
200
+ await wrapper.find('input').setValue('Groningen')
201
+ await nextTick()
202
+ await wrapper.find('[data-name="plus"]').trigger('click')
203
+ const emitted = wrapper.emitted('update:modelValue')
204
+ expect(emitted).toBeTruthy()
205
+ expect((emitted![emitted!.length - 1][0] as string[]).includes('Groningen')).toBe(true)
206
+ })
207
+
208
+ it('does not add the same custom item twice via Enter', async () => {
209
+ wrapper = mountPanel({ allowNew: true })
210
+ await wrapper.find('input').setValue('Groningen')
211
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
212
+ await nextTick()
213
+ await wrapper.setProps({ modelValue: ['Groningen'] })
214
+ await wrapper.find('input').setValue('Groningen')
215
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
216
+ await nextTick()
217
+ const emitted = wrapper.emitted('update:modelValue')
218
+ const lastEmit = emitted![emitted!.length - 1][0] as string[]
219
+ expect(lastEmit.filter((v) => v === 'Groningen')).toHaveLength(1)
220
+ })
221
+ })
222
+
223
+ describe('enter key behaviour', () => {
224
+ it('selects the single remaining option on Enter', async () => {
225
+ wrapper = mountPanel()
226
+ await wrapper.find('input').setValue('Amst')
227
+ await nextTick()
228
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
229
+ await nextTick()
230
+ const emitted = wrapper.emitted('update:modelValue')
231
+ expect(emitted).toBeTruthy()
232
+ expect((emitted![0][0] as string[]).includes('Amsterdam')).toBe(true)
233
+ })
234
+
235
+ it('adds new item on Enter when no options match', async () => {
236
+ wrapper = mountPanel({ allowNew: true })
237
+ await wrapper.find('input').setValue('Groningen')
238
+ await nextTick()
239
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
240
+ await nextTick()
241
+ const emitted = wrapper.emitted('update:modelValue')
242
+ expect(emitted).toBeTruthy()
243
+ expect((emitted![0][0] as string[]).includes('Groningen')).toBe(true)
244
+ })
245
+ })
246
+
247
+ describe('expose', () => {
248
+ it('exposes a focus() method', () => {
249
+ wrapper = mountPanel()
250
+ expect(typeof (wrapper.vm as unknown as { focus: () => void }).focus).toBe('function')
251
+ })
252
+ })
253
+
254
+ describe('showChips', () => {
255
+ it('shows chips by default', () => {
256
+ wrapper = mountPanel({}, ['Amsterdam'])
257
+ expect(wrapper.find('button[aria-label]').exists()).toBe(true)
258
+ })
259
+
260
+ it('hides chips when showChips is false', () => {
261
+ wrapper = mountPanel({ showChips: false }, ['Amsterdam'])
262
+ expect(wrapper.find('button[aria-label]').exists()).toBe(false)
263
+ })
264
+
265
+ it('hides chips by default when singleSelect is true', () => {
266
+ wrapper = mountPanel({ singleSelect: true }, ['Amsterdam'])
267
+ expect(wrapper.find('button[aria-label]').exists()).toBe(false)
268
+ })
269
+ })
270
+
271
+ describe('grouped options', () => {
272
+ const GROUPS: NexxtComboboxGroup[] = [
273
+ { label: 'Noord', options: ['Groningen', 'Leeuwarden'] },
274
+ {
275
+ label: 'Randstad',
276
+ options: [
277
+ { label: 'Amsterdam', value: 'AMS' },
278
+ { label: 'Rotterdam', value: 'RTM' },
279
+ ],
280
+ },
281
+ ]
282
+
283
+ // `floating` has to be passed explicitly: Vue casts an absent boolean prop to `false`
284
+ const mountGrouped = async (floating: boolean, props = {}, modelValue: string[] = []) => {
285
+ wrapper = mountPanel({ options: GROUPS, floating, ...props }, modelValue)
286
+ if (floating) await wrapper.find('input').trigger('focus')
287
+ return wrapper
288
+ }
289
+
290
+ const optionTexts = () => wrapper.findAll('[role="option"]').map((o) => o.text())
291
+ const groupNames = () =>
292
+ wrapper.findAll('[role="group"]').map((group) => {
293
+ const heading = document.getElementById(group.attributes('aria-labelledby')!)
294
+ return heading?.textContent
295
+ })
296
+
297
+ describe.each([true, false])('floating: %s', (floating) => {
298
+ it('renders each group inside the listbox, named by its heading', async () => {
299
+ await mountGrouped(floating)
300
+ const groups = wrapper.findAll('[role="listbox"] [role="group"]')
301
+ expect(groups).toHaveLength(2)
302
+ expect(groupNames()).toEqual(['Noord', 'Randstad'])
303
+ expect(groups[1].findAll('[role="option"]').map((o) => o.text())).toEqual([
304
+ 'Amsterdam',
305
+ 'Rotterdam',
306
+ ])
307
+ })
308
+
309
+ it('indents grouped options past their heading, and leaves flat options as they were', async () => {
310
+ await mountGrouped(floating)
311
+ const heading = wrapper.find('[role="group"] > [role="presentation"]')
312
+ expect(heading.classes()).toContain('px-6')
313
+ for (const option of wrapper.findAll('[role="option"]')) {
314
+ expect(option.classes()).toEqual(expect.arrayContaining(['pl-9', 'pr-6']))
315
+ expect(option.classes()).not.toContain('px-6')
316
+ }
317
+
318
+ wrapper = mountPanel({ options: STRING_OPTIONS, floating })
319
+ if (floating) await wrapper.find('input').trigger('focus')
320
+ for (const option of wrapper.findAll('[role="option"]')) {
321
+ expect(option.classes()).toContain('px-6')
322
+ expect(option.classes()).not.toContain('pl-9')
323
+ }
324
+ })
325
+
326
+ it('renders the heading as presentation, not as an option', async () => {
327
+ await mountGrouped(floating)
328
+ const heading = wrapper.find('[role="group"] > [role="presentation"]')
329
+ expect(heading.text()).toBe('Noord')
330
+ expect(optionTexts()).not.toContain('Noord')
331
+ })
332
+
333
+ it('filters within groups and drops a group without matches, heading included', async () => {
334
+ await mountGrouped(floating)
335
+ await wrapper.find('input').setValue('dam')
336
+ expect(groupNames()).toEqual(['Randstad'])
337
+ expect(optionTexts()).toEqual(['Amsterdam', 'Rotterdam'])
338
+ expect(wrapper.text()).not.toContain('Noord')
339
+ })
340
+
341
+ it('matches on the option label, not on the group label', async () => {
342
+ await mountGrouped(floating)
343
+ await wrapper.find('input').setValue('Randstad')
344
+ expect(optionTexts()).toEqual([])
345
+ })
346
+
347
+ it('shows the same empty state as flat options when nothing matches', async () => {
348
+ // Floating: the whole suggestion card is gone. Inline: an empty listbox stays
349
+ const listbox = () => {
350
+ const found = wrapper.find('[role="listbox"]')
351
+ return found.exists() ? found.html() : null
352
+ }
353
+ await mountGrouped(floating)
354
+ await wrapper.find('input').setValue('Zwolle')
355
+ const grouped = listbox()
356
+
357
+ wrapper = mountPanel({ options: ['Groningen'], floating })
358
+ if (floating) await wrapper.find('input').trigger('focus')
359
+ await wrapper.find('input').setValue('Zwolle')
360
+ expect(grouped).toBe(listbox())
361
+ expect(wrapper.find('[role="option"]').exists()).toBe(false)
362
+ })
363
+ })
364
+
365
+ it('gives every group a heading id unique to its component instance', () => {
366
+ // useId is unique per app, so both panels have to live in the same one
367
+ const host = mount(
368
+ {
369
+ components: { ComboboxPanel },
370
+ setup: () => ({ GROUPS }),
371
+ template: `
372
+ <ComboboxPanel :options="GROUPS" :floating="false" />
373
+ <ComboboxPanel :options="GROUPS" :floating="false" />
374
+ `,
375
+ },
376
+ { global: { stubs } },
377
+ )
378
+ const ids = host.findAll('[role="group"]').map((group) => group.attributes('aria-labelledby'))
379
+ expect(ids).toHaveLength(4)
380
+ expect(new Set(ids).size).toBe(4)
381
+ })
382
+
383
+ it('keeps the heading id while searching', async () => {
384
+ await mountGrouped(false)
385
+ const before = wrapper.findAll('[role="group"]')[1].attributes('aria-labelledby')
386
+ await wrapper.find('input').setValue('dam')
387
+ expect(wrapper.find('[role="group"]').attributes('aria-labelledby')).toBe(before)
388
+ })
389
+
390
+ it('shows the label of a selected object option from a group on its chip', async () => {
391
+ await mountGrouped(false, {}, ['AMS'])
392
+ expect(wrapper.find('button[aria-label]').attributes('aria-label')).toBe(
393
+ 'Verwijder Amsterdam',
394
+ )
395
+ })
396
+
397
+ it('removes a selected option from its group in multi-select', async () => {
398
+ await mountGrouped(false, {}, ['Groningen'])
399
+ expect(optionTexts()).toEqual(['Leeuwarden', 'Amsterdam', 'Rotterdam'])
400
+ })
401
+
402
+ describe('singleSelect', () => {
403
+ it('selects on click and emits select', async () => {
404
+ await mountGrouped(false, { singleSelect: true })
405
+ const rotterdam = wrapper.findAll('[role="option"]').find((o) => o.text() === 'Rotterdam')!
406
+ await rotterdam.trigger('click')
407
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['RTM']])
408
+ expect(wrapper.emitted('select')).toHaveLength(1)
409
+ })
410
+
411
+ it('selects the first visible option across groups on Enter', async () => {
412
+ await mountGrouped(true, { singleSelect: true })
413
+ // The first group has no match, so the first visible option sits in the second group
414
+ await wrapper.find('input').setValue('rot')
415
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
416
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['RTM']])
417
+ expect(wrapper.emitted('select')).toHaveLength(1)
418
+ })
419
+
420
+ it('selects the first option of the first group on Enter without a search', async () => {
421
+ await mountGrouped(false, { singleSelect: true })
422
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
423
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['Groningen']])
424
+ })
425
+
426
+ it('marks the selected option inside its group', async () => {
427
+ await mountGrouped(false, { singleSelect: true }, ['AMS'])
428
+ const amsterdam = wrapper.findAll('[role="option"]').find((o) => o.text() === 'Amsterdam')!
429
+ expect(amsterdam.attributes('aria-selected')).toBe('true')
430
+ expect(amsterdam.find('[data-name="check"]').exists()).toBe(true)
431
+ })
432
+ })
433
+
434
+ // Type-level: vue-tsc fails on an unused @ts-expect-error, and the valid twin pins the
435
+ // error on allowNew rather than on the groups themselves
436
+ it('does not accept allowNew together with groups', () => {
437
+ type PanelProps = InstanceType<typeof ComboboxPanel>['$props']
438
+ const valid: PanelProps = { options: GROUPS, allowNew: undefined }
439
+ // @ts-expect-error -- groups have no room for free-text entries
440
+ const invalid: PanelProps = { options: GROUPS, allowNew: true }
441
+ expect([valid.options, invalid.options]).toEqual([GROUPS, GROUPS])
442
+ })
443
+ })
444
+
445
+ describe('singleSelect', () => {
446
+ it('keeps selected options visible in the list', () => {
447
+ wrapper = mountPanel({ singleSelect: true }, ['Amsterdam'])
448
+ expect(wrapper.findAll('[role="option"]').map((o) => o.text())).toContain('Amsterdam')
449
+ })
450
+
451
+ it('replaces the selection when a different option is clicked', async () => {
452
+ wrapper = mountPanel({ singleSelect: true }, ['Amsterdam'])
453
+ const rotterdam = wrapper
454
+ .findAll('[role="option"]')
455
+ .find((o) => o.text().includes('Rotterdam'))!
456
+ await rotterdam.trigger('click')
457
+ const emitted = wrapper.emitted('update:modelValue')
458
+ expect(emitted).toBeTruthy()
459
+ const last = emitted![emitted!.length - 1][0] as (string | number)[]
460
+ expect(last).toEqual(['Rotterdam'])
461
+ })
462
+
463
+ it('deselects when the selected option is clicked again', async () => {
464
+ wrapper = mountPanel({ singleSelect: true }, ['Amsterdam'])
465
+ const amsterdam = wrapper
466
+ .findAll('[role="option"]')
467
+ .find((o) => o.text().includes('Amsterdam'))!
468
+ await amsterdam.trigger('click')
469
+ const emitted = wrapper.emitted('update:modelValue')
470
+ expect(emitted).toBeTruthy()
471
+ const last = emitted![emitted!.length - 1][0] as (string | number)[]
472
+ expect(last).toHaveLength(0)
473
+ })
474
+
475
+ it('shows a check icon on the selected option', () => {
476
+ wrapper = mountPanel({ singleSelect: true }, ['Amsterdam'])
477
+ const amsterdam = wrapper
478
+ .findAll('[role="option"]')
479
+ .find((o) => o.text().includes('Amsterdam'))!
480
+ expect(amsterdam.find('[data-name="check"]').exists()).toBe(true)
481
+ })
482
+
483
+ it('does not show a check icon on unselected options', () => {
484
+ wrapper = mountPanel({ singleSelect: true }, ['Amsterdam'])
485
+ const rotterdam = wrapper
486
+ .findAll('[role="option"]')
487
+ .find((o) => o.text().includes('Rotterdam'))!
488
+ expect(rotterdam.find('[data-name="check"]').exists()).toBe(false)
489
+ })
490
+
491
+ it('applies selected highlight class to the active option', () => {
492
+ wrapper = mountPanel({ singleSelect: true }, ['Amsterdam'])
493
+ const amsterdam = wrapper
494
+ .findAll('[role="option"]')
495
+ .find((o) => o.text().includes('Amsterdam'))!
496
+ expect(amsterdam.classes()).toContain('text-cornflower-blue-600')
497
+ })
498
+
499
+ it('sets aria-selected="true" on the selected option', () => {
500
+ wrapper = mountPanel({ singleSelect: true }, ['Amsterdam'])
501
+ const amsterdam = wrapper
502
+ .findAll('[role="option"]')
503
+ .find((o) => o.text().includes('Amsterdam'))!
504
+ expect(amsterdam.attributes('aria-selected')).toBe('true')
505
+ })
506
+
507
+ it('emits select event after picking an option', async () => {
508
+ wrapper = mountPanel({ singleSelect: true })
509
+ await wrapper.find('[role="option"]').trigger('click')
510
+ expect(wrapper.emitted('select')).toBeTruthy()
511
+ })
512
+
513
+ it('selects the first matching option on Enter', async () => {
514
+ wrapper = mountPanel({ singleSelect: true })
515
+ await wrapper.find('input').setValue('Amst')
516
+ await nextTick()
517
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
518
+ await nextTick()
519
+ const emitted = wrapper.emitted('update:modelValue')
520
+ expect(emitted).toBeTruthy()
521
+ expect((emitted![0][0] as string[]).includes('Amsterdam')).toBe(true)
522
+ })
523
+ })
524
+ })
@@ -22,7 +22,7 @@ const { icon = 'tag', placeholder = 'Zoeken...', floating = true } = props
22
22
 
23
23
  const emit = defineEmits<{ select: [] }>()
24
24
 
25
- const selected = defineModel<(string | number)[]>({ default: [] })
25
+ const selected = defineModel<(string | number)[]>({ default: () => [] })
26
26
 
27
27
  const inputFieldRef = useTemplateRef<InstanceType<typeof NexxtInputField>>('inputFieldRef')
28
28
  const wrapperRef = useTemplateRef('wrapperRef')
@@ -0,0 +1,139 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { expect, within } from 'storybook/test'
3
+ import NexxtCountrySelect from './CountrySelect.vue'
4
+
5
+ export default {
6
+ title: 'Components/Organisms/CountrySelect',
7
+ component: NexxtCountrySelect,
8
+ decorators: [
9
+ () => ({
10
+ template:
11
+ '<div style="min-height: 350px; padding: 16px; display: flex; justify-content: center;"><story /></div>',
12
+ }),
13
+ ],
14
+ } satisfies Meta<typeof NexxtCountrySelect>
15
+
16
+ type Story = StoryObj<typeof NexxtCountrySelect>
17
+
18
+ const render = (args: Story['args']) => ({
19
+ components: { NexxtCountrySelect },
20
+ setup() {
21
+ return { args }
22
+ },
23
+ template: `
24
+ <NexxtCountrySelect
25
+ v-bind="args"
26
+ :modelValue="args.modelValue"
27
+ @update:modelValue="args.modelValue = $event"
28
+ />
29
+ `,
30
+ })
31
+
32
+ export const Default: Story = {
33
+ render,
34
+ args: {
35
+ modelValue: null,
36
+ },
37
+ }
38
+
39
+ export const WithLabel: Story = {
40
+ render,
41
+ args: {
42
+ label: 'Land',
43
+ modelValue: null,
44
+ },
45
+ }
46
+
47
+ export const WithValue: Story = {
48
+ render,
49
+ args: {
50
+ label: 'Land',
51
+ modelValue: 'Nederland',
52
+ },
53
+ }
54
+
55
+ export const Required: Story = {
56
+ render,
57
+ args: {
58
+ label: 'Land',
59
+ required: true,
60
+ modelValue: null,
61
+ },
62
+ }
63
+
64
+ export const WithPinnedCountries: Story = {
65
+ render,
66
+ args: {
67
+ label: 'Land',
68
+ pinnedCountries: ['nl', 'be', 'de'],
69
+ modelValue: null,
70
+ },
71
+ }
72
+
73
+ export const Error: Story = {
74
+ render,
75
+ args: {
76
+ label: 'Land',
77
+ error: true,
78
+ errorMessage: 'Selecteer een land',
79
+ modelValue: null,
80
+ },
81
+ }
82
+
83
+ /**
84
+ * Narrow fields. Without a width of its own the select is 240px wide, also
85
+ * inside a shrink-to-fit wrapper (`w-fit`, or `absolute` without a width), but
86
+ * never wider than its container: in a 200px column it is 200px, with or
87
+ * without `w-full`. A width class of your own (`w-48`) just works; no `min-w-0`
88
+ * needed. A country name that does not fit is cut off with an ellipsis, and the chevron
89
+ * stays inside the ring.
90
+ */
91
+ export const Narrow: Story = {
92
+ render: (args) => ({
93
+ components: { NexxtCountrySelect },
94
+ setup() {
95
+ return { args }
96
+ },
97
+ template: `
98
+ <div style="position: relative; display: flex; flex-direction: column; gap: 24px; padding-bottom: 64px;">
99
+ <NexxtCountrySelect v-bind="args" class="w-60" />
100
+ <NexxtCountrySelect v-bind="args" class="w-48" />
101
+ <div style="width: 200px;">
102
+ <NexxtCountrySelect v-bind="args" class="w-full" />
103
+ </div>
104
+ <div style="width: 200px;">
105
+ <NexxtCountrySelect v-bind="args" />
106
+ </div>
107
+ <div class="w-fit">
108
+ <NexxtCountrySelect v-bind="args" />
109
+ </div>
110
+ <div style="position: absolute; bottom: 0; left: 0;">
111
+ <NexxtCountrySelect v-bind="args" />
112
+ </div>
113
+ </div>
114
+ `,
115
+ }),
116
+ args: {
117
+ modelValue: 'Saint Vincent en de Grenadines',
118
+ },
119
+ play: async ({ canvasElement }) => {
120
+ // In render order. The field has no accessible name to select by (its
121
+ // <label> cannot label a div).
122
+ const triggers = within(canvasElement).getAllByRole('combobox')
123
+ const expected = [240, 192, 200, 200, 240, 240]
124
+
125
+ for (const [index, trigger] of triggers.entries()) {
126
+ const width = expected[index]
127
+ // offsetWidth, not the rect: the test canvas is zoomed.
128
+ await expect(trigger.offsetWidth).toBe(width)
129
+
130
+ const chevron = trigger.lastElementChild as HTMLElement
131
+ const ring = trigger.getBoundingClientRect()
132
+ await expect(chevron.getBoundingClientRect().right).toBeLessThanOrEqual(ring.right)
133
+
134
+ const text = within(trigger).getByText('Saint Vincent en de Grenadines')
135
+ await expect(text.scrollWidth).toBeGreaterThan(text.clientWidth)
136
+ await expect(getComputedStyle(text).textOverflow).toBe('ellipsis')
137
+ }
138
+ },
139
+ }