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

45 KiB

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

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 moduleutilisateur (référence ci-dessous)
// 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
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 :

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
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 composableutilisateur (référence ci-dessous)
// 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
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.

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 composableutilisateur (référence ci-dessous)
// 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
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 composantutilisateur (référence ci-dessous)
<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
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 composantutilisateur (référence ci-dessous)
<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
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 composantutilisateur (référence ci-dessous)
<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
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 composantutilisateur (référence ci-dessous)

<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
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
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 storyutilisateur (référence ci-dessous)

<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 playgroundutilisateur (référence ci-dessous)
<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
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).