diff --git a/docs/superpowers/specs/2026-05-20-daterange-design.md b/docs/superpowers/specs/2026-05-20-daterange-design.md new file mode 100644 index 0000000..f7d0ea8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-daterange-design.md @@ -0,0 +1,243 @@ +# 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