[#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>
This commit was merged in pull request #50.
This commit is contained in:
373
docs/superpowers/specs/2026-05-19-datepicker-design.md
Normal file
373
docs/superpowers/specs/2026-05-19-datepicker-design.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# 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`
|
||||
|
||||
```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.
|
||||
|
||||
```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<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 initiale** — `modelValue="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-clearable** — `clearable=false`
|
||||
6. **Required** — avec error si vide
|
||||
7. **Override de classes** — `inputClass`, `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
|
||||
243
docs/superpowers/specs/2026-05-20-daterange-design.md
Normal file
243
docs/superpowers/specs/2026-05-20-daterange-design.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# MalioDateRange — Design Spec
|
||||
|
||||
Composant de sélection d'une **période** (date début / date fin) via un champ + popover calendrier. Deuxième brique de la famille temporelle, construite sur un shell partagé extrait de `MalioDate`.
|
||||
|
||||
**Ticket :** MUI-33 (suite)
|
||||
**Branche :** `feature/MUI-33-developper-le-composant-datepicker`
|
||||
**Spec liée :** `docs/superpowers/specs/2026-05-19-datepicker-design.md`
|
||||
|
||||
## Contexte & roadmap
|
||||
|
||||
`MalioDate` (sélection simple) est déjà livré. Suivront `DateWeek` et `DateTime`. Les 4 composants partagent le **même champ + popover + header + vue mois** ; seule la sélection diffère. On extrait donc un shell réutilisable `CalendarField` (Approche 3 retenue parce que la famille comptera 4 variantes — la duplication de coquille ×4 et la maintenance front en parallèle sont le vrai risque).
|
||||
|
||||
## Périmètre
|
||||
|
||||
Sélection d'une période sur **un seul mois** affiché (popover = largeur du champ, comme `Date`). Visuellement identique à `Date`, sauf :
|
||||
- on sélectionne **deux dates** (début/fin)
|
||||
- les jours **entre** les bornes ont un fond `bg-m-primary-light` (bleu clair)
|
||||
- les bornes (start/end) gardent le cercle plein `bg-m-primary`
|
||||
- **aperçu au survol** (hover preview) de la plage pendant la sélection
|
||||
|
||||
**Inclus :** sélection 2 clics, auto-inversion, hover preview, surlignage de plage (demi-barre aux bornes), bornes `min`/`max`, effacement, vue mois conservée.
|
||||
|
||||
**Reporté :** deux mois côte à côte, ajustement de la borne la plus proche au 3e clic (on garde le reset standard), saisie clavier, navigation clavier.
|
||||
|
||||
## Architecture (Approche 3 — shell partagé)
|
||||
|
||||
```
|
||||
app/components/malio/date/
|
||||
Date.vue # ENVELOPPE (refacto) — sélection simple
|
||||
DateRange.vue # ENVELOPPE (nouveau) — sélection période
|
||||
Date.test.ts # inchangé (filet de sécurité du refacto)
|
||||
DateRange.test.ts # nouveau
|
||||
internal/
|
||||
CalendarField.vue # NOUVEAU — shell : champ + popover + header + MonthPicker
|
||||
CalendarHeader.vue # inchangé
|
||||
MonthGrid.vue # étendu : props range + émission hover + data-range-role
|
||||
MonthPicker.vue # inchangé
|
||||
composables/
|
||||
useCalendarView.ts # NOUVEAU — état mois/année + navigation (extrait de Date.vue)
|
||||
useCalendarView.test.ts
|
||||
useCalendarPopover.ts # inchangé
|
||||
dateRange.ts # NOUVEAU — helpers purs de plage
|
||||
dateRange.test.ts
|
||||
dateFormat.ts # inchangé
|
||||
useMonthMatrix.ts # inchangé
|
||||
app/story/date/dateRange.story.vue
|
||||
.playground/pages/composant/date/dateRange.vue
|
||||
```
|
||||
|
||||
Flux :
|
||||
|
||||
```
|
||||
DateRange.vue (enveloppe)
|
||||
├─ état de sélection range ({start,end} + pendingStart + hoverDate)
|
||||
├─ displayValue ("19/05/2026 - 25/05/2026")
|
||||
└─ <CalendarField :display-value :sync-to=start ... @clear @close>
|
||||
├─ champ + popover (useCalendarPopover) + navigation (useCalendarView)
|
||||
├─ header + MonthPicker (viewMode='months')
|
||||
└─ <slot :current-month :current-year :close> ← viewMode='days'
|
||||
└─ <MonthGrid> mode range (rangeStart/rangeEnd/previewDate, @select, @hover)
|
||||
```
|
||||
|
||||
`CalendarField` ne connaît **rien** de la sélection : il gère champ, ouverture, navigation, et expose `{ currentMonth, currentYear, close }` au slot. Chaque enveloppe branche son `MonthGrid` et décide quand appeler `close()`.
|
||||
|
||||
## `useCalendarView.ts`
|
||||
|
||||
```ts
|
||||
function useCalendarView(viewMode: Ref<'days' | 'months'>): {
|
||||
currentMonth: Ref<number> // 0-11
|
||||
currentYear: Ref<number>
|
||||
goToPrev: () => void // viewMode==='months' ? année-1 : mois-1 (roulement déc↔jan)
|
||||
goToNext: () => void // idem +1
|
||||
selectMonth: (m: number) => void // currentMonth = m
|
||||
syncToIso: (iso: string | null) => void // mois/année depuis un ISO valide, sinon mois courant
|
||||
}
|
||||
```
|
||||
|
||||
Reprend la logique `onPrev`/`onNext`/`syncViewToValue` actuelle de `Date.vue`. Pur, testable seul.
|
||||
|
||||
## `CalendarField.vue` (shell)
|
||||
|
||||
**Props :**
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `displayValue` | `string` | **requis** | Texte affiché dans le champ (`''` si rien/incomplet) |
|
||||
| `syncTo` | `string \| null` | **requis** | ISO servant à caler le mois à l'ouverture |
|
||||
| `id`,`name`,`label`,`placeholder` | `string` | `''` / `'JJ/MM/AAAA'` | Champ |
|
||||
| `required`,`disabled`,`readonly` | `boolean` | `false` | États |
|
||||
| `hint`,`error`,`success` | `string` | `''` | Messages |
|
||||
| `clearable` | `boolean` | `true` | Croix d'effacement |
|
||||
| `inputClass`,`labelClass`,`groupClass` | `string` | `''` | Overrides twMerge |
|
||||
|
||||
**Events :**
|
||||
|
||||
| Event | Payload | Description |
|
||||
|-------|---------|-------------|
|
||||
| `clear` | — | Croix cliquée → l'enveloppe met son `modelValue` à `null` |
|
||||
| `close` | — | Popover fermé (clic dehors ou programmatique) → l'enveloppe annule sa sélection en cours |
|
||||
|
||||
**Slot par défaut** (scoped, rendu quand `viewMode==='days'`) : `{ currentMonth: number, currentYear: number, close: () => void }`.
|
||||
|
||||
**Comportement** (repris à l'identique de l'actuel `Date.vue`) : input readonly, label flottant (bleu à l'ouverture), icône calendrier, croix (si `clearable && displayValue && !disabled && !readonly`), grossissement calibré 48px, bordures/états, popover ombré largeur champ collé sous le champ, header (`prev`/`next`→`useCalendarView`, `toggle`→`useCalendarPopover.toggleView`), `MonthPicker` en vue mois (clic mois → `selectMonth` + retour vue jours), `syncToIso(syncTo)` à l'ouverture + watch resync. `isFilled` dérivé de `displayValue.length > 0`.
|
||||
|
||||
## `dateRange.ts` (helpers purs)
|
||||
|
||||
```ts
|
||||
type DateRangeValue = { start: string; end: string }
|
||||
|
||||
function normalizeRange(a: string, b: string): DateRangeValue
|
||||
// réordonne pour garantir start ≤ end
|
||||
|
||||
function resolveRangeBounds(
|
||||
start: string | null, end: string | null, preview: string | null,
|
||||
): { lo: string; hi: string } | null
|
||||
// pas de start → null ; end committé prioritaire, sinon preview, sinon {lo:start,hi:start}
|
||||
|
||||
type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
|
||||
function dayRangeRole(iso: string, bounds: { lo: string; hi: string } | null): DayRangeRole
|
||||
```
|
||||
|
||||
## `MonthGrid.vue` — extension
|
||||
|
||||
**Nouvelles props (optionnelles) :** `rangeStart?`, `rangeEnd?`, `previewDate?` (ISO ou null). Mode plage actif dès que `rangeStart` est passé ; sinon mode simple (`selectedDate`, comportement actuel inchangé).
|
||||
|
||||
**Nouvel event :** `hover` payload `string | null` — `mouseenter` d'un jour → ISO, `mouseleave` de la grille → `null`.
|
||||
|
||||
**Attribut testabilité :** chaque bouton jour porte `:data-range-role="role"` (`none`/`single`/`start`/`end`/`in-range`).
|
||||
|
||||
**Rendu d'un jour en mode plage** — bouton `relative` superposant 2 couches :
|
||||
1. Barre de fond absolue `bg-m-primary-light` : `in-range` → pleine largeur (`inset-0`) ; `start` → moitié droite (`left-1/2 right-0`) ; `end` → moitié gauche (`left-0 right-1/2`) ; `single`/`none` → aucune.
|
||||
2. Cercle (span `h-10 w-10`) au-dessus : `start`/`end`/`single` → `bg-m-primary` blanc ; `in-range` → transparent, texte noir ; `none` → rendu simple actuel (aujourd'hui, hors-mois…).
|
||||
|
||||
La barre passe sous les cercles, colonnes jointives → plage continue démarrant/finissant au centre des cercles.
|
||||
|
||||
## `DateRange.vue` (enveloppe)
|
||||
|
||||
**Props :** identiques à `Date` sauf `modelValue?: { start: string; end: string } | null`. (`id`,`name`,`label`,`placeholder`,`required`,`disabled`,`readonly`,`hint`,`error`,`success`,`min`,`max`,`clearable`,`inputClass`,`labelClass`,`groupClass`.)
|
||||
|
||||
**Emit :** `update:modelValue` → `{ start: string; end: string } | null`.
|
||||
|
||||
**État interne :**
|
||||
```ts
|
||||
pendingStart = ref<string | null>(null) // 1er clic en attente du 2e
|
||||
hoverDate = ref<string | null>(null) // survol pour le preview
|
||||
const isSelecting = computed(() => pendingStart.value !== null)
|
||||
```
|
||||
|
||||
**Passé au `<MonthGrid>` :**
|
||||
```ts
|
||||
rangeStart = isSelecting ? pendingStart : (modelValue?.start ?? null)
|
||||
rangeEnd = isSelecting ? null : (modelValue?.end ?? null)
|
||||
previewDate = isSelecting ? hoverDate : null
|
||||
// + :min :max :month :year (slot)
|
||||
```
|
||||
|
||||
**`displayValue` :** `''` pendant la sélection (1 seul jour choisi) ; `"JJ/MM/AAAA - JJ/MM/AAAA"` si plage complète ; `''` sinon. **`syncTo`** = `modelValue?.start ?? null`.
|
||||
|
||||
**Machine à états :**
|
||||
```
|
||||
onSelectDay(iso):
|
||||
si pendingStart === null: # 1er clic (ou reset après plage complète)
|
||||
pendingStart = iso ; hoverDate = null
|
||||
sinon: # 2e clic → complète
|
||||
{ start, end } = normalizeRange(pendingStart, iso) # auto-inversion
|
||||
emit('update:modelValue', { start, end })
|
||||
pendingStart = null ; hoverDate = null
|
||||
close() # ferme le popover (slot)
|
||||
|
||||
onHover(iso): # émis par MonthGrid
|
||||
si isSelecting: hoverDate = iso # preview seulement pendant la sélection
|
||||
|
||||
onClose(): # CalendarField émet 'close'
|
||||
pendingStart = null ; hoverDate = null # annule la sélection en cours, modelValue inchangé
|
||||
|
||||
onClear(): # CalendarField émet 'clear'
|
||||
emit('update:modelValue', null)
|
||||
pendingStart = null ; hoverDate = null
|
||||
```
|
||||
|
||||
- **3e clic** (plage complète) : `pendingStart===null` → nouveau `start`, ancienne plage masquée pendant la sélection (`rangeEnd=null`), remplacée à la complétion.
|
||||
- **min/max** : `MonthGrid` désactive les jours hors bornes → les 2 clics sont contraints.
|
||||
- **modelValue invalide** (start/end mal formés) : traité comme `null` + warning dev.
|
||||
|
||||
## Refacto `Date.vue`
|
||||
|
||||
API publique **inchangée**. Devient une enveloppe (~80 lignes) :
|
||||
```vue
|
||||
<CalendarField :display-value="displayValue" :sync-to="modelValue ?? null" ...props
|
||||
@clear="emit('update:modelValue', null)">
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid :month="currentMonth" :year="currentYear"
|
||||
:selected-date="modelValue ?? null" :min="min" :max="max"
|
||||
@select="(iso) => { emit('update:modelValue', iso); close() }" />
|
||||
</template>
|
||||
</CalendarField>
|
||||
```
|
||||
`displayValue = formatIsoToDisplay(modelValue)`. Watch modelValue invalide → warning dev (conservé). Mode simple : pas de `@close` (rien à annuler), pas de `@hover`.
|
||||
|
||||
**Les 21 tests de `Date.test.ts` doivent passer sans modification** : tous les `data-test` sont rendus par `CalendarField`/`MonthGrid`, donc présents dans le DOM monté de `Date`. C'est le filet de sécurité du refacto.
|
||||
|
||||
## Tests
|
||||
|
||||
### `dateRange.test.ts` (~12)
|
||||
- `normalizeRange` : ordonné, inversé, égal
|
||||
- `resolveRangeBounds` : pas de start → null ; start seul → `{lo,hi}=start` ; start+end ordonné ; start+preview ; preview avant start (inversion) ; end prioritaire sur preview
|
||||
- `dayRangeRole` : none (pas de bornes / hors plage), single (lo===hi), start, end, in-range
|
||||
|
||||
### `useCalendarView.test.ts` (~8, fake timers)
|
||||
- mois/année initiaux = aujourd'hui ; `goToNext`/`goToPrev` vue jours (+ roulement déc↔jan avec année) ; `goToNext`/`goToPrev` vue mois (année ±1) ; `selectMonth` ; `syncToIso` valide / null
|
||||
|
||||
### `Date.test.ts`
|
||||
Inchangé — doit rester vert (filet du refacto).
|
||||
|
||||
### `DateRange.test.ts` (~18)
|
||||
- Rendu : label, icône, `"19/05/2026 - 25/05/2026"` si modelValue, champ vide sinon
|
||||
- Ouverture popover, vue sur le mois du `start`
|
||||
- 1er clic → pas d'émission ; 2e clic → émet `{start,end}` + ferme
|
||||
- 2e clic avant le 1er → auto-inversion (start ≤ end)
|
||||
- Même jour ×2 → `{start:x, end:x}`
|
||||
- 3e clic → repart sur un nouveau start (pas d'émission avant le 2e)
|
||||
- Hover pendant sélection → `data-range-role="in-range"` sur jours intermédiaires ; pas de preview hors sélection
|
||||
- Rôles : `start`/`end`/`in-range` corrects via `data-range-role`
|
||||
- Clic dehors pendant sélection → annulation, `modelValue` inchangé
|
||||
- `clear` → émet `null`
|
||||
- min/max → jours hors bornes non cliquables
|
||||
- a11y : `aria-invalid` sur error
|
||||
|
||||
### Story `dateRange.story.vue`
|
||||
Default vide, plage initiale, min/max, états (disabled/readonly/error/success), non-clearable.
|
||||
|
||||
### Playground `.playground/pages/composant/date/dateRange.vue`
|
||||
`<MalioDateRange>` standalone + affichage `start → end`, boutons set/reset, cas borné.
|
||||
|
||||
## Découpage d'implémentation
|
||||
|
||||
1. Helpers purs `dateRange.ts` + tests
|
||||
2. Composable `useCalendarView.ts` + tests
|
||||
3. Shell `CalendarField.vue` (extraction depuis `Date.vue`)
|
||||
4. Refacto `Date.vue` en enveloppe → `Date.test.ts` doit rester vert
|
||||
5. Extension `MonthGrid.vue` (range + hover + data-range-role)
|
||||
6. `DateRange.vue` + `DateRange.test.ts`
|
||||
7. Story + playground
|
||||
168
docs/superpowers/specs/2026-05-20-dateweek-design.md
Normal file
168
docs/superpowers/specs/2026-05-20-dateweek-design.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# MalioDateWeek — Design Spec
|
||||
|
||||
Composant de sélection d'une **semaine ISO complète** (lundi→dimanche) via le shell calendrier partagé. Troisième brique de la famille temporelle.
|
||||
|
||||
**Ticket :** MUI-33 (suite)
|
||||
**Branche :** `feature/MUI-33-developper-le-composant-datepicker`
|
||||
**Specs liées :** `2026-05-19-datepicker-design.md`, `2026-05-20-daterange-design.md`
|
||||
|
||||
## Périmètre
|
||||
|
||||
Sélection d'une semaine entière en **un clic** (sur n'importe quel jour OU le numéro de semaine). Visuellement : la ligne de la semaine se surligne en pilule `bg-m-primary-light` (lundi arrondi gauche, dimanche arrondi droit), exactement comme une plage `DateRange` figée lun→dim. Survol = aperçu de la semaine. La cellule n° de la semaine sélectionnée passe en `bg-m-primary` (repère).
|
||||
|
||||
**Inclus :** clic jour/n° → semaine, hover de semaine, surlignage pilule, repère n° semaine, bornes `min`/`max` (semaine sélectionnable si elle chevauche), effacement, vue mois conservée.
|
||||
|
||||
**Reporté :** deux mois, saisie/navigation clavier.
|
||||
|
||||
## Donnée retournée
|
||||
|
||||
`modelValue: string | null` au format **ISO 8601 semaine `YYYY-Www`** (ex. `"2026-W21"`), comme l'`<input type="week">` natif. L'année est l'**année ISO de numérotation** (peut différer de l'année calendaire aux bords d'année). Affichage humain dans le champ : `"Semaine 21 (18/05 → 24/05/2026)"` (le `modelValue` reste `2026-W21`).
|
||||
|
||||
## Architecture (Approche 1 — réutilisation du rendu plage)
|
||||
|
||||
Une semaine sélectionnée **est** une plage lundi→dimanche : on réutilise le rendu pilule de `MonthGrid` (mode plage) en passant les bornes de la semaine active. Les events `select`/`hover` (jour) sont réutilisés ; l'enveloppe `DateWeek` mappe jour → semaine.
|
||||
|
||||
```
|
||||
app/components/malio/date/
|
||||
DateWeek.vue # NOUVEAU — enveloppe
|
||||
DateWeek.test.ts # nouveau
|
||||
internal/
|
||||
MonthGrid.vue # étendu : interactiveWeekNumber + markedWeekStart (additifs)
|
||||
CalendarField.vue # inchangé (shell réutilisé)
|
||||
CalendarHeader.vue # inchangé
|
||||
MonthPicker.vue # inchangé
|
||||
composables/
|
||||
dateWeek.ts # NOUVEAU — helpers semaine ISO (purs)
|
||||
dateWeek.test.ts # nouveau
|
||||
dateRange.ts # inchangé (rendu pilule réutilisé)
|
||||
dateFormat.ts # inchangé
|
||||
useCalendarView.ts # inchangé
|
||||
useCalendarPopover.ts # inchangé
|
||||
useMonthMatrix.ts # inchangé
|
||||
app/story/date/dateWeek.story.vue
|
||||
.playground/pages/composant/date/dateWeek.vue
|
||||
```
|
||||
|
||||
Flux :
|
||||
|
||||
```
|
||||
DateWeek.vue (enveloppe)
|
||||
├─ état : hoverWeekStart (lundi de la semaine survolée)
|
||||
├─ validWeek = isValidIsoWeek(modelValue) ? { monday: isoWeekToMonday(modelValue) } : null
|
||||
├─ activeMonday = hoverWeekStart ?? validWeek.monday → activeSunday = sundayOf(activeMonday)
|
||||
├─ displayValue = formatWeekDisplay(modelValue)
|
||||
└─ <CalendarField :display-value :sync-to=validWeek.monday @clear @close>
|
||||
└─ <MonthGrid
|
||||
:range-start=activeMonday :range-end=activeSunday ← pilule lun→dim réutilisée
|
||||
:marked-week-start=validWeek.monday ← repère n° semaine
|
||||
interactive-week-number ← n° semaine cliquable/hoverable
|
||||
:min :max
|
||||
@select="(iso)=>onSelect(iso, close)" @hover="onHover" />
|
||||
```
|
||||
|
||||
## `dateWeek.ts` (helpers purs)
|
||||
|
||||
```ts
|
||||
function mondayOf(iso: string): string // "2026-05-20" → "2026-05-18"
|
||||
function sundayOf(iso: string): string // "2026-05-20" → "2026-05-24"
|
||||
function toIsoWeek(iso: string): string // "2026-05-20" → "2026-W21" (année ISO + n° semaine)
|
||||
function isoWeekToMonday(week: string): string | null // "2026-W21" → "2026-05-18" ; invalide → null
|
||||
function isValidIsoWeek(week: string): boolean // "2026-W21" → true ; "2026-W54"/"2026-21" → false
|
||||
function formatWeekDisplay(week: string): string // "2026-W21" → "Semaine 21 (18/05 → 24/05/2026)" ; invalide → ""
|
||||
```
|
||||
|
||||
- Algo ISO 8601 : jeudi de la semaine pour l'année de numérotation ; lundi de la semaine contenant le 4 janvier = semaine 1.
|
||||
- `formatWeekDisplay` : `Semaine {n° sans zéro} ({JJ/MM lundi} → {JJ/MM/AAAA dimanche})`, réutilise `formatIsoToDisplay`.
|
||||
- Cas pièges testés : `2025-12-31` → `2026-W01`, `2027-01-01` → `2026-W53`, `2026-01-01` → `2026-W01`.
|
||||
|
||||
## `MonthGrid.vue` — ajouts additifs
|
||||
|
||||
Nouvelles props optionnelles (n'altèrent pas les modes simple/plage) :
|
||||
```ts
|
||||
interactiveWeekNumber?: boolean // défaut false
|
||||
markedWeekStart?: string | null // défaut null — lundi de la semaine repère
|
||||
```
|
||||
|
||||
Quand `interactiveWeekNumber === true` :
|
||||
- La cellule n° de semaine devient un `<button>` : `@click` émet `select(week.days[0].isoDate)`, `@mouseenter` émet `hover(week.days[0].isoDate)`. `:disabled` si `!weekSelectable`, où `weekSelectable = week.days.some(d => inRange(d.isoDate))`. `cursor-pointer` si sélectionnable.
|
||||
- Repère : si `week.days[0].isoDate === markedWeekStart`, la cellule n° passe en `bg-m-primary text-white` (au lieu de `bg-m-primary-light`).
|
||||
|
||||
Toujours (inoffensif hors mode semaine) : la cellule n° porte `:data-week-start="week.days[0].isoDate"` et `:data-marked="week.days[0].isoDate === markedWeekStart"`.
|
||||
|
||||
Inchangé : rendu pilule des jours piloté par `rangeStart`/`rangeEnd` ; events `select`/`hover` jour ; quand `interactiveWeekNumber` est `false`, la cellule n° reste un `<div>` non cliquable (aucune régression `Date`/`DateRange`).
|
||||
|
||||
## `DateWeek.vue` (enveloppe)
|
||||
|
||||
**Props :** identiques à `Date` sauf `modelValue?: string | null` (`YYYY-Www`).
|
||||
**Emit :** `update:modelValue` → `string | null`.
|
||||
|
||||
**État :**
|
||||
```ts
|
||||
hoverWeekStart = ref<string | null>(null)
|
||||
const validWeek = computed(() =>
|
||||
(props.modelValue && isValidIsoWeek(props.modelValue))
|
||||
? {monday: isoWeekToMonday(props.modelValue) as string}
|
||||
: null)
|
||||
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
|
||||
const activeSunday = computed(() => activeMonday.value ? sundayOf(activeMonday.value) : null)
|
||||
```
|
||||
|
||||
**Passé à `MonthGrid` :** `range-start=activeMonday`, `range-end=activeSunday`, `marked-week-start=validWeek?.monday ?? null`, `interactive-week-number`, `min`, `max`, month/year du slot.
|
||||
|
||||
**`displayValue`** = `validWeek ? formatWeekDisplay(modelValue) : ''`. **`syncTo`** = `validWeek?.monday ?? null`.
|
||||
|
||||
**Comportement (1 clic) :**
|
||||
```
|
||||
onSelect(iso, close): # jour OU n° de semaine (= lundi)
|
||||
emit('update:modelValue', toIsoWeek(iso))
|
||||
hoverWeekStart = null
|
||||
close()
|
||||
|
||||
onHover(iso): # jour/n° survolé ; null au mouseleave
|
||||
hoverWeekStart = iso ? mondayOf(iso) : null
|
||||
|
||||
onClose(): hoverWeekStart = null
|
||||
onClear(): emit('update:modelValue', null) ; hoverWeekStart = null
|
||||
```
|
||||
|
||||
- Survol → toute la ligne en pilule via `activeMonday`.
|
||||
- Sélection en un clic → `YYYY-Www` + fermeture.
|
||||
- Repère de la semaine committée conservé pendant le survol d'une autre.
|
||||
- `modelValue` invalide → traité comme `null` + warning dev.
|
||||
|
||||
## Tests
|
||||
|
||||
### `dateWeek.test.ts` (~14)
|
||||
- `mondayOf`/`sundayOf` : mercredi, lundi (idempotent), dimanche
|
||||
- `toIsoWeek` : nominal + bords d'année (`2025-12-31`→`2026-W01`, `2027-01-01`→`2026-W53`, `2026-01-01`→`2026-W01`)
|
||||
- `isoWeekToMonday` : `2026-W21`→`2026-05-18` ; round-trip ; invalide → null
|
||||
- `isValidIsoWeek` : valide / `W00` / `W54` / format faux
|
||||
- `formatWeekDisplay` : `2026-W21`→`"Semaine 21 (18/05 → 24/05/2026)"` ; invalide → `""`
|
||||
|
||||
### `DateWeek.test.ts` (~14, `setSystemTime(2026-05-19)`)
|
||||
- Rendu label/icône, affichage `"Semaine ..."` si modelValue, champ vide sinon
|
||||
- Ouverture sur le mois de la semaine du modelValue
|
||||
- Clic d'un jour → émet le `YYYY-Www` de sa semaine + ferme
|
||||
- Clic du n° de semaine (`[data-test="week-number"][data-week-start="..."]`) → émet + ferme
|
||||
- Hover d'un jour → `data-range-role` start/in-range/end sur les 7 jours de la ligne ; autre ligne `none`
|
||||
- Hover du n° de semaine → même surlignage
|
||||
- Semaine committée → roles corrects + `data-marked="true"` sur la cellule n°
|
||||
- `clear` → émet `null`
|
||||
- min/max : semaine hors bornes n° désactivé + jours non cliquables ; semaine qui chevauche reste sélectionnable
|
||||
- `disabled`/`readonly` → pas d'ouverture
|
||||
- a11y : `aria-invalid` sur error
|
||||
|
||||
`Date.test.ts` / `DateRange.test.ts` restent verts (props additives).
|
||||
|
||||
### Story `dateWeek.story.vue`
|
||||
Default vide, semaine initiale, min/max, états (disabled/readonly/error/success), non-clearable.
|
||||
|
||||
### Playground `.playground/pages/composant/date/dateWeek.vue`
|
||||
Comparatif Large (480px) / ERP (396px), affichage `modelValue` + bornes du champ, boutons set/reset, cas borné.
|
||||
|
||||
## Découpage d'implémentation
|
||||
|
||||
1. `dateWeek.ts` (purs) + tests
|
||||
2. Extension `MonthGrid.vue` (interactiveWeekNumber + markedWeekStart + data attrs)
|
||||
3. `DateWeek.vue` + `DateWeek.test.ts`
|
||||
4. Story + playground
|
||||
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