Files
malio-layer-ui/docs/superpowers/specs/2026-05-19-datepicker-design.md
2026-05-20 08:04:41 +02:00

374 lines
15 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.
# 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