@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.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/index.d.ts +4981 -0
- package/dist/spire-ui.css +1 -0
- package/dist/spire-ui.es.js +18403 -0
- package/dist/spire-ui.umd.js +45 -0
- package/package.json +83 -0
- package/src/components/Accordion/Accordion.test.ts +218 -0
- package/src/components/Accordion/AccordionContent.vue +112 -0
- package/src/components/Accordion/AccordionItem.vue +87 -0
- package/src/components/Accordion/AccordionRoot.vue +111 -0
- package/src/components/Accordion/AccordionTrigger.vue +125 -0
- package/src/components/Accordion/index.ts +11 -0
- package/src/components/Accordion/keys.ts +23 -0
- package/src/components/Avatar/Avatar.test.ts +181 -0
- package/src/components/Avatar/Avatar.vue +150 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Badge/Badge.test.ts +141 -0
- package/src/components/Badge/Badge.vue +133 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
- package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
- package/src/components/BadgeContainer/index.ts +2 -0
- package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
- package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
- package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
- package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
- package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
- package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
- package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
- package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
- package/src/components/Breadcrumb/index.ts +13 -0
- package/src/components/Breadcrumb/keys.ts +7 -0
- package/src/components/Button/Button.test.ts +231 -0
- package/src/components/Button/Button.vue +349 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Callout/Callout.test.ts +260 -0
- package/src/components/Callout/Callout.vue +341 -0
- package/src/components/Callout/index.ts +2 -0
- package/src/components/Card/Card.test.ts +565 -0
- package/src/components/Card/Card.vue +209 -0
- package/src/components/Card/CardContent.vue +57 -0
- package/src/components/Card/CardFooter.vue +72 -0
- package/src/components/Card/CardHeader.vue +111 -0
- package/src/components/Card/CardImage.vue +124 -0
- package/src/components/Card/index.ts +14 -0
- package/src/components/Chart/BarChart.vue +208 -0
- package/src/components/Chart/BaseChart.vue +444 -0
- package/src/components/Chart/Chart.test.ts +359 -0
- package/src/components/Chart/DonutChart.vue +283 -0
- package/src/components/Chart/LineChart.vue +211 -0
- package/src/components/Chart/index.ts +20 -0
- package/src/components/Chart/useChartTheme.ts +192 -0
- package/src/components/Checkbox/Checkbox.test.ts +209 -0
- package/src/components/Checkbox/Checkbox.vue +285 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
- package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
- package/src/components/ChoiceChip/index.ts +2 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
- package/src/components/ChoiceChipGroup/index.ts +2 -0
- package/src/components/ColorPicker/ColorArea.vue +159 -0
- package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
- package/src/components/ColorPicker/ColorPicker.vue +339 -0
- package/src/components/ColorPicker/ColorSlider.vue +191 -0
- package/src/components/ColorPicker/index.ts +7 -0
- package/src/components/Combobox/Combobox.test.ts +891 -0
- package/src/components/Combobox/Combobox.vue +934 -0
- package/src/components/Combobox/index.ts +2 -0
- package/src/components/DataTable/DataTable.test.ts +1221 -0
- package/src/components/DataTable/DataTable.vue +1415 -0
- package/src/components/DataTable/index.ts +10 -0
- package/src/components/DatePicker/DatePicker.test.ts +625 -0
- package/src/components/DatePicker/DatePicker.vue +1586 -0
- package/src/components/DatePicker/index.ts +2 -0
- package/src/components/Drawer/Drawer.test.ts +336 -0
- package/src/components/Drawer/Drawer.vue +466 -0
- package/src/components/Drawer/index.ts +2 -0
- package/src/components/Dropdown/Dropdown.test.ts +607 -0
- package/src/components/Dropdown/Dropdown.vue +807 -0
- package/src/components/Dropdown/DropdownItem.vue +227 -0
- package/src/components/Dropdown/DropdownSeparator.vue +14 -0
- package/src/components/Dropdown/DropdownSub.vue +104 -0
- package/src/components/Dropdown/DropdownSubContent.vue +187 -0
- package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
- package/src/components/Dropdown/index.ts +14 -0
- package/src/components/EmptyState/EmptyState.test.ts +180 -0
- package/src/components/EmptyState/EmptyState.vue +137 -0
- package/src/components/EmptyState/index.ts +2 -0
- package/src/components/FileUpload/FileUpload.test.ts +1151 -0
- package/src/components/FileUpload/FileUpload.vue +1042 -0
- package/src/components/FileUpload/index.ts +2 -0
- package/src/components/Heading/Heading.test.ts +107 -0
- package/src/components/Heading/Heading.vue +67 -0
- package/src/components/Heading/index.ts +2 -0
- package/src/components/Icon/Icon.test.ts +157 -0
- package/src/components/Icon/Icon.vue +86 -0
- package/src/components/Icon/index.ts +2 -0
- package/src/components/Input/Input.test.ts +273 -0
- package/src/components/Input/Input.vue +388 -0
- package/src/components/Input/index.ts +2 -0
- package/src/components/Layout/Container.vue +67 -0
- package/src/components/Layout/Grid.vue +159 -0
- package/src/components/Layout/GridItem.vue +154 -0
- package/src/components/Layout/Layout.test.ts +202 -0
- package/src/components/Layout/Stack.vue +128 -0
- package/src/components/Layout/index.ts +9 -0
- package/src/components/Layout/keys.ts +7 -0
- package/src/components/Modal/Modal.test.ts +311 -0
- package/src/components/Modal/Modal.vue +336 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/Pagination/Pagination.test.ts +303 -0
- package/src/components/Pagination/Pagination.vue +212 -0
- package/src/components/Pagination/index.ts +3 -0
- package/src/components/Pagination/utils.ts +86 -0
- package/src/components/Popover/Popover.test.ts +285 -0
- package/src/components/Popover/Popover.vue +441 -0
- package/src/components/Popover/index.ts +2 -0
- package/src/components/Progress/Progress.test.ts +361 -0
- package/src/components/Progress/Progress.vue +363 -0
- package/src/components/Progress/index.ts +7 -0
- package/src/components/Radio/Radio.test.ts +216 -0
- package/src/components/Radio/Radio.vue +214 -0
- package/src/components/Radio/index.ts +2 -0
- package/src/components/Rating/Rating.test.ts +319 -0
- package/src/components/Rating/Rating.vue +247 -0
- package/src/components/Rating/index.ts +2 -0
- package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
- package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
- package/src/components/SegmentedControl/index.ts +2 -0
- package/src/components/Select/Select.test.ts +589 -0
- package/src/components/Select/Select.vue +666 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Sidebar/Sidebar.test.ts +301 -0
- package/src/components/Sidebar/SidebarGroup.vue +103 -0
- package/src/components/Sidebar/SidebarItem.vue +196 -0
- package/src/components/Sidebar/SidebarLayout.vue +42 -0
- package/src/components/Sidebar/SidebarRoot.vue +122 -0
- package/src/components/Sidebar/index.ts +11 -0
- package/src/components/Sidebar/keys.ts +14 -0
- package/src/components/Skeleton/Skeleton.test.ts +130 -0
- package/src/components/Skeleton/Skeleton.vue +104 -0
- package/src/components/Skeleton/index.ts +2 -0
- package/src/components/Slider/Slider.test.ts +416 -0
- package/src/components/Slider/Slider.vue +435 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Slider/utils.ts +91 -0
- package/src/components/Spinner/Spinner.test.ts +79 -0
- package/src/components/Spinner/Spinner.vue +159 -0
- package/src/components/Spinner/index.ts +2 -0
- package/src/components/SpireProvider/SpireProvider.vue +71 -0
- package/src/components/SpireProvider/index.ts +11 -0
- package/src/components/Stepper/Stepper.test.ts +221 -0
- package/src/components/Stepper/StepperContent.vue +51 -0
- package/src/components/Stepper/StepperItem.vue +89 -0
- package/src/components/Stepper/StepperRoot.vue +101 -0
- package/src/components/Stepper/StepperSeparator.vue +52 -0
- package/src/components/Stepper/StepperTrigger.vue +144 -0
- package/src/components/Stepper/index.ts +11 -0
- package/src/components/Stepper/keys.ts +27 -0
- package/src/components/Switch/Switch.test.ts +214 -0
- package/src/components/Switch/Switch.vue +235 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Tabs/Tabs.test.ts +363 -0
- package/src/components/Tabs/Tabs.vue +318 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Text/Text.test.ts +154 -0
- package/src/components/Text/Text.vue +100 -0
- package/src/components/Text/index.ts +2 -0
- package/src/components/Textarea/Textarea.test.ts +432 -0
- package/src/components/Textarea/Textarea.vue +411 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/TimePicker/TimePicker.test.ts +352 -0
- package/src/components/TimePicker/TimePicker.vue +569 -0
- package/src/components/TimePicker/index.ts +2 -0
- package/src/components/Timeline/Timeline.test.ts +193 -0
- package/src/components/Timeline/Timeline.vue +111 -0
- package/src/components/Timeline/TimelineItem.vue +167 -0
- package/src/components/Timeline/index.ts +13 -0
- package/src/components/Timeline/keys.ts +21 -0
- package/src/components/Toast/ToastItem.test.ts +289 -0
- package/src/components/Toast/ToastItem.vue +370 -0
- package/src/components/Toast/ToastProvider.test.ts +158 -0
- package/src/components/Toast/ToastProvider.vue +181 -0
- package/src/components/Toast/index.ts +83 -0
- package/src/components/Toast/toastState.test.ts +165 -0
- package/src/components/Toast/toastState.ts +161 -0
- package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
- package/src/components/ToggleButton/ToggleButton.vue +197 -0
- package/src/components/ToggleButton/index.ts +2 -0
- package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
- package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
- package/src/components/ToggleGroup/index.ts +2 -0
- package/src/components/Tooltip/Tooltip.test.ts +238 -0
- package/src/components/Tooltip/Tooltip.vue +217 -0
- package/src/components/Tooltip/index.ts +2 -0
- package/src/components/TreeView/TreeView.test.ts +357 -0
- package/src/components/TreeView/TreeView.vue +251 -0
- package/src/components/TreeView/TreeViewItem.vue +288 -0
- package/src/components/TreeView/index.ts +11 -0
- package/src/components/TreeView/keys.ts +35 -0
- package/src/composables/index.ts +12 -0
- package/src/composables/useClickOutside.ts +36 -0
- package/src/composables/useClipboard.ts +35 -0
- package/src/composables/useEventListener.ts +48 -0
- package/src/composables/useFocusTrap.ts +58 -0
- package/src/composables/useHoverReveal.ts +98 -0
- package/src/composables/useId.ts +10 -0
- package/src/composables/useMagnetic.ts +171 -0
- package/src/composables/useRelativePosition.ts +127 -0
- package/src/composables/useRipple.ts +146 -0
- package/src/composables/useScrollLock.ts +25 -0
- package/src/composables/useSpireConfig.ts +27 -0
- package/src/composables/useStagger.ts +224 -0
- package/src/config/icons.test.ts +115 -0
- package/src/config/icons.ts +170 -0
- package/src/index.ts +361 -0
- package/src/styles/depth.css +129 -0
- package/src/styles/effects.css +169 -0
- package/src/styles/fallback.css +152 -0
- package/src/styles/main.css +25 -0
- package/src/styles/mood.css +211 -0
- package/src/styles/motion.css +159 -0
- package/src/styles/reset.css +97 -0
- package/src/styles/theme.css +708 -0
- package/src/styles/tokens.css +183 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/color.ts +277 -0
- package/src/utils/date.test.ts +522 -0
- package/src/utils/date.ts +380 -0
- package/src/utils/index.ts +23 -0
- package/src/utils/object.test.ts +80 -0
- package/src/utils/object.ts +25 -0
- package/src/utils/string.test.ts +64 -0
- package/src/utils/string.ts +32 -0
- 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
|
+
})
|