Files
malio-layer-ui/docs/superpowers/plans/2026-05-27-select-heure.md
T
tristan acd531f69e
Release / release (push) Successful in 2m38s
feat: Ajout des composants modal, accordeon, datetime avec selecteur d'heure à la molette (#56)
| 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>
2026-05-27 12:11:51 +00:00

1428 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<MalioTimePicker>`, un sélecteur d'heure à molettes style iOS (champ + popover), et rebrancher `MalioDateTime` dessus à la place de l'`<input type="time">` 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 `<script setup lang="ts">`, Tailwind (palette `m-*`), `tailwind-merge`, `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
**Conventions de commit :** un hook `commit-msg` (copié depuis `commit-msg` à la racine via `make copy-git-hook`) impose le format **`<type>(<scope minuscule>) : <message>`** — espace AVANT le `:`, type minuscule parmi `build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test`, scope optionnel `[a-z0-9._-]+` (donc **pas de majuscules** : `MUI-39` interdit en scope). Utiliser le scope du dossier (`time`, `date`) et mettre la réf ticket dans le message. Exemples : `feat(time) : ajoute timeFormat (MUI-39)`. ⚠️ Les libellés `[#MUI-39] …` dans les commits ci-dessous sont **descriptifs** : les traduire en format conventionnel à l'exécution.
Le hook `pre-commit` lance `make pre-commit` = `lint` + **toute la suite de tests**, qui est **flaky** (timeouts intermittents dans des fichiers SANS rapport : Accordion, ButtonIcon, InputTextArea, SiteSelector, Date, InputRichText). Si le commit échoue uniquement à cause de ces tests flaky non liés : relancer 1×, sinon `--no-verify` (toléré, cf. mémo). **Ne jamais** utiliser `--no-verify` pour masquer un échec dans un fichier que la tâche vient de créer/modifier, ni un message de commit non conforme.
**Commandes utiles :**
- Test ciblé : `npx vitest run <chemin>`
- Toute la suite : `npm run test`
- Lint : `npm run lint`
---
## Note sur jsdom et le scroll
jsdom ne simule **pas** le scroll-snap ni le scroll réel (`scrollTo` est un no-op, `scrollTop` est une propriété assignable mais aucun évènement `scroll` n'est émis spontanément). En conséquence :
- Toute la **math** vit dans des fonctions pures (`valueIndexFromScroll`, `scrollTopForValueIndex`, `loopCorrection`) testées exhaustivement.
- Les comportements **clic / clavier / émission** sont testés au niveau composant.
- Le ressenti molette (inertie, snap pixel) se valide **manuellement dans le playground**.
---
## Task 1 : composable `timeFormat`
**Files:**
- Create: `app/components/malio/time/composables/timeFormat.ts`
- Test: `app/components/malio/time/composables/timeFormat.test.ts`
- [ ] **Step 1 : Écrire le test qui échoue**
`app/components/malio/time/composables/timeFormat.test.ts` :
```ts
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')
})
})
```
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
Run: `npx vitest run app/components/malio/time/composables/timeFormat.test.ts`
Expected: FAIL — `Failed to resolve import "./timeFormat"`.
- [ ] **Step 3 : Implémenter**
`app/components/malio/time/composables/timeFormat.ts` :
```ts
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))}`
}
```
- [ ] **Step 4 : Lancer le test, vérifier le succès**
Run: `npx vitest run app/components/malio/time/composables/timeFormat.test.ts`
Expected: PASS (5 tests).
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/time/composables/timeFormat.ts app/components/malio/time/composables/timeFormat.test.ts
git commit -m "[#MUI-39] timeFormat : parse/format HH:MM pour le sélecteur d'heure"
```
---
## Task 2 : math pure de `useInfiniteWheel`
Les 3 fonctions pures qui pilotent la molette infinie. Modèle : `VISIBLE_ROWS = 5` lignes visibles, item de hauteur fixe `itemHeight`, ligne centrale à l'index `CENTER_OFFSET = 2`. Le buffer DOM est composé de **3 copies** de la liste de valeurs ; on maintient l'index centré dans la copie du milieu `[length, 2*length)`.
**Files:**
- Create: `app/components/malio/time/composables/useInfiniteWheel.ts`
- Test: `app/components/malio/time/composables/useInfiniteWheel.test.ts`
- [ ] **Step 1 : Écrire le test qui échoue**
`app/components/malio/time/composables/useInfiniteWheel.test.ts` :
```ts
import {describe, expect, it} from 'vitest'
import {
CENTER_OFFSET,
VISIBLE_ROWS,
loopCorrection,
scrollTopForValueIndex,
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)
// un bloc complet plus bas = même valeur logique
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', () => {
// index centré flat < length → on ajoute un bloc
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)
})
})
```
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
Run: `npx vitest run app/components/malio/time/composables/useInfiniteWheel.test.ts`
Expected: FAIL — import non résolu.
- [ ] **Step 3 : Implémenter les fonctions pures**
`app/components/malio/time/composables/useInfiniteWheel.ts` (partie 1 — la math ; le composable sera ajouté en Task 3) :
```ts
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
}
```
- [ ] **Step 4 : Lancer le test, vérifier le succès**
Run: `npx vitest run app/components/malio/time/composables/useInfiniteWheel.test.ts`
Expected: PASS (6 tests).
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/time/composables/useInfiniteWheel.ts app/components/malio/time/composables/useInfiniteWheel.test.ts
git commit -m "[#MUI-39] useInfiniteWheel : math de molette infinie (scroll-snap)"
```
---
## Task 3 : composable `useInfiniteWheel` (wiring DOM)
Ajoute le composable Vue qui câble la math aux évènements scroll/clavier. On le teste avec un faux élément (jsdom n'émet pas de scroll, mais `step`/`onKeydown` sont déterministes).
**Files:**
- Modify: `app/components/malio/time/composables/useInfiniteWheel.ts` (ajout en fin de fichier)
- Modify: `app/components/malio/time/composables/useInfiniteWheel.test.ts` (ajout d'un `describe`)
- [ ] **Step 1 : Écrire le test qui échoue**
Ajouter à `useInfiniteWheel.test.ts` :
```ts
import {nextTick, ref} from 'vue'
import {mount} from '@vue/test-utils'
import {defineComponent} from 'vue'
import {useInfiniteWheel} from './useInfiniteWheel'
function mountWheelHarness(initialIndex: number, onChange: (i: number) => void) {
let api!: ReturnType<typeof useInfiniteWheel>
const Harness = defineComponent({
setup() {
const container = ref<HTMLElement | null>(null)
api = useInfiniteWheel(container, {
length: 24,
itemHeight: 40,
initialIndex: () => initialIndex,
onChange,
})
return {container}
},
template: '<div ref="container" style="height:200px;overflow:auto"><div style="height:2880px" /></div>',
})
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)
})
})
```
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
Run: `npx vitest run app/components/malio/time/composables/useInfiniteWheel.test.ts`
Expected: FAIL — `useInfiniteWheel is not exported` / not a function.
- [ ] **Step 3 : Implémenter le composable**
Ajouter à la fin de `app/components/malio/time/composables/useInfiniteWheel.ts` :
```ts
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
export interface UseInfiniteWheelOptions {
length: number
itemHeight: number
initialIndex: () => number
onChange: (index: number) => void
}
export function useInfiniteWheel(
containerRef: Ref<HTMLElement | null>,
options: UseInfiniteWheelOptions,
) {
const centeredIndex = ref(options.initialIndex())
let programmatic = false
let scrollEndTimer: ReturnType<typeof setTimeout> | null = null
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
const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length)
if (corrected !== el.scrollTop) {
programmatic = true
el.scrollTop = corrected
}
readCentered()
options.onChange(centeredIndex.value)
}
function onScroll() {
if (programmatic) {
programmatic = false
return
}
readCentered()
if (scrollEndTimer) clearTimeout(scrollEndTimer)
scrollEndTimer = setTimeout(settle, 120)
}
function scrollToIndex(index: number, smooth = true) {
const el = containerRef.value
centeredIndex.value = index
if (el) {
programmatic = true
el.scrollTo({
top: scrollTopForValueIndex(index, options.itemHeight, options.length),
behavior: smooth ? 'smooth' : 'auto',
})
}
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})
programmatic = true
el.scrollTop = scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length)
})
onBeforeUnmount(() => {
containerRef.value?.removeEventListener('scroll', onScroll)
if (scrollEndTimer) clearTimeout(scrollEndTimer)
})
return {centeredIndex, scrollToIndex, step, onKeydown}
}
```
- [ ] **Step 4 : Lancer le test, vérifier le succès**
Run: `npx vitest run app/components/malio/time/composables/useInfiniteWheel.test.ts`
Expected: PASS (9 tests au total).
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/time/composables/useInfiniteWheel.ts app/components/malio/time/composables/useInfiniteWheel.test.ts
git commit -m "[#MUI-39] useInfiniteWheel : câblage scroll/clavier de la molette"
```
---
## Task 4 : composant `TimeWheel` (une colonne)
**Files:**
- Create: `app/components/malio/time/internal/TimeWheel.vue`
- Test: `app/components/malio/time/internal/TimeWheel.test.ts`
- [ ] **Step 1 : Écrire le test qui échoue**
`app/components/malio/time/internal/TimeWheel.test.ts` :
```ts
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)
// clique le premier item dont le label vaut "11"
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])
})
})
```
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
Run: `npx vitest run app/components/malio/time/internal/TimeWheel.test.ts`
Expected: FAIL — import non résolu.
- [ ] **Step 3 : Implémenter**
`app/components/malio/time/internal/TimeWheel.vue` :
```vue
<template>
<div
ref="container"
class="malio-wheel relative h-[200px] w-14 snap-y snap-mandatory overflow-y-scroll"
role="spinbutton"
:tabindex="0"
:aria-label="ariaLabel"
:aria-valuenow="modelValue"
:aria-valuemin="values[0]"
:aria-valuemax="values[values.length - 1]"
:aria-valuetext="pad(modelValue)"
@keydown="onKeydown"
>
<button
v-for="item in buffer"
:key="item.key"
type="button"
data-test="wheel-item"
class="flex h-10 w-full snap-center items-center justify-center text-lg outline-none transition-colors"
:class="item.value === centeredValue ? 'font-bold text-black' : 'text-m-muted'"
tabindex="-1"
@click="onItemClick(item.value)"
>
{{ pad(item.value) }}
</button>
</div>
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import {useInfiniteWheel} from '../composables/useInfiniteWheel'
import {padSegment} from '../composables/timeFormat'
defineOptions({name: 'MalioTimeWheel', inheritAttrs: false})
const props = defineProps<{
modelValue: number
values: number[]
ariaLabel: string
}>()
const emit = defineEmits<{(e: 'update:modelValue', value: number): void}>()
const ITEM_HEIGHT = 40
const container = ref<HTMLElement | null>(null)
const pad = (value: number) => padSegment(value)
const indexOfValue = (value: number) => Math.max(0, props.values.indexOf(value))
const {centeredIndex, scrollToIndex, onKeydown} = useInfiniteWheel(container, {
length: props.values.length,
itemHeight: ITEM_HEIGHT,
initialIndex: () => indexOfValue(props.modelValue),
onChange: (index) => emit('update:modelValue', props.values[index]),
})
const centeredValue = computed(() => props.values[centeredIndex.value])
const buffer = computed(() =>
[0, 1, 2].flatMap((copy) =>
props.values.map((value) => ({value, key: copy * props.values.length + value})),
),
)
const onItemClick = (value: number) => scrollToIndex(indexOfValue(value))
watch(
() => props.modelValue,
(value) => {
if (props.values[centeredIndex.value] !== value) scrollToIndex(indexOfValue(value), false)
},
)
</script>
<style scoped>
.malio-wheel {
scrollbar-width: none;
}
.malio-wheel::-webkit-scrollbar {
display: none;
}
</style>
```
- [ ] **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
<template>
<div
data-test="time-wheels"
class="relative flex items-center justify-center gap-3 py-2"
>
<!-- bande centrale (overlay, traverse les 2 colonnes) -->
<div
class="pointer-events-none absolute inset-x-2 top-1/2 z-0 h-10 -translate-y-1/2 rounded-lg bg-m-primary/10"
/>
<MalioTimeWheel
:model-value="hours"
:values="HOURS"
aria-label="Heures"
class="relative z-10"
@update:model-value="onHours"
/>
<span class="relative z-10 text-lg font-bold text-black">:</span>
<MalioTimeWheel
:model-value="minutes"
:values="MINUTES"
aria-label="Minutes"
class="relative z-10"
@update:model-value="onMinutes"
/>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import MalioTimeWheel from './TimeWheel.vue'
import {formatTime, parseTime} from '../composables/timeFormat'
defineOptions({name: 'MalioTimeWheels', inheritAttrs: false})
const props = withDefaults(
defineProps<{modelValue?: string | null}>(),
{modelValue: ''},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string): void}>()
const HOURS = Array.from({length: 24}, (_, i) => i)
const MINUTES = Array.from({length: 60}, (_, i) => i)
const parts = computed(() => parseTime(props.modelValue) ?? {hours: 0, minutes: 0})
const hours = computed(() => parts.value.hours)
const minutes = computed(() => parts.value.minutes)
const onHours = (value: number) => emit('update:modelValue', formatTime(value, minutes.value))
const onMinutes = (value: number) => emit('update:modelValue', formatTime(hours.value, value))
</script>
```
- [ ] **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<TimePickerProps>
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
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
data-test="time-field"
readonly
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
aria-haspopup="dialog"
v-bind="attrs"
placeholder="_"
type="text"
@click="onFieldClick"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="text-m-muted hover:text-m-primary"
aria-label="Effacer l'heure"
@click.stop="onClear"
>
<Icon icon="mdi:close" :width="16" :height="16" />
</button>
<Icon
data-test="clock-icon"
icon="mdi:clock-outline"
:width="24"
:height="24"
:class="iconStateClass"
/>
</div>
<div
v-if="isOpen"
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<TimeWheels
:model-value="wheelsValue"
@update:model-value="onWheelChange"
/>
</div>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import TimeWheels from './internal/TimeWheels.vue'
defineOptions({name: 'MalioTimePicker', inheritAttrs: false})
const props = withDefaults(
defineProps<{
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
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'HH:MM',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const localValue = ref<string | null>(null)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? props.modelValue : localValue.value))
const inputId = computed(() => props.id?.toString() || `malio-time-picker-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const displayValue = computed(() => currentValue.value ?? '')
const isFilled = computed(() => displayValue.value.length > 0)
const wheelsValue = computed(() => currentValue.value || '00:00')
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
const commit = (value: string | null) => {
if (!isControlled.value) localValue.value = value
emit('update:modelValue', value)
}
const onWheelChange = (value: string) => commit(value)
const onClear = () => {
commit(null)
}
const onFieldClick = () => {
if (props.disabled || props.readonly) return
isOpen.value = !isOpen.value
}
const onMouseDown = (event: MouseEvent) => {
if (!isOpen.value || !root.value) return
if (!root.value.contains(event.target as Node)) isOpen.value = false
}
onMounted(() => document.addEventListener('mousedown', onMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed border-m-muted text-black/60' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: 'focus:border-m-primary',
isOpen.value ? 'border-m-primary !rounded-b-none !py-[9px]' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left text-sm font-medium transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isOpen.value
? 'text-m-primary'
: 'text-black peer-placeholder-shown:text-m-muted',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>
```
> 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'`<input type="time">` natif par `<TimeWheels>`. 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
<!-- Bloc heure intérimaire : input natif, isolé pour remplacement futur par le sélecteur dédié. -->
<div class="mt-[26px] flex-col items-center gap-2">
<input
:id="timeInputId"
data-test="time-input"
type="time"
:value="timeValue"
class="w-full border border-m-muted bg-white px-2 py-1 text-base outline-none focus:border-m-primary"
@input="onTimeInput"
>
</div>
```
par :
```vue
<div class="mt-[26px]">
<TimeWheels
:model-value="timeValue || '00:00'"
@update:model-value="onTimeChange"
/>
</div>
```
Dans le `<script setup>`, ajouter l'import :
```ts
import TimeWheels from '../time/internal/TimeWheels.vue'
```
Supprimer `timeInputId` :
```ts
const timeInputId = computed(() => `${props.id || `malio-datetime-${generatedId}`}-time`)
```
(et l'import `useId` s'il n'est plus utilisé ailleurs — vérifier ; `generatedId` n'est utilisé que par `timeInputId`, donc supprimer aussi `const generatedId = useId()` et retirer `useId` de l'import `vue`).
Remplacer `onTimeInput` :
```ts
function onTimeInput(e: Event) {
const value = (e.target as HTMLInputElement).value
if (!value) return
if (datePart.value) {
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
pendingTime.value = value
}
}
```
par :
```ts
function onTimeChange(value: string) {
if (datePart.value) {
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
pendingTime.value = value
}
}
```
- [ ] **Step 4 : Lancer les tests, vérifier le succès**
Run: `npx vitest run app/components/malio/date/DateTime.test.ts`
Expected: PASS. ⚠️ Suite Date flaky : si échec sporadique non lié, relancer 2-3×.
- [ ] **Step 5 : Lancer toute la suite date + time + lint**
Run: `npx vitest run app/components/malio/date app/components/malio/time && npm run lint`
Expected: PASS (relancer si flaky).
- [ ] **Step 6 : Commit**
```bash
git add app/components/malio/date/DateTime.vue app/components/malio/date/DateTime.test.ts
git commit -m "[#MUI-39] DateTime : remplace l'input time natif par les molettes"
```
---
## Task 8 : page playground + entrée nav
**Files:**
- Create: `.playground/pages/composant/time/timePicker.vue`
- Modify: `.playground/playground.nav.ts`
- [ ] **Step 1 : Créer la page playground**
`.playground/pages/composant/time/timePicker.vue` :
```vue
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioTimePicker v-model="simpleValue" label="Heure" />
<p class="mt-2 text-sm text-m-muted">Valeur : {{ simpleValue || '—' }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioTimePicker v-model="initialValue" label="Heure de départ" hint="Format HH:MM" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioTimePicker v-model="disabledValue" label="Heure verrouillée" disabled />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioTimePicker v-model="errorValue" label="Heure de fermeture" error="Heure invalide" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioTimePicker v-model="successValue" label="Heure confirmée" success="Horaire enregistré" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioTimePicker v-model="noClearValue" label="Heure" :clearable="false" />
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const simpleValue = ref('')
const initialValue = ref('08:30')
const disabledValue = ref('14:15')
const errorValue = ref('25:90')
const successValue = ref('09:00')
const noClearValue = ref('10:00')
</script>
```
> `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
<template>
<Story title="Time/TimePicker">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioTimePicker v-model="simpleValue" label="Heure" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioTimePicker v-model="initialValue" label="Heure de départ" hint="Format HH:MM" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioTimePicker v-model="disabledValue" label="Heure verrouillée" disabled />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioTimePicker v-model="errorValue" label="Heure de fermeture" error="Heure invalide" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioTimePicker v-model="successValue" label="Heure confirmée" success="Horaire enregistré" />
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioTimePicker from '../../components/malio/time/TimePicker.vue'
const simpleValue = ref('')
const initialValue = ref('08:30')
const disabledValue = ref('14:15')
const errorValue = ref('25:90')
const successValue = ref('09:00')
</script>
```
- [ ] **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 `0023`, minutes `0059`, 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
<MalioTimePicker v-model="heure" label="Heure" />
<MalioTimePicker v-model="heure" label="Départ" hint="Format HH:MM" />
```
```
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`