From e6a46a9d608fb8cad6e57636f4875f945d5c4068 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 27 May 2026 12:01:29 +0000 Subject: [PATCH] =?UTF-8?q?[#MUI-39]=20Cr=C3=A9ation=20d'un=20s=C3=A9lecte?= =?UTF-8?q?ur=20d'heure=20=C3=A0=20molettes=20(MalioTimePicker)=20;=20Date?= =?UTF-8?q?Time=20rebranch=C3=A9=20dessus=20(remplace=20l'input=20time=20n?= =?UTF-8?q?atif=20int=C3=A9rimaire)=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | 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: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/55 Co-authored-by: tristan Co-committed-by: tristan --- .claude/settings.local.json | 7 +- .../pages/composant/time/timePicker.vue | 45 + .playground/playground.nav.ts | 1 + CHANGELOG.md | 1 + COMPONENTS.md | 31 +- app/components/malio/date/DateTime.test.ts | 25 +- app/components/malio/date/DateTime.vue | 33 +- app/components/malio/time/TimePicker.test.ts | 76 + app/components/malio/time/TimePicker.vue | 236 +++ .../malio/time/composables/timeFormat.test.ts | 31 + .../malio/time/composables/timeFormat.ts | 32 + .../time/composables/useInfiniteWheel.test.ts | 120 ++ .../time/composables/useInfiniteWheel.ts | 117 ++ .../malio/time/internal/TimeWheel.test.ts | 41 + .../malio/time/internal/TimeWheel.vue | 95 ++ .../malio/time/internal/TimeWheels.test.ts | 48 + .../malio/time/internal/TimeWheels.vue | 54 + app/story/time/timePicker.story.vue | 41 + .../plans/2026-05-27-select-heure.md | 1427 +++++++++++++++++ .../specs/2026-05-27-select-heure-design.md | 173 ++ 20 files changed, 2604 insertions(+), 30 deletions(-) create mode 100644 .playground/pages/composant/time/timePicker.vue create mode 100644 app/components/malio/time/TimePicker.test.ts create mode 100644 app/components/malio/time/TimePicker.vue create mode 100644 app/components/malio/time/composables/timeFormat.test.ts create mode 100644 app/components/malio/time/composables/timeFormat.ts create mode 100644 app/components/malio/time/composables/useInfiniteWheel.test.ts create mode 100644 app/components/malio/time/composables/useInfiniteWheel.ts create mode 100644 app/components/malio/time/internal/TimeWheel.test.ts create mode 100644 app/components/malio/time/internal/TimeWheel.vue create mode 100644 app/components/malio/time/internal/TimeWheels.test.ts create mode 100644 app/components/malio/time/internal/TimeWheels.vue create mode 100644 app/story/time/timePicker.story.vue create mode 100644 docs/superpowers/plans/2026-05-27-select-heure.md create mode 100644 docs/superpowers/specs/2026-05-27-select-heure-design.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 278a4af..8ae878a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,12 @@ "Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)", "Bash(mv inputCheckbox.story.vue checkbox/)", "Bash(npx eslint *)", - "Bash(echo \"LINT EXIT: $?\")" + "Bash(echo \"LINT EXIT: $?\")", + "Bash(git commit *)", + "mcp__chrome__navigate_page", + "mcp__chrome__take_snapshot", + "mcp__chrome__click", + "mcp__chrome__evaluate_script" ] } } diff --git a/.playground/pages/composant/time/timePicker.vue b/.playground/pages/composant/time/timePicker.vue new file mode 100644 index 0000000..127342c --- /dev/null +++ b/.playground/pages/composant/time/timePicker.vue @@ -0,0 +1,45 @@ + + + diff --git a/.playground/playground.nav.ts b/.playground/playground.nav.ts index d7d6eeb..a3c4a58 100644 --- a/.playground/playground.nav.ts +++ b/.playground/playground.nav.ts @@ -34,6 +34,7 @@ export const navSections: SidebarSection[] = [ {label: 'Semaine', to: '/composant/date/dateWeek'}, {label: 'Date & heure', to: '/composant/date/datetime'}, {label: 'Heure', to: '/composant/time/time'}, + {label: 'Sélecteur d\'heure', to: '/composant/time/timePicker'}, ], }, { diff --git a/CHANGELOG.md b/CHANGELOG.md index 466fc12..b5b0e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire) * [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe) * [#MUI-37] Création d'un composant accordéon +* [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire) ### Changed * [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`. diff --git a/COMPONENTS.md b/COMPONENTS.md index 715e076..b2676c2 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -563,11 +563,40 @@ Sélecteur d'heure. --- +## MalioTimePicker + +Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes infinies (heures `00–23`, minutes `00–59`, pas de 1) avec une bande de sélection centrale ; la valeur centrée est sélectionnée. Défilement, clic sur une valeur (recentrage) ou flèches clavier (`role="spinbutton"`). Pour une saisie clavier directe au format texte, voir plutôt `MalioTime`. + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `id` | `string` | auto | Identifiant HTML | +| `name` | `string` | `''` | Attribut name | +| `label` | `string` | `''` | Label flottant | +| `modelValue` | `string \| null` | `undefined` | Heure au format `"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` / `labelClass` / `groupClass` | `string` | `''` | Override des classes | + +**Events :** `update:modelValue(value: string | null)` + +```vue + + +``` + +--- + ## MalioDateTime Champ unique combinant **date et heure** dans un popover (grille de calendrier + sélecteur d'heure sous la grille). -> ⚠️ **Version intérimaire** : le sélecteur d'heure est un `` natif, en attendant la maquette d'un sélecteur d'heure dédié. Le bloc heure est isolé pour être remplacé sans impact sur le reste. +> Depuis MUI-39, le réglage de l'heure utilise le sélecteur à molettes (cf. `MalioTimePicker`), qui remplace l'ancien `` natif intérimaire. La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:MM:00"` (heure murale locale). Symfony (`DateTimeNormalizer`) parse ce format et applique son fuseau configuré côté back — pas de gestion de fuseau côté front. diff --git a/app/components/malio/date/DateTime.test.ts b/app/components/malio/date/DateTime.test.ts index e78d29d..803b861 100644 --- a/app/components/malio/date/DateTime.test.ts +++ b/app/components/malio/date/DateTime.test.ts @@ -2,6 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {mount} from '@vue/test-utils' import type {DefineComponent} from 'vue' import DateTime_ from './DateTime.vue' +import MalioTimePicker from '../time/TimePicker.vue' type DateTimeProps = { id?: string @@ -30,7 +31,7 @@ const mountDateTime = (props: DateTimeProps = {}) => describe('MalioDateTime', () => { beforeEach(() => { vi.useFakeTimers() - vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + vi.setSystemTime(new Date(2026, 4, 19, 9, 5, 0)) // 19 mai 2026, 09:05 }) afterEach(() => vi.useRealTimers()) @@ -49,28 +50,30 @@ describe('MalioDateTime', () => { }) describe('popover', () => { - it('ouvre la grille et l\'input heure au clic', async () => { + it('ouvre la grille et le champ sélecteur d\'heure 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-input"]').exists()).toBe(true) + expect(wrapper.findComponent(MalioTimePicker).exists()).toBe(true) + expect(wrapper.find('[data-test="time-field"]').exists()).toBe(true) }) }) describe('sélection', () => { - it('émet le jour à 00:00 et garde le popover ouvert', async () => { + it('émet le jour à l\'heure actuelle (si aucune heure choisie) et garde le popover ouvert', async () => { const wrapper = mountDateTime() await wrapper.get('[data-test="date-input"]').trigger('click') await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') - expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T00:00:00']) + // heure système figée à 09:05 + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:05:00']) expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) }) 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') - await wrapper.get('[data-test="time-input"]').setValue('09:15') - // pas d'émission tant qu'aucun jour n'est choisi + wrapper.findComponent(MalioTimePicker).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']) @@ -79,15 +82,15 @@ describe('MalioDateTime', () => { 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') - await wrapper.get('[data-test="time-input"]').setValue('08:45') + wrapper.findComponent(MalioTimePicker).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 l\'input heure depuis la valeur', async () => { + it('initialise le champ heure depuis la valeur', async () => { const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'}) await wrapper.get('[data-test="date-input"]').trigger('click') - const time = wrapper.get('[data-test="time-input"]').element as HTMLInputElement - expect(time.value).toBe('14:30') + expect(wrapper.findComponent(MalioTimePicker).props('modelValue')).toBe('14:30') }) }) diff --git a/app/components/malio/date/DateTime.vue b/app/components/malio/date/DateTime.vue index 8cf7c25..fb62f46 100644 --- a/app/components/malio/date/DateTime.vue +++ b/app/components/malio/date/DateTime.vue @@ -28,25 +28,25 @@ :max="max?.slice(0, 10)" @select="onSelectDay" /> - -
- +
+
+ + diff --git a/app/components/malio/time/composables/timeFormat.test.ts b/app/components/malio/time/composables/timeFormat.test.ts new file mode 100644 index 0000000..3eea58e --- /dev/null +++ b/app/components/malio/time/composables/timeFormat.test.ts @@ -0,0 +1,31 @@ +import {describe, expect, it} from 'vitest' +import {clampHours, clampMinutes, formatTime, padSegment, parseTime} from './timeFormat' + +describe('timeFormat', () => { + it('parse une chaîne HH:MM valide', () => { + expect(parseTime('09:05')).toEqual({hours: 9, minutes: 5}) + }) + + it('renvoie null pour vide ou invalide', () => { + expect(parseTime('')).toBeNull() + expect(parseTime(null)).toBeNull() + expect(parseTime('abc')).toBeNull() + expect(parseTime('12')).toBeNull() + }) + + it('clamp les valeurs hors bornes au parsing', () => { + expect(parseTime('99:88')).toEqual({hours: 23, minutes: 59}) + }) + + it('formate avec zéro-padding', () => { + expect(formatTime(9, 5)).toBe('09:05') + expect(formatTime(0, 0)).toBe('00:00') + }) + + it('clamp et pad les helpers', () => { + expect(clampHours(30)).toBe(23) + expect(clampHours(-2)).toBe(0) + expect(clampMinutes(75)).toBe(59) + expect(padSegment(7)).toBe('07') + }) +}) diff --git a/app/components/malio/time/composables/timeFormat.ts b/app/components/malio/time/composables/timeFormat.ts new file mode 100644 index 0000000..02221c2 --- /dev/null +++ b/app/components/malio/time/composables/timeFormat.ts @@ -0,0 +1,32 @@ +export interface TimeParts { + hours: number + minutes: number +} + +export function clampHours(value: number): number { + if (Number.isNaN(value)) return 0 + return Math.min(23, Math.max(0, Math.trunc(value))) +} + +export function clampMinutes(value: number): number { + if (Number.isNaN(value)) return 0 + return Math.min(59, Math.max(0, Math.trunc(value))) +} + +export function padSegment(value: number): string { + return value.toString().padStart(2, '0') +} + +export function parseTime(value: string | null | undefined): TimeParts | null { + if (!value) return null + const match = /^(\d{1,2}):(\d{1,2})$/.exec(value.trim()) + if (!match) return null + return { + hours: clampHours(Number.parseInt(match[1], 10)), + minutes: clampMinutes(Number.parseInt(match[2], 10)), + } +} + +export function formatTime(hours: number, minutes: number): string { + return `${padSegment(clampHours(hours))}:${padSegment(clampMinutes(minutes))}` +} diff --git a/app/components/malio/time/composables/useInfiniteWheel.test.ts b/app/components/malio/time/composables/useInfiniteWheel.test.ts new file mode 100644 index 0000000..88f3d76 --- /dev/null +++ b/app/components/malio/time/composables/useInfiniteWheel.test.ts @@ -0,0 +1,120 @@ +import {describe, expect, it, vi} from 'vitest' +import {defineComponent, nextTick, ref} from 'vue' +import {mount} from '@vue/test-utils' +import { + CENTER_OFFSET, + VISIBLE_ROWS, + loopCorrection, + scrollTopForValueIndex, + useInfiniteWheel, + valueIndexFromScroll, +} from './useInfiniteWheel' + +const H = 40 // itemHeight +const LEN = 24 // ex. heures + +describe('useInfiniteWheel — math pure', () => { + it('expose 5 lignes visibles et un offset central de 2', () => { + expect(VISIBLE_ROWS).toBe(5) + expect(CENTER_OFFSET).toBe(2) + }) + + it('scrollTopForValueIndex et valueIndexFromScroll font un aller-retour', () => { + for (const index of [0, 1, 9, 23]) { + const top = scrollTopForValueIndex(index, H, LEN) + expect(valueIndexFromScroll(top, H, LEN)).toBe(index) + } + }) + + it('valueIndexFromScroll boucle en modulo', () => { + const top = scrollTopForValueIndex(0, H, LEN) + expect(valueIndexFromScroll(top + LEN * H, H, LEN)).toBe(0) + }) + + it('loopCorrection laisse le scroll de la copie du milieu inchangé', () => { + const top = scrollTopForValueIndex(12, H, LEN) + expect(loopCorrection(top, H, LEN)).toBe(top) + }) + + it('loopCorrection ramène vers le milieu quand on dérive vers le haut', () => { + const drifted = scrollTopForValueIndex(0, H, LEN) - LEN * H + expect(loopCorrection(drifted, H, LEN)).toBe(drifted + LEN * H) + }) + + it('loopCorrection ramène vers le milieu quand on dérive vers le bas', () => { + const drifted = scrollTopForValueIndex(0, H, LEN) + LEN * H + expect(loopCorrection(drifted, H, LEN)).toBe(drifted - LEN * H) + }) +}) + +function mountWheelHarness(initialIndex: number, onChange: (i: number) => void) { + let api!: ReturnType + const Harness = defineComponent({ + setup() { + const container = ref(null) + api = useInfiniteWheel(container, { + length: 24, + itemHeight: 40, + initialIndex: () => initialIndex, + onChange, + }) + return {container} + }, + template: '
', + }) + const wrapper = mount(Harness, {attachTo: document.body}) + return {wrapper, api: () => api} +} + +describe('useInfiniteWheel — composable', () => { + it('step(+1) émet l\'index suivant', async () => { + const changes: number[] = [] + const {api} = mountWheelHarness(9, (i) => changes.push(i)) + await nextTick() + api().step(1) + expect(changes.at(-1)).toBe(10) + }) + + it('step boucle de 23 à 0', async () => { + const changes: number[] = [] + const {api} = mountWheelHarness(23, (i) => changes.push(i)) + await nextTick() + api().step(1) + expect(changes.at(-1)).toBe(0) + }) + + it('onKeydown ArrowUp décrémente (avec wrap)', async () => { + const changes: number[] = [] + const {api} = mountWheelHarness(0, (i) => changes.push(i)) + await nextTick() + api().onKeydown(new KeyboardEvent('keydown', {key: 'ArrowUp'})) + expect(changes.at(-1)).toBe(23) + }) + + // Anti-boucle navigateur : un scroll programmatique déclenche une rafale d'évènements + // scroll (animation/snap). Ils ne doivent PAS être pris pour du scroll utilisateur, + // sinon settle() ré-émet en boucle et corrompt le patch DOM de Vue. + it('n\'émet pas en double quand un scroll programmatique déclenche une rafale de scroll', async () => { + vi.useFakeTimers() + try { + const changes: number[] = [] + const {wrapper, api} = mountWheelHarness(9, (i) => changes.push(i)) + await nextTick() + const el = wrapper.element as HTMLElement + changes.length = 0 + + api().scrollToIndex(12) + + el.dispatchEvent(new Event('scroll')) + el.dispatchEvent(new Event('scroll')) + el.dispatchEvent(new Event('scroll')) + + vi.advanceTimersByTime(300) + + expect(changes).toEqual([12]) + } + finally { + vi.useRealTimers() + } + }) +}) diff --git a/app/components/malio/time/composables/useInfiniteWheel.ts b/app/components/malio/time/composables/useInfiniteWheel.ts new file mode 100644 index 0000000..97849e6 --- /dev/null +++ b/app/components/malio/time/composables/useInfiniteWheel.ts @@ -0,0 +1,117 @@ +import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue' + +export const VISIBLE_ROWS = 5 +export const CENTER_OFFSET = (VISIBLE_ROWS - 1) / 2 // 2 + +/** Index de valeur logique (0..length-1) centré pour un scrollTop donné. */ +export function valueIndexFromScroll(scrollTop: number, itemHeight: number, length: number): number { + const flat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET + return ((flat % length) + length) % length +} + +/** scrollTop qui centre l'index donné dans la copie du milieu (buffer à 3 copies). */ +export function scrollTopForValueIndex(valueIndex: number, itemHeight: number, length: number): number { + const flat = length + valueIndex - CENTER_OFFSET + return flat * itemHeight +} + +/** Recentre le scrollTop dans la copie du milieu [length, 2*length) si on a dérivé. */ +export function loopCorrection(scrollTop: number, itemHeight: number, length: number): number { + const block = length * itemHeight + const centeredFlat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET + if (centeredFlat < length) return scrollTop + block + if (centeredFlat >= 2 * length) return scrollTop - block + return scrollTop +} + +export interface UseInfiniteWheelOptions { + length: number + itemHeight: number + initialIndex: () => number + onChange: (index: number) => void +} + +export function useInfiniteWheel( + containerRef: Ref, + options: UseInfiniteWheelOptions, +) { + const centeredIndex = ref(options.initialIndex()) + let scrollEndTimer: ReturnType | null = null + // Fenêtre de suppression : ignore les évènements scroll provoqués par NOS + // repositionnements programmatiques (et les réajustements de scroll-snap), qui + // arrivent en rafale. Un booléen one-shot n'en absorberait qu'un seul : les + // suivants seraient pris pour du scroll utilisateur → settle() → onChange en + // boucle (re-render ré-entrant qui corrompt le patch DOM dans le navigateur). + let suppressed = false + let suppressTimer: ReturnType | null = null + + // Scroll programmatique INSTANTANÉ : pas de 'smooth', dont l'animation multi-frames + // émettrait justement la rafale d'évènements scroll problématique. + function applyScroll(top: number) { + const el = containerRef.value + if (!el) return + suppressed = true + if (suppressTimer) clearTimeout(suppressTimer) + suppressTimer = setTimeout(() => { suppressed = false }, 100) + el.scrollTop = top + } + + function readCentered() { + const el = containerRef.value + if (!el) return + centeredIndex.value = valueIndexFromScroll(el.scrollTop, options.itemHeight, options.length) + } + + function settle() { + const el = containerRef.value + if (!el) return + readCentered() + options.onChange(centeredIndex.value) + const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length) + if (corrected !== el.scrollTop) applyScroll(corrected) + } + + function onScroll() { + if (suppressed) return + readCentered() + if (scrollEndTimer) clearTimeout(scrollEndTimer) + scrollEndTimer = setTimeout(settle, 120) + } + + function scrollToIndex(index: number) { + centeredIndex.value = index + applyScroll(scrollTopForValueIndex(index, options.itemHeight, options.length)) + options.onChange(index) + } + + function step(delta: number) { + const next = (((centeredIndex.value + delta) % options.length) + options.length) % options.length + scrollToIndex(next) + } + + function onKeydown(event: KeyboardEvent) { + if (event.key === 'ArrowUp') { + event.preventDefault() + step(-1) + } + else if (event.key === 'ArrowDown') { + event.preventDefault() + step(1) + } + } + + onMounted(() => { + const el = containerRef.value + if (!el) return + el.addEventListener('scroll', onScroll, {passive: true}) + applyScroll(scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length)) + }) + + onBeforeUnmount(() => { + containerRef.value?.removeEventListener('scroll', onScroll) + if (scrollEndTimer) clearTimeout(scrollEndTimer) + if (suppressTimer) clearTimeout(suppressTimer) + }) + + return {centeredIndex, scrollToIndex, step, onKeydown} +} diff --git a/app/components/malio/time/internal/TimeWheel.test.ts b/app/components/malio/time/internal/TimeWheel.test.ts new file mode 100644 index 0000000..2c7b183 --- /dev/null +++ b/app/components/malio/time/internal/TimeWheel.test.ts @@ -0,0 +1,41 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import TimeWheel from './TimeWheel.vue' + +const HOURS = Array.from({length: 24}, (_, i) => i) + +const mountWheel = (modelValue = 9) => + mount(TimeWheel, { + props: {modelValue, values: HOURS, ariaLabel: 'Heures'}, + attachTo: document.body, + }) + +describe('MalioTimeWheel', () => { + it('expose le rôle spinbutton et les attributs aria', () => { + const wrapper = mountWheel(9) + const el = wrapper.get('[role="spinbutton"]') + expect(el.attributes('aria-label')).toBe('Heures') + expect(el.attributes('aria-valuenow')).toBe('9') + expect(el.attributes('aria-valuemin')).toBe('0') + expect(el.attributes('aria-valuemax')).toBe('23') + expect(el.attributes('aria-valuetext')).toBe('09') + }) + + it('rend 3 copies des valeurs (buffer infini)', () => { + const wrapper = mountWheel() + expect(wrapper.findAll('[data-test="wheel-item"]')).toHaveLength(24 * 3) + }) + + it('émet la nouvelle valeur au clavier ArrowDown', async () => { + const wrapper = mountWheel(9) + await wrapper.get('[role="spinbutton"]').trigger('keydown', {key: 'ArrowDown'}) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([10]) + }) + + it('émet la valeur cliquée', async () => { + const wrapper = mountWheel(9) + const item = wrapper.findAll('[data-test="wheel-item"]').find((w) => w.text() === '11')! + await item.trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([11]) + }) +}) diff --git a/app/components/malio/time/internal/TimeWheel.vue b/app/components/malio/time/internal/TimeWheel.vue new file mode 100644 index 0000000..acea3e5 --- /dev/null +++ b/app/components/malio/time/internal/TimeWheel.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/app/components/malio/time/internal/TimeWheels.test.ts b/app/components/malio/time/internal/TimeWheels.test.ts new file mode 100644 index 0000000..9134def --- /dev/null +++ b/app/components/malio/time/internal/TimeWheels.test.ts @@ -0,0 +1,48 @@ +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) + }) +}) diff --git a/app/components/malio/time/internal/TimeWheels.vue b/app/components/malio/time/internal/TimeWheels.vue new file mode 100644 index 0000000..094cd65 --- /dev/null +++ b/app/components/malio/time/internal/TimeWheels.vue @@ -0,0 +1,54 @@ + + + diff --git a/app/story/time/timePicker.story.vue b/app/story/time/timePicker.story.vue new file mode 100644 index 0000000..7cc6890 --- /dev/null +++ b/app/story/time/timePicker.story.vue @@ -0,0 +1,41 @@ + + + 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..e986aaa --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-select-heure.md @@ -0,0 +1,1427 @@ +# 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` diff --git a/docs/superpowers/specs/2026-05-27-select-heure-design.md b/docs/superpowers/specs/2026-05-27-select-heure-design.md new file mode 100644 index 0000000..7a5dab0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-select-heure-design.md @@ -0,0 +1,173 @@ +# 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 `` +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'`` natif | + +## Arborescence des fichiers + +``` +app/components/malio/time/ + TimePicker.vue # NOUVEAU — public : 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 + ``. +- **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` (0–23), `clampMinutes` (0–59). + +## 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 `` (lignes ~31-41) par : + ``. +- `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 2–3× 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.