| 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>
9.0 KiB
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 00–23, minutes 00–59, 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 à chaqueTimeWheel; recompose et émet à chaque changement. - Bande centrale : pastille teintée (
bg-m-primary/10ou é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 deTimePicker).
TimePicker.vue (public MalioTimePicker)
Champ + popover.
- Input lecture-seule affichant
"HH:MM"(ou placeholder), floating-label, icônemdi:clock-outline, bouton clear (siclearableet 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
useCalendarPopoverqui porte une logiqueviewModepropre au calendrier). disabled/readonlyn'ouvrent pas le popover.- Ligne
hint/error/success+aria-invalid/aria-describedbycommeCalendarField.
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), handlersonScroll/onScrollEnd/ clavier. - Boucle infinie : buffer répété N fois ; quand
scrollTopapproche un bord, on repositionnescrollTopd'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 } | nullformatTime(hours: number, minutes: number): string(zéro-paddé"HH:MM")padSegment,clampHours(0–23),clampMinutes(0–59).
Flux de données
TimePickerdétientmodelValue"HH:MM" | null(contrôlé/non-contrôlé).- À l'ouverture,
TimeWheelsreçoit la valeur courante ; si vide, les molettes se centrent sur un défaut neutre00:00sans émettre. La 1ʳᵉ interaction (scroll/clic/clavier) committe et émet. TimeWheelssplitte"HH:MM"→ 2 nombres →TimeWheel; tout changement recompose"HH:MM"et remonte viaupdate:modelValue.- Le bouton clear remet la valeur à vide/
null. - 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 deonTimeInput: sidatePartprésent →composeDateTime(datePart, hhmm); sinon →pendingTime.value = hhmm.- Supprimer
timeInputIdet le handleronTimeInputnatif.pendingTime/composeDateTime/splitDateTimeinchangés. - Mettre à jour
DateTime.test.ts: l'ancien test ciblaitdata-test="time-input"/type="time"; le réécrire pour interagir avecTimeWheels(émission deupdate:modelValuedepuis 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, popoverrole="dialog",aria-invalid+aria-describedbyreliés à la ligne hint/error/success. - Label lié
for/id.
Stratégie de tests
useInfiniteWheel.test.ts: index centré depuisscrollTop,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 demodelValue, clear, contrôlé/non-contrôlé,disabled/readonlyn'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/itemHeightmockées) ; les tests composant portent sur émissions/clavier/clic/aria, pas le snap pixel. - ⚠️ Tests flaky connus (Date & InputRichText) : relancer 2–3× avant de conclure à une
régression ; hook pre-commit parfois flaky →
--no-verifydocumenté.
Livrables documentation (conventions projet)
COMPONENTS.md: ajoutMalioTimePicker+ note «DateTimeutilise 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-componentpendant 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.