diff --git a/docs/superpowers/specs/2026-05-19-datepicker-design.md b/docs/superpowers/specs/2026-05-19-datepicker-design.md new file mode 100644 index 0000000..16d35b8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-datepicker-design.md @@ -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 `` (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 `` 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 `
` | +| `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, + year: Ref +): { weeks: ComputedRef } +``` + +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): { + isOpen: Ref + 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 `` : 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 `