# MalioTimePicker (sélecteur d'heure molette) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Construire ``, un sélecteur d'heure à molettes style iOS (champ + popover), et rebrancher `MalioDateTime` dessus à la place de l'`` natif. **Architecture :** Une brique interne testable `useInfiniteWheel` (math de molette infinie en scroll-snap) + `timeFormat` (parse/format `"HH:MM"`). Deux composants internes — `TimeWheel` (une colonne infinie) et `TimeWheels` (2 colonnes + bande centrale, `v-model "HH:MM"`) — réutilisés à la fois par le composant public `TimePicker` (champ + popover) et par `DateTime`. **Tech Stack :** Nuxt 4 layer, Vue 3 ` ``` - [ ] **Step 4 : Lancer le test, vérifier le succès** Run: `npx vitest run app/components/malio/time/internal/TimeWheel.test.ts` Expected: PASS (4 tests). > Note : `centeredValue` dépend de `centeredIndex`, initialisé à `indexOfValue(modelValue)` par le composable — donc l'item correspondant au `modelValue` est bien en gras au premier rendu, même sans scroll réel. - [ ] **Step 5 : Commit** ```bash git add app/components/malio/time/internal/TimeWheel.vue app/components/malio/time/internal/TimeWheel.test.ts git commit -m "[#MUI-39] TimeWheel : colonne molette infinie (clic + clavier + aria)" ``` --- ## Task 5 : composant `TimeWheels` (2 colonnes + bande) **Files:** - Create: `app/components/malio/time/internal/TimeWheels.vue` - Test: `app/components/malio/time/internal/TimeWheels.test.ts` - [ ] **Step 1 : Écrire le test qui échoue** `app/components/malio/time/internal/TimeWheels.test.ts` : ```ts import {describe, expect, it} from 'vitest' import {mount} from '@vue/test-utils' import TimeWheels from './TimeWheels.vue' import TimeWheel from './TimeWheel.vue' const mountWheels = (modelValue = '09:30') => mount(TimeWheels, {props: {modelValue}, attachTo: document.body}) describe('MalioTimeWheels', () => { it('rend deux molettes (heures + minutes) et un séparateur', () => { const wrapper = mountWheels('09:30') const wheels = wrapper.findAllComponents(TimeWheel) expect(wheels).toHaveLength(2) expect(wheels[0].props('ariaLabel')).toBe('Heures') expect(wheels[1].props('ariaLabel')).toBe('Minutes') expect(wrapper.text()).toContain(':') }) it('splitte modelValue vers les bonnes molettes', () => { const wrapper = mountWheels('09:30') const wheels = wrapper.findAllComponents(TimeWheel) expect(wheels[0].props('modelValue')).toBe(9) expect(wheels[1].props('modelValue')).toBe(30) }) it('recompose et émet HH:MM quand l\'heure change', async () => { const wrapper = mountWheels('09:30') const wheels = wrapper.findAllComponents(TimeWheel) wheels[0].vm.$emit('update:modelValue', 14) await wrapper.vm.$nextTick() expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['14:30']) }) it('recompose et émet HH:MM quand la minute change', async () => { const wrapper = mountWheels('09:30') const wheels = wrapper.findAllComponents(TimeWheel) wheels[1].vm.$emit('update:modelValue', 5) await wrapper.vm.$nextTick() expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['09:05']) }) it('par défaut 00:00 quand modelValue est vide', () => { const wrapper = mountWheels('') const wheels = wrapper.findAllComponents(TimeWheel) expect(wheels[0].props('modelValue')).toBe(0) expect(wheels[1].props('modelValue')).toBe(0) }) }) ``` - [ ] **Step 2 : Lancer le test, vérifier l'échec** Run: `npx vitest run app/components/malio/time/internal/TimeWheels.test.ts` Expected: FAIL — import non résolu. - [ ] **Step 3 : Implémenter** `app/components/malio/time/internal/TimeWheels.vue` : ```vue ``` - [ ] **Step 4 : Lancer le test, vérifier le succès** Run: `npx vitest run app/components/malio/time/internal/TimeWheels.test.ts` Expected: PASS (5 tests). - [ ] **Step 5 : Commit** ```bash git add app/components/malio/time/internal/TimeWheels.vue app/components/malio/time/internal/TimeWheels.test.ts git commit -m "[#MUI-39] TimeWheels : deux molettes HH:MM + bande centrale" ``` --- ## Task 6 : composant public `TimePicker` (champ + popover) **Files:** - Create: `app/components/malio/time/TimePicker.vue` - Test: `app/components/malio/time/TimePicker.test.ts` - [ ] **Step 1 : Écrire le test qui échoue** `app/components/malio/time/TimePicker.test.ts` : ```ts import {describe, expect, it} from 'vitest' import {mount} from '@vue/test-utils' import type {DefineComponent} from 'vue' import TimePicker from './TimePicker.vue' type TimePickerProps = { id?: string name?: string label?: string modelValue?: string | null placeholder?: string required?: boolean disabled?: boolean readonly?: boolean hint?: string error?: string success?: string clearable?: boolean inputClass?: string labelClass?: string groupClass?: string } const TimePickerForTest = TimePicker as DefineComponent const mountPicker = (props: TimePickerProps = {}) => mount(TimePickerForTest, {props, attachTo: document.body}) describe('MalioTimePicker', () => { it('affiche le label et l\'icône horloge', () => { const wrapper = mountPicker({label: 'Heure'}) expect(wrapper.get('label').text()).toBe('Heure') expect(wrapper.find('[data-test="clock-icon"]').exists()).toBe(true) }) it('affiche la valeur HH:MM dans le champ', () => { const wrapper = mountPicker({modelValue: '14:30'}) const input = wrapper.get('[data-test="time-field"]').element as HTMLInputElement expect(input.value).toBe('14:30') }) it('ouvre le popover à molettes au clic', async () => { const wrapper = mountPicker() expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) await wrapper.get('[data-test="time-field"]').trigger('click') expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) expect(wrapper.find('[data-test="time-wheels"]').exists()).toBe(true) }) it('n\'ouvre pas le popover si disabled', async () => { const wrapper = mountPicker({disabled: true}) await wrapper.get('[data-test="time-field"]').trigger('click') expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) }) it('émet la valeur réglée depuis les molettes', async () => { const wrapper = mountPicker({modelValue: '09:30'}) await wrapper.get('[data-test="time-field"]').trigger('click') wrapper.findComponent({name: 'MalioTimeWheels'}).vm.$emit('update:modelValue', '10:30') await wrapper.vm.$nextTick() expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['10:30']) }) it('émet null au clic sur la croix', async () => { const wrapper = mountPicker({modelValue: '14:30'}) await wrapper.get('[data-test="clear"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) }) it('positionne aria-invalid et describedby sur erreur', () => { const wrapper = mountPicker({error: 'Heure requise'}) const input = wrapper.get('[data-test="time-field"]') expect(input.attributes('aria-invalid')).toBe('true') expect(input.attributes('aria-describedby')).toBeTruthy() expect(wrapper.text()).toContain('Heure requise') }) }) ``` - [ ] **Step 2 : Lancer le test, vérifier l'échec** Run: `npx vitest run app/components/malio/time/TimePicker.test.ts` Expected: FAIL — import non résolu. - [ ] **Step 3 : Implémenter** `app/components/malio/time/TimePicker.vue` : ```vue ``` > Le popover **n'a pas** de `rounded-b-md` (contrairement à `CalendarField`), conformément à la maquette. - [ ] **Step 4 : Lancer le test, vérifier le succès** Run: `npx vitest run app/components/malio/time/TimePicker.test.ts` Expected: PASS (7 tests). - [ ] **Step 5 : Commit** ```bash git add app/components/malio/time/TimePicker.vue app/components/malio/time/TimePicker.test.ts git commit -m "[#MUI-39] TimePicker : champ + popover molette (MalioTimePicker)" ``` --- ## Task 7 : rebrancher `DateTime` sur `TimeWheels` Remplace l'`` natif par ``. On garde `data-test="time-input"` non plus, on ciblera la brique molette. **Files:** - Modify: `app/components/malio/date/DateTime.vue` - Modify: `app/components/malio/date/DateTime.test.ts` - [ ] **Step 1 : Mettre à jour les tests (rouge)** Dans `app/components/malio/date/DateTime.test.ts`, **remplacer** le bloc `describe('popover', ...)` et les tests `sélection` qui utilisent `[data-test="time-input"]` par les versions ci-dessous (le reste du fichier — rendu, bornes, effacement, accessibilité — est inchangé) : ```ts import TimeWheels from '../time/internal/TimeWheels.vue' // ... dans describe('popover') it('ouvre la grille et les molettes au clic', async () => { const wrapper = mountDateTime() await wrapper.get('[data-test="date-input"]').trigger('click') expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true) expect(wrapper.find('[data-test="time-wheels"]').exists()).toBe(true) }) // ... dans describe('sélection'), remplacer les 3 tests heure par : it('applique l\'heure réglée avant le clic du jour', async () => { const wrapper = mountDateTime() await wrapper.get('[data-test="date-input"]').trigger('click') wrapper.findComponent(TimeWheels).vm.$emit('update:modelValue', '09:15') await wrapper.vm.$nextTick() expect(wrapper.emitted('update:modelValue')).toBeUndefined() await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00']) }) it('met à jour l\'heure quand une date est déjà choisie', async () => { const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'}) await wrapper.get('[data-test="date-input"]').trigger('click') wrapper.findComponent(TimeWheels).vm.$emit('update:modelValue', '08:45') await wrapper.vm.$nextTick() expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00']) }) it('initialise les molettes depuis la valeur', async () => { const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'}) await wrapper.get('[data-test="date-input"]').trigger('click') expect(wrapper.findComponent(TimeWheels).props('modelValue')).toBe('14:30') }) ``` - [ ] **Step 2 : Lancer les tests, vérifier l'échec** Run: `npx vitest run app/components/malio/date/DateTime.test.ts` Expected: FAIL — `[data-test="time-wheels"]` introuvable (DateTime utilise encore l'input natif). - [ ] **Step 3 : Modifier `DateTime.vue`** Dans `app/components/malio/date/DateTime.vue`, **remplacer** le bloc heure intérimaire (lignes ~31-41) : ```vue
``` par : ```vue
``` Dans le ` ``` > `MalioTimePicker` est auto-importé par le layer (dossier `malio/`), pas besoin d'import explicite dans la page playground. - [ ] **Step 2 : Ajouter l'entrée nav** Dans `.playground/playground.nav.ts`, section `DATES & HEURES`, ajouter après la ligne `{label: 'Heure', to: '/composant/time/time'},` : ```ts {label: 'Sélecteur d\'heure', to: '/composant/time/timePicker'}, ``` - [ ] **Step 3 : Vérifier la génération de types / build playground** Run: `npm run dev:prepare` Expected: succès sans erreur de type sur `MalioTimePicker`. - [ ] **Step 4 : Commit** ```bash git add .playground/pages/composant/time/timePicker.vue .playground/playground.nav.ts git commit -m "[#MUI-39] playground : page et nav pour MalioTimePicker" ``` --- ## Task 9 : Histoire (story) **Files:** - Create: `app/story/time/timePicker.story.vue` - [ ] **Step 1 : Créer la story** `app/story/time/timePicker.story.vue` : ```vue ``` - [ ] **Step 2 : Commit** ```bash git add app/story/time/timePicker.story.vue git commit -m "[#MUI-39] story : MalioTimePicker" ``` --- ## Task 10 : documentation (COMPONENTS.md + CHANGELOG.md) **Files:** - Modify: `COMPONENTS.md` - Modify: `CHANGELOG.md` - [ ] **Step 1 : Ajouter la section dans `COMPONENTS.md`** Ajouter une section `## MalioTimePicker` (la placer près de la section `MalioTime` / famille date) : ```markdown ## MalioTimePicker Sélecteur d'heure à molettes style iOS (champ + popover). Deux colonnes infinies (heures `00–23`, minutes `00–59`, pas de 1) avec bande de sélection centrale. Scroll, clic ou flèches clavier. Pour la saisie clavier directe, voir `MalioTime`. | Prop | Type | Défaut | Description | |------|------|--------|-------------| | `id` | `string` | auto | Identifiant HTML | | `name` | `string` | `''` | Attribut name | | `label` | `string` | `''` | Label flottant | | `modelValue` | `string \| null` | `undefined` | Valeur `"HH:MM"` (v-model) | | `placeholder` | `string` | `'HH:MM'` | Placeholder | | `required` | `boolean` | `false` | Champ requis | | `disabled` | `boolean` | `false` | Désactive le champ | | `readonly` | `boolean` | `false` | Lecture seule | | `clearable` | `boolean` | `true` | Affiche la croix d'effacement | | `hint` | `string` | `''` | Message d'aide | | `error` | `string` | `''` | Message d'erreur | | `success` | `string` | `''` | Message de succès | | `inputClass` | `string` | `''` | Classes CSS input | | `labelClass` | `string` | `''` | Classes CSS label | | `groupClass` | `string` | `''` | Classes CSS conteneur | **Events :** `update:modelValue(value: string | null)` ```vue ``` ``` Puis, dans la section `MalioDateTime` existante, ajouter une note : ```markdown > Depuis MUI-39, le réglage de l'heure utilise le sélecteur à molettes (cf. `MalioTimePicker`). ``` - [ ] **Step 2 : Ajouter l'entrée dans `CHANGELOG.md`** Sous `### Added` : ```markdown * [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) + DateTime rebranché dessus ``` - [ ] **Step 3 : Commit** ```bash git add COMPONENTS.md CHANGELOG.md git commit -m "[#MUI-39] doc : MalioTimePicker dans COMPONENTS.md + CHANGELOG" ``` --- ## Task 11 : vérification finale - [ ] **Step 1 : Suite complète + lint** Run: `npm run test && npm run lint` Expected: PASS. ⚠️ Date & InputRichText flaky → relancer 2-3× avant de conclure à une régression. - [ ] **Step 2 : Vérification manuelle playground** Run: `npm run dev` puis ouvrir `/composant/time/timePicker`. Vérifier : ouverture du popover, défilement des molettes avec snap, valeur centrée en gras, clic sur une valeur voisine qui recentre, flèches clavier, boucle 23→00, croix d'effacement, affichage `HH:MM` dans le champ. Vérifier aussi `/composant/date/datetime` : les molettes remplacent l'input natif et l'heure se compose bien avec la date. > Cette étape valide le ressenti molette que jsdom ne peut pas tester. --- ## Récapitulatif des fichiers **Créés :** - `app/components/malio/time/composables/timeFormat.ts` (+ test) - `app/components/malio/time/composables/useInfiniteWheel.ts` (+ test) - `app/components/malio/time/internal/TimeWheel.vue` (+ test) - `app/components/malio/time/internal/TimeWheels.vue` (+ test) - `app/components/malio/time/TimePicker.vue` (+ test) - `.playground/pages/composant/time/timePicker.vue` - `app/story/time/timePicker.story.vue` **Modifiés :** - `app/components/malio/date/DateTime.vue` (+ test) - `.playground/playground.nav.ts` - `COMPONENTS.md` - `CHANGELOG.md`