feat : composant MalioDateRange (sélection période) (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
app/components/malio/date/DateRange.test.ts
Normal file
155
app/components/malio/date/DateRange.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import DateRange from './DateRange.vue'
|
||||
|
||||
type RangeValue = {start: string; end: string}
|
||||
type DateRangeProps = {
|
||||
modelValue?: RangeValue | null
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
error?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
const DateRangeForTest = DateRange as DefineComponent<DateRangeProps>
|
||||
const mountRange = (props: DateRangeProps = {}) =>
|
||||
mount(DateRangeForTest, {props, attachTo: document.body})
|
||||
|
||||
const openAndClickDays = async (wrapper: ReturnType<typeof mountRange>, isos: string[]) => {
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
for (const iso of isos) {
|
||||
await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click')
|
||||
}
|
||||
}
|
||||
|
||||
describe('MalioDateRange', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('renders the label and calendar icon', () => {
|
||||
const wrapper = mountRange({label: 'Période'})
|
||||
expect(wrapper.get('label').text()).toBe('Période')
|
||||
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays the formatted range when modelValue is set', () => {
|
||||
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('19/05/2026 - 25/05/2026')
|
||||
})
|
||||
|
||||
it('shows an empty field without a value', () => {
|
||||
const wrapper = mountRange()
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('opens on the start month when a range is set', async () => {
|
||||
const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
|
||||
})
|
||||
|
||||
it('does not emit on the first click', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19'])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits the range and closes on the second click', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('auto-inverts when the second click is before the first', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19'])
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
|
||||
})
|
||||
|
||||
it('allows a single-day range', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19'])
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}])
|
||||
})
|
||||
|
||||
it('restarts a new range on the third click', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}])
|
||||
})
|
||||
|
||||
it('previews the range on hover while selecting', async () => {
|
||||
const wrapper = mountRange()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
|
||||
})
|
||||
|
||||
it('does not preview before selecting', async () => {
|
||||
const wrapper = mountRange()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('none')
|
||||
})
|
||||
|
||||
it('marks start, end and in-range roles for a committed range', async () => {
|
||||
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
|
||||
})
|
||||
|
||||
it('cancels an in-progress selection on outside click', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19'])
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none')
|
||||
})
|
||||
|
||||
it('emits null on clear', async () => {
|
||||
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('disables days outside min/max', async () => {
|
||||
const wrapper = mountRange({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()
|
||||
})
|
||||
|
||||
it('sets aria-invalid on error', () => {
|
||||
const wrapper = mountRange({error: 'Période requise'})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Période requise')
|
||||
})
|
||||
|
||||
it('does not open when disabled', async () => {
|
||||
const wrapper = mountRange({disabled: true})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
140
app/components/malio/date/DateRange.vue
Normal file
140
app/components/malio/date/DateRange.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<CalendarField
|
||||
:id="id"
|
||||
:display-value="displayValue"
|
||||
:sync-to="validRange?.start ?? null"
|
||||
:name="name"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:hint="hint"
|
||||
:error="error"
|
||||
:success="success"
|
||||
:clearable="clearable"
|
||||
:input-class="inputClass"
|
||||
:label-class="labelClass"
|
||||
:group-class="groupClass"
|
||||
v-bind="$attrs"
|
||||
@clear="onClear"
|
||||
@close="onClose"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid
|
||||
:month="currentMonth"
|
||||
:year="currentYear"
|
||||
:range-start="rangeStart"
|
||||
:range-end="rangeEnd"
|
||||
:preview-date="previewDate"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="(iso) => onSelectDay(iso, close)"
|
||||
@hover="onHover"
|
||||
/>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||
import {normalizeRange, type DateRangeValue} from './composables/dateRange'
|
||||
|
||||
defineOptions({name: 'MalioDateRange', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: DateRangeValue | 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
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
label: '',
|
||||
modelValue: undefined,
|
||||
placeholder: 'JJ/MM/AAAA',
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
clearable: true,
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{(e: 'update:modelValue', value: DateRangeValue | null): void}>()
|
||||
|
||||
const pendingStart = ref<string | null>(null)
|
||||
const hoverDate = ref<string | null>(null)
|
||||
const isSelecting = computed(() => pendingStart.value !== null)
|
||||
|
||||
const validRange = computed<DateRangeValue | null>(() => {
|
||||
const v = props.modelValue
|
||||
if (v && isValidIso(v.start) && isValidIso(v.end)) return v
|
||||
return null
|
||||
})
|
||||
|
||||
const rangeStart = computed(() =>
|
||||
isSelecting.value ? pendingStart.value : (validRange.value?.start ?? null),
|
||||
)
|
||||
const rangeEnd = computed(() =>
|
||||
isSelecting.value ? null : (validRange.value?.end ?? null),
|
||||
)
|
||||
const previewDate = computed(() => (isSelecting.value ? hoverDate.value : null))
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (isSelecting.value || !validRange.value) return ''
|
||||
return `${formatIsoToDisplay(validRange.value.start)} - ${formatIsoToDisplay(validRange.value.end)}`
|
||||
})
|
||||
|
||||
const onSelectDay = (iso: string, close: () => void) => {
|
||||
if (pendingStart.value === null) {
|
||||
pendingStart.value = iso
|
||||
hoverDate.value = null
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', normalizeRange(pendingStart.value, iso))
|
||||
pendingStart.value = null
|
||||
hoverDate.value = null
|
||||
close()
|
||||
}
|
||||
|
||||
const onHover = (iso: string | null) => {
|
||||
if (isSelecting.value) hoverDate.value = iso
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
pendingStart.value = null
|
||||
hoverDate.value = null
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
emit('update:modelValue', null)
|
||||
pendingStart.value = null
|
||||
hoverDate.value = null
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user