1441 lines
45 KiB
Markdown
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).
|