dc33cf4135
Polish across the form input components, plus two new features and a few
standalone fixes.
Fixes
-----
* Reserve hint/error/success paragraph space (min-h-[1rem]) in 15
components so a single error message no longer shifts neighboring grid
cells: InputText, Email, Password, Phone, Amount, Number, Upload,
Autocomplete, RichText, TextArea, Select, SelectCheckbox, Time,
TimePicker, CalendarField, Checkbox.
* InputPhone: the '+' add button now follows the icon-state cascade
(muted / primary on focus / black when filled / danger / success) like
the other field icons instead of being permanently primary.
* Select and SelectCheckbox: chevron color follows the field state
(muted by default, primary when open, black when an option is
selected, danger / success on error / success) instead of always being
text-current.
* InputTextArea: single-root component (was multi-root). The message
wrapper used to occupy its own grid cell, breaking row-span layouts.
Now flex flex-col, with the textarea area filling the available height
via flex-1 and the message inside the same root.
* Disabled labels use text-m-muted (border-gray) instead of text-black/60
(dark) across InputText, Email, Password, Amount, Phone, Upload,
Autocomplete, TextArea, RichText. Also removes an unreachable
peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60 rule that
twMerge was silently overriding with text-black.
* InputAutocomplete: eliminates four sources of visual jitter when
focusing / opening a field that already has a selected value.
- Drop peer-focus:-translate-y-[1.55rem] extra label translate.
- Drop the .grow-height:focus padding rule (no more height growth or
downward text shift on focus).
- Drop focus:pl-[11px] (no more 1px horizontal jump).
- Replace !border-b-0 with !border-b-transparent so the bottom border
still reserves its 1px while remaining invisible against the
dropdown.
* Select / SelectCheckbox: same anti-jitter treatment.
- Drop .grow-height:focus padding rule (~12px height growth gone).
- Replace !border-b-0 / !border-t-0 with !border-b-transparent /
!border-t-transparent across danger / success / primary branches.
* Button: default width 240px -> 200px to match the form button sizing
used across the app. Test updated to match.
Features
--------
* InputTextArea: scrollbar turns primary blue on focus
(scrollbar-color: rgb(var(--m-primary)) transparent), matching the
Select listbox styling.
* InputAutocomplete: new localFilter prop (default false). When enabled,
filters the options prop client-side based on the input value
(case-insensitive label.includes(query)), so static lists no longer
need a @search listener. Async/API usage keeps the existing behavior.
Playground "Simple statique" and "Avec icône à gauche" examples use
local-filter.
Playground
----------
* client.vue: tighter grid gap (gap-y-5) plus an example error on a
SelectCheckbox to visually exercise the message-space fix.
Tests
-----
All component test files include regression coverage for the above.
720/720 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
277 lines
8.1 KiB
TypeScript
277 lines
8.1 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
|
|
}
|
|
|
|
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('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')
|
|
})
|
|
})
|