From 50699bd5829b3bb28c51d51daaf8503411bef7d4 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 27 May 2026 09:47:46 +0200 Subject: [PATCH] =?UTF-8?q?[#MUI-39]=20plan=20d'impl=C3=A9mentation=20du?= =?UTF-8?q?=20s=C3=A9lecteur=20d'heure=20molette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-27-select-heure.md | 1425 +++++++++++++++++ 1 file changed, 1425 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-select-heure.md diff --git a/docs/superpowers/plans/2026-05-27-select-heure.md b/docs/superpowers/plans/2026-05-27-select-heure.md new file mode 100644 index 0000000..5fd5319 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-select-heure.md @@ -0,0 +1,1425 @@ +# 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`