docs : spec de conception du composant MalioDateRange + shell partagé (#MUI-33)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 11:44:31 +02:00
parent c96cb5112d
commit 840a5c6c52

View File

@@ -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")
└─ <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`
```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)
```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<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>` :**
```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
<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é, é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`
`<MalioDateRange>` 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