Files
malio-layer-ui/docs/superpowers/specs/2026-05-27-select-heure-design.md
T
tristan acd531f69e
Release / release (push) Successful in 2m38s
feat: Ajout des composants modal, accordeon, datetime avec selecteur d'heure à la molette (#56)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #56
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:11:51 +00:00

174 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design — `MalioTimePicker` (sélecteur d'heure molette, MUI-39)
Date : 2026-05-27
Branche : `feature/MUI-39-developper-le-composant-select-heure`
Statut : validé (design), prêt pour plan d'implémentation
## Contexte
`MalioDateTime` a été livré en version intérimaire (MUI-33) avec un `<input type="time">`
natif sous la grille du calendrier, volontairement isolé dans `DateTime.vue` en attendant
une maquette pour le sélecteur d'heure dédié. La maquette est maintenant fournie
(`time.png` à la racine) : c'est une **molette de défilement style iOS** avec bande de
sélection centrale (pastille teintée), valeur centrée en noir/gras, voisins estompés.
Ce ticket développe ce sélecteur dédié comme **nouveau composant** et rebranche `DateTime`
dessus.
## Décisions (issues du brainstorming)
| Sujet | Décision |
|-------|----------|
| Relation à `MalioTime` (champs texte HH/MM) | **Nouveau composant séparé** ; `MalioTime` reste intact |
| Nom public | **`MalioTimePicker`** (`time/TimePicker.vue`) |
| Mécanique | **Molette iOS** : scroll vertical, snap, bande centrale ; valeur centrée = sélection |
| Colonnes | **2 molettes** : heures `0023`, minutes `0059`, pas de **1** |
| Format `modelValue` | `"HH:MM"` (24h) `string \| null` |
| Bornes min/max | **Non** (YAGNI) — colonnes pleines |
| Interaction | **Scroll + clic (recentre) + clavier (↑/↓)** — accessible |
| Forme | **Champ + popover** (floating-label + icône horloge), comme `Date`/`DateTime` |
| Style panneau | Même style que le popover date **mais sans `rounded-b`** |
| Extrémités molette | **Boucle infinie** (23→00 sans fin) |
| Approche technique | **CSS `scroll-snap` natif** + repositionnement par bloc pour la boucle (zéro dépendance) |
| Rebranchement `DateTime` | **Dans cette itération** : retrait de l'`<input type="time">` natif |
## Arborescence des fichiers
```
app/components/malio/time/
TimePicker.vue # NOUVEAU — public <MalioTimePicker> : champ + popover
TimePicker.test.ts
internal/
TimeWheels.vue # NOUVEAU — brique réutilisable : les 2 molettes (v-model "HH:MM")
TimeWheel.vue # NOUVEAU — une colonne molette infinie (v-model number)
composables/
useInfiniteWheel.ts # NOUVEAU — scroll-snap + boucle infinie + index centré
useInfiniteWheel.test.ts
timeFormat.ts # NOUVEAU — parse/format/pad/clamp "HH:MM"
timeFormat.test.ts
```
`Time.vue` (`MalioTime`, champs texte) **n'est pas modifié**.
## Composants & responsabilités
### `TimeWheel.vue` (interne)
Une colonne molette infinie.
- **Props** : `modelValue: number`, `values: number[]` (ex. `0..23`), `ariaLabel: string`.
- **Emits** : `update:modelValue (value: number)`.
- Délègue scroll/snap/boucle/index-centré au composable `useInfiniteWheel`.
- Rendu : buffer de valeurs répété ; item centré en noir/gras, voisins estompés (opacité
décroissante avec la distance au centre).
- **Clic** sur un item visible → recentre (`scrollToValue`).
- **Clavier** : ↑/↓ changent l'index (et scrollent), `role="spinbutton"`, `tabindex=0`,
`aria-valuenow` / `aria-valuemin` / `aria-valuemax` / `aria-valuetext`, `aria-label`.
### `TimeWheels.vue` (interne — la brique partagée)
Compose les 2 molettes + la bande centrale.
- **Props** : `modelValue: string` (`"HH:MM"`).
- **Emits** : `update:modelValue (value: string)`.
- Splitte via `timeFormat``heures` + `minutes` ; passe à chaque `TimeWheel` ; recompose
et émet à chaque changement.
- **Bande centrale** : pastille teintée (`bg-m-primary/10` ou équivalent) en overlay
positionné au centre, traversant les 2 colonnes ; le « : » séparateur entre les colonnes.
- **C'est ce bloc qui est inséré dans `DateTime`** (et dans le popover de `TimePicker`).
### `TimePicker.vue` (public `MalioTimePicker`)
Champ + popover.
- Input **lecture-seule** affichant `"HH:MM"` (ou placeholder), floating-label, icône
`mdi:clock-outline`, bouton **clear** (si `clearable` et rempli).
- Au clic → ouvre un **popover** au style du popover date **sans `rounded-b`**, contenant
`<TimeWheels v-model>`.
- **Props famille** : `id`, `name`, `label`, `modelValue`, `placeholder`, `required`,
`disabled`, `readonly`, `hint`, `error`, `success`, `clearable`, `inputClass`,
`labelClass`, `groupClass`.
- Pattern **contrôlé/non-contrôlé** (`isControlled = computed(() => props.modelValue !== undefined)`).
- Fermeture au **clic extérieur** (handler local sur le root ; on ne réutilise pas
`useCalendarPopover` qui porte une logique `viewMode` propre au calendrier).
- `disabled`/`readonly` n'ouvrent pas le popover.
- Ligne `hint`/`error`/`success` + `aria-invalid`/`aria-describedby` comme `CalendarField`.
### `useInfiniteWheel.ts` (composable — cœur logique)
Toute la mécanique délicate, isolée et testable.
- **Entrées** : ref du conteneur scrollable, `itemHeight`, longueur des valeurs, valeur
courante, callback de changement.
- **Sorties** : `centeredIndex` (`round(scrollTop / itemHeight) % len`), `scrollToValue(value, smooth)`,
handlers `onScroll` / `onScrollEnd` / clavier.
- **Boucle infinie** : buffer répété N fois ; quand `scrollTop` approche un bord, on
repositionne `scrollTop` d'un bloc (hauteur d'un cycle de valeurs) **sans animation**,
position visuelle identique → illusion d'infini.
- Garde anti-boucle entre scroll programmatique et émission `modelValue`.
### `timeFormat.ts` (composable pur)
- `parseTime(value: string | null): { hours: number; minutes: number } | null`
- `formatTime(hours: number, minutes: number): string` (zéro-paddé `"HH:MM"`)
- `padSegment`, `clampHours` (023), `clampMinutes` (059).
## Flux de données
1. `TimePicker` détient `modelValue` `"HH:MM" | null` (contrôlé/non-contrôlé).
2. À l'ouverture, `TimeWheels` reçoit la valeur courante ; si **vide**, les molettes se
centrent sur un **défaut neutre `00:00` sans émettre**. La **1ʳᵉ interaction**
(scroll/clic/clavier) committe et émet.
3. `TimeWheels` splitte `"HH:MM"` → 2 nombres → `TimeWheel` ; tout changement recompose
`"HH:MM"` et remonte via `update:modelValue`.
4. Le **bouton clear** remet la valeur à vide/`null`.
5. Le popover **reste ouvert** pendant le réglage (cohérent avec `DateTime`) ; se ferme au
clic extérieur.
## Rebranchement `DateTime.vue`
- Remplacer le bloc `<input type="time">` (lignes ~31-41) par :
`<TimeWheels :model-value="timeValue || '00:00'" @update:model-value="onTimeChange" />`.
- `onTimeChange(hhmm)` reprend la logique existante de `onTimeInput` : si `datePart`
présent → `composeDateTime(datePart, hhmm)` ; sinon → `pendingTime.value = hhmm`.
- Supprimer `timeInputId` et le handler `onTimeInput` natif. `pendingTime` / `composeDateTime`
/ `splitDateTime` inchangés.
- **Mettre à jour `DateTime.test.ts`** : l'ancien test ciblait `data-test="time-input"` /
`type="time"` ; le réécrire pour interagir avec `TimeWheels` (émission de
`update:modelValue` depuis la brique).
## Accessibilité
- Molette : `role="spinbutton"`, `tabindex=0`, `aria-label` « Heures » / « Minutes »,
`aria-valuenow/valuemin/valuemax/valuetext`, flèches ↑/↓.
- Champ : `aria-haspopup="dialog"`, `aria-expanded`, popover `role="dialog"`,
`aria-invalid` + `aria-describedby` reliés à la ligne hint/error/success.
- Label lié `for`/`id`.
## Stratégie de tests
- **`useInfiniteWheel.test.ts`** : index centré depuis `scrollTop`, `scrollToValue`, math du
repositionnement de boucle (jump par bloc), modulo/clamp.
- **`TimeWheel.test.ts`** : flèches clavier changent la valeur & émettent, clic recentre,
attributs aria (`role`, `aria-valuenow`...).
- **`TimeWheels.test.ts`** : split/compose `"HH:MM"`, émission de la valeur combinée, 2
molettes rendues, séparateur.
- **`TimePicker.test.ts`** : rendu, label/id, ouverture popover au clic, affichage de
`modelValue`, clear, contrôlé/non-contrôlé, `disabled`/`readonly` n'ouvrent pas, aria.
- **`timeFormat.test.ts`** : parse/format/pad/clamp (valeurs limites, `null`, invalides).
- **`DateTime.test.ts`** : mis à jour pour la brique molette.
- ⚠️ **Limite jsdom** : pas de scroll-snap réel. La mécanique est testée via le composable
(métriques `scrollTop`/`itemHeight` mockées) ; les tests composant portent sur
émissions/clavier/clic/aria, pas le snap pixel.
- ⚠️ **Tests flaky connus** (Date & InputRichText) : relancer 23× avant de conclure à une
régression ; hook pre-commit parfois flaky → `--no-verify` documenté.
## Livrables documentation (conventions projet)
- **`COMPONENTS.md`** : ajout `MalioTimePicker` + note « `DateTime` utilise désormais la
molette ». (manuel)
- **`CHANGELOG.md`** : entrée. (manuel)
- **Playground** : page dédiée + entrée dans `playground.nav.ts` (routage Nuxt centralisé).
- **Histoire** : `TimePicker.story.vue`.
- Appui sur la skill `creating-malio-component` pendant l'implémentation.
## Hors scope
- Bornes horaires `min`/`max`.
- Format 12h / AM-PM.
- Granularité minutes configurable (`minuteStep`).
- Colonne secondes.
Ces points pourront faire l'objet d'itérations ultérieures si le besoin métier émerge.