# 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 `