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

15 KiB
Raw Blame History

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

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
}
  • 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 initialemodelValue="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-clearableclearable=false
  6. Required — avec error si vide
  7. Override de classesinputClass, 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