import {describe, expect, it} from 'vitest' import {mount} from '@vue/test-utils' import type {DefineComponent} from 'vue' import Select from './Select.vue' type Option = { label: string value: string | number | null } type SelectProps = { modelValue?: string | number | null options?: Option[] emptyOptionLabel?: string label?: string hint?: string error?: string success?: string textField?: string textValue?: string textLabel?: string rounded?: string disabled?: boolean readonly?: boolean required?: boolean reserveMessageSpace?: boolean } const SelectForTest = Select as DefineComponent const options: Option[] = [ {label: 'France', value: 'fr'}, {label: 'Belgique', value: 'be'}, {label: 'Canada', value: 'ca'}, ] describe('MalioSelect', () => { it('renders the label text', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, label: 'Country'}, }) expect(wrapper.get('label').text()).toBe('Country') }) it('generates button and listbox ids and links them together', async () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options}, }) const button = wrapper.get('button') expect(button.attributes('id')?.startsWith('custom-select-btn-')).toBe(true) expect(button.attributes('aria-controls')?.startsWith('custom-select-listbox-')).toBe(true) await button.trigger('click') expect(wrapper.get('ul').attributes('id')).toBe(button.attributes('aria-controls')) }) it('uses disabled styles and prevents opening when disabled', async () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, disabled: true, options}, }) const button = wrapper.get('button') expect(button.attributes('disabled')).toBeDefined() expect(button.classes()).toContain('cursor-not-allowed') await button.trigger('click') expect(wrapper.find('ul').exists()).toBe(false) }) it('opens the list and rotates the icon on click', async () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options}, }) await wrapper.get('button').trigger('click') expect(wrapper.get('ul').exists()).toBe(true) expect(wrapper.get('button').attributes('aria-expanded')).toBe('true') expect(wrapper.get('svg').classes()).toContain('rotate-180') }) it('emits update:modelValue when selecting an option', async () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options}, }) await wrapper.get('button').trigger('click') await wrapper.findAll('li')[1].trigger('click') expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be']) }) it('does not render empty option when emptyOptionLabel is empty', async () => { const wrapper = mount(SelectForTest, { props: { modelValue: null, options: [ {label: 'AM', value: 'am'}, {label: 'PM', value: 'pm'}, ], }, }) await wrapper.get('button').trigger('click') const items = wrapper.findAll('li[role="option"]') expect(items).toHaveLength(2) expect(items[0].text()).toBe('AM') expect(items[1].text()).toBe('PM') }) it('renders empty option when emptyOptionLabel is provided', async () => { const wrapper = mount(SelectForTest, { props: { modelValue: null, options: [{label: 'AM', value: 'am'}], emptyOptionLabel: 'Choisir...', }, }) await wrapper.get('button').trigger('click') const items = wrapper.findAll('li[role="option"]') expect(items).toHaveLength(2) expect(items[0].text()).toBe('Choisir...') }) it('renders the empty option with muted text style', async () => { const wrapper = mount(SelectForTest, { props: { modelValue: null, options, emptyOptionLabel: 'Aucune selection', }, }) await wrapper.get('button').trigger('click') const firstOption = wrapper.findAll('li')[0] expect(firstOption.text()).toBe('Aucune selection') expect(firstOption.classes()).toContain('text-black/40') }) it('shows the selected value text when an option is selected', () => { const wrapper = mount(SelectForTest, { props: { options, modelValue: 'fr', }, }) expect(wrapper.text()).toContain('France') expect(wrapper.get('button').classes()).toContain('border-black') }) it('shows hint message in muted color', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, hint: 'Select a country'}, }) expect(wrapper.get('p.text-m-muted').text()).toBe('Select a country') }) it('shows error state on button, label and helper text', () => { const wrapper = mount(SelectForTest, { props: { modelValue: null, options, label: 'Country', error: 'Selection error', }, }) expect(wrapper.get('button').classes()).toContain('border-m-danger') expect(wrapper.get('label').classes()).toContain('text-m-danger') expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error') expect(wrapper.get('button').attributes('aria-invalid')).toBe('true') }) it('shows success state on button, label and helper text', () => { const wrapper = mount(SelectForTest, { props: { modelValue: null, options, label: 'Country', success: 'Selection success', }, }) expect(wrapper.get('button').classes()).toContain('border-m-success') expect(wrapper.get('label').classes()).toContain('text-m-success') expect(wrapper.get('p.text-m-success').text()).toBe('Selection success') }) it('prioritizes error over success', () => { const wrapper = mount(SelectForTest, { props: { modelValue: null, options, error: 'Selection error', success: 'Selection success', }, }) expect(wrapper.get('button').classes()).toContain('border-m-danger') expect(wrapper.find('p.text-m-success').exists()).toBe(false) expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error') }) it('shows muted chevron color when empty and closed', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options}, }) expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted') }) it('shows primary chevron color when open', async () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options}, }) await wrapper.get('button').trigger('click') expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary') }) it('shows black chevron color when an option is selected and closed', () => { const wrapper = mount(SelectForTest, { props: {modelValue: 'fr', options}, }) expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black') }) it('shows muted chevron color when disabled', () => { const wrapper = mount(SelectForTest, { props: {modelValue: 'fr', options, disabled: true}, }) expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted') }) it('shows danger chevron color on error even when open', async () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options, error: 'Selection error'}, }) await wrapper.get('button').trigger('click') expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger') }) it('shows success chevron color on success', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options, success: 'OK'}, }) expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success') }) it('affiche l\'astérisque quand required est vrai', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, label: 'Champ', required: true}, }) expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true) }) it('n\'affiche pas l\'astérisque par défaut', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, label: 'Champ'}, }) expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false) }) it('expose aria-required quand required est vrai', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options, required: true}, }) expect(wrapper.find('[aria-required="true"]').exists()).toBe(true) }) it('n\'expose pas aria-required par défaut', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options}, }) expect(wrapper.find('[aria-required="true"]').exists()).toBe(false) }) it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, options}, }) await wrapper.get('button').trigger('click') const buttonClasses = wrapper.get('button').classes() // !border-b-0 would shrink the bottom border to 0px and grow content area by 1px; // !border-b-transparent keeps the 1px allocation but hides the line expect(buttonClasses).not.toContain('!border-b-0') expect(buttonClasses).toContain('!border-b-transparent') }) it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]}, }) const trigger = wrapper.get('button') expect(trigger.classes()).toContain('border-black') expect(trigger.classes()).not.toContain('border-m-muted') expect(trigger.classes()).not.toContain('grow-height') expect(trigger.classes()).not.toContain('focus-visible:border-m-primary') }) it('readonly vide : label gris, pas de bleu', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]}, }) const label = wrapper.get('label') expect(label.classes()).not.toContain('text-m-primary') expect(label.classes()).toContain('text-m-muted') }) it('readonly sélectionné : label noir + chevron noir', () => { const wrapper = mount(SelectForTest, { props: {label: 'Champ', readonly: true, modelValue: 'a', options: [{label: 'A', value: 'a'}]}, }) expect(wrapper.get('label').classes()).toContain('text-black') expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black') }) it('readonly empêche l’ouverture du dropdown', async () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]}, }) await wrapper.get('button').trigger('click') expect(wrapper.find('[role="listbox"]').exists()).toBe(false) }) it('readonly expose aria-readonly et reste focusable (pas disabled)', () => { const wrapper = mount(SelectForTest, { props: {modelValue: null, label: 'Champ', readonly: true, options}, }) const trigger = wrapper.get('button') expect(trigger.attributes('aria-readonly')).toBe('true') expect(trigger.attributes('disabled')).toBeUndefined() }) it('disabled + readonly : pas d’aria-readonly (disabled prime)', () => { const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}}) const trigger = wrapper.get('button') expect(trigger.attributes('aria-readonly')).toBeUndefined() expect(trigger.attributes('disabled')).toBeDefined() }) it('réserve l’espace message par défaut même sans message', () => { const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options}}) const msg = wrapper.find('[id$="-describedby"]') expect(msg.exists()).toBe(true) expect(msg.classes()).toContain('min-h-[1rem]') }) it('reserveMessageSpace=false sans message : pas de ligne réservée', () => { const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false}}) expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false) }) it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => { const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'}}) const msg = wrapper.find('[id$="-describedby"]') expect(msg.exists()).toBe(true) expect(msg.classes()).not.toContain('min-h-[1rem]') }) })