# 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") └─ ├─ champ + popover (useCalendarPopover) + navigation (useCalendarView) ├─ header + MonthPicker (viewMode='months') └─ ← viewMode='days' └─ 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` ```ts function useCalendarView(viewMode: Ref<'days' | 'months'>): { currentMonth: Ref // 0-11 currentYear: Ref 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) ```ts 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 : 1. 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. 2. Cercle (span `h-10 w-10`) au-dessus : `start`/`end`/`single` → `bg-m-primary` blanc ; `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 :** ```ts pendingStart = ref(null) // 1er clic en attente du 2e hoverDate = ref(null) // survol pour le preview const isSelecting = computed(() => pendingStart.value !== null) ``` **Passé au `` :** ```ts 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` → nouveau `start`, ancienne plage masquée pendant la sélection (`rangeEnd=null`), remplacée à la complétion. - **min/max** : `MonthGrid` dé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) : ```vue ``` `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é, égal - `resolveRangeBounds` : pas de start → null ; start seul → `{lo,hi}=start` ; start+end ordonné ; start+preview ; preview avant start (inversion) ; end prioritaire sur preview - `dayRangeRole` : 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`/`goToPrev` vue jours (+ roulement déc↔jan avec année) ; `goToNext`/`goToPrev` vue mois (année ±1) ; `selectMonth` ; `syncToIso` valide / 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-range` corrects via `data-range-role` - Clic dehors pendant sélection → annulation, `modelValue` inchangé - `clear` → émet `null` - min/max → jours hors bornes non cliquables - a11y : `aria-invalid` sur 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` `` standalone + affichage `start → end`, boutons set/reset, cas borné. ## Découpage d'implémentation 1. Helpers purs `dateRange.ts` + tests 2. Composable `useCalendarView.ts` + tests 3. Shell `CalendarField.vue` (extraction depuis `Date.vue`) 4. Refacto `Date.vue` en enveloppe → `Date.test.ts` doit rester vert 5. Extension `MonthGrid.vue` (range + hover + data-range-role) 6. `DateRange.vue` + `DateRange.test.ts` 7. Story + playground