15 KiB
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/AAAAdans le champ, valeur ISOYYYY-MM-DDenmodelValue - 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/AAAAmanuel) - 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
DateJS 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: numberselectedDate?: string | null(ISO)min?: string(ISO)max?: string(ISO)
Events :
selectpayloadstring— date ISOYYYY-MM-DDdu 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 :
selectpayloadnumber(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
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.
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
function useCalendarPopover(rootRef: Ref<HTMLElement | null>): {
isOpen: Ref<boolean>
viewMode: Ref<'days' | 'months'>
open: () => void
close: () => void
toggleView: () => void
}
isOpenetviewModereset àfalse/'days'à la fermeture- Listener
mousedownglobal 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
modelValuevalide → 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 :
- Émission
update:modelValueavec la date ISO - Fermeture du popover
- 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 -= 1si 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é, retourviewMode = '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:closeapparaît à gauche de l'icône calendrier - Clic émet
nulletstopPropagationpour ne pas ouvrir le popover
États
disabled: opacity réduite, curseur not-allowed, clic sans effet, croix masquéereadonly: 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
nullet 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 :
opacity150ms à l'apparition, respectprefers-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-down16px à 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-outline20px, à droite, couleur dynamique selon état - Croix d'effacement
mdi:close16px, à gauche de l'icône,text-m-muted hover:text-black
Accessibilité
aria-invalidsynchronisé surerroraria-describedbylié au texte dehint/error/successaria-expandedsur le champ pour signaler l'état du popoveraria-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/maxnon 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 :
disabledetreadonlybloquent l'ouverture - A11y :
aria-invalid,aria-describedby - Synchro externe : changement de
modelValueprogrammatique
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 invalideparseDisplayToIso: nominal, format invalide, jour ou mois hors borneisValidIso: 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 :
- Default — vierge, label "Date de naissance"
- Avec valeur initiale —
modelValue="2026-05-19" - Avec min/max — borné aujourd'hui → +30 jours, label "Date du rendez-vous"
- États — disabled, readonly, error, success, hint
- Non-clearable —
clearable=false - Required — avec error si vide
- Override de classes —
inputClass,groupClasscustom
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 :
- Composables purs (
dateFormat,useMonthMatrix,useCalendarPopover) + leurs tests - Sous-composants internes (
CalendarHeader,MonthGrid,MonthPicker) - Composant public
Date.vue - Tests d'intégration
Date.test.ts - Story Histoire + page playground