Files
malio-layer-ui/docs/superpowers/specs/2026-05-27-select-heure-design.md
T
tristan e6a46a9d60 [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire) (#55)
| 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é

Reviewed-on: #55
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:01:29 +00:00

9.0 KiB
Raw Blame History

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 timeFormatheures + 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.