feat : composant MalioDate (datepicker) avec calendrier, vue mois, bornes et effacement (#MUI-33)
Sous-composants internes (CalendarHeader, MonthGrid, MonthPicker), composant public Date.vue, tests d'intégration, story Histoire et page playground. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
198
app/components/malio/date/Date.test.ts
Normal file
198
app/components/malio/date/Date.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import Date_ from './Date.vue'
|
||||
|
||||
type DateProps = {
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
}
|
||||
|
||||
const DateForTest = Date_ as DefineComponent<DateProps>
|
||||
const mountDate = (props: DateProps = {}) => mount(DateForTest, {props, attachTo: document.body})
|
||||
|
||||
describe('MalioDate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('rendu', () => {
|
||||
it('renders the label and the calendar icon', () => {
|
||||
const wrapper = mountDate({label: 'Date de naissance'})
|
||||
expect(wrapper.get('label').text()).toBe('Date de naissance')
|
||||
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays the formatted value in the field', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('19/05/2026')
|
||||
})
|
||||
|
||||
it('does not show the popover initially', () => {
|
||||
const wrapper = mountDate()
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('popover', () => {
|
||||
it('opens on field click', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('opens on the current month when there is no value', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
|
||||
})
|
||||
|
||||
it('opens on the value month when a value is set', async () => {
|
||||
const wrapper = mountDate({modelValue: '2025-12-25'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
|
||||
})
|
||||
|
||||
it('closes on outside mousedown', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation jours', () => {
|
||||
it('goes to the next month on the right chevron', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Juin 2026')
|
||||
})
|
||||
|
||||
it('rolls December to January and bumps the year', async () => {
|
||||
const wrapper = mountDate({modelValue: '2026-12-15'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2027')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sélection', () => {
|
||||
it('emits the ISO date and closes on day click', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bornes min/max', () => {
|
||||
it('disables days outside the range', async () => {
|
||||
const wrapper = mountDate({min: '2026-05-10', max: '2026-05-20'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
|
||||
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
|
||||
await outside.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vue mois', () => {
|
||||
it('switches to month view on header toggle', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('navigates the year with chevrons in month view', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2027')
|
||||
})
|
||||
|
||||
it('returns to day view on month click', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2026')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('effacement', () => {
|
||||
it('shows the clear button when there is a value', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
expect(wrapper.find('[data-test="clear"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the clear button when empty', () => {
|
||||
const wrapper = mountDate()
|
||||
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits null and does not open the popover on clear', async () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('états', () => {
|
||||
it('does not open when disabled', async () => {
|
||||
const wrapper = mountDate({disabled: true})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not open when readonly', async () => {
|
||||
const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibilité', () => {
|
||||
it('sets aria-invalid and describedby on error', () => {
|
||||
const wrapper = mountDate({error: 'Date requise'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||
expect(wrapper.text()).toContain('Date requise')
|
||||
})
|
||||
})
|
||||
|
||||
describe('synchronisation externe', () => {
|
||||
it('updates the displayed value when modelValue changes', async () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
await wrapper.setProps({modelValue: '2026-12-25'})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('25/12/2026')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user