Files
malio-layer-ui/docs/superpowers/plans/2026-05-20-datepicker.md
2026-05-20 08:13:37 +02:00

1441 lines
45 KiB
Markdown

# MalioDate (Datepicker) 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:** Composant `<MalioDate>` : champ readonly + popover calendrier (vue jours + vue mois), valeur ISO `YYYY-MM-DD`, bornes `min`/`max`, effacement.
**Architecture:** Composant public `Date.vue` orchestrant 3 sous-composants internes (`CalendarHeader`, `MonthGrid`, `MonthPicker`) et 3 modules colocalisés (`dateFormat`, `useMonthMatrix`, `useCalendarPopover`). On construit du bas vers le haut : modules purs d'abord (TDD strict), puis sous-composants, puis composant public.
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, TypeScript strict, Tailwind (palette `m-*`), `tailwind-merge`, `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
**Répartition de travail :** L'utilisateur écrit l'implémentation. L'assistant écrit les tests. **Le test fait foi** : une implémentation est correcte dès qu'elle fait passer les tests de la tâche. Le code d'implémentation montré ci-dessous est une **référence** ; l'utilisateur peut l'adapter tant que les tests passent.
**Référence spec :** `docs/superpowers/specs/2026-05-19-datepicker-design.md`
---
## Conventions partagées (à respecter par tests ET implémentation)
**Attributs `data-test`** (ancrage des tests, n'apparaissent pas dans l'API publique) :
| `data-test` | Élément |
|---|---|
| `date-input` | le champ `<input>` readonly |
| `calendar-icon` | l'icône calendrier à droite |
| `clear` | la croix d'effacement |
| `popover` | conteneur du popover |
| `header-prev` | chevron gauche du header |
| `header-next` | chevron droit du header |
| `header-toggle` | bouton central `Mois Année ⌄` |
| `day` | une cellule jour cliquable (porte aussi `data-iso="YYYY-MM-DD"`) |
| `week-number` | une cellule numéro de semaine |
| `month` | une cellule mois dans `MonthPicker` (porte aussi `data-month="0..11"`) |
**Libellés FR :**
- Jours (en-tête grille) : `['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']`
- Mois courts (MonthPicker + header) : `['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc']`
- Mois longs (header texte central) : `['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']`
**Emplacement des fichiers :**
```
app/components/malio/date/
Date.vue
Date.test.ts
internal/
CalendarHeader.vue
MonthGrid.vue
MonthPicker.vue
composables/
dateFormat.ts
dateFormat.test.ts
useMonthMatrix.ts
useMonthMatrix.test.ts
useCalendarPopover.ts
useCalendarPopover.test.ts
app/story/date/Date.story.vue
.playground/pages/composant/date/date.vue
```
> Note : `vitest.config.ts` n'inclut que `app/**/*.test.ts`. Tous les tests vivent donc sous `app/` (les tests des composables sont bien dans `app/components/malio/date/composables/`). La page playground est auto-découverte par le glob `composant/**/*.vue` ; la catégorie de menu = nom du dossier (`date` → "Date"). **Aucune édition de `.playground/pages/index.vue` n'est nécessaire.**
---
## Task 1 : Module `dateFormat.ts` (fonctions pures)
**Files:**
- Create: `app/components/malio/date/composables/dateFormat.ts`
- Test: `app/components/malio/date/composables/dateFormat.test.ts`
- [ ] **Step 1 : Écrire les tests (échouent)***assistant*
```ts
import {describe, expect, it} from 'vitest'
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './dateFormat'
describe('dateFormat', () => {
describe('isValidIso', () => {
it('accepts a real ISO date', () => {
expect(isValidIso('2026-05-19')).toBe(true)
})
it('rejects a malformed string', () => {
expect(isValidIso('19/05/2026')).toBe(false)
expect(isValidIso('2026-5-9')).toBe(false)
expect(isValidIso('')).toBe(false)
})
it('rejects an impossible date', () => {
expect(isValidIso('2026-02-30')).toBe(false)
expect(isValidIso('2026-13-01')).toBe(false)
})
it('accepts Feb 29 on a leap year and rejects it otherwise', () => {
expect(isValidIso('2024-02-29')).toBe(true)
expect(isValidIso('2026-02-29')).toBe(false)
})
})
describe('formatIsoToDisplay', () => {
it('formats ISO to DD/MM/YYYY', () => {
expect(formatIsoToDisplay('2026-05-19')).toBe('19/05/2026')
})
it('returns empty string for null or invalid input', () => {
expect(formatIsoToDisplay(null)).toBe('')
expect(formatIsoToDisplay('nope')).toBe('')
})
})
describe('parseDisplayToIso', () => {
it('parses DD/MM/YYYY to ISO', () => {
expect(parseDisplayToIso('19/05/2026')).toBe('2026-05-19')
})
it('returns null for malformed or impossible input', () => {
expect(parseDisplayToIso('2026-05-19')).toBeNull()
expect(parseDisplayToIso('31/02/2026')).toBeNull()
expect(parseDisplayToIso('')).toBeNull()
})
})
describe('isDateInRange', () => {
it('returns true when no bounds are given', () => {
expect(isDateInRange('2026-05-19')).toBe(true)
})
it('respects the min bound (inclusive)', () => {
expect(isDateInRange('2026-05-19', '2026-05-19')).toBe(true)
expect(isDateInRange('2026-05-18', '2026-05-19')).toBe(false)
})
it('respects the max bound (inclusive)', () => {
expect(isDateInRange('2026-05-19', undefined, '2026-05-19')).toBe(true)
expect(isDateInRange('2026-05-20', undefined, '2026-05-19')).toBe(false)
})
it('respects both bounds', () => {
expect(isDateInRange('2026-05-15', '2026-05-10', '2026-05-20')).toBe(true)
expect(isDateInRange('2026-05-25', '2026-05-10', '2026-05-20')).toBe(false)
})
})
})
```
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
Run: `npm run test -- dateFormat`
Expected: FAIL (`Failed to resolve import './dateFormat'`)
- [ ] **Step 3 : Implémenter le module***utilisateur* (référence ci-dessous)
```ts
// app/components/malio/date/composables/dateFormat.ts
export function isValidIso(iso: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
const [y, m, d] = iso.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d
}
export function formatIsoToDisplay(iso: string | null): string {
if (!iso || !isValidIso(iso)) return ''
const [y, m, d] = iso.split('-')
return `${d}/${m}/${y}`
}
export function parseDisplayToIso(display: string): string | null {
const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(display.trim())
if (!match) return null
const [, dd, mm, yyyy] = match
const iso = `${yyyy}-${mm}-${dd}`
return isValidIso(iso) ? iso : null
}
export function isDateInRange(iso: string, min?: string, max?: string): boolean {
if (min && iso < min) return false
if (max && iso > max) return false
return true
}
```
- [ ] **Step 4 : Lancer les tests, vérifier le succès**
Run: `npm run test -- dateFormat`
Expected: PASS (toutes les assertions)
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/composables/dateFormat.ts app/components/malio/date/composables/dateFormat.test.ts
git commit -m "feat : utilitaires de format et validation de date (#MUI-33)"
```
---
## Task 2 : Composable `useMonthMatrix.ts`
**Files:**
- Create: `app/components/malio/date/composables/useMonthMatrix.ts`
- Test: `app/components/malio/date/composables/useMonthMatrix.test.ts`
Types exportés :
```ts
export type DayCell = {
isoDate: string
day: number
isCurrentMonth: boolean
isToday: boolean
}
export type WeekRow = {
weekNumber: number
days: DayCell[]
}
```
- [ ] **Step 1 : Écrire les tests (échouent)***assistant*
```ts
import {describe, expect, it, afterEach, beforeEach, vi} from 'vitest'
import {ref} from 'vue'
import {useMonthMatrix} from './useMonthMatrix'
describe('useMonthMatrix', () => {
it('always produces 6 weeks of 7 days', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai 2026
expect(weeks.value).toHaveLength(6)
weeks.value.forEach(week => expect(week.days).toHaveLength(7))
})
it('starts every week on a Monday', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026))
weeks.value.forEach(week => {
const first = new Date(`${week.days[0].isoDate}T00:00:00`)
expect(first.getDay()).toBe(1) // 1 = lundi
})
})
it('flags exactly the days of the current month', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai = 31 jours
const currentMonthDays = weeks.value
.flatMap(w => w.days)
.filter(d => d.isCurrentMonth)
expect(currentMonthDays).toHaveLength(31)
expect(currentMonthDays.every(d => d.isoDate.startsWith('2026-05'))).toBe(true)
})
it('handles leap year February (29 days)', () => {
const {weeks} = useMonthMatrix(ref(1), ref(2024)) // février 2024
const days = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth)
expect(days).toHaveLength(29)
})
it('assigns ISO week 1 to the week containing Jan 4th', () => {
const {weeks} = useMonthMatrix(ref(0), ref(2026)) // janvier 2026
const weekWithJan4 = weeks.value.find(w =>
w.days.some(d => d.isoDate === '2026-01-04'),
)
expect(weekWithJan4?.weekNumber).toBe(1)
})
it('reacts to month/year changes', () => {
const month = ref(4)
const year = ref(2026)
const {weeks} = useMonthMatrix(month, year)
const mayCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
month.value = 1 // février
year.value = 2024
const febCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
expect(mayCount).toBe(31)
expect(febCount).toBe(29)
})
describe('isToday', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('flags only today', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026))
const todays = weeks.value.flatMap(w => w.days).filter(d => d.isToday)
expect(todays).toHaveLength(1)
expect(todays[0].isoDate).toBe('2026-05-19')
})
})
})
```
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
Run: `npm run test -- useMonthMatrix`
Expected: FAIL (import non résolu)
- [ ] **Step 3 : Implémenter le composable***utilisateur* (référence ci-dessous)
```ts
// app/components/malio/date/composables/useMonthMatrix.ts
import {computed, type ComputedRef, type Ref} from 'vue'
export type DayCell = {
isoDate: string
day: number
isCurrentMonth: boolean
isToday: boolean
}
export type WeekRow = {
weekNumber: number
days: DayCell[]
}
const toIso = (d: Date): string => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const isoWeek = (d: Date): number => {
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
const dayNum = target.getUTCDay() || 7 // dimanche = 7
target.setUTCDate(target.getUTCDate() + 4 - dayNum) // jeudi de la semaine
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
return Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
}
export function useMonthMatrix(
month: Ref<number>,
year: Ref<number>,
): {weeks: ComputedRef<WeekRow[]>} {
const weeks = computed<WeekRow[]>(() => {
const todayIso = toIso(new Date())
const first = new Date(year.value, month.value, 1)
// recule jusqu'au lundi (getDay : 0 = dimanche)
const offset = (first.getDay() + 6) % 7
const start = new Date(year.value, month.value, 1 - offset)
const rows: WeekRow[] = []
const cursor = new Date(start)
for (let w = 0; w < 6; w++) {
const days: DayCell[] = []
for (let i = 0; i < 7; i++) {
const iso = toIso(cursor)
days.push({
isoDate: iso,
day: cursor.getDate(),
isCurrentMonth: cursor.getMonth() === month.value,
isToday: iso === todayIso,
})
cursor.setDate(cursor.getDate() + 1)
}
rows.push({weekNumber: isoWeek(new Date(`${days[0].isoDate}T00:00:00`)), days})
}
return rows
})
return {weeks}
}
```
- [ ] **Step 4 : Lancer les tests, vérifier le succès**
Run: `npm run test -- useMonthMatrix`
Expected: PASS
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/composables/useMonthMatrix.ts app/components/malio/date/composables/useMonthMatrix.test.ts
git commit -m "feat : composable de matrice mensuelle avec n° de semaine ISO (#MUI-33)"
```
---
## Task 3 : Composable `useCalendarPopover.ts`
**Files:**
- Create: `app/components/malio/date/composables/useCalendarPopover.ts`
- Test: `app/components/malio/date/composables/useCalendarPopover.test.ts`
- [ ] **Step 1 : Écrire les tests (échouent)***assistant*
Le composable utilise `onMounted`/`onBeforeUnmount`, on le monte donc dans un composant hôte minimal pour avoir un contexte de cycle de vie.
```ts
import {describe, expect, it} from 'vitest'
import {defineComponent, ref, h} from 'vue'
import {mount} from '@vue/test-utils'
import {useCalendarPopover} from './useCalendarPopover'
const mountHost = () => {
const api: ReturnType<typeof useCalendarPopover> = {} as never
const Host = defineComponent({
setup() {
const root = ref<HTMLElement | null>(null)
Object.assign(api, useCalendarPopover(root))
return () => h('div', {ref: root}, 'host')
},
})
const wrapper = mount(Host, {attachTo: document.body})
return {wrapper, api}
}
describe('useCalendarPopover', () => {
it('starts closed in days view', () => {
const {api} = mountHost()
expect(api.isOpen.value).toBe(false)
expect(api.viewMode.value).toBe('days')
})
it('open() opens in days view', () => {
const {api} = mountHost()
api.open()
expect(api.isOpen.value).toBe(true)
expect(api.viewMode.value).toBe('days')
})
it('toggleView() switches between days and months', () => {
const {api} = mountHost()
api.open()
api.toggleView()
expect(api.viewMode.value).toBe('months')
api.toggleView()
expect(api.viewMode.value).toBe('days')
})
it('close() resets isOpen and viewMode', () => {
const {api} = mountHost()
api.open()
api.toggleView()
api.close()
expect(api.isOpen.value).toBe(false)
expect(api.viewMode.value).toBe('days')
})
it('closes on outside mousedown', async () => {
const {api} = mountHost()
api.open()
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
expect(api.isOpen.value).toBe(false)
})
it('stays open on inside mousedown', async () => {
const {wrapper, api} = mountHost()
api.open()
wrapper.element.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
expect(api.isOpen.value).toBe(true)
})
})
```
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
Run: `npm run test -- useCalendarPopover`
Expected: FAIL (import non résolu)
- [ ] **Step 3 : Implémenter le composable***utilisateur* (référence ci-dessous)
```ts
// app/components/malio/date/composables/useCalendarPopover.ts
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
const isOpen = ref(false)
const viewMode = ref<'days' | 'months'>('days')
const open = () => {
isOpen.value = true
viewMode.value = 'days'
}
const close = () => {
isOpen.value = false
viewMode.value = 'days'
}
const toggleView = () => {
viewMode.value = viewMode.value === 'days' ? 'months' : 'days'
}
const onMouseDown = (event: MouseEvent) => {
if (!isOpen.value || !rootRef.value) return
if (!rootRef.value.contains(event.target as Node)) close()
}
onMounted(() => document.addEventListener('mousedown', onMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
return {isOpen, viewMode, open, close, toggleView}
}
```
- [ ] **Step 4 : Lancer les tests, vérifier le succès**
Run: `npm run test -- useCalendarPopover`
Expected: PASS
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/composables/useCalendarPopover.ts app/components/malio/date/composables/useCalendarPopover.test.ts
git commit -m "feat : composable d'état du popover calendrier (#MUI-33)"
```
---
## Task 4 : Sous-composant `MonthGrid.vue`
**Files:**
- Create: `app/components/malio/date/internal/MonthGrid.vue`
- Test: (couvert par `Date.test.ts` en Task 7 ; pas de test dédié pour ce sous-composant interne)
Props : `month: number`, `year: number`, `selectedDate?: string | null`, `min?: string`, `max?: string`. Emit : `select` (payload `string` ISO).
- [ ] **Step 1 : Implémenter le composant***utilisateur* (référence ci-dessous)
```vue
<template>
<div data-test="month-grid">
<div class="grid grid-cols-8">
<div class="flex h-8 items-center justify-center text-xs font-medium uppercase text-m-muted">
Sem
</div>
<div
v-for="d in dayLabels"
:key="d"
class="flex h-8 items-center justify-center text-xs font-medium uppercase text-m-muted"
>
{{ d }}
</div>
<template
v-for="week in weeks"
:key="week.days[0].isoDate"
>
<div
data-test="week-number"
class="flex h-10 items-center justify-center bg-m-primary/10 text-sm text-m-primary/70"
>
{{ week.weekNumber }}
</div>
<button
v-for="cell in week.days"
:key="cell.isoDate"
type="button"
data-test="day"
:data-iso="cell.isoDate"
:disabled="!inRange(cell.isoDate)"
:aria-label="ariaLabel(cell)"
:aria-disabled="!inRange(cell.isoDate)"
class="flex h-10 w-10 items-center justify-center text-sm transition-colors duration-100"
:class="cellClass(cell)"
@click="onSelect(cell.isoDate)"
>
{{ cell.day }}
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, toRef} from 'vue'
import {useMonthMatrix, type DayCell} from '../composables/useMonthMatrix'
import {isDateInRange} from '../composables/dateFormat'
defineOptions({name: 'MalioDateMonthGrid'})
const props = withDefaults(
defineProps<{
month: number
year: number
selectedDate?: string | null
min?: string
max?: string
}>(),
{selectedDate: null, min: undefined, max: undefined},
)
const emit = defineEmits<{(e: 'select', iso: string): void}>()
const dayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const {weeks} = useMonthMatrix(toRef(props, 'month'), toRef(props, 'year'))
const inRange = (iso: string) => isDateInRange(iso, props.min, props.max)
const ariaLabel = (cell: DayCell) => {
const [, m, d] = cell.isoDate.split('-')
return `${Number(d)} ${monthsLong[Number(m) - 1]} ${cell.isoDate.slice(0, 4)}`
}
const cellClass = (cell: DayCell) => {
const selected = props.selectedDate === cell.isoDate
if (!inRange(cell.isoDate)) return 'text-m-muted/30 cursor-not-allowed'
if (selected) return 'bg-m-primary text-white font-medium rounded-full'
const parts = ['cursor-pointer hover:bg-m-primary/10 rounded-full']
if (cell.isToday) parts.push('border border-m-primary text-m-primary font-semibold')
else if (cell.isCurrentMonth) parts.push('text-black')
else parts.push('text-m-muted/50')
return parts.join(' ')
}
const onSelect = (iso: string) => {
if (!inRange(iso)) return
emit('select', iso)
}
</script>
```
- [ ] **Step 2 : Vérifier le lint**
Run: `npm run lint`
Expected: aucune nouvelle erreur (les warnings préexistants sont tolérés)
- [ ] **Step 3 : Commit**
```bash
git add app/components/malio/date/internal/MonthGrid.vue
git commit -m "feat : grille mensuelle du datepicker (#MUI-33)"
```
---
## Task 5 : Sous-composant `CalendarHeader.vue`
**Files:**
- Create: `app/components/malio/date/internal/CalendarHeader.vue`
Props : `viewMode: 'days' | 'months'`, `currentMonth: number`, `currentYear: number`. Emits : `prev`, `next`, `toggle-view`.
- [ ] **Step 1 : Implémenter le composant***utilisateur* (référence ci-dessous)
```vue
<template>
<div class="flex h-12 items-center justify-between border-b border-m-primary/20 px-2">
<button
type="button"
data-test="header-prev"
class="rounded p-2 hover:bg-m-primary/10"
:aria-label="viewMode === 'days' ? 'Mois précédent' : 'Année précédente'"
@click="emit('prev')"
>
<Icon icon="mdi:chevron-left" :width="20" :height="20" />
</button>
<button
type="button"
data-test="header-toggle"
class="flex items-center gap-1 rounded px-2 py-1 text-base font-medium hover:bg-m-primary/10"
@click="emit('toggle-view')"
>
{{ label }}
<Icon icon="mdi:chevron-down" :width="16" :height="16" />
</button>
<button
type="button"
data-test="header-next"
class="rounded p-2 hover:bg-m-primary/10"
:aria-label="viewMode === 'days' ? 'Mois suivant' : 'Année suivante'"
@click="emit('next')"
>
<Icon icon="mdi:chevron-right" :width="20" :height="20" />
</button>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {Icon} from '@iconify/vue'
defineOptions({name: 'MalioDateCalendarHeader'})
const props = defineProps<{
viewMode: 'days' | 'months'
currentMonth: number
currentYear: number
}>()
const emit = defineEmits<{
(e: 'prev' | 'next' | 'toggle-view'): void
}>()
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const label = computed(() => {
const name = monthsLong[props.currentMonth]
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
})
</script>
```
- [ ] **Step 2 : Vérifier le lint**
Run: `npm run lint`
Expected: aucune nouvelle erreur
- [ ] **Step 3 : Commit**
```bash
git add app/components/malio/date/internal/CalendarHeader.vue
git commit -m "feat : header de navigation du datepicker (#MUI-33)"
```
---
## Task 6 : Sous-composant `MonthPicker.vue`
**Files:**
- Create: `app/components/malio/date/internal/MonthPicker.vue`
Props : `selectedMonth?: number`. Emit : `select` (payload `number` 0-11).
- [ ] **Step 1 : Implémenter le composant***utilisateur* (référence ci-dessous)
```vue
<template>
<div
data-test="month-picker"
class="grid grid-cols-4 gap-2 p-3"
>
<button
v-for="(name, index) in monthsShort"
:key="name"
type="button"
data-test="month"
:data-month="index"
class="rounded py-3 text-sm transition-colors duration-100"
:class="index === selectedMonth
? 'bg-m-primary text-white'
: 'text-black hover:bg-m-primary/10'"
@click="emit('select', index)"
>
{{ name }}
</button>
</div>
</template>
<script setup lang="ts">
defineOptions({name: 'MalioDateMonthPicker'})
defineProps<{selectedMonth?: number}>()
const emit = defineEmits<{(e: 'select', month: number): void}>()
const monthsShort = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin',
'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc']
</script>
```
- [ ] **Step 2 : Vérifier le lint**
Run: `npm run lint`
Expected: aucune nouvelle erreur
- [ ] **Step 3 : Commit**
```bash
git add app/components/malio/date/internal/MonthPicker.vue
git commit -m "feat : sélecteur de mois du datepicker (#MUI-33)"
```
---
## Task 7 : Composant public `Date.vue` + tests d'intégration
**Files:**
- Create: `app/components/malio/date/Date.vue`
- Test: `app/components/malio/date/Date.test.ts`
- [ ] **Step 1 : Implémenter le composant***utilisateur* (référence ci-dessous)
```vue
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
data-test="date-input"
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-black"
aria-label="Effacer la date"
@click.stop="onClear"
>
<Icon icon="mdi:close" :width="16" :height="16" />
</button>
<Icon
data-test="calendar-icon"
icon="mdi:calendar-outline"
:width="20"
:height="20"
:class="iconStateClass"
/>
</div>
<div
v-if="isOpen"
data-test="popover"
role="dialog"
class="absolute left-0 top-[calc(100%-4px)] z-20 min-w-[320px] rounded-b-md border border-t-0 bg-white"
:class="popoverBorderClass"
>
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
@prev="onPrev"
@next="onNext"
@toggle-view="toggleView"
/>
<MonthGrid
v-if="viewMode === 'days'"
:month="currentMonth"
:year="currentYear"
:selected-date="modelValue ?? null"
:min="min"
:max="max"
@select="onSelectDay"
/>
<MonthPicker
v-else
:selected-month="currentMonth"
@select="onSelectMonth"
/>
</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, ref, useAttrs, useId, watch} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import CalendarHeader from './internal/CalendarHeader.vue'
import MonthGrid from './internal/MonthGrid.vue'
import MonthPicker from './internal/MonthPicker.vue'
import {useCalendarPopover} from './composables/useCalendarPopover'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
defineOptions({name: 'MalioDate', 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
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '', name: '', label: '', modelValue: undefined, placeholder: 'JJ/MM/AAAA',
required: false, disabled: false, readonly: false, hint: '', error: '', success: '',
min: undefined, max: undefined, 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, viewMode, open, close, toggleView} = useCalendarPopover(root)
const today = new Date()
const currentMonth = ref(today.getMonth())
const currentYear = ref(today.getFullYear())
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
const isFilled = computed(() => displayValue.value.length > 0)
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 syncViewToValue = () => {
const iso = props.modelValue
if (iso && isValidIso(iso)) {
currentMonth.value = Number(iso.slice(5, 7)) - 1
currentYear.value = Number(iso.slice(0, 4))
} else {
const now = new Date()
currentMonth.value = now.getMonth()
currentYear.value = now.getFullYear()
}
}
watch(() => props.modelValue, (val) => {
if (val && !isValidIso(val) && import.meta.dev) {
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
}
if (isOpen.value) syncViewToValue()
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (isOpen.value) {
close()
return
}
syncViewToValue()
open()
}
const onClear = () => emit('update:modelValue', null)
const onSelectDay = (iso: string) => {
emit('update:modelValue', iso)
close()
}
const onSelectMonth = (month: number) => {
currentMonth.value = month
toggleView()
}
const onPrev = () => {
if (viewMode.value === 'months') {
currentYear.value -= 1
return
}
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value -= 1
} else {
currentMonth.value -= 1
}
}
const onNext = () => {
if (viewMode.value === 'months') {
currentYear.value += 1
return
}
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value += 1
} else {
currentMonth.value += 1
}
}
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 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: 'focus:border-m-primary',
isOpen.value ? '!rounded-b-none !border-b-0 border-m-primary' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted text-black',
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'
})
const popoverBorderClass = computed(() =>
hasError.value ? 'border-m-danger' : hasSuccess.value ? 'border-m-success' : 'border-m-primary',
)
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>
```
- [ ] **Step 2 : Écrire les tests d'intégration (échouent au départ si lancés avant l'impl)***assistant*
```ts
import {describe, expect, it, beforeEach, afterEach, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Date_ from './Date.vue'
type DateProps = {
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}
const DateForTest = Date_ as DefineComponent<DateProps>
const mountDate = (props: DateProps = {}) => mount(DateForTest, {props, attachTo: document.body})
describe('MalioDate', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('renders the label and the calendar icon', () => {
const wrapper = mountDate({label: 'Date de naissance'})
expect(wrapper.get('label').text()).toBe('Date de naissance')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('displays the formatted value in the field', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('19/05/2026')
})
it('does not show the popover initially', () => {
const wrapper = mountDate()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('popover', () => {
it('opens on field click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on the current month when there is no value', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
})
it('opens on the value month when a value is set', async () => {
const wrapper = mountDate({modelValue: '2025-12-25'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('closes on outside mousedown', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('navigation jours', () => {
it('goes to the next month on the right chevron', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Juin 2026')
})
it('rolls December to January and bumps the year', async () => {
const wrapper = mountDate({modelValue: '2026-12-15'})
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2027')
})
})
describe('sélection', () => {
it('emits the ISO date and closes on day click', async () => {
const wrapper = mountDate()
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-19'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('bornes min/max', () => {
it('disables days outside the range', async () => {
const wrapper = mountDate({min: '2026-05-10', max: '2026-05-20'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
await outside.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('vue mois', () => {
it('switches to month view on header toggle', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
})
it('navigates the year with chevrons in month view', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2027')
})
it('returns to day view on month click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2026')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('effacement', () => {
it('shows the clear button when there is a value', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
expect(wrapper.find('[data-test="clear"]').exists()).toBe(true)
})
it('hides the clear button when empty', () => {
const wrapper = mountDate()
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
})
it('emits null and does not open the popover on clear', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('états', () => {
it('does not open when disabled', async () => {
const wrapper = mountDate({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('does not open when readonly', async () => {
const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('accessibilité', () => {
it('sets aria-invalid and describedby on error', () => {
const wrapper = mountDate({error: 'Date requise'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Date requise')
})
})
describe('synchronisation externe', () => {
it('updates the displayed value when modelValue changes', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.setProps({modelValue: '2026-12-25'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('25/12/2026')
})
})
})
```
- [ ] **Step 3 : Lancer les tests, vérifier le succès**
Run: `npm run test -- Date`
Expected: PASS (toutes les assertions). Si un test échoue, ajuster l'implémentation (les tests sont le contrat).
- [ ] **Step 4 : Vérifier le lint et la suite complète**
Run: `npm run lint && npm run test`
Expected: 0 erreur de lint nouvelle ; toute la suite verte.
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/Date.vue app/components/malio/date/Date.test.ts
git commit -m "feat : composant MalioDate (datepicker) (#MUI-33)"
```
---
## Task 8 : Story Histoire + page Playground
**Files:**
- Create: `app/story/date/Date.story.vue`
- Create: `.playground/pages/composant/date/date.vue`
- [ ] **Step 1 : Créer la story***utilisateur* (référence ci-dessous)
```vue
<template>
<Story title="Date/Date">
<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>
<MalioDate v-model="simpleValue" label="Date de naissance" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDate v-model="initialValue" label="Date du jour" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDate
v-model="boundedValue"
label="Date du rendez-vous"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDate v-model="initialValue" label="Date verrouillée" :clearable="false" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDate v-model="initialValue" label="Désactivé" disabled />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
<MalioDate v-model="initialValue" label="Lecture seule" readonly />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDate v-model="errorValue" label="Date limite" error="Date invalide" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioDate v-model="initialValue" label="Date confirmée" success="Enregistrée" />
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDate from '../../components/malio/date/Date.vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>(todayIso)
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
</script>
```
- [ ] **Step 2 : Créer la page playground***utilisateur* (référence ci-dessous)
```vue
<template>
<div class="max-w-md space-y-6">
<h1 class="text-2xl font-bold">MalioDate</h1>
<MalioDate
v-model="value"
label="Date de naissance"
hint="Clique pour ouvrir le calendrier"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-12-25'"
>
Forcer le 25/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
<MalioDate
v-model="bounded"
label="Date bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>
```
- [ ] **Step 3 : Vérification visuelle manuelle**
Run: `npm run dev` (playground) puis ouvrir la page "Date" du menu.
Vérifier : ouverture/fermeture, navigation mois, bascule vue mois, sélection, effacement, bornes grisées, état error/disabled/readonly.
Run aussi : `npm run story:dev` pour la story Histoire.
- [ ] **Step 4 : Lint final**
Run: `npm run lint`
Expected: aucune nouvelle erreur.
- [ ] **Step 5 : Commit**
```bash
git add app/story/date/Date.story.vue .playground/pages/composant/date/date.vue
git commit -m "feat : story et page playground du datepicker (#MUI-33)"
```
---
## Self-Review (effectuée à l'écriture du plan)
**Couverture spec :** modelValue ISO ✓ (T1), props/emits ✓ (T7), CalendarHeader ✓ (T5), MonthGrid + colonne semaine + min/max + today/sélection ✓ (T4), MonthPicker ✓ (T6), composables ✓ (T1-3), comportements ouverture/sélection/navigation/vue mois/fermeture/clear/états/synchro ✓ (T7 tests), a11y `aria-*` ✓ (T7), tests ✓ (T1-3, T7), story ✓ (T8), playground ✓ (T8). Reportés v2 (clavier, vue années, disabledDates, saisie éditable) : non planifiés, conforme.
**Placeholders :** aucun TODO/TBD ; tout le code de référence est complet.
**Cohérence des types :** `DayCell`/`WeekRow` définis en T2 et réutilisés en T4. `useCalendarPopover` renvoie `{isOpen, viewMode, open, close, toggleView}` (T3) — consommés tels quels en T7. `data-test` cohérents entre composants (T4-6) et tests (T7). Emit `select` (ISO string en T4, number en T6) consommés correctement par T7 (`onSelectDay`/`onSelectMonth`).
**Écart assumé vs spec :** la spec mentionnait "lien ajouté dans index.vue" pour le playground ; en réalité le menu est auto-globé → aucune édition manuelle requise (noté en tête de plan).