docs : spec de conception du composant datepicker MalioDate (#MUI-33)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 08:04:41 +02:00
parent ac06ed9ae6
commit 2ce444ec65

View File

@@ -0,0 +1,373 @@
# MalioDate — Design Spec
Composant de sélection de date avec champ + popover calendrier. Première brique d'une famille de pickers temporels (futurs `DateRange`, `DateTime`).
**Ticket :** MUI-33
**Branche :** `feature/MUI-33-developper-le-composant-datepicker`
## Périmètre v1
Sélection d'une date unique via un calendrier. Le champ est **readonly** (clic uniquement, pas de saisie clavier en v1). Locale FR hardcodée, semaine commençant le lundi.
**Inclus en v1 :**
- Affichage `JJ/MM/AAAA` dans le champ, valeur ISO `YYYY-MM-DD` en `modelValue`
- Surlignage du jour sélectionné et du jour "aujourd'hui"
- Jours du mois précédent/suivant affichés grisés mais cliquables (naviguent vers le mois cible)
- Bornes `min` / `max` (jours hors bornes désactivés)
- Bouton effacer (croix) si `clearable`
- Vue mois (grille 4×3) accessible via clic sur `Mois Année ⌄` dans le header
- Numéros de semaine ISO 8601 dans une colonne à fond `m-primary/10`
**Reporté à plus tard :**
- Saisie clavier dans le champ (parsing `JJ/MM/AAAA` manuel)
- Navigation clavier dans la grille (flèches, Enter, Escape)
- Vue années (sélection rapide d'une année)
- Prop `disabledDates` (prédicat ou array)
- i18n (autres langues)
## Architecture
Composant public unique `<MalioDate>` (autoimporté depuis `app/components/malio/date/Date.vue`), composé de sous-composants internes et de modules utilitaires colocalisés.
```
app/components/malio/date/
Date.vue # composant public (orchestration)
Date.test.ts
internal/
CalendarHeader.vue # header mois/année + chevrons + toggle vue
MonthGrid.vue # grille 6×7 jours + colonne semaine
MonthPicker.vue # grille 4×3 mois
composables/
useMonthMatrix.ts # calcule la matrice 6×7 + n° semaines ISO
dateFormat.ts # fonctions pures de format/parsing/validation
useCalendarPopover.ts # état ouvert/fermé + click outside
```
Les sous-composants `internal/` ne sont pas destinés à être consommés directement. Ils seront réutilisés par `DateRange` et `DateTime` à venir.
## Type `modelValue`
`string | null`, au format ISO `YYYY-MM-DD`. Le composant interne convertit en affichage `JJ/MM/AAAA` via `dateFormat.formatIsoToDisplay()`. Cette représentation a été retenue pour :
- Cohérence avec `<MalioTime>` qui émet déjà une string (`"HH:MM"`)
- Sérialisation directe vers une API REST/JSON sans conversion
- Pas de piège de fuseau horaire (un objet `Date` JS porte une heure + un fuseau)
- Comparaison lexicographique = comparaison chronologique (utile pour `min`/`max`)
## Props
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto-généré | Identifiant HTML du champ |
| `name` | `string` | `''` | Attribut `name` pour les `<form>` |
| `label` | `string` | `''` | Label flottant |
| `modelValue` | `string \| null` | `undefined` | Date ISO `YYYY-MM-DD` (v-model) |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder du champ |
| `required` | `boolean` | `false` | Attribut required |
| `disabled` | `boolean` | `false` | Verrouille champ et calendrier |
| `readonly` | `boolean` | `false` | Affiche la valeur mais bloque l'ouverture |
| `hint` | `string` | `''` | Texte d'aide sous le champ |
| `error` | `string` | `''` | Message d'erreur (bordure et texte rouges) |
| `success` | `string` | `''` | Message succès (bordure et texte verts) |
| `min` | `string` | `undefined` | Borne inférieure incluse, format ISO |
| `max` | `string` | `undefined` | Borne supérieure incluse, format ISO |
| `clearable` | `boolean` | `true` | Affiche une croix pour effacer la valeur |
| `inputClass` | `string` | `''` | Override classes input (twMerge) |
| `labelClass` | `string` | `''` | Override classes label (twMerge) |
| `groupClass` | `string` | `''` | Override classes wrapper (twMerge) |
Si `min`/`max` sont invalides (format incorrect ou `min > max`), ils sont ignorés silencieusement avec un warning console en dev.
## Events
| Event | Payload | Description |
|-------|---------|-------------|
| `update:modelValue` | `string \| null` | Date ISO sélectionnée, ou `null` si effacée |
## Slots
Aucun slot en v1. L'icône calendrier est fixée (`mdi:calendar-outline`).
## Sous-composants internes
### `CalendarHeader.vue`
Affiche la barre du haut du popover : `[] Mois Année [⌄] []`.
**Props :**
- `viewMode: 'days' | 'months'`
- `currentMonth: number` (0-11)
- `currentYear: number`
**Events :**
- `prev` — chevron gauche (interprété par le parent : mois précédent en vue jours, année précédente en vue mois)
- `next` — chevron droit (idem)
- `toggle-view` — clic sur le bouton central
### `MonthGrid.vue`
Rend la grille 6 lignes × 8 colonnes (semaine + 7 jours).
**Props :**
- `month: number` (0-11)
- `year: number`
- `selectedDate?: string | null` (ISO)
- `min?: string` (ISO)
- `max?: string` (ISO)
**Events :**
- `select` payload `string` — date ISO `YYYY-MM-DD` du jour cliqué
Utilise `useMonthMatrix(month, year)` pour générer les 6 lignes. La grille fait toujours 6 lignes (forcé) pour stabiliser la hauteur du popover entre les mois.
### `MonthPicker.vue`
Rend la grille 4×3 des mois.
**Props :**
- `selectedMonth?: number` (0-11, mois courant à surligner)
**Events :**
- `select` payload `number` (0-11)
Pas de gestion `min`/`max` au niveau mois en v1 — `MonthGrid` filtrera les jours hors bornes au retour vue jours.
## Composables
### `useMonthMatrix.ts`
```ts
type DayCell = {
isoDate: string // "YYYY-MM-DD"
day: number // 1-31
isCurrentMonth: boolean
isToday: boolean
}
type WeekRow = {
weekNumber: number // ISO 8601, 1-53
days: DayCell[] // toujours 7, Lun → Dim
}
function useMonthMatrix(
month: Ref<number>,
year: Ref<number>
): { weeks: ComputedRef<WeekRow[]> }
```
Le premier jour de la grille est le lundi de la semaine contenant le 1er du mois affiché. La grille fait **toujours** 6 lignes (`WeekRow[]` de longueur 6), au besoin en débordant sur le mois suivant.
Les numéros de semaine suivent **ISO 8601** : la semaine 1 contient le premier jeudi de l'année.
### `dateFormat.ts`
Module de fonctions pures, **pas un composable réactif**. Le nommage sans préfixe `use` reflète sa nature.
```ts
function formatIsoToDisplay(iso: string | null): string
// "2026-05-19" → "19/05/2026", null/invalide → ""
function parseDisplayToIso(display: string): string | null
// "19/05/2026" → "2026-05-19", invalide → null
function isValidIso(iso: string): boolean
// "2026-05-19" → true, "2026-13-45" → false
function isDateInRange(iso: string, min?: string, max?: string): boolean
// Comparaison lexicographique (= chronologique pour ISO)
```
`parseDisplayToIso` est écrit dès la v1 même si non utilisé (le champ est readonly) — il sera réutilisé en v2 quand on rendra le champ éditable.
### `useCalendarPopover.ts`
```ts
function useCalendarPopover(rootRef: Ref<HTMLElement | null>): {
isOpen: Ref<boolean>
viewMode: Ref<'days' | 'months'>
open: () => void
close: () => void
toggleView: () => void
}
```
- `isOpen` et `viewMode` reset à `false` / `'days'` à la fermeture
- Listener `mousedown` global attaché à `onMounted`, retiré à `onBeforeUnmount`
- Fermeture si le clic est hors de `rootRef`
- Pas de gestion clavier en v1
## Comportements détaillés
### Ouverture du popover
Clic sur le champ ou l'icône calendrier (sauf si `disabled` ou `readonly`) → `open()`. Vue initiale :
- Si `modelValue` valide → grille du mois de cette date
- Sinon → grille du mois courant (`new Date()`)
Le champ passe en mode "popover ouvert" : bordure du bas retirée, `rounded-b-none`, bordure latérale colorée (`m-primary` ou `m-danger`/`m-success` selon état).
### Sélection d'un jour (vue jours)
Clic sur une cellule jour cliquable :
1. Émission `update:modelValue` avec la date ISO
2. Fermeture du popover
3. Réaffichage du champ avec la valeur formatée `JJ/MM/AAAA`
Cas spéciaux :
- Jour hors mois courant : sélection normale, le popover se ferme (peu importe que la vue interne saute au mois cible, elle n'est plus visible)
- Jour hors `min`/`max` : non cliquable, `cursor-not-allowed`, pas d'émission
- Re-clic sur la date déjà sélectionnée : ré-émission de la même valeur, popover ferme
### Navigation chevrons (vue jours)
- Chevron gauche : `currentMonth -= 1` (décembre + `year -= 1` si on était en janvier)
- Chevron droit : symétrique
- Pas de bornage de navigation par `min`/`max` — on peut naviguer où on veut, seuls les jours sont désactivés
### Bascule vers la vue mois
Clic sur `Mois Année ⌄``toggleView()``viewMode = 'months'`.
En vue mois :
- Header inchangé visuellement, mais les chevrons naviguent désormais l'**année** (`year ± 1`)
- Le bouton central reste cliquable : un nouveau clic ramène à `viewMode = 'days'` (toggle binaire, validé Q4b)
- Clic sur un mois dans la grille 4×3 → `currentMonth = mois cliqué`, retour `viewMode = 'days'` sans sélection de date
### Fermeture sans sélection
Clic en dehors du champ ET du popover → `close()`. `modelValue` inchangé. L'état interne (`currentMonth`, `currentYear`, `viewMode`) est **reset à la prochaine ouverture** selon la règle "Ouverture du popover" (pas de mémorisation).
### Bouton effacer
Si `modelValue !== null && clearable && !disabled && !readonly` :
- Une croix `mdi:close` apparaît à gauche de l'icône calendrier
- Clic émet `null` et `stopPropagation` pour ne pas ouvrir le popover
### États
- `disabled` : opacity réduite, curseur not-allowed, clic sans effet, croix masquée
- `readonly` : affichage normal, clic sans effet sur l'ouverture, croix masquée
### Synchronisation `modelValue` externe
Si le parent change `modelValue` programmatiquement :
- Le champ se met à jour (re-format)
- Si le popover est ouvert, la vue saute au mois de la nouvelle valeur
- Si la nouvelle valeur a un format invalide, le composant traite comme `null` et log un warning console en dev
## Style / CSS
### Popover
- `min-w-[320px]`, hauteur fixe ~`360px` (6 semaines × ~38px + header)
- Position : `absolute top-[calc(100%-4px)] left-0 z-20`
- `bg-white border border-t-0` (couleur selon état : `m-primary` / `m-danger` / `m-success`)
- `rounded-b-md`
- Transition : `opacity` 150ms à l'apparition, respect `prefers-reduced-motion`
### Header
- Hauteur `h-12`, `border-b border-m-primary/20`
- Chevrons (`mdi:chevron-left` / `mdi:chevron-right`) : 20px, padding cliquable 8px, `hover:bg-m-primary/10 rounded`
- Texte central : `text-base font-medium`, cliquable, `mdi:chevron-down` 16px à côté
### Grille jours
- En-tête `Sem | Lun | Mar | Mer | Jeu | Ven | Sam | Dim` : `text-xs uppercase text-m-muted font-medium`, 32px de hauteur
- Cellule : `w-10 h-10 text-sm`, centrée
- Colonne semaine : `bg-m-primary/10`, `text-m-primary/70`, non cliquable
- Jour du mois courant : `text-black`
- Jour hors mois : `text-m-muted/50`
- Jour "aujourd'hui" : `border border-m-primary`, `text-m-primary font-semibold`
- Jour sélectionné : `bg-m-primary text-white font-medium rounded-full` (prime sur "aujourd'hui")
- Jour hors `min`/`max` : `text-m-muted/30 cursor-not-allowed`, non cliquable
- Hover : `hover:bg-m-primary/10 rounded-full`
### Grille mois (MonthPicker)
- `grid grid-cols-4 gap-2 p-3`
- Cellule : `py-3 text-sm rounded`
- Libellés : `Janv | Févr | Mars | Avr | Mai | Juin | Juil | Août | Sept | Oct | Nov | Déc`
- Mois sélectionné : `bg-m-primary text-white`
- Hover : `hover:bg-m-primary/10`
### Champ
Reprend le pattern de `<MalioInputAutocomplete>` : label flottant, bordure `m-muted` au repos, `m-primary` au focus/open, `m-danger`/`m-success` selon état.
- Icône calendrier `mdi:calendar-outline` 20px, à droite, couleur dynamique selon état
- Croix d'effacement `mdi:close` 16px, à gauche de l'icône, `text-m-muted hover:text-black`
## Accessibilité
- `aria-invalid` synchronisé sur `error`
- `aria-describedby` lié au texte de `hint`/`error`/`success`
- `aria-expanded` sur le champ pour signaler l'état du popover
- `aria-haspopup="dialog"` sur le champ
- Label `<label for>` lié au champ
- Cellules jour : `role="button"`, `aria-label="19 mai 2026"` (jour en toutes lettres pour les lecteurs d'écran)
- Cellules désactivées : `aria-disabled="true"`
- Navigation clavier dans la grille : **reportée v2** (Escape, flèches, Enter)
## Tests
### `Date.test.ts` (~30 cas)
Tests groupés par `describe` :
- **Rendu** : label, placeholder, icône calendrier, affichage de la valeur formatée
- **Popover** : ouverture au clic, fermeture au click outside, vue initiale selon `modelValue`
- **Navigation** : chevrons en vue jours, passage décembre↔janvier avec changement d'année
- **Sélection** : émission ISO correcte, fermeture après sélection, sélection d'un jour hors mois
- **Bornes** : jours hors `min`/`max` non cliquables, comparaison ISO
- **Vue mois** : bascule, chevrons en vue mois naviguent l'année, clic mois retourne en vue jours
- **Clearable** : présence/absence de la croix, émission `null`, pas d'ouverture
- **États** : `disabled` et `readonly` bloquent l'ouverture
- **A11y** : `aria-invalid`, `aria-describedby`
- **Synchro externe** : changement de `modelValue` programmatique
### `useMonthMatrix.test.ts` (~10 cas)
- Mois standard (mai 2026) produit 6×7 cellules
- Mois commençant un lundi (toutes les cases du premier lundi sont du mois courant)
- Mois finissant un dimanche
- Année bissextile (février 2024 : 29 jours)
- Numéro de semaine ISO en début d'année (janvier 2026 commence en semaine 1 ou 52/53 de 2025 ?)
- Numéro de semaine ISO en fin d'année
### `dateFormat.test.ts` (~10 cas)
- `formatIsoToDisplay` : nominal, null, format invalide
- `parseDisplayToIso` : nominal, format invalide, jour ou mois hors borne
- `isValidIso` : nominal, faux positifs (32 jours, mois 13)
- `isDateInRange` : sans bornes, avec min seul, avec max seul, avec les deux
Helper `mountComponent(props)` reprend le pattern existant des autres tests Malio. Environnement Vitest + jsdom (déjà configurés).
## Story Histoire `Date.story.vue`
Dans `app/story/date/Date.story.vue`. Variants :
1. **Default** — vierge, label "Date de naissance"
2. **Avec valeur initiale**`modelValue="2026-05-19"`
3. **Avec min/max** — borné aujourd'hui → +30 jours, label "Date du rendez-vous"
4. **États** — disabled, readonly, error, success, hint
5. **Non-clearable**`clearable=false`
6. **Required** — avec error si vide
7. **Override de classes**`inputClass`, `groupClass` custom
## Playground `.playground/pages/composant/date.vue`
Page de test dev :
- Un `<MalioDate>` standalone
- Affichage de la valeur courante en dessous
- Boutons pour reset (`value = null`) et forcer une date (`value = '2026-12-25'`)
- Un cas avec `min`/`max`
Lien ajouté dans `.playground/pages/index.vue`.
## Découpage de l'implémentation
Le plan d'implémentation (généré ensuite via `writing-plans`) découpera en étapes ordonnées :
1. Composables purs (`dateFormat`, `useMonthMatrix`, `useCalendarPopover`) + leurs tests
2. Sous-composants internes (`CalendarHeader`, `MonthGrid`, `MonthPicker`)
3. Composant public `Date.vue`
4. Tests d'intégration `Date.test.ts`
5. Story Histoire + page playground