@mozaic-ds/vue
Version:
Mozaic-Vue is the Vue.js implementation of ADEO Design system
391 lines (340 loc) • 11.5 kB
text/typescript
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, ref, nextTick } from 'vue';
import MCombobox from './MCombobox.vue';
const MOptionListboxStub = defineComponent({
name: 'MOptionListbox',
props: [
'modelValue',
'open',
'multiple',
'search',
'actions',
'checkableSections',
'searchPlaceholder',
'selectLabel',
'clearLabel',
'options',
'id',
],
emits: ['update:modelValue', 'open', 'close'],
setup() {
const activeIndex = ref(-1);
const listboxEl = ref(document.createElement('div'));
// On crée une fonction mock que l’on expose
const toggleValue = vi.fn();
return {
activeIndex,
listboxEl,
handleKeydown: () => {},
toggleValue, // <- expose ici
};
},
template: `<div />`,
});
const MTagStub = defineComponent({
name: 'MTag',
props: ['id', 'label', 'type', 'size'],
emits: ['remove-tag'],
template: `<div class="m-tag-stub">{{ label }}</div>`,
});
const MButtonStub = defineComponent({
name: 'MButton',
props: ['outlined', 'size'],
emits: ['click'],
template: `<button @click="$emit('click')"><slot/></button>`,
});
const CrossCircleFilled24 = defineComponent({
name: 'CrossCircleFilled24',
template: `<svg/>`,
});
const ChevronDown24 = defineComponent({
name: 'ChevronDown24',
template: `<svg/>`,
});
describe('MCombobox', () => {
const options = [
{ label: 'One', value: 1 },
{ label: 'Two', value: 2 },
{ label: 'Three', value: 3 },
];
it('renders placeholder when no selection', () => {
const wrapper = mount(MCombobox, {
props: { modelValue: null, options },
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
const control = wrapper.find('.mc-combobox__control');
expect(control.exists()).toBe(true);
expect(control.text()).toBe('Select an option');
});
it('renders selected label for single value', () => {
const wrapper = mount(MCombobox, {
props: { modelValue: 1, options },
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
const control = wrapper.find('.mc-combobox__control');
expect(control.text()).toBe('One');
});
it('multiple selection shows joins values', async () => {
const wrapper = mount(MCombobox, {
props: { modelValue: [1, 2], multiple: true, options },
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
expect(wrapper.find('.mc-combobox__control').text()).toBe('One, Two');
});
it('toggles listbox open/close on control click', async () => {
const wrapper = mount(MCombobox, {
props: { modelValue: null, options },
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
const root = wrapper.find('.mc-combobox');
const control = wrapper.find('.mc-combobox__control');
await control.trigger('click');
expect(root.classes()).toContain('mc-combobox--open');
await control.trigger('click');
expect(root.classes()).not.toContain('mc-combobox--open');
});
it('clear button clears selection and emits update:modelValue', async () => {
const wrapperSingle = mount(MCombobox, {
props: { modelValue: 1, clearable: true, options },
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
const clearBtnSingle = wrapperSingle.find('.mc-combobox__clear');
expect(clearBtnSingle.exists()).toBe(true);
await clearBtnSingle.trigger('click');
const emittedSingle = wrapperSingle.emitted('update:modelValue') || [];
expect(emittedSingle.length).toBeGreaterThan(0);
expect(emittedSingle[emittedSingle.length - 1][0]).toBeNull();
const wrapperMulti = mount(MCombobox, {
props: { modelValue: [1], multiple: true, clearable: true, options },
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
const clearBtnMulti = wrapperMulti.find('.mc-combobox__clear');
expect(clearBtnMulti.exists()).toBe(true);
await clearBtnMulti.trigger('click');
const emittedMulti = wrapperMulti.emitted('update:modelValue') || [];
expect(emittedMulti.length).toBeGreaterThan(0);
const last = emittedMulti[emittedMulti.length - 1][0];
expect(Array.isArray(last)).toBe(true);
expect(last).toEqual([]);
});
it('activeDescendant reflects child listbox activeIndex', async () => {
const wrapper = mount(MCombobox, {
props: { modelValue: null, options },
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
const listboxRef = (wrapper.vm as InstanceType<typeof MCombobox>).$refs
.listbox as { activeIndex: number };
expect(listboxRef).toBeTruthy();
listboxRef.activeIndex = 2;
await nextTick();
const control = wrapper.find('.mc-combobox__control');
const attr = control.attributes()['aria-activedescendant'];
expect(attr).toBeTruthy();
expect(attr.includes('-2')).toBe(true);
});
describe('accessibility attributes', () => {
function mountCombobox(props = {}) {
return mount(MCombobox, {
props: { modelValue: null, options, ...props },
global: {
components: {
MOptionListbox: MOptionListboxStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
}
it('control button has aria-expanded="false" when closed', () => {
const wrapper = mountCombobox();
const control = wrapper.find('.mc-combobox__control');
expect(control.attributes('aria-expanded')).toBe('false');
expect(control.attributes('aria-haspopup')).toBe('listbox');
});
it('control button has default aria-label "Combobox input"', () => {
const wrapper = mountCombobox();
expect(
wrapper.find('.mc-combobox__control').attributes('aria-label'),
).toBe('Combobox input');
});
it('control button uses controlAriaLabel prop when provided', () => {
const wrapper = mountCombobox({ controlAriaLabel: 'Select a country' });
expect(
wrapper.find('.mc-combobox__control').attributes('aria-label'),
).toBe('Select a country');
});
it('expand icon button has tabindex="-1" and aria-hidden="true"', () => {
const wrapper = mountCombobox();
const icon = wrapper.find('.mc-combobox__icon');
expect(icon.attributes('tabindex')).toBe('-1');
expect(icon.attributes('aria-hidden')).toBe('true');
});
});
describe('open prop (controlled mode)', () => {
function mountControlled(open: boolean) {
return mount(MCombobox, {
props: { modelValue: null, options, open },
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
}
it('renders open when open prop is true', () => {
const wrapper = mountControlled(true);
expect(wrapper.find('.mc-combobox').classes()).toContain(
'mc-combobox--open',
);
});
it('renders closed when open prop is false', () => {
const wrapper = mountControlled(false);
expect(wrapper.find('.mc-combobox').classes()).not.toContain(
'mc-combobox--open',
);
});
it('emits update:open with toggled value on click without changing visual state', async () => {
const wrapper = mountControlled(false);
await wrapper.find('.mc-combobox__control').trigger('click');
expect(wrapper.emitted('update:open')?.[0]).toEqual([true]);
expect(wrapper.find('.mc-combobox').classes()).not.toContain(
'mc-combobox--open',
);
const wrapper2 = mountControlled(true);
await wrapper2.find('.mc-combobox__control').trigger('click');
expect(wrapper2.emitted('update:open')?.[0]).toEqual([false]);
expect(wrapper2.find('.mc-combobox').classes()).toContain(
'mc-combobox--open',
);
});
it('reflects open prop changes from parent', async () => {
const wrapper = mountControlled(false);
expect(wrapper.find('.mc-combobox').classes()).not.toContain(
'mc-combobox--open',
);
await wrapper.setProps({ open: true });
expect(wrapper.find('.mc-combobox').classes()).toContain(
'mc-combobox--open',
);
await wrapper.setProps({ open: false });
expect(wrapper.find('.mc-combobox').classes()).not.toContain(
'mc-combobox--open',
);
});
it('opens and closes correctly with v-model:open', async () => {
const Parent = defineComponent({
components: { MCombobox },
setup() {
const open = ref(false);
return { open, options };
},
template: `<MCombobox v-model:open="open" :model-value="null" :options="options" />`,
});
const wrapper = mount(Parent, {
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
});
expect(wrapper.find('.mc-combobox').classes()).not.toContain(
'mc-combobox--open',
);
await wrapper.find('.mc-combobox__control').trigger('click');
expect(wrapper.find('.mc-combobox').classes()).toContain(
'mc-combobox--open',
);
await wrapper.find('.mc-combobox__control').trigger('click');
expect(wrapper.find('.mc-combobox').classes()).not.toContain(
'mc-combobox--open',
);
});
});
it('clicking outside closes the listbox', async () => {
const wrapper = mount(MCombobox, {
props: { modelValue: null, options },
global: {
components: {
MOptionListbox: MOptionListboxStub,
MTag: MTagStub,
MButton: MButtonStub,
CrossCircleFilled24,
ChevronDown24,
},
},
attachTo: document.body,
});
const root = wrapper.find('.mc-combobox');
const control = wrapper.find('.mc-combobox__control');
await control.trigger('click');
expect(root.classes()).toContain('mc-combobox--open');
document.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(root.classes()).not.toContain('mc-combobox--open');
});
});