@mozaic-ds/vue 2.14.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mozaic-vue.css +1 -1
- package/dist/mozaic-vue.d.ts +1582 -500
- package/dist/mozaic-vue.js +8020 -3218
- package/dist/mozaic-vue.js.map +1 -1
- package/dist/mozaic-vue.umd.cjs +24 -5
- package/dist/mozaic-vue.umd.cjs.map +1 -1
- package/package.json +6 -4
- package/src/components/DarkMode.mdx +115 -0
- package/src/components/actionlistbox/MActionListbox.spec.ts +20 -10
- package/src/components/actionlistbox/MActionListbox.stories.ts +15 -8
- package/src/components/actionlistbox/MActionListbox.vue +15 -12
- package/src/components/actionlistbox/README.md +2 -1
- package/src/components/avatar/MAvatar.stories.ts +1 -1
- package/src/components/breadcrumb/MBreadcrumb.vue +2 -2
- package/src/components/button/README.md +2 -0
- package/src/components/combobox/MCombobox.spec.ts +246 -0
- package/src/components/combobox/MCombobox.stories.ts +190 -0
- package/src/components/combobox/MCombobox.vue +277 -0
- package/src/components/combobox/README.md +52 -0
- package/src/components/field/MField.stories.ts +105 -0
- package/src/components/optionListbox/MOptionListbox.spec.ts +527 -0
- package/src/components/optionListbox/MOptionListbox.vue +470 -0
- package/src/components/optionListbox/README.md +63 -0
- package/src/components/pageheader/MPageHeader.spec.ts +12 -12
- package/src/components/pageheader/MPageHeader.stories.ts +9 -1
- package/src/components/pageheader/MPageHeader.vue +3 -6
- package/src/components/segmentedcontrol/MSegmentedControl.spec.ts +57 -25
- package/src/components/segmentedcontrol/MSegmentedControl.stories.ts +6 -19
- package/src/components/segmentedcontrol/MSegmentedControl.vue +27 -13
- package/src/components/segmentedcontrol/README.md +6 -3
- package/src/components/select/MSelect.vue +4 -3
- package/src/components/sidebar/stories/DefaultCase.stories.vue +2 -2
- package/src/components/sidebar/stories/README.md +8 -0
- package/src/components/sidebar/stories/WithExpandOnly.stories.vue +1 -1
- package/src/components/sidebar/stories/WithProfileInfoOnly.stories.vue +2 -2
- package/src/components/sidebar/stories/WithSingleLevel.stories.vue +2 -2
- package/src/components/stepperinline/MStepperInline.spec.ts +63 -28
- package/src/components/stepperinline/MStepperInline.stories.ts +18 -10
- package/src/components/stepperinline/MStepperInline.vue +24 -10
- package/src/components/stepperinline/README.md +6 -2
- package/src/components/stepperstacked/MStepperStacked.spec.ts +162 -0
- package/src/components/stepperstacked/MStepperStacked.stories.ts +57 -0
- package/src/components/stepperstacked/MStepperStacked.vue +106 -0
- package/src/components/stepperstacked/README.md +15 -0
- package/src/components/tabs/MTabs.stories.ts +18 -0
- package/src/components/tabs/MTabs.vue +30 -14
- package/src/components/tabs/Mtabs.spec.ts +56 -10
- package/src/components/tabs/README.md +6 -3
- package/src/components/textinput/MTextInput.vue +13 -1
- package/src/components/textinput/README.md +15 -1
- package/src/components/tileclickable/README.md +1 -1
- package/src/main.ts +10 -2
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mount, VueWrapper } from '@vue/test-utils';
|
|
3
|
+
import { nextTick } from 'vue';
|
|
4
|
+
import MOptionListbox from './MOptionListbox.vue';
|
|
5
|
+
import type { ListboxOption } from './MOptionListbox.vue';
|
|
6
|
+
|
|
7
|
+
const globalStubs = {
|
|
8
|
+
MTextInput: {
|
|
9
|
+
name: 'MTextInput',
|
|
10
|
+
template: `
|
|
11
|
+
<div>
|
|
12
|
+
<slot name="icon" />
|
|
13
|
+
<input
|
|
14
|
+
:value="modelValue"
|
|
15
|
+
:placeholder="placeholder"
|
|
16
|
+
:role="role"
|
|
17
|
+
:id="id"
|
|
18
|
+
@input="$emit('update:modelValue', $event.target.value); $emit('input', $event)"
|
|
19
|
+
@keydown="$emit('keydown', $event)"
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
`,
|
|
23
|
+
props: [
|
|
24
|
+
'modelValue',
|
|
25
|
+
'placeholder',
|
|
26
|
+
'role',
|
|
27
|
+
'id',
|
|
28
|
+
'size',
|
|
29
|
+
'autocomplete',
|
|
30
|
+
'ariaExpanded',
|
|
31
|
+
'ariaControls',
|
|
32
|
+
'ariaAutocomplete',
|
|
33
|
+
'ariaActivedescendant',
|
|
34
|
+
],
|
|
35
|
+
emits: ['update:modelValue', 'input', 'keydown'],
|
|
36
|
+
methods: {
|
|
37
|
+
focus() {},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
MButton: {
|
|
41
|
+
name: 'MButton',
|
|
42
|
+
template: `<button @click="$emit('click')"><slot /></button>`,
|
|
43
|
+
emits: ['click'],
|
|
44
|
+
},
|
|
45
|
+
Search24: { template: '<span class="icon-search" />' },
|
|
46
|
+
Less20: { template: '<span class="icon-less" />' },
|
|
47
|
+
Check20: { template: '<span class="icon-check" />' },
|
|
48
|
+
CheckCircleFilled24: { template: '<span class="icon-check-circle" />' },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const baseOptions: ListboxOption[] = [
|
|
52
|
+
{ label: 'Apple', value: 'apple' },
|
|
53
|
+
{ label: 'Banana', value: 'banana' },
|
|
54
|
+
{ label: 'Cherry', value: 'cherry' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const optionsWithDisabled: ListboxOption[] = [
|
|
58
|
+
{ label: 'Apple', value: 'apple' },
|
|
59
|
+
{ label: 'Banana', value: 'banana', disabled: true },
|
|
60
|
+
{ label: 'Cherry', value: 'cherry' },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const optionsWithSections: ListboxOption[] = [
|
|
64
|
+
{ label: 'Fruits', value: 'fruits', type: 'section' },
|
|
65
|
+
{ label: 'Apple', value: 'apple' },
|
|
66
|
+
{ label: 'Banana', value: 'banana' },
|
|
67
|
+
{ label: 'Vegetables', value: 'vegetables', type: 'section' },
|
|
68
|
+
{ label: 'Carrot', value: 'carrot' },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function mountListbox(props: Record<string, unknown> = {}): VueWrapper {
|
|
72
|
+
return mount(MOptionListbox, {
|
|
73
|
+
props: {
|
|
74
|
+
id: 'test-listbox',
|
|
75
|
+
modelValue: null,
|
|
76
|
+
options: baseOptions,
|
|
77
|
+
open: true,
|
|
78
|
+
...props,
|
|
79
|
+
},
|
|
80
|
+
global: { stubs: globalStubs },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('MOptionListbox – rendering', () => {
|
|
85
|
+
it('renders all options', () => {
|
|
86
|
+
const wrapper = mountListbox();
|
|
87
|
+
const items = wrapper.findAll('.mc-option-listbox__item');
|
|
88
|
+
expect(items).toHaveLength(baseOptions.length);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('displays option labels', () => {
|
|
92
|
+
const wrapper = mountListbox();
|
|
93
|
+
const texts = wrapper
|
|
94
|
+
.findAll('.mc-option-listbox__text')
|
|
95
|
+
.map((w) => w.text());
|
|
96
|
+
expect(texts).toEqual(['Apple', 'Banana', 'Cherry']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('displays additional content when provided', () => {
|
|
100
|
+
const options: ListboxOption[] = [
|
|
101
|
+
{ label: 'Apple', value: 'apple', content: 'Extra info' },
|
|
102
|
+
];
|
|
103
|
+
const wrapper = mountListbox({ options });
|
|
104
|
+
expect(wrapper.find('.mc-option-listbox__additional').text()).toBe(
|
|
105
|
+
'Extra info',
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('does NOT render the search block by default', () => {
|
|
110
|
+
const wrapper = mountListbox();
|
|
111
|
+
expect(wrapper.find('.mc-option-listbox__search').exists()).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('renders the search block when search=true', () => {
|
|
115
|
+
const wrapper = mountListbox({ search: true });
|
|
116
|
+
expect(wrapper.find('.mc-option-listbox__search').exists()).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does NOT render actions block by default', () => {
|
|
120
|
+
const wrapper = mountListbox();
|
|
121
|
+
expect(wrapper.find('.mc-option-listbox__actions').exists()).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('renders actions block when multiple=true and actions=true', () => {
|
|
125
|
+
const wrapper = mountListbox({
|
|
126
|
+
multiple: true,
|
|
127
|
+
actions: true,
|
|
128
|
+
modelValue: [],
|
|
129
|
+
});
|
|
130
|
+
expect(wrapper.find('.mc-option-listbox__actions').exists()).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('sets aria-multiselectable="true" when multiple=true', () => {
|
|
134
|
+
const wrapper = mountListbox({ multiple: true, modelValue: [] });
|
|
135
|
+
expect(
|
|
136
|
+
wrapper.find('ul[role="listbox"]').attributes('aria-multiselectable'),
|
|
137
|
+
).toBe('true');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('sets aria-multiselectable="false" when multiple=false', () => {
|
|
141
|
+
const wrapper = mountListbox();
|
|
142
|
+
expect(
|
|
143
|
+
wrapper.find('ul[role="listbox"]').attributes('aria-multiselectable'),
|
|
144
|
+
).toBe('false');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('marks a disabled item with the disabled class', () => {
|
|
148
|
+
const wrapper = mountListbox({ options: optionsWithDisabled });
|
|
149
|
+
const items = wrapper.findAll('.mc-option-listbox__item');
|
|
150
|
+
expect(items[1].classes()).toContain('mc-option-listbox__item--disabled');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('renders section titles with the correct class', () => {
|
|
154
|
+
const wrapper = mountListbox({ options: optionsWithSections });
|
|
155
|
+
const sectionTitles = wrapper.findAll('.mc-option-listbox__section-title');
|
|
156
|
+
expect(sectionTitles).toHaveLength(2);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('MOptionListbox – single selection', () => {
|
|
161
|
+
it('marks the selected option with --selected class', () => {
|
|
162
|
+
const wrapper = mountListbox({ modelValue: 'banana' });
|
|
163
|
+
const items = wrapper.findAll('.mc-option-listbox__item');
|
|
164
|
+
expect(items[1].classes()).toContain('mc-option-listbox__item--selected');
|
|
165
|
+
expect(items[0].classes()).not.toContain(
|
|
166
|
+
'mc-option-listbox__item--selected',
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('emits update:modelValue with option value on click', async () => {
|
|
171
|
+
const wrapper = mountListbox({ modelValue: null });
|
|
172
|
+
await wrapper.findAll('.mc-option-listbox__item')[0].trigger('click');
|
|
173
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['apple']);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('deselects (emits null) when the same option is clicked again', async () => {
|
|
177
|
+
const wrapper = mountListbox({ modelValue: 'apple' });
|
|
178
|
+
await wrapper.findAll('.mc-option-listbox__item')[0].trigger('click');
|
|
179
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([null]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('MOptionListbox – multiple selection', () => {
|
|
184
|
+
it('adds a value to the array on click', async () => {
|
|
185
|
+
const wrapper = mountListbox({ multiple: true, modelValue: [] });
|
|
186
|
+
await wrapper.findAll('.mc-option-listbox__item')[0].trigger('click');
|
|
187
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['apple']]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('removes a value from the array when already selected', async () => {
|
|
191
|
+
const wrapper = mountListbox({
|
|
192
|
+
multiple: true,
|
|
193
|
+
modelValue: ['apple', 'cherry'],
|
|
194
|
+
});
|
|
195
|
+
await wrapper.findAll('.mc-option-listbox__item')[0].trigger('click');
|
|
196
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['cherry']]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('selectAll emits all non-disabled, non-section values', async () => {
|
|
200
|
+
const wrapper = mountListbox({
|
|
201
|
+
multiple: true,
|
|
202
|
+
actions: true,
|
|
203
|
+
modelValue: [],
|
|
204
|
+
});
|
|
205
|
+
await wrapper
|
|
206
|
+
.find('.mc-option-listbox__actions button:first-child')
|
|
207
|
+
.trigger('click');
|
|
208
|
+
const emitted = wrapper.emitted('update:modelValue')?.[0]?.[0] as string[];
|
|
209
|
+
expect(emitted.sort()).toEqual(['apple', 'banana', 'cherry']);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('clearSelection emits empty array', async () => {
|
|
213
|
+
const wrapper = mountListbox({
|
|
214
|
+
multiple: true,
|
|
215
|
+
actions: true,
|
|
216
|
+
modelValue: ['apple', 'cherry'],
|
|
217
|
+
});
|
|
218
|
+
const buttons = wrapper.findAll('.mc-option-listbox__actions button');
|
|
219
|
+
await buttons[1].trigger('click');
|
|
220
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('renders Check20 icon (checkbox) for each selectable item in multiple mode', () => {
|
|
224
|
+
const wrapper = mountListbox({ multiple: true, modelValue: [] });
|
|
225
|
+
const checkboxes = wrapper.findAll('.mc-option-listbox__checkbox');
|
|
226
|
+
expect(checkboxes.length).toBeGreaterThan(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('renders CheckCircleFilled24 for single mode', () => {
|
|
230
|
+
const wrapper = mountListbox({ modelValue: null });
|
|
231
|
+
expect(wrapper.find('.mc-option-listbox__selection-icon').exists()).toBe(
|
|
232
|
+
true,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('MOptionListbox – sections (checkableSections)', () => {
|
|
238
|
+
it('section has role="presentation" when checkableSections=false', () => {
|
|
239
|
+
const wrapper = mountListbox({ options: optionsWithSections });
|
|
240
|
+
const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0];
|
|
241
|
+
expect(sectionItem.attributes('role')).toBe('presentation');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('section has role="option" when checkableSections=true and multiple=true', () => {
|
|
245
|
+
const wrapper = mountListbox({
|
|
246
|
+
options: optionsWithSections,
|
|
247
|
+
checkableSections: true,
|
|
248
|
+
multiple: true,
|
|
249
|
+
modelValue: [],
|
|
250
|
+
});
|
|
251
|
+
const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0];
|
|
252
|
+
expect(sectionItem.attributes('role')).toBe('option');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('toggleSection selects all items in the section', async () => {
|
|
256
|
+
const wrapper = mountListbox({
|
|
257
|
+
options: optionsWithSections,
|
|
258
|
+
checkableSections: true,
|
|
259
|
+
multiple: true,
|
|
260
|
+
modelValue: [],
|
|
261
|
+
});
|
|
262
|
+
const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0];
|
|
263
|
+
await sectionItem.trigger('click');
|
|
264
|
+
const emitted = wrapper.emitted('update:modelValue')?.[0]?.[0] as string[];
|
|
265
|
+
expect(emitted.sort()).toEqual(['apple', 'banana']);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('toggleSection deselects all items in the section when all are selected', async () => {
|
|
269
|
+
const wrapper = mountListbox({
|
|
270
|
+
options: optionsWithSections,
|
|
271
|
+
checkableSections: true,
|
|
272
|
+
multiple: true,
|
|
273
|
+
modelValue: ['apple', 'banana'],
|
|
274
|
+
});
|
|
275
|
+
const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0];
|
|
276
|
+
await sectionItem.trigger('click');
|
|
277
|
+
const emitted = wrapper.emitted('update:modelValue')?.[0]?.[0] as string[];
|
|
278
|
+
expect(emitted).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('section is indeterminate when only some items are selected', async () => {
|
|
282
|
+
const wrapper = mountListbox({
|
|
283
|
+
options: optionsWithSections,
|
|
284
|
+
checkableSections: true,
|
|
285
|
+
multiple: true,
|
|
286
|
+
modelValue: ['apple'], // only one of the two "Fruits" items selected
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0];
|
|
290
|
+
// The --selected class is applied when selected OR indeterminate
|
|
291
|
+
expect(sectionItem.classes()).toContain(
|
|
292
|
+
'mc-option-listbox__item--selected',
|
|
293
|
+
);
|
|
294
|
+
// The Less20 icon (indeterminate) should be rendered instead of Check20
|
|
295
|
+
expect(sectionItem.find('.icon-less').exists()).toBe(true);
|
|
296
|
+
expect(sectionItem.find('.icon-check').exists()).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('section is NOT indeterminate when no items are selected', async () => {
|
|
300
|
+
const wrapper = mountListbox({
|
|
301
|
+
options: optionsWithSections,
|
|
302
|
+
checkableSections: true,
|
|
303
|
+
multiple: true,
|
|
304
|
+
modelValue: [],
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0];
|
|
308
|
+
expect(sectionItem.classes()).not.toContain(
|
|
309
|
+
'mc-option-listbox__item--selected',
|
|
310
|
+
);
|
|
311
|
+
expect(sectionItem.find('.icon-less').exists()).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('section is NOT indeterminate when all items are selected', async () => {
|
|
315
|
+
const wrapper = mountListbox({
|
|
316
|
+
options: optionsWithSections,
|
|
317
|
+
checkableSections: true,
|
|
318
|
+
multiple: true,
|
|
319
|
+
modelValue: ['apple', 'banana'],
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0];
|
|
323
|
+
expect(sectionItem.find('.icon-less').exists()).toBe(false);
|
|
324
|
+
expect(sectionItem.find('.icon-check').exists()).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('MOptionListbox – search / filtering', () => {
|
|
329
|
+
beforeEach(() => {
|
|
330
|
+
vi.useFakeTimers();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
afterEach(() => {
|
|
334
|
+
vi.useRealTimers();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('filters options based on search text', async () => {
|
|
338
|
+
const wrapper = mountListbox({ search: true });
|
|
339
|
+
const input = wrapper.find('input');
|
|
340
|
+
await input.setValue('ban');
|
|
341
|
+
await input.trigger('input');
|
|
342
|
+
|
|
343
|
+
await vi.runAllTimersAsync();
|
|
344
|
+
await nextTick();
|
|
345
|
+
const items = wrapper.findAll('.mc-option-listbox__item');
|
|
346
|
+
expect(items).toHaveLength(1);
|
|
347
|
+
expect(items[0].text()).toContain('Banana');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('shows all options when search text is cleared', async () => {
|
|
351
|
+
const wrapper = mountListbox({ search: true });
|
|
352
|
+
const input = wrapper.find('input');
|
|
353
|
+
await input.setValue('ban');
|
|
354
|
+
await input.trigger('input');
|
|
355
|
+
await vi.runAllTimersAsync();
|
|
356
|
+
await nextTick();
|
|
357
|
+
|
|
358
|
+
await input.setValue('');
|
|
359
|
+
await input.trigger('input');
|
|
360
|
+
await vi.runAllTimersAsync();
|
|
361
|
+
await nextTick();
|
|
362
|
+
|
|
363
|
+
expect(wrapper.findAll('.mc-option-listbox__item')).toHaveLength(
|
|
364
|
+
baseOptions.length,
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('is case-insensitive', async () => {
|
|
369
|
+
const wrapper = mountListbox({ search: true });
|
|
370
|
+
const input = wrapper.find('input');
|
|
371
|
+
await input.setValue('APPLE');
|
|
372
|
+
await input.trigger('input');
|
|
373
|
+
await vi.runAllTimersAsync();
|
|
374
|
+
await nextTick();
|
|
375
|
+
expect(wrapper.findAll('.mc-option-listbox__item')).toHaveLength(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('shows no items when no match found', async () => {
|
|
379
|
+
const wrapper = mountListbox({ search: true });
|
|
380
|
+
const input = wrapper.find('input');
|
|
381
|
+
await input.setValue('zzz');
|
|
382
|
+
await input.trigger('input');
|
|
383
|
+
await vi.runAllTimersAsync();
|
|
384
|
+
await nextTick();
|
|
385
|
+
expect(wrapper.findAll('.mc-option-listbox__item')).toHaveLength(0);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe('MOptionListbox – keyboard navigation', () => {
|
|
390
|
+
beforeEach(() => {
|
|
391
|
+
vi.useFakeTimers();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
afterEach(() => {
|
|
395
|
+
vi.useRealTimers();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('ArrowDown sets activeIndex to 0 when no item is active', async () => {
|
|
399
|
+
const wrapper = mountListbox({ search: true });
|
|
400
|
+
const input = wrapper.find('input');
|
|
401
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
402
|
+
await nextTick();
|
|
403
|
+
expect(
|
|
404
|
+
wrapper.findAll('.mc-option-listbox__item--active')[0],
|
|
405
|
+
).toBeDefined();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('ArrowDown wraps around to first item from last', async () => {
|
|
409
|
+
const wrapper = mountListbox({ search: true });
|
|
410
|
+
const input = wrapper.find('input');
|
|
411
|
+
|
|
412
|
+
for (let i = 0; i < baseOptions.length; i++) {
|
|
413
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
417
|
+
await nextTick();
|
|
418
|
+
const activeItems = wrapper.findAll('.mc-option-listbox__item--active');
|
|
419
|
+
const items = wrapper.findAll('.mc-option-listbox__item');
|
|
420
|
+
expect(items[0].classes()).toContain('mc-option-listbox__item--active');
|
|
421
|
+
expect(activeItems).toHaveLength(1);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('ArrowUp wraps to last item from first', async () => {
|
|
425
|
+
const wrapper = mountListbox({ search: true });
|
|
426
|
+
const input = wrapper.find('input');
|
|
427
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
428
|
+
await input.trigger('keydown', { key: 'ArrowUp' });
|
|
429
|
+
await nextTick();
|
|
430
|
+
const items = wrapper.findAll('.mc-option-listbox__item');
|
|
431
|
+
expect(items[items.length - 1].classes()).toContain(
|
|
432
|
+
'mc-option-listbox__item--active',
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('Enter selects the active item', async () => {
|
|
437
|
+
const wrapper = mountListbox({ search: true, modelValue: null });
|
|
438
|
+
const input = wrapper.find('input');
|
|
439
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
440
|
+
await input.trigger('keydown', { key: 'Enter' });
|
|
441
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['apple']);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('Escape emits close', async () => {
|
|
445
|
+
const wrapper = mountListbox({ search: true });
|
|
446
|
+
const input = wrapper.find('input');
|
|
447
|
+
await input.trigger('keydown', { key: 'Escape' });
|
|
448
|
+
expect(wrapper.emitted('close')).toBeDefined();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('ArrowDown when closed emits open and sets activeIndex to 0', async () => {
|
|
452
|
+
const wrapper = mountListbox({ search: true, open: false });
|
|
453
|
+
const input = wrapper.find('input');
|
|
454
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
455
|
+
expect(wrapper.emitted('open')).toBeDefined();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('ArrowUp when closed emits open and sets activeIndex to last item', async () => {
|
|
459
|
+
const wrapper = mountListbox({ search: true, open: false });
|
|
460
|
+
const input = wrapper.find('input');
|
|
461
|
+
await input.trigger('keydown', { key: 'ArrowUp' });
|
|
462
|
+
expect(wrapper.emitted('open')).toBeDefined();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('skips disabled items during navigation', async () => {
|
|
466
|
+
const wrapper = mountListbox({
|
|
467
|
+
search: true,
|
|
468
|
+
options: optionsWithDisabled,
|
|
469
|
+
});
|
|
470
|
+
const input = wrapper.find('input');
|
|
471
|
+
await input.trigger('keydown', { key: 'ArrowDown' }); // activeIndex = 0 (Apple)
|
|
472
|
+
await input.trigger('keydown', { key: 'ArrowDown' }); // skip Banana (disabled) → Cherry
|
|
473
|
+
await nextTick();
|
|
474
|
+
const items = wrapper.findAll('.mc-option-listbox__item');
|
|
475
|
+
expect(items[2].classes()).toContain('mc-option-listbox__item--active');
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('MOptionListbox – exposed API', () => {
|
|
480
|
+
it('exposes handleKeydown, toggleValue, listboxEl, activeIndex', () => {
|
|
481
|
+
const wrapper = mountListbox();
|
|
482
|
+
const exposed = wrapper.vm as unknown as {
|
|
483
|
+
handleKeydown: (e: KeyboardEvent) => void;
|
|
484
|
+
toggleValue: (item: ListboxOption) => void;
|
|
485
|
+
listboxEl: unknown;
|
|
486
|
+
activeIndex: number;
|
|
487
|
+
};
|
|
488
|
+
expect(typeof exposed.handleKeydown).toBe('function');
|
|
489
|
+
expect(typeof exposed.toggleValue).toBe('function');
|
|
490
|
+
expect('listboxEl' in wrapper.vm).toBe(true);
|
|
491
|
+
expect('activeIndex' in wrapper.vm).toBe(true);
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
describe('MOptionListbox – accessibility', () => {
|
|
496
|
+
it('list element has role="listbox"', () => {
|
|
497
|
+
const wrapper = mountListbox();
|
|
498
|
+
expect(wrapper.find('ul').attributes('role')).toBe('listbox');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('selectable option items have role="option"', () => {
|
|
502
|
+
const wrapper = mountListbox();
|
|
503
|
+
const items = wrapper.findAll('[role="option"]');
|
|
504
|
+
expect(items.length).toBe(baseOptions.length);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('aria-selected is true for selected item', () => {
|
|
508
|
+
const wrapper = mountListbox({ modelValue: 'cherry' });
|
|
509
|
+
const items = wrapper.findAll('[role="option"]');
|
|
510
|
+
const cherry = items.find((i) => i.text().includes('Cherry'));
|
|
511
|
+
expect(cherry?.attributes('aria-selected')).toBe('true');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('aria-selected is false for unselected items', () => {
|
|
515
|
+
const wrapper = mountListbox({ modelValue: 'cherry' });
|
|
516
|
+
const items = wrapper.findAll('[role="option"]');
|
|
517
|
+
const apple = items.find((i) => i.text().includes('Apple'));
|
|
518
|
+
expect(apple?.attributes('aria-selected')).toBe('false');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('aria-disabled is set on disabled items', () => {
|
|
522
|
+
const wrapper = mountListbox({ options: optionsWithDisabled });
|
|
523
|
+
const items = wrapper.findAll('[role="option"]');
|
|
524
|
+
const banana = items.find((i) => i.text().includes('Banana'));
|
|
525
|
+
expect(banana?.attributes('aria-disabled')).toBe('true');
|
|
526
|
+
});
|
|
527
|
+
});
|