1b5a4c9920
Ajoute reserveMessageSpace (défaut true) pour permettre de ne pas réserver la ligne de message d'aide quand aucun message n'est présent. Comportement inchangé par défaut. La famille date hérite via $attrs → CalendarField. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
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<SelectProps>
|
||
|
||
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]')
|
||
})
|
||
})
|