Files
malio-layer-ui/docs/superpowers/specs/2026-05-22-datetime-design.md
tristan 7ac097e7f0 [#MUI-33] Développer le composant Datepicker (#50)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #50
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-22 07:56:07 +00:00

7.4 KiB
Raw Blame History

MalioDateTime — Design (version intérimaire)

Date : 2026-05-22 Ticket : #MUI-33 (famille Datepicker) Statut : validé, prêt pour le plan d'implémentation

Objectif

Ajouter un composant MalioDateTime à la famille temporelle de @malio/layer-ui, permettant de saisir une date ET une heure dans un seul champ avec popover.

Cette version est intérimaire : le sélecteur d'heure utilise un <input type="time"> natif, le temps que la maquette du sélecteur d'heure dédié soit fournie. Le bloc heure est volontairement isolé pour pouvoir être remplacé sans toucher au reste.

Valeur du modèle

modelValue: string | null au format ISO naïf sans fuseau : "YYYY-MM-DDTHH:MM:00" (ex. "2026-05-20T14:30:00").

  • Heure murale locale : un picker n'a pas de notion de fuseau.
  • Symfony (DateTimeNormalizer, RFC 3339) parse ce format et applique son fuseau configuré → zéro bug TZ/DST côté front.
  • Les secondes sont toujours 00 (le natif type="time" par défaut n'expose pas les secondes).
  • Cohérent avec MalioDate (YYYY-MM-DD) et MalioTime (HH:MM).

Architecture

Fine enveloppe autour du shell partagé internal/CalendarField.vue, comme MalioDate / MalioDateRange / MalioDateWeek.

MalioDateTime (Date/DateTime.vue)
  └─ CalendarField (champ + popover + header + navigation mois)
       slot #default={ currentMonth, currentYear, close }
         ├─ MonthGrid          (sélection du jour)
         └─ <input type="time"> (sélection de l'heure, sous la grille)

Le close du slot n'est pas appelé sur sélection (contrairement à MalioDate) : on a besoin de date ET heure, la fermeture se fait au clic extérieur (déjà gérée par CalendarField via useCalendarPopover).

Props

Identiques à MalioDate :

Prop Type Défaut Note
id string ''
name string ''
label string ''
modelValue string | null undefined "YYYY-MM-DDTHH:MM:00"
placeholder string 'JJ/MM/AAAA HH:MM'
required boolean false
disabled boolean false
readonly boolean false
hint string ''
error string ''
success string ''
min string undefined datetime ou date ; on borne la grille avec la partie date
max string undefined idem
clearable boolean true
inputClass / labelClass / groupClass string ''

Émissions : update:modelValue: string | null.

Affichage du champ

displayValue = formatIsoDateTimeToDisplay(modelValue)"JJ/MM/AAAA HH:MM" (ex. 20/05/2026 14:30). Chaîne vide si modelValue nul ou invalide.

Flux de sélection

État : modelValue est la source de vérité. Une ref locale pendingTime: string ('HH:MM' ou '') sert de pont quand l'heure est réglée avant qu'un jour soit choisi.

  • Partie date courante = splitDateTime(modelValue).date (ou null).
  • Valeur de l'<input type="time"> = splitDateTime(modelValue).time si présent, sinon pendingTime.

Interactions :

  1. Clic sur un jour iso :
    • heureEffective = partie heure de modelValue, sinon pendingTime, sinon '00:00'.
    • émet composeDateTime(iso, heureEffective)"iso T heure:00".
    • le popover reste ouvert.
  2. Changement de l'<input type="time"> (hhmm) :
    • si une partie date existe → émet composeDateTime(datePart, hhmm).
    • sinon → stocke hhmm dans pendingTime (pas d'émission tant qu'aucun jour n'est choisi).
    • si hhmm est vidé ('') et qu'une date existe → on garde la date avec 00:00 ? Non : on n'émet rien sur vidage, on conserve la dernière valeur émise (le natif renvoie rarement '' une fois rempli ; cas négligé pour l'intérim).
  3. Effacer (croix) : émet null, pendingTime = ''.

Bornes min/max : passées à MonthGrid après slice de la partie date (min?.slice(0, 10)). Pas de borne sur l'heure dans cette version.

Composable composables/datetimeFormat.ts

Réutilise isValidIso de dateFormat.ts pour valider la partie date.

// "YYYY-MM-DDTHH:MM:00" complet et valide ?
export function isValidIsoDateTime(s: string): boolean

// "YYYY-MM-DDTHH:MM:00" -> "JJ/MM/AAAA HH:MM" (chaîne vide si invalide/nul)
export function formatIsoDateTimeToDisplay(s: string | null): string

// "YYYY-MM-DDTHH:MM:00" -> { date: "YYYY-MM-DD" | null, time: "HH:MM" | '' }
export function splitDateTime(s: string | null): { date: string | null; time: string }

// (date "YYYY-MM-DD", time "HH:MM") -> "YYYY-MM-DDTHH:MM:00"
export function composeDateTime(date: string, time: string): string

Règles :

  • isValidIsoDateTime : regex ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$, partie date valide via isValidIso, heure 0023, minutes 0059, secondes 0059.
  • splitDateTime : si s nul ou ne matche pas le motif datetime → { date: null, time: '' }. Sinon découpe sur T, renvoie la date et HH:MM (5 premiers car. de la partie heure).
  • composeDateTime(date, time) : ${date}T${time}:00. Si time vide → ${date}T00:00:00.
  • formatIsoDateTimeToDisplay : si !isValidIsoDateTime''. Sinon ${dd}/${mm}/${yyyy} ${hh}:${min}.

Tests

Colocalisés, Vitest + @vue/test-utils (jsdom), vi.setSystemTime(new Date(2026, 4, 19)) pour déterminisme.

datetimeFormat.test.ts (helpers purs) :

  • isValidIsoDateTime : accepte "2026-05-20T14:30:00" ; rejette "2026-05-20", "2026-13-01T00:00:00", "2026-05-20T24:00:00", "2026-05-20T14:60:00", "", format sans secondes.
  • formatIsoDateTimeToDisplay : "2026-05-20T14:30:00""20/05/2026 14:30" ; nul/invalide → ''.
  • splitDateTime : "2026-05-20T14:30:00"{ date: "2026-05-20", time: "14:30" } ; null{ date: null, time: '' } ; "2026-05-20" (date seule, non datetime) → { date: null, time: '' }.
  • composeDateTime : ("2026-05-20", "14:30")"2026-05-20T14:30:00" ; ("2026-05-20", "")"2026-05-20T00:00:00".

DateTime.test.ts (composant) :

  • Rendu : champ présent, placeholder JJ/MM/AAAA HH:MM, displayValue correct quand modelValue fourni.
  • Ouverture popover au clic → MonthGrid + input[type=time] visibles.
  • Clic sur un jour sans heure → émet "<iso>T00:00:00", popover reste ouvert.
  • Clic sur un jour avec pendingTime réglé d'abord → émet "<iso>T<pendingTime>:00".
  • Changement de l'heure avec date déjà choisie → émet "<date>T<nouvelleHeure>:00".
  • Changement de l'heure sans date → aucune émission.
  • clearable : croix émet null.
  • min/max datetime → jours hors bornes désactivés dans la grille.
  • Accessibilité : label lié, aria-invalid sur erreur.

Livrables

  1. app/components/malio/date/composables/datetimeFormat.ts
  2. app/components/malio/date/composables/datetimeFormat.test.ts
  3. app/components/malio/date/DateTime.vue
  4. app/components/malio/date/DateTime.test.ts
  5. Page playground .playground/pages/composant/date/datetime.vue + entrée nav (playground.nav.ts)
  6. Story app/story/date/dateTime.story.vue
  7. COMPONENTS.md (section MalioDateTime)
  8. CHANGELOG.md (ligne sous ### Added)

Hors périmètre (intérim)

  • Sélecteur d'heure dédié / maquette → itération ultérieure ; on remplacera le bloc <input type="time"> isolé.
  • Bornes horaires (min/max sur l'heure).
  • Pas de minutes configurable (granularité native du navigateur).
  • Secondes dans l'UI.