43 KiB
MalioDateRange + shell partagé — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Composant <MalioDateRange> (sélection période, hover preview, surlignage demi-barre) bâti sur un shell CalendarField extrait de MalioDate, sans changer l'API publique de Date.
Architecture: Extraction d'un shell CalendarField.vue (champ + popover + header + vue mois + navigation) consommé par Date et DateRange via slot scoped. MonthGrid étendu pour le mode plage. Logique pure dans dateRange.ts + useCalendarView.ts.
Tech Stack: Nuxt 4 layer, Vue 3 <script setup lang="ts">, TypeScript strict, Tailwind (m-*), tailwind-merge, @iconify/vue, Vitest + @vue/test-utils (jsdom).
Référence spec : docs/superpowers/specs/2026-05-20-daterange-design.md
Conventions partagées
data-test / attributs (ancrage tests) :
| attribut | élément |
|---|---|
date-input, calendar-icon, clear, popover |
shell CalendarField (déjà existants, préservés) |
header-prev, header-next, header-toggle |
CalendarHeader (inchangé) |
month (+data-month) |
MonthPicker (inchangé) |
day (+data-iso) |
MonthGrid (existant) |
data-range-role |
nouveau sur chaque bouton jour : none/single/start/end/in-range |
week-number |
colonne semaine (inchangé) |
Commande de test ciblée : npx vitest run <chemin> (le hook pre-commit lance toute la suite ; en cas de timeout flaky, réessayer, puis --no-verify après 2 échecs).
Ordre des tâches (du bas vers le haut, chaque tâche commit-able) :
dateRange.ts(purs) → 2.useCalendarView.ts→ 3.CalendarField.vue(shell) → 4. refactoDate.vue(Date.test.ts vert) → 5. extensionMonthGrid.vue→ 6.DateRange.vue+ tests → 7. story + playground.
Task 1 : Helpers purs dateRange.ts
Files:
-
Create:
app/components/malio/date/composables/dateRange.ts -
Test:
app/components/malio/date/composables/dateRange.test.ts -
Step 1 : Écrire les tests (échouent)
import {describe, expect, it} from 'vitest'
import {dayRangeRole, normalizeRange, resolveRangeBounds} from './dateRange'
describe('dateRange', () => {
describe('normalizeRange', () => {
it('keeps an already ordered pair', () => {
expect(normalizeRange('2026-05-19', '2026-05-25')).toEqual({start: '2026-05-19', end: '2026-05-25'})
})
it('swaps a reversed pair', () => {
expect(normalizeRange('2026-05-25', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-25'})
})
it('handles an equal pair', () => {
expect(normalizeRange('2026-05-19', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-19'})
})
})
describe('resolveRangeBounds', () => {
it('returns null without a start', () => {
expect(resolveRangeBounds(null, null, null)).toBeNull()
})
it('returns a single-point range when only start is set', () => {
expect(resolveRangeBounds('2026-05-19', null, null)).toEqual({lo: '2026-05-19', hi: '2026-05-19'})
})
it('orders start and committed end', () => {
expect(resolveRangeBounds('2026-05-19', '2026-05-25', null)).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
})
it('uses preview when end is not set', () => {
expect(resolveRangeBounds('2026-05-19', null, '2026-05-22')).toEqual({lo: '2026-05-19', hi: '2026-05-22'})
})
it('inverts when preview is before start', () => {
expect(resolveRangeBounds('2026-05-19', null, '2026-05-10')).toEqual({lo: '2026-05-10', hi: '2026-05-19'})
})
it('prioritises committed end over preview', () => {
expect(resolveRangeBounds('2026-05-19', '2026-05-25', '2026-05-30')).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
})
})
describe('dayRangeRole', () => {
const bounds = {lo: '2026-05-19', hi: '2026-05-25'}
it('returns none without bounds', () => {
expect(dayRangeRole('2026-05-20', null)).toBe('none')
})
it('returns single when lo === hi and matches', () => {
expect(dayRangeRole('2026-05-19', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('single')
expect(dayRangeRole('2026-05-20', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('none')
})
it('returns start, end and in-range', () => {
expect(dayRangeRole('2026-05-19', bounds)).toBe('start')
expect(dayRangeRole('2026-05-25', bounds)).toBe('end')
expect(dayRangeRole('2026-05-22', bounds)).toBe('in-range')
})
it('returns none outside the bounds', () => {
expect(dayRangeRole('2026-05-10', bounds)).toBe('none')
expect(dayRangeRole('2026-05-30', bounds)).toBe('none')
})
})
})
-
Step 2 : Lancer, vérifier l'échec —
npx vitest run app/components/malio/date/composables/dateRange.test.ts→ FAIL (import non résolu) -
Step 3 : Implémenter
// app/components/malio/date/composables/dateRange.ts
export type DateRangeValue = {start: string; end: string}
export function normalizeRange(a: string, b: string): DateRangeValue {
return a <= b ? {start: a, end: b} : {start: b, end: a}
}
export function resolveRangeBounds(
start: string | null,
end: string | null,
preview: string | null,
): {lo: string; hi: string} | null {
if (!start) return null
const other = end ?? preview
if (!other) return {lo: start, hi: start}
return start <= other ? {lo: start, hi: other} : {lo: other, hi: start}
}
export type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
export function dayRangeRole(
iso: string,
bounds: {lo: string; hi: string} | null,
): DayRangeRole {
if (!bounds) return 'none'
const {lo, hi} = bounds
if (lo === hi) return iso === lo ? 'single' : 'none'
if (iso === lo) return 'start'
if (iso === hi) return 'end'
if (iso > lo && iso < hi) return 'in-range'
return 'none'
}
-
Step 4 : Lancer, vérifier le succès —
npx vitest run app/components/malio/date/composables/dateRange.test.ts→ PASS -
Step 5 : Commit
git add app/components/malio/date/composables/dateRange.ts app/components/malio/date/composables/dateRange.test.ts
git commit -m "feat : helpers purs de plage de dates (#MUI-33)"
Task 2 : Composable useCalendarView.ts
Files:
-
Create:
app/components/malio/date/composables/useCalendarView.ts -
Test:
app/components/malio/date/composables/useCalendarView.test.ts -
Step 1 : Écrire les tests (échouent)
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {ref} from 'vue'
import {useCalendarView} from './useCalendarView'
describe('useCalendarView', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('initialises to the current month and year', () => {
const {currentMonth, currentYear} = useCalendarView(ref('days'))
expect(currentMonth.value).toBe(4)
expect(currentYear.value).toBe(2026)
})
it('goToNext advances the month in days view', () => {
const {currentMonth, goToNext} = useCalendarView(ref('days'))
goToNext()
expect(currentMonth.value).toBe(5)
})
it('rolls December to January and bumps the year', () => {
const view = ref<'days' | 'months'>('days')
const {currentMonth, currentYear, goToNext} = useCalendarView(view)
currentMonth.value = 11
goToNext()
expect(currentMonth.value).toBe(0)
expect(currentYear.value).toBe(2027)
})
it('rolls January to December backwards', () => {
const {currentMonth, currentYear, goToPrev} = useCalendarView(ref('days'))
currentMonth.value = 0
goToPrev()
expect(currentMonth.value).toBe(11)
expect(currentYear.value).toBe(2025)
})
it('navigates the year in months view', () => {
const {currentYear, goToNext, goToPrev} = useCalendarView(ref('months'))
goToNext()
expect(currentYear.value).toBe(2027)
goToPrev()
expect(currentYear.value).toBe(2026)
})
it('selectMonth sets the current month', () => {
const {currentMonth, selectMonth} = useCalendarView(ref('days'))
selectMonth(0)
expect(currentMonth.value).toBe(0)
})
it('syncToIso sets month/year from a valid ISO', () => {
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
syncToIso('2025-12-25')
expect(currentMonth.value).toBe(11)
expect(currentYear.value).toBe(2025)
})
it('syncToIso falls back to today for null/invalid', () => {
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
syncToIso('2025-12-25')
syncToIso(null)
expect(currentMonth.value).toBe(4)
expect(currentYear.value).toBe(2026)
})
})
-
Step 2 : Lancer, vérifier l'échec —
npx vitest run app/components/malio/date/composables/useCalendarView.test.ts→ FAIL -
Step 3 : Implémenter
// app/components/malio/date/composables/useCalendarView.ts
import {ref, type Ref} from 'vue'
import {isValidIso} from './dateFormat'
export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
const today = new Date()
const currentMonth = ref(today.getMonth())
const currentYear = ref(today.getFullYear())
const goToPrev = () => {
if (viewMode.value === 'months') {
currentYear.value -= 1
return
}
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value -= 1
} else {
currentMonth.value -= 1
}
}
const goToNext = () => {
if (viewMode.value === 'months') {
currentYear.value += 1
return
}
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value += 1
} else {
currentMonth.value += 1
}
}
const selectMonth = (m: number) => {
currentMonth.value = m
}
const syncToIso = (iso: string | null) => {
if (iso && isValidIso(iso)) {
currentMonth.value = Number(iso.slice(5, 7)) - 1
currentYear.value = Number(iso.slice(0, 4))
} else {
const now = new Date()
currentMonth.value = now.getMonth()
currentYear.value = now.getFullYear()
}
}
return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso}
}
-
Step 4 : Lancer, vérifier le succès — PASS
-
Step 5 : Commit
git add app/components/malio/date/composables/useCalendarView.ts app/components/malio/date/composables/useCalendarView.test.ts
git commit -m "feat : composable de navigation mois/année du calendrier (#MUI-33)"
Task 3 : Shell CalendarField.vue
Files:
- Create:
app/components/malio/date/internal/CalendarField.vue
Pas de test dédié — couvert indirectement par Date.test.ts (Task 4) et DateRange.test.ts (Task 6).
- Step 1 : Créer le composant
<!-- app/components/malio/date/internal/CalendarField.vue -->
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
data-test="date-input"
readonly
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
aria-haspopup="dialog"
v-bind="attrs"
placeholder="_"
type="text"
@click="onFieldClick"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="text-m-muted hover:text-black"
aria-label="Effacer la date"
@click.stop="emit('clear')"
>
<Icon
icon="mdi:close"
:width="16"
:height="16"
/>
</button>
<Icon
data-test="calendar-icon"
icon="mdi:calendar-blank"
:width="24"
:height="24"
:class="iconStateClass"
/>
</div>
<div
v-if="isOpen"
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
@prev="goToPrev"
@next="goToNext"
@toggle-view="toggleView"
/>
<slot
v-if="viewMode === 'days'"
:current-month="currentMonth"
:current-year="currentYear"
:close="closePopover"
/>
<MonthPicker
v-else
:selected-month="currentMonth"
@select="onSelectMonth"
/>
</div>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId, watch} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover'
import {useCalendarView} from '../composables/useCalendarView'
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
const props = withDefaults(
defineProps<{
displayValue: string
syncTo: string | null
id?: string
name?: string
label?: string
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => props.displayValue.length > 0)
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
watch(isOpen, (value) => {
if (!value) emit('close')
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (isOpen.value) {
closePopover()
return
}
syncToIso(props.syncTo)
open()
}
watch(() => props.syncTo, (value) => {
if (isOpen.value) syncToIso(value)
})
const onSelectMonth = (m: number) => {
selectMonth(m)
toggleView()
}
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: 'focus:border-m-primary',
isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>
-
Step 2 : Lint —
npx eslint app/components/malio/date/internal/CalendarField.vue→ 0 erreur -
Step 3 : Commit
git add app/components/malio/date/internal/CalendarField.vue
git commit -m "feat : shell CalendarField partagé (champ + popover + navigation) (#MUI-33)"
Task 4 : Refacto Date.vue en enveloppe
Files:
-
Modify:
app/components/malio/date/Date.vue(remplacement complet du fichier) -
Test:
app/components/malio/date/Date.test.ts(inchangé — doit rester vert) -
Step 1 : Remplacer
Date.vuepar l'enveloppe
<!-- app/components/malio/date/Date.vue -->
<template>
<CalendarField
:display-value="displayValue"
:sync-to="modelValue ?? null"
:id="id"
: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="emit('update:modelValue', null)"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="modelValue ?? null"
:min="min"
:max="max"
@select="(iso) => { emit('update:modelValue', iso); close() }"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
defineOptions({name: 'MalioDate', inheritAttrs: false})
const props = withDefaults(
defineProps<{
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
}>(),
{
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: string | null): void}>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
watch(() => props.modelValue, (val) => {
if (val && !isValidIso(val) && import.meta.dev) {
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
}
})
</script>
- Step 2 : Lancer Date.test.ts, vérifier qu'il reste vert
Run: npx vitest run app/components/malio/date/Date.test.ts
Expected: PASS (21 tests). Si un test casse → régression de refacto à corriger dans CalendarField/Date (ne pas adapter le test).
-
Step 3 : Lint —
npx eslint app/components/malio/date/Date.vue→ 0 erreur -
Step 4 : Commit
git add app/components/malio/date/Date.vue
git commit -m "refactor : Date.vue devient une enveloppe du shell CalendarField (#MUI-33)"
Task 5 : Extension MonthGrid.vue (mode plage)
Files:
- Modify:
app/components/malio/date/internal/MonthGrid.vue
Couvert par DateRange.test.ts (Task 6) pour le mode plage et Date.test.ts pour le mode simple.
- Step 1 : Remplacer
MonthGrid.vue(ajout props range, hover, data-range-role, double couche)
<!-- app/components/malio/date/internal/MonthGrid.vue -->
<template>
<div
data-test="month-grid"
@mouseleave="emit('hover', null)"
>
<div class="grid grid-cols-[auto_repeat(7,minmax(0,1fr))]">
<div class="mr-[12px] flex h-8 w-[35px] items-center justify-center text-[14px] font-medium opacity-[60%]">
S
</div>
<div
v-for="d in dayLabels"
:key="d"
class="flex h-8 items-center justify-center text-[14px] font-medium opacity-[60%]"
>
{{ d }}
</div>
<template
v-for="(week, wIndex) in weeks"
:key="week.days[0].isoDate"
>
<div
data-test="week-number"
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center bg-m-primary-light p-[10px] text-sm"
:class="[
week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60',
wIndex === 0 ? 'rounded-t-md' : '',
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
]"
>
{{ week.weekNumber }}
</div>
<button
v-for="cell in week.days"
:key="cell.isoDate"
type="button"
data-test="day"
:data-iso="cell.isoDate"
:data-range-role="roleOf(cell)"
:disabled="!inRange(cell.isoDate)"
:aria-label="ariaLabel(cell)"
:aria-disabled="!inRange(cell.isoDate)"
class="relative flex h-[45px] w-full items-center justify-center"
:class="inRange(cell.isoDate) ? 'cursor-pointer' : 'cursor-not-allowed'"
@click="onSelect(cell.isoDate)"
@mouseenter="emit('hover', cell.isoDate)"
>
<span
v-if="roleOf(cell) === 'in-range'"
class="absolute inset-0 bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'start'"
class="absolute inset-y-0 left-1/2 right-0 bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'end'"
class="absolute inset-y-0 left-0 right-1/2 bg-m-primary-light"
/>
<span
class="relative flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium transition-colors duration-100"
:class="cellClass(cell)"
>
{{ cell.day }}
</span>
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, toRef} from 'vue'
import {useMonthMatrix, type DayCell} from '../composables/useMonthMatrix'
import {isDateInRange} from '../composables/dateFormat'
import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composables/dateRange'
defineOptions({name: 'MalioDateMonthGrid'})
const props = withDefaults(
defineProps<{
month: number
year: number
selectedDate?: string | null
rangeStart?: string | null
rangeEnd?: string | null
previewDate?: string | null
min?: string
max?: string
}>(),
{
selectedDate: null,
rangeStart: undefined,
rangeEnd: undefined,
previewDate: undefined,
min: undefined,
max: undefined,
},
)
const emit = defineEmits<{
(e: 'select', iso: string): void
(e: 'hover', iso: string | null): void
}>()
const dayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const {weeks} = useMonthMatrix(toRef(props, 'month'), toRef(props, 'year'))
const inRange = (iso: string) => isDateInRange(iso, props.min, props.max)
const isRangeMode = computed(() => props.rangeStart !== undefined)
const bounds = computed(() =>
isRangeMode.value
? resolveRangeBounds(props.rangeStart ?? null, props.rangeEnd ?? null, props.previewDate ?? null)
: null,
)
const roleOf = (cell: DayCell): DayRangeRole => {
if (isRangeMode.value) return dayRangeRole(cell.isoDate, bounds.value)
return props.selectedDate === cell.isoDate ? 'single' : 'none'
}
const ariaLabel = (cell: DayCell) => {
const [, m, d] = cell.isoDate.split('-')
return `${Number(d)} ${monthsLong[Number(m) - 1]} ${cell.isoDate.slice(0, 4)}`
}
const cellClass = (cell: DayCell) => {
if (!inRange(cell.isoDate)) return 'text-m-muted/30'
const role = roleOf(cell)
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
if (role === 'in-range') return 'text-black'
const parts = ['hover:bg-m-primary/10']
if (cell.isToday) parts.push('border border-m-primary text-m-primary')
else if (cell.isCurrentMonth) parts.push('text-black')
else parts.push('opacity-[60%]')
return parts.join(' ')
}
const onSelect = (iso: string) => {
if (!inRange(iso)) return
emit('select', iso)
}
</script>
-
Step 2 : Vérifier la non-régression simple —
npx vitest run app/components/malio/date/Date.test.ts→ PASS (21) -
Step 3 : Lint —
npx eslint app/components/malio/date/internal/MonthGrid.vue→ 0 erreur -
Step 4 : Commit
git add app/components/malio/date/internal/MonthGrid.vue
git commit -m "feat : MonthGrid mode plage (surlignage demi-barre + hover + data-range-role) (#MUI-33)"
Task 6 : DateRange.vue + tests d'intégration
Files:
-
Create:
app/components/malio/date/DateRange.vue -
Test:
app/components/malio/date/DateRange.test.ts -
Step 1 : Créer
DateRange.vue
<!-- app/components/malio/date/DateRange.vue -->
<template>
<CalendarField
:display-value="displayValue"
:sync-to="validRange?.start ?? null"
:id="id"
: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"
@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>
- Step 2 : Écrire
DateRange.test.ts
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']) // complete
await wrapper.get('[data-test="date-input"]').trigger('click') // reopen
await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click') // 3rd click = new start
expect(wrapper.emitted('update:modelValue')).toHaveLength(1) // still only the first range
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') // start
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter') // hover
const between = wrapper.get('[data-test="day"][data-iso="2026-05-20"]')
expect(between.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')
const day = wrapper.get('[data-test="day"][data-iso="2026-05-20"]')
expect(day.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']) // only start chosen
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
// reopening: no pending start (role none for the previously clicked day)
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)
})
})
-
Step 3 : Lancer, vérifier le succès —
npx vitest run app/components/malio/date/DateRange.test.ts→ PASS (~18). Ajuster l'implémentation si besoin (tests = contrat). -
Step 4 : Lint + suite date complète
Run: npx eslint app/components/malio/date/ && npx vitest run app/components/malio/date/
Expected: 0 erreur lint ; tous verts (dateFormat, useMonthMatrix, useCalendarPopover, useCalendarView, dateRange, Date, DateRange).
- Step 5 : Commit
git add app/components/malio/date/DateRange.vue app/components/malio/date/DateRange.test.ts
git commit -m "feat : composant MalioDateRange (sélection période) (#MUI-33)"
Task 7 : Story + playground
Files:
-
Create:
app/story/date/dateRange.story.vue -
Create:
.playground/pages/composant/date/dateRange.vue -
Step 1 : Créer la story
<!-- app/story/date/dateRange.story.vue -->
<template>
<Story title="Date/DateRange">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDateRange
v-model="simpleValue"
label="Période"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateRange
v-model="initialValue"
label="Séjour"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateRange
v-model="boundedValue"
label="Plage bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDateRange
v-model="initialValue"
label="Période verrouillée"
:clearable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateRange
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDateRange
v-model="errorValue"
label="Période"
error="Période invalide"
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateRange from '../../components/malio/date/DateRange.vue'
type RangeValue = {start: string; end: string}
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<RangeValue | null>(null)
const initialValue = ref<RangeValue | null>({start: todayIso, end: maxIso})
const boundedValue = ref<RangeValue | null>(null)
const errorValue = ref<RangeValue | null>(null)
</script>
- Step 2 : Créer la page playground
<!-- .playground/pages/composant/date/dateRange.vue -->
<template>
<div class="max-w-md space-y-6">
<h1 class="text-2xl font-bold">MalioDateRange</h1>
<MalioDateRange
v-model="value"
label="Période"
hint="Clique deux fois pour définir une plage"
/>
<div class="rounded border p-3 text-sm">
<p>Début : <code>{{ value?.start ?? 'null' }}</code></p>
<p>Fin : <code>{{ value?.end ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = {start: '2026-12-20', end: '2026-12-31'}"
>
Forcer 20→31/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
<MalioDateRange
v-model="bounded"
label="Plage bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type RangeValue = {start: string; end: string}
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<RangeValue | null>(null)
const bounded = ref<RangeValue | null>(null)
</script>
-
Step 3 : Vérification visuelle —
npm run dev→ menu "Date" → page DateRange : sélection 2 clics, hover preview, demi-barres aux bornes, reset 3e clic, bornes. Etnpm run story:dev. -
Step 4 : Lint —
npx eslint app/story/date/dateRange.story.vue .playground/pages/composant/date/dateRange.vue→ 0 erreur -
Step 5 : Commit
git add app/story/date/dateRange.story.vue .playground/pages/composant/date/dateRange.vue
git commit -m "feat : story et page playground de MalioDateRange (#MUI-33)"
Self-Review (effectuée à l'écriture)
Couverture spec : shell CalendarField ✓ (T3), useCalendarView ✓ (T2), dateRange.ts ✓ (T1), MonthGrid range+hover+data-range-role+demi-barre ✓ (T5), refacto Date ✓ (T4, Date.test.ts vert), DateRange API + machine à états (1er/2e clic, auto-inversion, single-day, 3e clic reset, hover preview, close annule, clear) ✓ (T6), min/max ✓ (T5/T6), displayValue vide pendant sélection ✓ (T6), story+playground ✓ (T7). Reportés (2 mois, ajustement borne proche, clavier) : non planifiés, conforme.
Placeholders : aucun ; tout le code est complet.
Cohérence des types : DateRangeValue {start,end} défini en T1, réutilisé en T6. DayRangeRole T1 → T5. resolveRangeBounds(start,end,preview) / dayRangeRole(iso,bounds) cohérents T1↔T5. useCalendarView(viewMode) renvoie {currentMonth,currentYear,goToPrev,goToNext,selectMonth,syncToIso} T2 → consommés tels quels en T3. CalendarField props displayValue/syncTo + events clear/close + slot {currentMonth,currentYear,close} T3 → consommés en T4 (Date) et T6 (DateRange). MonthGrid mode plage activé par rangeStart !== undefined ; Date ne passe pas rangeStart (reste single) — cohérent T4↔T5.
Écart assumé : MonthGrid gagne data-range-role (testabilité + debug), documenté dans la spec.