docs : spec du composant DateTime (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
146
docs/superpowers/specs/2026-05-22-datetime-design.md
Normal file
146
docs/superpowers/specs/2026-05-22-datetime-design.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// "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 `00–23`, minutes `00–59`, secondes `00–59`.
|
||||||
|
- `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.
|
||||||
Reference in New Issue
Block a user