@sabrenski/spire-ui 0.0.1

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 (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/index.d.ts +4981 -0
  4. package/dist/spire-ui.css +1 -0
  5. package/dist/spire-ui.es.js +18403 -0
  6. package/dist/spire-ui.umd.js +45 -0
  7. package/package.json +83 -0
  8. package/src/components/Accordion/Accordion.test.ts +218 -0
  9. package/src/components/Accordion/AccordionContent.vue +112 -0
  10. package/src/components/Accordion/AccordionItem.vue +87 -0
  11. package/src/components/Accordion/AccordionRoot.vue +111 -0
  12. package/src/components/Accordion/AccordionTrigger.vue +125 -0
  13. package/src/components/Accordion/index.ts +11 -0
  14. package/src/components/Accordion/keys.ts +23 -0
  15. package/src/components/Avatar/Avatar.test.ts +181 -0
  16. package/src/components/Avatar/Avatar.vue +150 -0
  17. package/src/components/Avatar/index.ts +2 -0
  18. package/src/components/Badge/Badge.test.ts +141 -0
  19. package/src/components/Badge/Badge.vue +133 -0
  20. package/src/components/Badge/index.ts +2 -0
  21. package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
  22. package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
  23. package/src/components/BadgeContainer/index.ts +2 -0
  24. package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
  25. package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
  26. package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
  27. package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
  28. package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
  29. package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
  30. package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
  31. package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
  32. package/src/components/Breadcrumb/index.ts +13 -0
  33. package/src/components/Breadcrumb/keys.ts +7 -0
  34. package/src/components/Button/Button.test.ts +231 -0
  35. package/src/components/Button/Button.vue +349 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Callout/Callout.test.ts +260 -0
  38. package/src/components/Callout/Callout.vue +341 -0
  39. package/src/components/Callout/index.ts +2 -0
  40. package/src/components/Card/Card.test.ts +565 -0
  41. package/src/components/Card/Card.vue +209 -0
  42. package/src/components/Card/CardContent.vue +57 -0
  43. package/src/components/Card/CardFooter.vue +72 -0
  44. package/src/components/Card/CardHeader.vue +111 -0
  45. package/src/components/Card/CardImage.vue +124 -0
  46. package/src/components/Card/index.ts +14 -0
  47. package/src/components/Chart/BarChart.vue +208 -0
  48. package/src/components/Chart/BaseChart.vue +444 -0
  49. package/src/components/Chart/Chart.test.ts +359 -0
  50. package/src/components/Chart/DonutChart.vue +283 -0
  51. package/src/components/Chart/LineChart.vue +211 -0
  52. package/src/components/Chart/index.ts +20 -0
  53. package/src/components/Chart/useChartTheme.ts +192 -0
  54. package/src/components/Checkbox/Checkbox.test.ts +209 -0
  55. package/src/components/Checkbox/Checkbox.vue +285 -0
  56. package/src/components/Checkbox/index.ts +2 -0
  57. package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
  58. package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
  59. package/src/components/ChoiceChip/index.ts +2 -0
  60. package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
  61. package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
  62. package/src/components/ChoiceChipGroup/index.ts +2 -0
  63. package/src/components/ColorPicker/ColorArea.vue +159 -0
  64. package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
  65. package/src/components/ColorPicker/ColorPicker.vue +339 -0
  66. package/src/components/ColorPicker/ColorSlider.vue +191 -0
  67. package/src/components/ColorPicker/index.ts +7 -0
  68. package/src/components/Combobox/Combobox.test.ts +891 -0
  69. package/src/components/Combobox/Combobox.vue +934 -0
  70. package/src/components/Combobox/index.ts +2 -0
  71. package/src/components/DataTable/DataTable.test.ts +1221 -0
  72. package/src/components/DataTable/DataTable.vue +1415 -0
  73. package/src/components/DataTable/index.ts +10 -0
  74. package/src/components/DatePicker/DatePicker.test.ts +625 -0
  75. package/src/components/DatePicker/DatePicker.vue +1586 -0
  76. package/src/components/DatePicker/index.ts +2 -0
  77. package/src/components/Drawer/Drawer.test.ts +336 -0
  78. package/src/components/Drawer/Drawer.vue +466 -0
  79. package/src/components/Drawer/index.ts +2 -0
  80. package/src/components/Dropdown/Dropdown.test.ts +607 -0
  81. package/src/components/Dropdown/Dropdown.vue +807 -0
  82. package/src/components/Dropdown/DropdownItem.vue +227 -0
  83. package/src/components/Dropdown/DropdownSeparator.vue +14 -0
  84. package/src/components/Dropdown/DropdownSub.vue +104 -0
  85. package/src/components/Dropdown/DropdownSubContent.vue +187 -0
  86. package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
  87. package/src/components/Dropdown/index.ts +14 -0
  88. package/src/components/EmptyState/EmptyState.test.ts +180 -0
  89. package/src/components/EmptyState/EmptyState.vue +137 -0
  90. package/src/components/EmptyState/index.ts +2 -0
  91. package/src/components/FileUpload/FileUpload.test.ts +1151 -0
  92. package/src/components/FileUpload/FileUpload.vue +1042 -0
  93. package/src/components/FileUpload/index.ts +2 -0
  94. package/src/components/Heading/Heading.test.ts +107 -0
  95. package/src/components/Heading/Heading.vue +67 -0
  96. package/src/components/Heading/index.ts +2 -0
  97. package/src/components/Icon/Icon.test.ts +157 -0
  98. package/src/components/Icon/Icon.vue +86 -0
  99. package/src/components/Icon/index.ts +2 -0
  100. package/src/components/Input/Input.test.ts +273 -0
  101. package/src/components/Input/Input.vue +388 -0
  102. package/src/components/Input/index.ts +2 -0
  103. package/src/components/Layout/Container.vue +67 -0
  104. package/src/components/Layout/Grid.vue +159 -0
  105. package/src/components/Layout/GridItem.vue +154 -0
  106. package/src/components/Layout/Layout.test.ts +202 -0
  107. package/src/components/Layout/Stack.vue +128 -0
  108. package/src/components/Layout/index.ts +9 -0
  109. package/src/components/Layout/keys.ts +7 -0
  110. package/src/components/Modal/Modal.test.ts +311 -0
  111. package/src/components/Modal/Modal.vue +336 -0
  112. package/src/components/Modal/index.ts +2 -0
  113. package/src/components/Pagination/Pagination.test.ts +303 -0
  114. package/src/components/Pagination/Pagination.vue +212 -0
  115. package/src/components/Pagination/index.ts +3 -0
  116. package/src/components/Pagination/utils.ts +86 -0
  117. package/src/components/Popover/Popover.test.ts +285 -0
  118. package/src/components/Popover/Popover.vue +441 -0
  119. package/src/components/Popover/index.ts +2 -0
  120. package/src/components/Progress/Progress.test.ts +361 -0
  121. package/src/components/Progress/Progress.vue +363 -0
  122. package/src/components/Progress/index.ts +7 -0
  123. package/src/components/Radio/Radio.test.ts +216 -0
  124. package/src/components/Radio/Radio.vue +214 -0
  125. package/src/components/Radio/index.ts +2 -0
  126. package/src/components/Rating/Rating.test.ts +319 -0
  127. package/src/components/Rating/Rating.vue +247 -0
  128. package/src/components/Rating/index.ts +2 -0
  129. package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
  130. package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
  131. package/src/components/SegmentedControl/index.ts +2 -0
  132. package/src/components/Select/Select.test.ts +589 -0
  133. package/src/components/Select/Select.vue +666 -0
  134. package/src/components/Select/index.ts +2 -0
  135. package/src/components/Sidebar/Sidebar.test.ts +301 -0
  136. package/src/components/Sidebar/SidebarGroup.vue +103 -0
  137. package/src/components/Sidebar/SidebarItem.vue +196 -0
  138. package/src/components/Sidebar/SidebarLayout.vue +42 -0
  139. package/src/components/Sidebar/SidebarRoot.vue +122 -0
  140. package/src/components/Sidebar/index.ts +11 -0
  141. package/src/components/Sidebar/keys.ts +14 -0
  142. package/src/components/Skeleton/Skeleton.test.ts +130 -0
  143. package/src/components/Skeleton/Skeleton.vue +104 -0
  144. package/src/components/Skeleton/index.ts +2 -0
  145. package/src/components/Slider/Slider.test.ts +416 -0
  146. package/src/components/Slider/Slider.vue +435 -0
  147. package/src/components/Slider/index.ts +2 -0
  148. package/src/components/Slider/utils.ts +91 -0
  149. package/src/components/Spinner/Spinner.test.ts +79 -0
  150. package/src/components/Spinner/Spinner.vue +159 -0
  151. package/src/components/Spinner/index.ts +2 -0
  152. package/src/components/SpireProvider/SpireProvider.vue +71 -0
  153. package/src/components/SpireProvider/index.ts +11 -0
  154. package/src/components/Stepper/Stepper.test.ts +221 -0
  155. package/src/components/Stepper/StepperContent.vue +51 -0
  156. package/src/components/Stepper/StepperItem.vue +89 -0
  157. package/src/components/Stepper/StepperRoot.vue +101 -0
  158. package/src/components/Stepper/StepperSeparator.vue +52 -0
  159. package/src/components/Stepper/StepperTrigger.vue +144 -0
  160. package/src/components/Stepper/index.ts +11 -0
  161. package/src/components/Stepper/keys.ts +27 -0
  162. package/src/components/Switch/Switch.test.ts +214 -0
  163. package/src/components/Switch/Switch.vue +235 -0
  164. package/src/components/Switch/index.ts +2 -0
  165. package/src/components/Tabs/Tabs.test.ts +363 -0
  166. package/src/components/Tabs/Tabs.vue +318 -0
  167. package/src/components/Tabs/index.ts +2 -0
  168. package/src/components/Text/Text.test.ts +154 -0
  169. package/src/components/Text/Text.vue +100 -0
  170. package/src/components/Text/index.ts +2 -0
  171. package/src/components/Textarea/Textarea.test.ts +432 -0
  172. package/src/components/Textarea/Textarea.vue +411 -0
  173. package/src/components/Textarea/index.ts +2 -0
  174. package/src/components/TimePicker/TimePicker.test.ts +352 -0
  175. package/src/components/TimePicker/TimePicker.vue +569 -0
  176. package/src/components/TimePicker/index.ts +2 -0
  177. package/src/components/Timeline/Timeline.test.ts +193 -0
  178. package/src/components/Timeline/Timeline.vue +111 -0
  179. package/src/components/Timeline/TimelineItem.vue +167 -0
  180. package/src/components/Timeline/index.ts +13 -0
  181. package/src/components/Timeline/keys.ts +21 -0
  182. package/src/components/Toast/ToastItem.test.ts +289 -0
  183. package/src/components/Toast/ToastItem.vue +370 -0
  184. package/src/components/Toast/ToastProvider.test.ts +158 -0
  185. package/src/components/Toast/ToastProvider.vue +181 -0
  186. package/src/components/Toast/index.ts +83 -0
  187. package/src/components/Toast/toastState.test.ts +165 -0
  188. package/src/components/Toast/toastState.ts +161 -0
  189. package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
  190. package/src/components/ToggleButton/ToggleButton.vue +197 -0
  191. package/src/components/ToggleButton/index.ts +2 -0
  192. package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
  193. package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
  194. package/src/components/ToggleGroup/index.ts +2 -0
  195. package/src/components/Tooltip/Tooltip.test.ts +238 -0
  196. package/src/components/Tooltip/Tooltip.vue +217 -0
  197. package/src/components/Tooltip/index.ts +2 -0
  198. package/src/components/TreeView/TreeView.test.ts +357 -0
  199. package/src/components/TreeView/TreeView.vue +251 -0
  200. package/src/components/TreeView/TreeViewItem.vue +288 -0
  201. package/src/components/TreeView/index.ts +11 -0
  202. package/src/components/TreeView/keys.ts +35 -0
  203. package/src/composables/index.ts +12 -0
  204. package/src/composables/useClickOutside.ts +36 -0
  205. package/src/composables/useClipboard.ts +35 -0
  206. package/src/composables/useEventListener.ts +48 -0
  207. package/src/composables/useFocusTrap.ts +58 -0
  208. package/src/composables/useHoverReveal.ts +98 -0
  209. package/src/composables/useId.ts +10 -0
  210. package/src/composables/useMagnetic.ts +171 -0
  211. package/src/composables/useRelativePosition.ts +127 -0
  212. package/src/composables/useRipple.ts +146 -0
  213. package/src/composables/useScrollLock.ts +25 -0
  214. package/src/composables/useSpireConfig.ts +27 -0
  215. package/src/composables/useStagger.ts +224 -0
  216. package/src/config/icons.test.ts +115 -0
  217. package/src/config/icons.ts +170 -0
  218. package/src/index.ts +361 -0
  219. package/src/styles/depth.css +129 -0
  220. package/src/styles/effects.css +169 -0
  221. package/src/styles/fallback.css +152 -0
  222. package/src/styles/main.css +25 -0
  223. package/src/styles/mood.css +211 -0
  224. package/src/styles/motion.css +159 -0
  225. package/src/styles/reset.css +97 -0
  226. package/src/styles/theme.css +708 -0
  227. package/src/styles/tokens.css +183 -0
  228. package/src/utils/.gitkeep +0 -0
  229. package/src/utils/color.ts +277 -0
  230. package/src/utils/date.test.ts +522 -0
  231. package/src/utils/date.ts +380 -0
  232. package/src/utils/index.ts +23 -0
  233. package/src/utils/object.test.ts +80 -0
  234. package/src/utils/object.ts +25 -0
  235. package/src/utils/string.test.ts +64 -0
  236. package/src/utils/string.ts +32 -0
  237. package/src/utils/time.ts +156 -0
@@ -0,0 +1,891 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { mount, config } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import Combobox from './Combobox.vue'
5
+
6
+ // Disable teleport for testing
7
+ config.global.stubs = {
8
+ teleport: true
9
+ }
10
+
11
+ const defaultOptions = [
12
+ { label: 'Apple', value: 'apple' },
13
+ { label: 'Banana', value: 'banana' },
14
+ { label: 'Cherry', value: 'cherry' }
15
+ ]
16
+
17
+ describe('Combobox', () => {
18
+ afterEach(() => {
19
+ vi.restoreAllMocks()
20
+ })
21
+
22
+ describe('Rendering', () => {
23
+ it('renders input with combobox role', () => {
24
+ const wrapper = mount(Combobox, {
25
+ props: { options: defaultOptions }
26
+ })
27
+ expect(wrapper.find('[role="combobox"]').exists()).toBe(true)
28
+ expect(wrapper.find('input[role="combobox"]').exists()).toBe(true)
29
+ })
30
+
31
+ it('renders label when provided', () => {
32
+ const wrapper = mount(Combobox, {
33
+ props: { options: defaultOptions, label: 'Select Fruit' }
34
+ })
35
+ expect(wrapper.find('label').text()).toBe('Select Fruit')
36
+ })
37
+
38
+ it('renders required indicator when required', () => {
39
+ const wrapper = mount(Combobox, {
40
+ props: { options: defaultOptions, label: 'Fruit', required: true }
41
+ })
42
+ expect(wrapper.find('.ui-combobox__required').exists()).toBe(true)
43
+ })
44
+
45
+ it('renders placeholder when no selection', () => {
46
+ const wrapper = mount(Combobox, {
47
+ props: { options: defaultOptions, placeholder: 'Choose...' }
48
+ })
49
+ expect(wrapper.find('input').attributes('placeholder')).toBe('Choose...')
50
+ })
51
+
52
+ it('renders hint text when provided', () => {
53
+ const wrapper = mount(Combobox, {
54
+ props: { options: defaultOptions, hint: 'Pick your favorite' }
55
+ })
56
+ expect(wrapper.find('.ui-combobox__message--hint').text()).toBe('Pick your favorite')
57
+ })
58
+
59
+ it('renders error text when provided', () => {
60
+ const wrapper = mount(Combobox, {
61
+ props: { options: defaultOptions, error: 'Selection required' }
62
+ })
63
+ expect(wrapper.find('.ui-combobox__message--error').text()).toBe('Selection required')
64
+ })
65
+
66
+ it('renders chevron icon', () => {
67
+ const wrapper = mount(Combobox, {
68
+ props: { options: defaultOptions }
69
+ })
70
+ expect(wrapper.find('.ui-combobox__chevron').exists()).toBe(true)
71
+ })
72
+ })
73
+
74
+ describe('Open/Close behavior', () => {
75
+ it('opens dropdown on focus', async () => {
76
+ const wrapper = mount(Combobox, {
77
+ props: { options: defaultOptions },
78
+ attachTo: document.body
79
+ })
80
+ await wrapper.find('input').trigger('focus')
81
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
82
+ wrapper.unmount()
83
+ })
84
+
85
+ it('does not open when disabled', async () => {
86
+ const wrapper = mount(Combobox, {
87
+ props: { options: defaultOptions, disabled: true },
88
+ attachTo: document.body
89
+ })
90
+ await wrapper.find('input').trigger('focus')
91
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
92
+ wrapper.unmount()
93
+ })
94
+
95
+ it('renders all options when open', async () => {
96
+ const wrapper = mount(Combobox, {
97
+ props: { options: defaultOptions },
98
+ attachTo: document.body
99
+ })
100
+ await wrapper.find('input').trigger('focus')
101
+ const options = wrapper.findAll('[role="option"]')
102
+ expect(options).toHaveLength(3)
103
+ wrapper.unmount()
104
+ })
105
+
106
+ it('closes with Escape key', async () => {
107
+ const wrapper = mount(Combobox, {
108
+ props: { options: defaultOptions },
109
+ attachTo: document.body
110
+ })
111
+ const input = wrapper.find('input')
112
+ await input.trigger('focus')
113
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
114
+ await input.trigger('keydown', { key: 'Escape' })
115
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
116
+ wrapper.unmount()
117
+ })
118
+
119
+ it('chevron rotates when open', async () => {
120
+ const wrapper = mount(Combobox, {
121
+ props: { options: defaultOptions },
122
+ attachTo: document.body
123
+ })
124
+ await wrapper.find('input').trigger('focus')
125
+ expect(wrapper.find('.ui-combobox__chevron--open').exists()).toBe(true)
126
+ wrapper.unmount()
127
+ })
128
+ })
129
+
130
+ describe('Filtering', () => {
131
+ it('filters options based on input value', async () => {
132
+ const wrapper = mount(Combobox, {
133
+ props: { options: defaultOptions },
134
+ attachTo: document.body
135
+ })
136
+ await wrapper.find('input').trigger('focus')
137
+ await wrapper.find('input').setValue('ban')
138
+ await nextTick()
139
+ const options = wrapper.findAll('[role="option"]')
140
+ expect(options).toHaveLength(1)
141
+ expect(options[0].text()).toContain('Banana')
142
+ wrapper.unmount()
143
+ })
144
+
145
+ it('shows "No results found" when no matches', async () => {
146
+ const wrapper = mount(Combobox, {
147
+ props: { options: defaultOptions },
148
+ attachTo: document.body
149
+ })
150
+ await wrapper.find('input').trigger('focus')
151
+ await wrapper.find('input').setValue('xyz')
152
+ await nextTick()
153
+ expect(wrapper.find('.ui-combobox__empty').exists()).toBe(true)
154
+ expect(wrapper.find('.ui-combobox__empty').text()).toBe('No results found')
155
+ wrapper.unmount()
156
+ })
157
+
158
+ it('filters case-insensitively', async () => {
159
+ const wrapper = mount(Combobox, {
160
+ props: { options: defaultOptions },
161
+ attachTo: document.body
162
+ })
163
+ await wrapper.find('input').trigger('focus')
164
+ await wrapper.find('input').setValue('BAN')
165
+ await nextTick()
166
+ const options = wrapper.findAll('[role="option"]')
167
+ expect(options).toHaveLength(1)
168
+ wrapper.unmount()
169
+ })
170
+ })
171
+
172
+ describe('Single selection', () => {
173
+ it('emits update:modelValue on option click', async () => {
174
+ const wrapper = mount(Combobox, {
175
+ props: { options: defaultOptions },
176
+ attachTo: document.body
177
+ })
178
+ await wrapper.find('input').trigger('focus')
179
+ const options = wrapper.findAll('[role="option"]')
180
+ await options[1].trigger('click')
181
+ expect(wrapper.emitted('update:modelValue')).toEqual([['banana']])
182
+ wrapper.unmount()
183
+ })
184
+
185
+ it('closes dropdown after selection in single mode', async () => {
186
+ const wrapper = mount(Combobox, {
187
+ props: { options: defaultOptions },
188
+ attachTo: document.body
189
+ })
190
+ await wrapper.find('input').trigger('focus')
191
+ const options = wrapper.findAll('[role="option"]')
192
+ await options[1].trigger('click')
193
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
194
+ wrapper.unmount()
195
+ })
196
+
197
+ it('updates input value to selected label', async () => {
198
+ const wrapper = mount(Combobox, {
199
+ props: { options: defaultOptions, modelValue: 'banana' },
200
+ attachTo: document.body
201
+ })
202
+ // When modelValue is set, input should be populated with the selected label
203
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('Banana')
204
+ wrapper.unmount()
205
+ })
206
+
207
+ it('does not emit when disabled option clicked', async () => {
208
+ const options = [
209
+ { label: 'Apple', value: 'apple' },
210
+ { label: 'Banana', value: 'banana', disabled: true }
211
+ ]
212
+ const wrapper = mount(Combobox, {
213
+ props: { options },
214
+ attachTo: document.body
215
+ })
216
+ await wrapper.find('input').trigger('focus')
217
+ const optionElements = wrapper.findAll('[role="option"]')
218
+ await optionElements[1].trigger('click')
219
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
220
+ wrapper.unmount()
221
+ })
222
+
223
+ it('shows checkmark for selected option', async () => {
224
+ const wrapper = mount(Combobox, {
225
+ props: { options: defaultOptions, modelValue: 'banana' },
226
+ attachTo: document.body
227
+ })
228
+ await wrapper.find('input').trigger('focus')
229
+ // Clear the input to show all options (modelValue populates input with selected label)
230
+ await wrapper.find('input').setValue('')
231
+ await nextTick()
232
+ const options = wrapper.findAll('[role="option"]')
233
+ const selectedOption = options[1] // Banana is at index 1
234
+ expect(selectedOption.find('.ui-combobox__check').exists()).toBe(true)
235
+ wrapper.unmount()
236
+ })
237
+ })
238
+
239
+ describe('Multi-selection', () => {
240
+ it('renders chips for selected values', () => {
241
+ const wrapper = mount(Combobox, {
242
+ props: { options: defaultOptions, modelValue: ['apple', 'banana'], multiple: true }
243
+ })
244
+ const chips = wrapper.findAll('.ui-combobox__chip')
245
+ expect(chips).toHaveLength(2)
246
+ expect(chips[0].text()).toContain('Apple')
247
+ expect(chips[1].text()).toContain('Banana')
248
+ })
249
+
250
+ it('emits array value when selecting in multi mode', async () => {
251
+ const wrapper = mount(Combobox, {
252
+ props: { options: defaultOptions, multiple: true },
253
+ attachTo: document.body
254
+ })
255
+ await wrapper.find('input').trigger('focus')
256
+ const options = wrapper.findAll('[role="option"]')
257
+ await options[0].trigger('click')
258
+ expect(wrapper.emitted('update:modelValue')).toEqual([[['apple']]])
259
+ wrapper.unmount()
260
+ })
261
+
262
+ it('keeps dropdown open in multi mode', async () => {
263
+ const wrapper = mount(Combobox, {
264
+ props: { options: defaultOptions, multiple: true },
265
+ attachTo: document.body
266
+ })
267
+ await wrapper.find('input').trigger('focus')
268
+ const options = wrapper.findAll('[role="option"]')
269
+ await options[0].trigger('click')
270
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
271
+ wrapper.unmount()
272
+ })
273
+
274
+ it('toggles selection in multi mode', async () => {
275
+ const wrapper = mount(Combobox, {
276
+ props: { options: defaultOptions, modelValue: ['apple'], multiple: true },
277
+ attachTo: document.body
278
+ })
279
+ await wrapper.find('input').trigger('focus')
280
+ const options = wrapper.findAll('[role="option"]')
281
+ // Click already selected option to deselect
282
+ await options[0].trigger('click')
283
+ expect(wrapper.emitted('update:modelValue')).toEqual([[[]]])
284
+ wrapper.unmount()
285
+ })
286
+
287
+ it('removes chip on X click', async () => {
288
+ const wrapper = mount(Combobox, {
289
+ props: { options: defaultOptions, modelValue: ['apple', 'banana'], multiple: true },
290
+ attachTo: document.body
291
+ })
292
+ const removeButtons = wrapper.findAll('.ui-combobox__chip-remove')
293
+ await removeButtons[0].trigger('click')
294
+ expect(wrapper.emitted('update:modelValue')).toEqual([[['banana']]])
295
+ wrapper.unmount()
296
+ })
297
+
298
+ it('shows checkbox in multi-select options', async () => {
299
+ const wrapper = mount(Combobox, {
300
+ props: { options: defaultOptions, multiple: true },
301
+ attachTo: document.body
302
+ })
303
+ await wrapper.find('input').trigger('focus')
304
+ const checkboxes = wrapper.findAll('.ui-combobox__checkbox')
305
+ expect(checkboxes.length).toBeGreaterThan(0)
306
+ wrapper.unmount()
307
+ })
308
+
309
+ it('checkbox shows checkmark when selected', async () => {
310
+ const wrapper = mount(Combobox, {
311
+ props: { options: defaultOptions, modelValue: ['apple'], multiple: true },
312
+ attachTo: document.body
313
+ })
314
+ await wrapper.find('input').trigger('focus')
315
+ const selectedOption = wrapper.findAll('[role="option"]')[0]
316
+ expect(selectedOption.find('.ui-combobox__checkbox svg').exists()).toBe(true)
317
+ wrapper.unmount()
318
+ })
319
+ })
320
+
321
+ describe('Backspace chip deletion', () => {
322
+ it('marks last chip on first backspace', async () => {
323
+ const wrapper = mount(Combobox, {
324
+ props: { options: defaultOptions, modelValue: ['apple', 'banana'], multiple: true },
325
+ attachTo: document.body
326
+ })
327
+ const input = wrapper.find('input')
328
+ await input.trigger('focus')
329
+ await input.trigger('keydown', { key: 'Backspace' })
330
+ expect(wrapper.find('.ui-combobox__chip--marked').exists()).toBe(true)
331
+ wrapper.unmount()
332
+ })
333
+
334
+ it('deletes marked chip on second backspace', async () => {
335
+ const wrapper = mount(Combobox, {
336
+ props: { options: defaultOptions, modelValue: ['apple', 'banana'], multiple: true },
337
+ attachTo: document.body
338
+ })
339
+ const input = wrapper.find('input')
340
+ await input.trigger('focus')
341
+ await input.trigger('keydown', { key: 'Backspace' })
342
+ await input.trigger('keydown', { key: 'Backspace' })
343
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['apple']])
344
+ wrapper.unmount()
345
+ })
346
+
347
+ it('does not trigger backspace delete when input has value', async () => {
348
+ const wrapper = mount(Combobox, {
349
+ props: { options: defaultOptions, modelValue: ['apple'], multiple: true },
350
+ attachTo: document.body
351
+ })
352
+ const input = wrapper.find('input')
353
+ await input.trigger('focus')
354
+ await input.setValue('test')
355
+ await input.trigger('keydown', { key: 'Backspace' })
356
+ expect(wrapper.find('.ui-combobox__chip--marked').exists()).toBe(false)
357
+ wrapper.unmount()
358
+ })
359
+ })
360
+
361
+ describe('Overflow handling', () => {
362
+ it('shows overflow badge when chips exceed maxDisplayedChips', () => {
363
+ const wrapper = mount(Combobox, {
364
+ props: {
365
+ options: defaultOptions,
366
+ modelValue: ['apple', 'banana', 'cherry'],
367
+ multiple: true,
368
+ maxDisplayedChips: 2
369
+ }
370
+ })
371
+ expect(wrapper.find('.ui-combobox__overflow').exists()).toBe(true)
372
+ expect(wrapper.find('.ui-combobox__overflow').text()).toBe('+1')
373
+ })
374
+
375
+ it('displays correct number of chips up to max', () => {
376
+ const wrapper = mount(Combobox, {
377
+ props: {
378
+ options: defaultOptions,
379
+ modelValue: ['apple', 'banana', 'cherry'],
380
+ multiple: true,
381
+ maxDisplayedChips: 2
382
+ }
383
+ })
384
+ const chips = wrapper.findAll('.ui-combobox__chip')
385
+ expect(chips).toHaveLength(2)
386
+ })
387
+
388
+ it('does not show overflow badge when within limit', () => {
389
+ const wrapper = mount(Combobox, {
390
+ props: {
391
+ options: defaultOptions,
392
+ modelValue: ['apple', 'banana'],
393
+ multiple: true,
394
+ maxDisplayedChips: 3
395
+ }
396
+ })
397
+ expect(wrapper.find('.ui-combobox__overflow').exists()).toBe(false)
398
+ })
399
+ })
400
+
401
+ describe('Keyboard navigation', () => {
402
+ it('opens with ArrowDown key', async () => {
403
+ const wrapper = mount(Combobox, {
404
+ props: { options: defaultOptions },
405
+ attachTo: document.body
406
+ })
407
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' })
408
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
409
+ wrapper.unmount()
410
+ })
411
+
412
+ it('opens with ArrowUp key', async () => {
413
+ const wrapper = mount(Combobox, {
414
+ props: { options: defaultOptions },
415
+ attachTo: document.body
416
+ })
417
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
418
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
419
+ wrapper.unmount()
420
+ })
421
+
422
+ it('navigates down with ArrowDown', async () => {
423
+ const wrapper = mount(Combobox, {
424
+ props: { options: defaultOptions },
425
+ attachTo: document.body
426
+ })
427
+ const input = wrapper.find('input')
428
+ await input.trigger('focus')
429
+ await input.trigger('keydown', { key: 'ArrowDown' })
430
+ await nextTick()
431
+ const options = wrapper.findAll('[role="option"]')
432
+ expect(options[1].classes()).toContain('ui-combobox__option--highlighted')
433
+ wrapper.unmount()
434
+ })
435
+
436
+ it('navigates up with ArrowUp', async () => {
437
+ const wrapper = mount(Combobox, {
438
+ props: { options: defaultOptions },
439
+ attachTo: document.body
440
+ })
441
+ const input = wrapper.find('input')
442
+ await input.trigger('focus')
443
+ // Navigate to end first, then up
444
+ await input.trigger('keydown', { key: 'End' })
445
+ await input.trigger('keydown', { key: 'ArrowUp' })
446
+ await nextTick()
447
+ const options = wrapper.findAll('[role="option"]')
448
+ // End goes to index 2 (Cherry), ArrowUp goes to index 1 (Banana)
449
+ expect(options[1].classes()).toContain('ui-combobox__option--highlighted')
450
+ wrapper.unmount()
451
+ })
452
+
453
+ it('wraps around at end', async () => {
454
+ const wrapper = mount(Combobox, {
455
+ props: { options: defaultOptions },
456
+ attachTo: document.body
457
+ })
458
+ const input = wrapper.find('input')
459
+ await input.trigger('focus')
460
+ // Navigate to last option
461
+ await input.trigger('keydown', { key: 'End' })
462
+ await nextTick()
463
+ // Then down should wrap to first
464
+ await input.trigger('keydown', { key: 'ArrowDown' })
465
+ await nextTick()
466
+ const options = wrapper.findAll('[role="option"]')
467
+ expect(options[0].classes()).toContain('ui-combobox__option--highlighted')
468
+ wrapper.unmount()
469
+ })
470
+
471
+ it('selects with Enter on highlighted option', async () => {
472
+ const wrapper = mount(Combobox, {
473
+ props: { options: defaultOptions },
474
+ attachTo: document.body
475
+ })
476
+ const input = wrapper.find('input')
477
+ await input.trigger('focus')
478
+ await input.trigger('keydown', { key: 'ArrowDown' })
479
+ await input.trigger('keydown', { key: 'Enter' })
480
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['banana'])
481
+ wrapper.unmount()
482
+ })
483
+
484
+ it('goes to first option with Home key', async () => {
485
+ const wrapper = mount(Combobox, {
486
+ props: { options: defaultOptions },
487
+ attachTo: document.body
488
+ })
489
+ const input = wrapper.find('input')
490
+ await input.trigger('focus')
491
+ await input.trigger('keydown', { key: 'End' })
492
+ await input.trigger('keydown', { key: 'Home' })
493
+ await nextTick()
494
+ const options = wrapper.findAll('[role="option"]')
495
+ expect(options[0].classes()).toContain('ui-combobox__option--highlighted')
496
+ wrapper.unmount()
497
+ })
498
+
499
+ it('goes to last option with End key', async () => {
500
+ const wrapper = mount(Combobox, {
501
+ props: { options: defaultOptions },
502
+ attachTo: document.body
503
+ })
504
+ const input = wrapper.find('input')
505
+ await input.trigger('focus')
506
+ await input.trigger('keydown', { key: 'End' })
507
+ await nextTick()
508
+ const options = wrapper.findAll('[role="option"]')
509
+ expect(options[2].classes()).toContain('ui-combobox__option--highlighted')
510
+ wrapper.unmount()
511
+ })
512
+
513
+ it('skips disabled options', async () => {
514
+ const options = [
515
+ { label: 'Apple', value: 'apple' },
516
+ { label: 'Banana', value: 'banana', disabled: true },
517
+ { label: 'Cherry', value: 'cherry' }
518
+ ]
519
+ const wrapper = mount(Combobox, {
520
+ props: { options },
521
+ attachTo: document.body
522
+ })
523
+ const input = wrapper.find('input')
524
+ await input.trigger('focus')
525
+ await input.trigger('keydown', { key: 'ArrowDown' })
526
+ await nextTick()
527
+ const optionElements = wrapper.findAll('[role="option"]')
528
+ expect(optionElements[2].classes()).toContain('ui-combobox__option--highlighted')
529
+ wrapper.unmount()
530
+ })
531
+ })
532
+
533
+ describe('Highlight on hover', () => {
534
+ it('highlights option on mouseenter', async () => {
535
+ const wrapper = mount(Combobox, {
536
+ props: { options: defaultOptions },
537
+ attachTo: document.body
538
+ })
539
+ await wrapper.find('input').trigger('focus')
540
+ await wrapper.findAll('[role="option"]')[2].trigger('mouseenter')
541
+ await nextTick()
542
+ const options = wrapper.findAll('[role="option"]')
543
+ expect(options[2].classes()).toContain('ui-combobox__option--highlighted')
544
+ wrapper.unmount()
545
+ })
546
+
547
+ it('does not highlight disabled option on mouseenter', async () => {
548
+ const options = [
549
+ { label: 'Apple', value: 'apple' },
550
+ { label: 'Banana', value: 'banana', disabled: true }
551
+ ]
552
+ const wrapper = mount(Combobox, {
553
+ props: { options },
554
+ attachTo: document.body
555
+ })
556
+ await wrapper.find('input').trigger('focus')
557
+ const optionElements = wrapper.findAll('[role="option"]')
558
+ await optionElements[1].trigger('mouseenter')
559
+ expect(optionElements[1].classes()).not.toContain('ui-combobox__option--highlighted')
560
+ wrapper.unmount()
561
+ })
562
+ })
563
+
564
+ describe('Create mode (allowCreate)', () => {
565
+ it('shows create option when allowCreate and no exact match', async () => {
566
+ const wrapper = mount(Combobox, {
567
+ props: { options: defaultOptions, allowCreate: true },
568
+ attachTo: document.body
569
+ })
570
+ await wrapper.find('input').trigger('focus')
571
+ await wrapper.find('input').setValue('Mango')
572
+ await nextTick()
573
+ expect(wrapper.find('.ui-combobox__option--create').exists()).toBe(true)
574
+ expect(wrapper.find('.ui-combobox__option--create').text()).toContain('Create "Mango"')
575
+ wrapper.unmount()
576
+ })
577
+
578
+ it('does not show create option when exact match exists', async () => {
579
+ const wrapper = mount(Combobox, {
580
+ props: { options: defaultOptions, allowCreate: true },
581
+ attachTo: document.body
582
+ })
583
+ await wrapper.find('input').trigger('focus')
584
+ await wrapper.find('input').setValue('Apple')
585
+ await nextTick()
586
+ expect(wrapper.find('.ui-combobox__option--create').exists()).toBe(false)
587
+ wrapper.unmount()
588
+ })
589
+
590
+ it('emits create event on create option click', async () => {
591
+ const wrapper = mount(Combobox, {
592
+ props: { options: defaultOptions, allowCreate: true },
593
+ attachTo: document.body
594
+ })
595
+ await wrapper.find('input').trigger('focus')
596
+ await wrapper.find('input').setValue('Mango')
597
+ await nextTick()
598
+ await wrapper.find('.ui-combobox__option--create').trigger('click')
599
+ expect(wrapper.emitted('create')).toEqual([['Mango']])
600
+ wrapper.unmount()
601
+ })
602
+
603
+ it('emits create via Enter when no options match', async () => {
604
+ const wrapper = mount(Combobox, {
605
+ props: { options: defaultOptions, allowCreate: true },
606
+ attachTo: document.body
607
+ })
608
+ const input = wrapper.find('input')
609
+ await input.trigger('focus')
610
+ await input.setValue('NewFruit')
611
+ await nextTick()
612
+ await input.trigger('keydown', { key: 'Enter' })
613
+ expect(wrapper.emitted('create')).toEqual([['NewFruit']])
614
+ wrapper.unmount()
615
+ })
616
+ })
617
+
618
+ describe('Disabled state', () => {
619
+ it('applies disabled attribute to input', () => {
620
+ const wrapper = mount(Combobox, {
621
+ props: { options: defaultOptions, disabled: true }
622
+ })
623
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
624
+ })
625
+
626
+ it('applies disabled class to container', () => {
627
+ const wrapper = mount(Combobox, {
628
+ props: { options: defaultOptions, disabled: true }
629
+ })
630
+ expect(wrapper.find('.ui-combobox--disabled').exists()).toBe(true)
631
+ })
632
+
633
+ it('applies disabled class to disabled options', async () => {
634
+ const options = [
635
+ { label: 'Apple', value: 'apple' },
636
+ { label: 'Banana', value: 'banana', disabled: true }
637
+ ]
638
+ const wrapper = mount(Combobox, {
639
+ props: { options },
640
+ attachTo: document.body
641
+ })
642
+ await wrapper.find('input').trigger('focus')
643
+ const optionElements = wrapper.findAll('[role="option"]')
644
+ expect(optionElements[1].classes()).toContain('ui-combobox__option--disabled')
645
+ wrapper.unmount()
646
+ })
647
+ })
648
+
649
+ describe('Error state', () => {
650
+ it('applies error class when error provided', () => {
651
+ const wrapper = mount(Combobox, {
652
+ props: { options: defaultOptions, error: 'Required' }
653
+ })
654
+ expect(wrapper.find('.ui-combobox--error').exists()).toBe(true)
655
+ })
656
+
657
+ it('applies error class to trigger', () => {
658
+ const wrapper = mount(Combobox, {
659
+ props: { options: defaultOptions, error: 'Required' }
660
+ })
661
+ expect(wrapper.find('.ui-combobox__trigger--error').exists()).toBe(true)
662
+ })
663
+
664
+ it('error has alert role', () => {
665
+ const wrapper = mount(Combobox, {
666
+ props: { options: defaultOptions, error: 'Required' }
667
+ })
668
+ expect(wrapper.find('.ui-combobox__message--error').attributes('role')).toBe('alert')
669
+ })
670
+ })
671
+
672
+ describe('Sizes', () => {
673
+ const sizes = ['sm', 'md', 'lg'] as const
674
+
675
+ sizes.forEach(size => {
676
+ it(`applies ${size} size class`, () => {
677
+ const wrapper = mount(Combobox, {
678
+ props: { options: defaultOptions, size }
679
+ })
680
+ expect(wrapper.find(`.ui-combobox--${size}`).exists()).toBe(true)
681
+ expect(wrapper.find(`.ui-combobox__trigger--${size}`).exists()).toBe(true)
682
+ })
683
+ })
684
+ })
685
+
686
+ describe('Block mode', () => {
687
+ it('applies block class when block prop is true', () => {
688
+ const wrapper = mount(Combobox, {
689
+ props: { options: defaultOptions, block: true }
690
+ })
691
+ expect(wrapper.find('.ui-combobox--block').exists()).toBe(true)
692
+ })
693
+ })
694
+
695
+ describe('Hidden native select', () => {
696
+ it('renders hidden native select when name provided', () => {
697
+ const wrapper = mount(Combobox, {
698
+ props: { options: defaultOptions, name: 'fruit' }
699
+ })
700
+ expect(wrapper.find('select').exists()).toBe(true)
701
+ })
702
+
703
+ it('does not render hidden select without name', () => {
704
+ const wrapper = mount(Combobox, {
705
+ props: { options: defaultOptions }
706
+ })
707
+ expect(wrapper.find('select').exists()).toBe(false)
708
+ })
709
+
710
+ it('hidden select has correct name attribute', () => {
711
+ const wrapper = mount(Combobox, {
712
+ props: { options: defaultOptions, name: 'fruit', modelValue: 'banana' }
713
+ })
714
+ const select = wrapper.find('select')
715
+ expect(select.attributes('name')).toBe('fruit')
716
+ })
717
+
718
+ it('hidden select supports multiple attribute', () => {
719
+ const wrapper = mount(Combobox, {
720
+ props: { options: defaultOptions, name: 'fruits', multiple: true }
721
+ })
722
+ const select = wrapper.find('select')
723
+ expect(select.attributes('multiple')).toBeDefined()
724
+ })
725
+
726
+ it('hidden select is aria-hidden', () => {
727
+ const wrapper = mount(Combobox, {
728
+ props: { options: defaultOptions, name: 'fruit' }
729
+ })
730
+ expect(wrapper.find('select').attributes('aria-hidden')).toBe('true')
731
+ })
732
+ })
733
+
734
+ describe('Accessibility', () => {
735
+ it('input has aria-expanded', async () => {
736
+ const wrapper = mount(Combobox, {
737
+ props: { options: defaultOptions },
738
+ attachTo: document.body
739
+ })
740
+ const input = wrapper.find('input')
741
+ expect(input.attributes('aria-expanded')).toBe('false')
742
+ await input.trigger('focus')
743
+ expect(input.attributes('aria-expanded')).toBe('true')
744
+ wrapper.unmount()
745
+ })
746
+
747
+ it('input has aria-controls linking to listbox', async () => {
748
+ const wrapper = mount(Combobox, {
749
+ props: { options: defaultOptions, id: 'test-combobox' },
750
+ attachTo: document.body
751
+ })
752
+ await wrapper.find('input').trigger('focus')
753
+ expect(wrapper.find('input').attributes('aria-controls')).toBe('test-combobox-listbox')
754
+ expect(wrapper.find('[role="listbox"]').attributes('id')).toBe('test-combobox-listbox')
755
+ wrapper.unmount()
756
+ })
757
+
758
+ it('options have role="option"', async () => {
759
+ const wrapper = mount(Combobox, {
760
+ props: { options: defaultOptions },
761
+ attachTo: document.body
762
+ })
763
+ await wrapper.find('input').trigger('focus')
764
+ wrapper.findAll('[role="option"]').forEach(option => {
765
+ expect(option.attributes('role')).toBe('option')
766
+ })
767
+ wrapper.unmount()
768
+ })
769
+
770
+ it('selected option has aria-selected="true"', async () => {
771
+ const wrapper = mount(Combobox, {
772
+ props: { options: defaultOptions, modelValue: 'banana' },
773
+ attachTo: document.body
774
+ })
775
+ await wrapper.find('input').trigger('focus')
776
+ // Clear input to show all options (modelValue populates input with selected label)
777
+ await wrapper.find('input').setValue('')
778
+ await nextTick()
779
+ const options = wrapper.findAll('[role="option"]')
780
+ expect(options[0].attributes('aria-selected')).toBe('false')
781
+ expect(options[1].attributes('aria-selected')).toBe('true')
782
+ expect(options[2].attributes('aria-selected')).toBe('false')
783
+ wrapper.unmount()
784
+ })
785
+
786
+ it('disabled options have aria-disabled', async () => {
787
+ const options = [
788
+ { label: 'Apple', value: 'apple' },
789
+ { label: 'Banana', value: 'banana', disabled: true }
790
+ ]
791
+ const wrapper = mount(Combobox, {
792
+ props: { options },
793
+ attachTo: document.body
794
+ })
795
+ await wrapper.find('input').trigger('focus')
796
+ const optionElements = wrapper.findAll('[role="option"]')
797
+ expect(optionElements[1].attributes('aria-disabled')).toBe('true')
798
+ wrapper.unmount()
799
+ })
800
+
801
+ it('input has aria-activedescendant when open', async () => {
802
+ const wrapper = mount(Combobox, {
803
+ props: { options: defaultOptions, id: 'test-combobox' },
804
+ attachTo: document.body
805
+ })
806
+ const input = wrapper.find('input')
807
+ await input.trigger('focus')
808
+ expect(input.attributes('aria-activedescendant')).toBe('test-combobox-option-0')
809
+ wrapper.unmount()
810
+ })
811
+
812
+ it('aria-invalid set when error exists', () => {
813
+ const wrapper = mount(Combobox, {
814
+ props: { options: defaultOptions, error: 'Required' }
815
+ })
816
+ expect(wrapper.find('input').attributes('aria-invalid')).toBe('true')
817
+ })
818
+
819
+ it('aria-required set when required', () => {
820
+ const wrapper = mount(Combobox, {
821
+ props: { options: defaultOptions, required: true }
822
+ })
823
+ expect(wrapper.find('input').attributes('aria-required')).toBe('true')
824
+ })
825
+
826
+ it('aria-describedby links to hint', () => {
827
+ const wrapper = mount(Combobox, {
828
+ props: { options: defaultOptions, hint: 'Choose wisely', id: 'test-combobox' }
829
+ })
830
+ expect(wrapper.find('input').attributes('aria-describedby')).toBe('test-combobox-hint')
831
+ expect(wrapper.find('.ui-combobox__message--hint').attributes('id')).toBe('test-combobox-hint')
832
+ })
833
+
834
+ it('aria-describedby links to error when present', () => {
835
+ const wrapper = mount(Combobox, {
836
+ props: { options: defaultOptions, hint: 'Choose wisely', error: 'Required', id: 'test-combobox' }
837
+ })
838
+ expect(wrapper.find('input').attributes('aria-describedby')).toBe('test-combobox-error')
839
+ })
840
+
841
+ it('label links to input via for attribute', () => {
842
+ const wrapper = mount(Combobox, {
843
+ props: { options: defaultOptions, label: 'Fruit', id: 'test-combobox' }
844
+ })
845
+ expect(wrapper.find('label').attributes('for')).toBe('test-combobox')
846
+ expect(wrapper.find('input').attributes('id')).toBe('test-combobox')
847
+ })
848
+
849
+ it('listbox has aria-labelledby', async () => {
850
+ const wrapper = mount(Combobox, {
851
+ props: { options: defaultOptions, id: 'test-combobox' },
852
+ attachTo: document.body
853
+ })
854
+ await wrapper.find('input').trigger('focus')
855
+ expect(wrapper.find('[role="listbox"]').attributes('aria-labelledby')).toBe('test-combobox')
856
+ wrapper.unmount()
857
+ })
858
+
859
+ it('listbox has aria-multiselectable in multi mode', async () => {
860
+ const wrapper = mount(Combobox, {
861
+ props: { options: defaultOptions, multiple: true },
862
+ attachTo: document.body
863
+ })
864
+ await wrapper.find('input').trigger('focus')
865
+ expect(wrapper.find('[role="listbox"]').attributes('aria-multiselectable')).toBe('true')
866
+ wrapper.unmount()
867
+ })
868
+
869
+ it('input has aria-autocomplete="list"', () => {
870
+ const wrapper = mount(Combobox, {
871
+ props: { options: defaultOptions }
872
+ })
873
+ expect(wrapper.find('input').attributes('aria-autocomplete')).toBe('list')
874
+ })
875
+ })
876
+
877
+ describe('Match highlighting', () => {
878
+ it('highlights matching text in options', async () => {
879
+ const wrapper = mount(Combobox, {
880
+ props: { options: defaultOptions },
881
+ attachTo: document.body
882
+ })
883
+ await wrapper.find('input').trigger('focus')
884
+ await wrapper.find('input').setValue('an')
885
+ await nextTick()
886
+ const optionLabel = wrapper.find('.ui-combobox__option-label')
887
+ expect(optionLabel.html()).toContain('<mark>')
888
+ wrapper.unmount()
889
+ })
890
+ })
891
+ })