Files
malio-layer-ui/docs/superpowers/specs/2026-05-20-daterange-design.md
2026-05-20 11:44:31 +02:00

244 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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