@mozaic-ds/vue
Version:
Mozaic-Vue is the Vue.js implementation of ADEO Design system
605 lines (529 loc) • 20.5 kB
text/typescript
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');
});
});