12 KiB
MalioDateRange — Design Spec
Composant de sélection d'une période (date début / date fin) via un champ + popover calendrier. Deuxième brique de la famille temporelle, construite sur un shell partagé extrait de MalioDate.
Ticket : MUI-33 (suite)
Branche : feature/MUI-33-developper-le-composant-datepicker
Spec liée : docs/superpowers/specs/2026-05-19-datepicker-design.md
Contexte & roadmap
MalioDate (sélection simple) est déjà livré. Suivront DateWeek et DateTime. Les 4 composants partagent le même champ + popover + header + vue mois ; seule la sélection diffère. On extrait donc un shell réutilisable CalendarField (Approche 3 retenue parce que la famille comptera 4 variantes — la duplication de coquille ×4 et la maintenance front en parallèle sont le vrai risque).
Périmètre
Sélection d'une période sur un seul mois affiché (popover = largeur du champ, comme Date). Visuellement identique à Date, sauf :
- on sélectionne deux dates (début/fin)
- les jours entre les bornes ont un fond
bg-m-primary-light(bleu clair) - les bornes (start/end) gardent le cercle plein
bg-m-primary - aperçu au survol (hover preview) de la plage pendant la sélection
Inclus : sélection 2 clics, auto-inversion, hover preview, surlignage de plage (demi-barre aux bornes), bornes min/max, effacement, vue mois conservée.
Reporté : deux mois côte à côte, ajustement de la borne la plus proche au 3e clic (on garde le reset standard), saisie clavier, navigation clavier.
Architecture (Approche 3 — shell partagé)
app/components/malio/date/
Date.vue # ENVELOPPE (refacto) — sélection simple
DateRange.vue # ENVELOPPE (nouveau) — sélection période
Date.test.ts # inchangé (filet de sécurité du refacto)
DateRange.test.ts # nouveau
internal/
CalendarField.vue # NOUVEAU — shell : champ + popover + header + MonthPicker
CalendarHeader.vue # inchangé
MonthGrid.vue # étendu : props range + émission hover + data-range-role
MonthPicker.vue # inchangé
composables/
useCalendarView.ts # NOUVEAU — état mois/année + navigation (extrait de Date.vue)
useCalendarView.test.ts
useCalendarPopover.ts # inchangé
dateRange.ts # NOUVEAU — helpers purs de plage
dateRange.test.ts
dateFormat.ts # inchangé
useMonthMatrix.ts # inchangé
app/story/date/dateRange.story.vue
.playground/pages/composant/date/dateRange.vue
Flux :
DateRange.vue (enveloppe)
├─ état de sélection range ({start,end} + pendingStart + hoverDate)
├─ displayValue ("19/05/2026 - 25/05/2026")
└─ <CalendarField :display-value :sync-to=start ... @clear @close>
├─ champ + popover (useCalendarPopover) + navigation (useCalendarView)
├─ header + MonthPicker (viewMode='months')
└─ <slot :current-month :current-year :close> ← viewMode='days'
└─ <MonthGrid> mode range (rangeStart/rangeEnd/previewDate, @select, @hover)
CalendarField ne connaît rien de la sélection : il gère champ, ouverture, navigation, et expose { currentMonth, currentYear, close } au slot. Chaque enveloppe branche son MonthGrid et décide quand appeler close().
useCalendarView.ts
function useCalendarView(viewMode: Ref<'days' | 'months'>): {
currentMonth: Ref<number> // 0-11
currentYear: Ref<number>
goToPrev: () => void // viewMode==='months' ? année-1 : mois-1 (roulement déc↔jan)
goToNext: () => void // idem +1
selectMonth: (m: number) => void // currentMonth = m
syncToIso: (iso: string | null) => void // mois/année depuis un ISO valide, sinon mois courant
}
Reprend la logique onPrev/onNext/syncViewToValue actuelle de Date.vue. Pur, testable seul.
CalendarField.vue (shell)
Props :
| Prop | Type | Défaut | Description |
|---|---|---|---|
displayValue |
string |
requis | Texte affiché dans le champ ('' si rien/incomplet) |
syncTo |
string | null |
requis | ISO servant à caler le mois à l'ouverture |
id,name,label,placeholder |
string |
'' / 'JJ/MM/AAAA' |
Champ |
required,disabled,readonly |
boolean |
false |
États |
hint,error,success |
string |
'' |
Messages |
clearable |
boolean |
true |
Croix d'effacement |
inputClass,labelClass,groupClass |
string |
'' |
Overrides twMerge |
Events :
| Event | Payload | Description |
|---|---|---|
clear |
— | Croix cliquée → l'enveloppe met son modelValue à null |
close |
— | Popover fermé (clic dehors ou programmatique) → l'enveloppe annule sa sélection en cours |
Slot par défaut (scoped, rendu quand viewMode==='days') : { currentMonth: number, currentYear: number, close: () => void }.
Comportement (repris à l'identique de l'actuel Date.vue) : input readonly, label flottant (bleu à l'ouverture), icône calendrier, croix (si clearable && displayValue && !disabled && !readonly), grossissement calibré 48px, bordures/états, popover ombré largeur champ collé sous le champ, header (prev/next→useCalendarView, toggle→useCalendarPopover.toggleView), MonthPicker en vue mois (clic mois → selectMonth + retour vue jours), syncToIso(syncTo) à l'ouverture + watch resync. isFilled dérivé de displayValue.length > 0.
dateRange.ts (helpers purs)
type DateRangeValue = { start: string; end: string }
function normalizeRange(a: string, b: string): DateRangeValue
// réordonne pour garantir start ≤ end
function resolveRangeBounds(
start: string | null, end: string | null, preview: string | null,
): { lo: string; hi: string } | null
// pas de start → null ; end committé prioritaire, sinon preview, sinon {lo:start,hi:start}
type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
function dayRangeRole(iso: string, bounds: { lo: string; hi: string } | null): DayRangeRole
MonthGrid.vue — extension
Nouvelles props (optionnelles) : rangeStart?, rangeEnd?, previewDate? (ISO ou null). Mode plage actif dès que rangeStart est passé ; sinon mode simple (selectedDate, comportement actuel inchangé).
Nouvel event : hover payload string | null — mouseenter d'un jour → ISO, mouseleave de la grille → null.
Attribut testabilité : chaque bouton jour porte :data-range-role="role" (none/single/start/end/in-range).
Rendu d'un jour en mode plage — bouton relative superposant 2 couches :
- Barre de fond absolue
bg-m-primary-light:in-range→ pleine largeur (inset-0) ;start→ moitié droite (left-1/2 right-0) ;end→ moitié gauche (left-0 right-1/2) ;single/none→ aucune. - Cercle (span
h-10 w-10) au-dessus :start/end/single→bg-m-primaryblanc ;in-range→ transparent, texte noir ;none→ rendu simple actuel (aujourd'hui, hors-mois…).
La barre passe sous les cercles, colonnes jointives → plage continue démarrant/finissant au centre des cercles.
DateRange.vue (enveloppe)
Props : identiques à Date sauf modelValue?: { start: string; end: string } | null. (id,name,label,placeholder,required,disabled,readonly,hint,error,success,min,max,clearable,inputClass,labelClass,groupClass.)
Emit : update:modelValue → { start: string; end: string } | null.
État interne :
pendingStart = ref<string | null>(null) // 1er clic en attente du 2e
hoverDate = ref<string | null>(null) // survol pour le preview
const isSelecting = computed(() => pendingStart.value !== null)
Passé au <MonthGrid> :
rangeStart = isSelecting ? pendingStart : (modelValue?.start ?? null)
rangeEnd = isSelecting ? null : (modelValue?.end ?? null)
previewDate = isSelecting ? hoverDate : null
// + :min :max :month :year (slot)
displayValue : '' pendant la sélection (1 seul jour choisi) ; "JJ/MM/AAAA - JJ/MM/AAAA" si plage complète ; '' sinon. syncTo = modelValue?.start ?? null.
Machine à états :
onSelectDay(iso):
si pendingStart === null: # 1er clic (ou reset après plage complète)
pendingStart = iso ; hoverDate = null
sinon: # 2e clic → complète
{ start, end } = normalizeRange(pendingStart, iso) # auto-inversion
emit('update:modelValue', { start, end })
pendingStart = null ; hoverDate = null
close() # ferme le popover (slot)
onHover(iso): # émis par MonthGrid
si isSelecting: hoverDate = iso # preview seulement pendant la sélection
onClose(): # CalendarField émet 'close'
pendingStart = null ; hoverDate = null # annule la sélection en cours, modelValue inchangé
onClear(): # CalendarField émet 'clear'
emit('update:modelValue', null)
pendingStart = null ; hoverDate = null
- 3e clic (plage complète) :
pendingStart===null→ nouveaustart, ancienne plage masquée pendant la sélection (rangeEnd=null), remplacée à la complétion. - min/max :
MonthGriddésactive les jours hors bornes → les 2 clics sont contraints. - modelValue invalide (start/end mal formés) : traité comme
null+ warning dev.
Refacto Date.vue
API publique inchangée. Devient une enveloppe (~80 lignes) :
<CalendarField :display-value="displayValue" :sync-to="modelValue ?? null" ...props
@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>
displayValue = formatIsoToDisplay(modelValue). Watch modelValue invalide → warning dev (conservé). Mode simple : pas de @close (rien à annuler), pas de @hover.
Les 21 tests de Date.test.ts doivent passer sans modification : tous les data-test sont rendus par CalendarField/MonthGrid, donc présents dans le DOM monté de Date. C'est le filet de sécurité du refacto.
Tests
dateRange.test.ts (~12)
normalizeRange: ordonné, inversé, égalresolveRangeBounds: pas de start → null ; start seul →{lo,hi}=start; start+end ordonné ; start+preview ; preview avant start (inversion) ; end prioritaire sur previewdayRangeRole: none (pas de bornes / hors plage), single (lo===hi), start, end, in-range
useCalendarView.test.ts (~8, fake timers)
- mois/année initiaux = aujourd'hui ;
goToNext/goToPrevvue jours (+ roulement déc↔jan avec année) ;goToNext/goToPrevvue mois (année ±1) ;selectMonth;syncToIsovalide / null
Date.test.ts
Inchangé — doit rester vert (filet du refacto).
DateRange.test.ts (~18)
- Rendu : label, icône,
"19/05/2026 - 25/05/2026"si modelValue, champ vide sinon - Ouverture popover, vue sur le mois du
start - 1er clic → pas d'émission ; 2e clic → émet
{start,end}+ ferme - 2e clic avant le 1er → auto-inversion (start ≤ end)
- Même jour ×2 →
{start:x, end:x} - 3e clic → repart sur un nouveau start (pas d'émission avant le 2e)
- Hover pendant sélection →
data-range-role="in-range"sur jours intermédiaires ; pas de preview hors sélection - Rôles :
start/end/in-rangecorrects viadata-range-role - Clic dehors pendant sélection → annulation,
modelValueinchangé clear→ émetnull- min/max → jours hors bornes non cliquables
- a11y :
aria-invalidsur error
Story dateRange.story.vue
Default vide, plage initiale, min/max, états (disabled/readonly/error/success), non-clearable.
Playground .playground/pages/composant/date/dateRange.vue
<MalioDateRange> standalone + affichage start → end, boutons set/reset, cas borné.
Découpage d'implémentation
- Helpers purs
dateRange.ts+ tests - Composable
useCalendarView.ts+ tests - Shell
CalendarField.vue(extraction depuisDate.vue) - Refacto
Date.vueen enveloppe →Date.test.tsdoit rester vert - Extension
MonthGrid.vue(range + hover + data-range-role) DateRange.vue+DateRange.test.ts- Story + playground