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