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 @@
+
+
+
+
Simple
+
+
Valeur : {{ simpleValue || '—' }}
+
+
+
+
Valeur initiale
+
+
+
+
+
Désactivé
+
+
+
+
+
Erreur
+
+
+
+
+
Succès
+
+
+
+
+
Non effaçable
+
+
+
+
+
+
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 @@
+
+
+
+
+
Simple
+
+
+
+
+
Valeur initiale
+
+
+
+
+
Désactivé
+
+
+
+
+
Erreur
+
+
+
+
+
Succès
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ error || success || hint }}
+
+
+
+
+
+
+
+```
+
+> 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
+
+
+
+
+
Simple
+
+
+
+
+
Valeur initiale
+
+
+
+
+
Désactivé
+
+
+
+
+
Erreur
+
+
+
+
+
Succès
+
+
+
+
+
+
+
+```
+
+- [ ] **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.