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:
243
docs/superpowers/specs/2026-05-20-daterange-design.md
Normal file
243
docs/superpowers/specs/2026-05-20-daterange-design.md
Normal 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
|
||||||
Reference in New Issue
Block a user