UNPKG

@mozaic-ds/vue

Version:

Mozaic-Vue is the Vue.js implementation of ADEO Design system

391 lines (340 loc) 11.5 kB
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'); }); });