UNPKG

@mozaic-ds/vue

Version:

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

605 lines (529 loc) 20.5 kB
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach, } from 'vitest'; import { mount, VueWrapper } from '@vue/test-utils'; import { nextTick } from 'vue'; import MOptionListbox from './MOptionListbox.vue'; import type { ListboxOption } from './MOptionListbox.vue'; const globalStubs = { MTextInput: { name: 'MTextInput', template: ` <div> <slot name="icon" /> <input :value="modelValue" :placeholder="placeholder" :role="role" :id="id" @input="$emit('update:modelValue', $event.target.value); $emit('input', $event)" @keydown="$emit('keydown', $event)" /> </div> `, props: [ 'modelValue', 'placeholder', 'role', 'id', 'size', 'autocomplete', 'ariaExpanded', 'ariaControls', 'ariaAutocomplete', 'ariaActivedescendant', ], emits: ['update:modelValue', 'input', 'keydown'], methods: { focus() {}, }, }, MButton: { name: 'MButton', template: `<button @click="$emit('click')"><slot /></button>`, emits: ['click'], }, Search24: { template: '<span class="icon-search" />' }, Less20: { template: '<span class="icon-less" />' }, Check20: { template: '<span class="icon-check" />' }, CheckCircleFilled24: { template: '<span class="icon-check-circle" />' }, }; const baseOptions: ListboxOption[] = [ { label: 'Apple', value: 'apple' }, { label: 'Banana', value: 'banana' }, { label: 'Cherry', value: 'cherry' }, ]; const optionsWithDisabled: ListboxOption[] = [ { label: 'Apple', value: 'apple' }, { label: 'Banana', value: 'banana', disabled: true }, { label: 'Cherry', value: 'cherry' }, ]; const optionsWithSections: ListboxOption[] = [ { label: 'Fruits', value: 'fruits', type: 'section' }, { label: 'Apple', value: 'apple' }, { label: 'Banana', value: 'banana' }, { label: 'Vegetables', value: 'vegetables', type: 'section' }, { label: 'Carrot', value: 'carrot' }, ]; let originalScrollIntoViewDescriptor: PropertyDescriptor | undefined; const scrollIntoViewMock = vi.fn(); beforeAll(() => { originalScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor( HTMLElement.prototype, 'scrollIntoView', ); Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoViewMock, }); }); afterAll(() => { if (originalScrollIntoViewDescriptor) { Object.defineProperty( HTMLElement.prototype, 'scrollIntoView', originalScrollIntoViewDescriptor, ); } else { delete (HTMLElement.prototype as { scrollIntoView?: unknown }) .scrollIntoView; } }); function mountListbox(props: Record<string, unknown> = {}): VueWrapper { return mount(MOptionListbox, { props: { id: 'test-listbox', modelValue: null, options: baseOptions, open: true, ...props, }, global: { stubs: globalStubs }, }); } describe('MOptionListbox – rendering', () => { it('renders all options', () => { const wrapper = mountListbox(); const items = wrapper.findAll('.mc-option-listbox__item'); expect(items).toHaveLength(baseOptions.length); }); it('displays option labels', () => { const wrapper = mountListbox(); const texts = wrapper .findAll('.mc-option-listbox__text') .map((w) => w.text()); expect(texts).toEqual(['Apple', 'Banana', 'Cherry']); }); it('displays additional content when provided', () => { const options: ListboxOption[] = [ { label: 'Apple', value: 'apple', content: 'Extra info' }, ]; const wrapper = mountListbox({ options }); expect(wrapper.find('.mc-option-listbox__additional').text()).toBe( 'Extra info', ); }); it('does NOT render the search block by default', () => { const wrapper = mountListbox(); expect(wrapper.find('.mc-option-listbox__search').exists()).toBe(false); }); it('renders the search block when search=true', () => { const wrapper = mountListbox({ search: true }); expect(wrapper.find('.mc-option-listbox__search').exists()).toBe(true); }); it('does NOT render actions block by default', () => { const wrapper = mountListbox(); expect(wrapper.find('.mc-option-listbox__actions').exists()).toBe(false); }); it('renders actions block when multiple=true and actions=true', () => { const wrapper = mountListbox({ multiple: true, actions: true, modelValue: [], }); expect(wrapper.find('.mc-option-listbox__actions').exists()).toBe(true); }); it('sets aria-multiselectable="true" when multiple=true', () => { const wrapper = mountListbox({ multiple: true, modelValue: [] }); expect( wrapper.find('ul[role="listbox"]').attributes('aria-multiselectable'), ).toBe('true'); }); it('sets aria-multiselectable="false" when multiple=false', () => { const wrapper = mountListbox(); expect( wrapper.find('ul[role="listbox"]').attributes('aria-multiselectable'), ).toBe('false'); }); it('marks a disabled item with the disabled class', () => { const wrapper = mountListbox({ options: optionsWithDisabled }); const items = wrapper.findAll('.mc-option-listbox__item'); expect(items[1].classes()).toContain('mc-option-listbox__item--disabled'); }); it('renders section titles with the correct class', () => { const wrapper = mountListbox({ options: optionsWithSections }); const sectionTitles = wrapper.findAll('.mc-option-listbox__section-title'); expect(sectionTitles).toHaveLength(2); }); }); describe('MOptionListbox – single selection', () => { it('marks the selected option with --selected class', () => { const wrapper = mountListbox({ modelValue: 'banana' }); const items = wrapper.findAll('.mc-option-listbox__item'); expect(items[1].classes()).toContain('mc-option-listbox__item--selected'); expect(items[0].classes()).not.toContain( 'mc-option-listbox__item--selected', ); }); it('emits update:modelValue with option value on click', async () => { const wrapper = mountListbox({ modelValue: null }); await wrapper.findAll('.mc-option-listbox__item')[0].trigger('click'); expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['apple']); }); it('deselects (emits null) when the same option is clicked again', async () => { const wrapper = mountListbox({ modelValue: 'apple' }); await wrapper.findAll('.mc-option-listbox__item')[0].trigger('click'); expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([null]); }); }); describe('MOptionListbox – multiple selection', () => { it('adds a value to the array on click', async () => { const wrapper = mountListbox({ multiple: true, modelValue: [] }); await wrapper.findAll('.mc-option-listbox__item')[0].trigger('click'); expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['apple']]); }); it('removes a value from the array when already selected', async () => { const wrapper = mountListbox({ multiple: true, modelValue: ['apple', 'cherry'], }); await wrapper.findAll('.mc-option-listbox__item')[0].trigger('click'); expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['cherry']]); }); it('selectAll emits all non-disabled, non-section values', async () => { const wrapper = mountListbox({ multiple: true, actions: true, modelValue: [], }); await wrapper .find('.mc-option-listbox__actions button:first-child') .trigger('click'); const emitted = wrapper.emitted('update:modelValue')?.[0]?.[0] as string[]; expect(emitted.sort()).toEqual(['apple', 'banana', 'cherry']); }); it('clearSelection emits empty array', async () => { const wrapper = mountListbox({ multiple: true, actions: true, modelValue: ['apple', 'cherry'], }); const buttons = wrapper.findAll('.mc-option-listbox__actions button'); await buttons[1].trigger('click'); expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]]); }); it('renders Check20 icon (checkbox) for each selectable item in multiple mode', () => { const wrapper = mountListbox({ multiple: true, modelValue: [] }); const checkboxes = wrapper.findAll('.mc-option-listbox__checkbox'); expect(checkboxes.length).toBeGreaterThan(0); }); it('renders CheckCircleFilled24 for single mode', () => { const wrapper = mountListbox({ modelValue: null }); expect(wrapper.find('.mc-option-listbox__selection-icon').exists()).toBe( true, ); }); }); describe('MOptionListbox – sections (checkableSections)', () => { it('section has role="presentation" when checkableSections=false', () => { const wrapper = mountListbox({ options: optionsWithSections }); const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0]; expect(sectionItem.attributes('role')).toBe('presentation'); }); it('section has role="option" when checkableSections=true and multiple=true', () => { const wrapper = mountListbox({ options: optionsWithSections, checkableSections: true, multiple: true, modelValue: [], }); const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0]; expect(sectionItem.attributes('role')).toBe('option'); }); it('toggleSection selects all items in the section', async () => { const wrapper = mountListbox({ options: optionsWithSections, checkableSections: true, multiple: true, modelValue: [], }); const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0]; await sectionItem.trigger('click'); const emitted = wrapper.emitted('update:modelValue')?.[0]?.[0] as string[]; expect(emitted.sort()).toEqual(['apple', 'banana']); }); it('toggleSection deselects all items in the section when all are selected', async () => { const wrapper = mountListbox({ options: optionsWithSections, checkableSections: true, multiple: true, modelValue: ['apple', 'banana'], }); const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0]; await sectionItem.trigger('click'); const emitted = wrapper.emitted('update:modelValue')?.[0]?.[0] as string[]; expect(emitted).toEqual([]); }); it('section is indeterminate when only some items are selected', async () => { const wrapper = mountListbox({ options: optionsWithSections, checkableSections: true, multiple: true, modelValue: ['apple'], // only one of the two "Fruits" items selected }); const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0]; // The --selected class is applied when selected OR indeterminate expect(sectionItem.classes()).toContain( 'mc-option-listbox__item--selected', ); // The Less20 icon (indeterminate) should be rendered instead of Check20 expect(sectionItem.find('.icon-less').exists()).toBe(true); expect(sectionItem.find('.icon-check').exists()).toBe(false); }); it('section is NOT indeterminate when no items are selected', async () => { const wrapper = mountListbox({ options: optionsWithSections, checkableSections: true, multiple: true, modelValue: [], }); const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0]; expect(sectionItem.classes()).not.toContain( 'mc-option-listbox__item--selected', ); expect(sectionItem.find('.icon-less').exists()).toBe(false); }); it('section is NOT indeterminate when all items are selected', async () => { const wrapper = mountListbox({ options: optionsWithSections, checkableSections: true, multiple: true, modelValue: ['apple', 'banana'], }); const sectionItem = wrapper.findAll('.mc-option-listbox__item')[0]; expect(sectionItem.find('.icon-less').exists()).toBe(false); expect(sectionItem.find('.icon-check').exists()).toBe(true); }); }); describe('MOptionListbox – search / filtering', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('filters options based on search text', async () => { const wrapper = mountListbox({ search: true }); const input = wrapper.find('input'); await input.setValue('ban'); await input.trigger('input'); await vi.runAllTimersAsync(); await nextTick(); const items = wrapper.findAll('.mc-option-listbox__item'); expect(items).toHaveLength(1); expect(items[0].text()).toContain('Banana'); }); it('shows all options when search text is cleared', async () => { const wrapper = mountListbox({ search: true }); const input = wrapper.find('input'); await input.setValue('ban'); await input.trigger('input'); await vi.runAllTimersAsync(); await nextTick(); await input.setValue(''); await input.trigger('input'); await vi.runAllTimersAsync(); await nextTick(); expect(wrapper.findAll('.mc-option-listbox__item')).toHaveLength( baseOptions.length, ); }); it('is case-insensitive', async () => { const wrapper = mountListbox({ search: true }); const input = wrapper.find('input'); await input.setValue('APPLE'); await input.trigger('input'); await vi.runAllTimersAsync(); await nextTick(); expect(wrapper.findAll('.mc-option-listbox__item')).toHaveLength(1); }); it('shows no items when no match found', async () => { const wrapper = mountListbox({ search: true }); const input = wrapper.find('input'); await input.setValue('zzz'); await input.trigger('input'); await vi.runAllTimersAsync(); await nextTick(); expect(wrapper.findAll('.mc-option-listbox__item')).toHaveLength(0); }); }); describe('MOptionListbox – keyboard navigation', () => { beforeEach(() => { vi.useFakeTimers(); scrollIntoViewMock.mockClear(); }); afterEach(() => { vi.useRealTimers(); }); it('ArrowDown sets activeIndex to 0 when no item is active', async () => { const wrapper = mountListbox({ search: true }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'ArrowDown' }); await nextTick(); expect( wrapper.findAll('.mc-option-listbox__item--active')[0], ).toBeDefined(); }); it('ArrowDown wraps around to first item from last', async () => { const wrapper = mountListbox({ search: true }); const input = wrapper.find('input'); for (let i = 0; i < baseOptions.length; i++) { await input.trigger('keydown', { key: 'ArrowDown' }); } await input.trigger('keydown', { key: 'ArrowDown' }); await nextTick(); const activeItems = wrapper.findAll('.mc-option-listbox__item--active'); const items = wrapper.findAll('.mc-option-listbox__item'); expect(items[0].classes()).toContain('mc-option-listbox__item--active'); expect(activeItems).toHaveLength(1); }); it('ArrowUp wraps to last item from first', async () => { const wrapper = mountListbox({ search: true }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'ArrowDown' }); await input.trigger('keydown', { key: 'ArrowUp' }); await nextTick(); const items = wrapper.findAll('.mc-option-listbox__item'); expect(items[items.length - 1].classes()).toContain( 'mc-option-listbox__item--active', ); }); it('Enter selects the active item', async () => { const wrapper = mountListbox({ search: true, modelValue: null }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'ArrowDown' }); await input.trigger('keydown', { key: 'Enter' }); expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['apple']); }); it('Escape emits close', async () => { const wrapper = mountListbox({ search: true }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'Escape' }); expect(wrapper.emitted('close')).toBeDefined(); }); it('ArrowDown when closed emits open and sets activeIndex to 0', async () => { const wrapper = mountListbox({ search: true, open: false }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'ArrowDown' }); expect(wrapper.emitted('open')).toBeDefined(); }); it('ArrowDown when closed sets activeIndex to selected option', async () => { const wrapper = mountListbox({ search: true, open: false, modelValue: 'banana', }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'ArrowDown' }); await nextTick(); const items = wrapper.findAll('.mc-option-listbox__item'); expect(items[1].classes()).toContain('mc-option-listbox__item--active'); }); it('ArrowUp when closed emits open and sets activeIndex to last item', async () => { const wrapper = mountListbox({ search: true, open: false }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'ArrowUp' }); expect(wrapper.emitted('open')).toBeDefined(); }); it('skips disabled items during navigation', async () => { const wrapper = mountListbox({ search: true, options: optionsWithDisabled, }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'ArrowDown' }); // activeIndex = 0 (Apple) await input.trigger('keydown', { key: 'ArrowDown' }); // skip Banana (disabled) → Cherry await nextTick(); const items = wrapper.findAll('.mc-option-listbox__item'); expect(items[2].classes()).toContain('mc-option-listbox__item--active'); }); it('scrolls the active option into view during ArrowDown navigation', async () => { const wrapper = mountListbox({ search: true, open: true }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'ArrowDown' }); await nextTick(); expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); expect(scrollIntoViewMock).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest', }); }); it('does not scroll active option when listbox is closed', async () => { const wrapper = mountListbox({ search: true, open: false }); const input = wrapper.find('input'); await input.trigger('keydown', { key: 'ArrowDown' }); await nextTick(); expect(scrollIntoViewMock).not.toHaveBeenCalled(); }); }); describe('MOptionListbox – exposed API', () => { it('exposes handleKeydown, toggleValue, listboxEl, activeIndex', () => { const wrapper = mountListbox(); const exposed = wrapper.vm as unknown as { handleKeydown: (e: KeyboardEvent) => void; toggleValue: (item: ListboxOption) => void; listboxEl: unknown; activeIndex: number; }; expect(typeof exposed.handleKeydown).toBe('function'); expect(typeof exposed.toggleValue).toBe('function'); expect('listboxEl' in wrapper.vm).toBe(true); expect('activeIndex' in wrapper.vm).toBe(true); }); }); describe('MOptionListbox – accessibility', () => { it('list element has role="listbox"', () => { const wrapper = mountListbox(); expect(wrapper.find('ul').attributes('role')).toBe('listbox'); }); it('selectable option items have role="option"', () => { const wrapper = mountListbox(); const items = wrapper.findAll('[role="option"]'); expect(items.length).toBe(baseOptions.length); }); it('aria-selected is true for selected item', () => { const wrapper = mountListbox({ modelValue: 'cherry' }); const items = wrapper.findAll('[role="option"]'); const cherry = items.find((i) => i.text().includes('Cherry')); expect(cherry?.attributes('aria-selected')).toBe('true'); }); it('aria-selected is false for unselected items', () => { const wrapper = mountListbox({ modelValue: 'cherry' }); const items = wrapper.findAll('[role="option"]'); const apple = items.find((i) => i.text().includes('Apple')); expect(apple?.attributes('aria-selected')).toBe('false'); }); it('aria-disabled is set on disabled items', () => { const wrapper = mountListbox({ options: optionsWithDisabled }); const items = wrapper.findAll('[role="option"]'); const banana = items.find((i) => i.text().includes('Banana')); expect(banana?.attributes('aria-disabled')).toBe('true'); }); });