Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
33 KiB
Sélecteur d'année dans le calendrier — 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: Ajouter un 3ᵉ niveau de navigation au calendrier de la famille date/ : depuis la vue mois, recliquer sur le header ouvre un sélecteur d'année calqué sur le sélecteur de mois, avec respect des bornes min/max.
Architecture: Le shell partagé internal/CalendarField.vue orchestre l'input, le popover, le header (CalendarHeader) et la commutation entre les vues days / months / years. MonthPicker et le nouveau YearPicker sont rendus dans CalendarField ; la grille de jours reste fournie par chaque consommateur via slot scoped. La logique d'état vit dans deux composables (useCalendarPopover, useCalendarView) et les bornes dans des helpers purs (dateFormat.ts).
Tech Stack: Nuxt 4 layer, Vue 3 <script setup lang="ts">, TypeScript strict, Tailwind (palette m-*), @iconify/vue, Vitest + @vue/test-utils (jsdom).
Global Constraints
- Composants :
defineOptions({name: 'MalioXxx'})(les internes utilisentMalioDateXxx, sansinheritAttrs: falsepour les pickers — calquer l'existantMonthPicker). - Valeurs ISO :
min/maxsont des chaînesYYYY-MM-DD(DateTimeles tronque via.slice(0, 10)). - Tests : Vitest run mode, fichiers colocalisés
*.test.ts, jsdom. Déterminisme viavi.setSystemTime(new Date(2026, 4, 19))(19 mai 2026). - Lancer un test ciblé :
npx vitest run <chemin>. - Commits : Conventional Commits avec espace avant
:(feat : message (#…)), type minuscule, pas de scope majuscule (préférer sans scope). Terminer parCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. - Hook pre-commit flaky (timeouts WSL2) : après 2 échecs flaky, vérifier le test ciblé à la main puis committer avec
--no-verify. - Ne PAS toucher / committer
nuxt.config.ts(modif ngrok locale intentionnelle). Stager des fichiers explicites, jamaisgit add -A. - Aucune API publique des 4 composants consommateurs ne change.
Task 1: Helpers de plage mois/année (dateFormat.ts)
Files:
- Modify:
app/components/malio/date/composables/dateFormat.ts - Test:
app/components/malio/date/composables/dateFormat.test.ts
Interfaces:
-
Consumes: rien.
-
Produces:
isMonthInRange(year: number, month: number, min?: string, max?: string): boolean(month 0-11)isYearInRange(year: number, min?: string, max?: string): boolean
-
Step 1: Write the failing tests
Ajouter dans dateFormat.test.ts — l'import en tête devient :
import {formatIsoToDisplay, isDateInRange, isMonthInRange, isValidIso, isYearInRange, parseDisplayToIso} from './dateFormat'
Puis ajouter ces deux blocs describe à l'intérieur du describe('dateFormat', …) (après le bloc isDateInRange) :
describe('isMonthInRange', () => {
it('returns true when no bounds are given', () => {
expect(isMonthInRange(2026, 4)).toBe(true)
})
it('respects the min bound by month (inclusive)', () => {
expect(isMonthInRange(2026, 4, '2026-05-10')).toBe(true) // mai chevauche
expect(isMonthInRange(2026, 3, '2026-05-10')).toBe(false) // avril < mai
})
it('respects the max bound by month (inclusive)', () => {
expect(isMonthInRange(2026, 4, undefined, '2026-05-31')).toBe(true)
expect(isMonthInRange(2026, 5, undefined, '2026-05-31')).toBe(false) // juin > mai
})
it('disables months in years outside the range', () => {
expect(isMonthInRange(2025, 11, '2026-05-10')).toBe(false)
expect(isMonthInRange(2027, 0, undefined, '2026-05-31')).toBe(false)
})
})
describe('isYearInRange', () => {
it('returns true when no bounds are given', () => {
expect(isYearInRange(2026)).toBe(true)
})
it('respects the min bound by year (inclusive)', () => {
expect(isYearInRange(2026, '2026-05-10')).toBe(true)
expect(isYearInRange(2025, '2026-05-10')).toBe(false)
})
it('respects the max bound by year (inclusive)', () => {
expect(isYearInRange(2026, undefined, '2026-05-31')).toBe(true)
expect(isYearInRange(2027, undefined, '2026-05-31')).toBe(false)
})
})
- Step 2: Run tests to verify they fail
Run: npx vitest run app/components/malio/date/composables/dateFormat.test.ts
Expected: FAIL — isMonthInRange is not a function / isYearInRange is not a function.
- Step 3: Write minimal implementation
Ajouter à la fin de dateFormat.ts :
export function isMonthInRange(year: number, month: number, min?: string, max?: string): boolean {
const ym = `${year}-${String(month + 1).padStart(2, '0')}`
if (min && ym < min.slice(0, 7)) return false
if (max && ym > max.slice(0, 7)) return false
return true
}
export function isYearInRange(year: number, min?: string, max?: string): boolean {
if (min && year < Number(min.slice(0, 4))) return false
if (max && year > Number(max.slice(0, 4))) return false
return true
}
- Step 4: Run tests to verify they pass
Run: npx vitest run app/components/malio/date/composables/dateFormat.test.ts
Expected: PASS (tous les blocs, anciens + nouveaux).
- Step 5: Commit
git add app/components/malio/date/composables/dateFormat.ts app/components/malio/date/composables/dateFormat.test.ts
git commit -m "feat : helpers isMonthInRange/isYearInRange (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 2: Machine à états 3 vues (useCalendarPopover.ts)
Files:
- Modify:
app/components/malio/date/composables/useCalendarPopover.ts - Test:
app/components/malio/date/composables/useCalendarPopover.test.ts
Interfaces:
-
Consumes: rien.
-
Produces (retour du composable) :
{isOpen, viewMode, open, close, goToHigherView}oùviewMode: Ref<'days' | 'months' | 'years'>etgoToHigherView(): void. RemplacetoggleView. -
Step 1: Update the failing test
Dans useCalendarPopover.test.ts, remplacer le test toggleView() switches between days and months (lignes ~33-40) par :
it('goToHigherView() climbs days -> months -> years and stops', () => {
const {api} = mountHost()
api.open()
api.goToHigherView()
expect(api.viewMode.value).toBe('months')
api.goToHigherView()
expect(api.viewMode.value).toBe('years')
api.goToHigherView()
expect(api.viewMode.value).toBe('years') // no-op au niveau le plus haut
})
Et dans le test close() resets isOpen and viewMode (lignes ~42-49), remplacer api.toggleView() par api.goToHigherView().
- Step 2: Run test to verify it fails
Run: npx vitest run app/components/malio/date/composables/useCalendarPopover.test.ts
Expected: FAIL — api.goToHigherView is not a function.
- Step 3: Write minimal implementation
Dans useCalendarPopover.ts, remplacer la ligne du ref et la fonction toggleView :
const viewMode = ref<'days' | 'months' | 'years'>('days')
const goToHigherView = () => {
if (viewMode.value === 'days') viewMode.value = 'months'
else if (viewMode.value === 'months') viewMode.value = 'years'
// 'years' : niveau le plus haut, no-op
}
Et le return :
return {isOpen, viewMode, open, close, goToHigherView}
- Step 4: Run test to verify it passes
Run: npx vitest run app/components/malio/date/composables/useCalendarPopover.test.ts
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 : viewMode 3 niveaux + goToHigherView (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 3: Navigation & fenêtre d'années (useCalendarView.ts)
Files:
- Modify:
app/components/malio/date/composables/useCalendarView.ts - Test:
app/components/malio/date/composables/useCalendarView.test.ts
Interfaces:
-
Consumes:
viewMode: Ref<'days' | 'months' | 'years'>(depuis Task 2). -
Produces (retour) :
{currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}oùyearPageStart: Ref<number>etselectYear(y: number): void. -
Step 1: Write the failing tests
Dans useCalendarView.test.ts : ajouter l'import de nextTick en tête :
import {nextTick, ref} from 'vue'
Puis ajouter ces tests dans le describe('useCalendarView', …) :
it('paginates years by 12 in years view', () => {
const {yearPageStart, goToNext, goToPrev} = useCalendarView(ref('years'))
const start = yearPageStart.value
goToNext()
expect(yearPageStart.value).toBe(start + 12)
goToPrev()
expect(yearPageStart.value).toBe(start)
})
it('selectYear sets the current year', () => {
const {currentYear, selectYear} = useCalendarView(ref('days'))
selectYear(2030)
expect(currentYear.value).toBe(2030)
})
it('recenters the year page on entering years view (current - 5)', async () => {
const mode = ref<'days' | 'months' | 'years'>('days')
const {yearPageStart} = useCalendarView(mode)
mode.value = 'years'
await nextTick()
expect(yearPageStart.value).toBe(2021) // 2026 - 5
})
- Step 2: Run tests to verify they fail
Run: npx vitest run app/components/malio/date/composables/useCalendarView.test.ts
Expected: FAIL — yearPageStart / selectYear undefined.
- Step 3: Write minimal implementation
Dans useCalendarView.ts :
Élargir la signature et importer watch :
import {ref, watch, type Ref} from 'vue'
import {isValidIso} from './dateFormat'
export function useCalendarView(viewMode: Ref<'days' | 'months' | 'years'>) {
const today = new Date()
const currentMonth = ref(today.getMonth())
const currentYear = ref(today.getFullYear())
const yearPageStart = ref(today.getFullYear() - 5)
watch(viewMode, (mode) => {
if (mode === 'years') yearPageStart.value = currentYear.value - 5
})
Dans goToPrev, ajouter la branche years en tête :
const goToPrev = () => {
if (viewMode.value === 'years') {
yearPageStart.value -= 12
return
}
if (viewMode.value === 'months') {
currentYear.value -= 1
return
}
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value -= 1
} else {
currentMonth.value -= 1
}
}
Dans goToNext, idem :
const goToNext = () => {
if (viewMode.value === 'years') {
yearPageStart.value += 12
return
}
if (viewMode.value === 'months') {
currentYear.value += 1
return
}
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value += 1
} else {
currentMonth.value += 1
}
}
Ajouter selectYear après selectMonth :
const selectYear = (y: number) => {
currentYear.value = y
}
Mettre à jour le return :
return {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}
- Step 4: Run tests to verify they pass
Run: npx vitest run app/components/malio/date/composables/useCalendarView.test.ts
Expected: PASS (anciens + nouveaux).
- Step 5: Commit
git add app/components/malio/date/composables/useCalendarView.ts app/components/malio/date/composables/useCalendarView.test.ts
git commit -m "feat : pagination des années + selectYear + recentrage (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 4: Header contextuel (CalendarHeader.vue)
Files:
- Modify:
app/components/malio/date/internal/CalendarHeader.vue
Interfaces:
- Consumes:
viewMode,currentMonth,currentYear,yearPageStart(props). - Produces: émet
prev/next/toggle-view(inchangé). En vueyears, le bouton libellé n'émet pastoggle-viewet ne montre pas le chevron-bas.
Pas de test colocalisé pour
CalendarHeader(l'existant n'en a pas) — vérifié via l'e2e en Task 8. Ce Task est purement implémentation ; pas de cycle test propre, donc on le commit avec son cycle de vérification = build/lint + e2e en Task 8. Le step de vérif ci-dessous est un contrôle visuel du diff.
- Step 1: Implement the contextual label and years behavior
Remplacer le <template> par :
<template>
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
<button
type="button"
data-test="header-prev"
class="ml-2 flex self-start rounded"
:aria-label="prevLabel"
@click="emit('prev')"
>
<Icon
icon="mdi:chevron-left"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-toggle"
class="flex gap-1 rounded text-base font-medium"
@click="viewMode !== 'years' && emit('toggle-view')"
>
<span class="mt-[2px]">{{ label }}</span>
<Icon
v-if="viewMode !== 'years'"
icon="mdi:chevron-down"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-next"
class="mr-2 flex self-start rounded"
:aria-label="nextLabel"
@click="emit('next')"
>
<Icon
icon="mdi:chevron-right"
:width="25"
:height="25"
/>
</button>
</div>
</template>
Dans le <script setup> : élargir le type viewMode, ajouter yearPageStart, et calculer label + prevLabel + nextLabel :
const props = defineProps<{
viewMode: 'days' | 'months' | 'years'
currentMonth: number
currentYear: number
yearPageStart: number
}>()
const label = computed(() => {
if (props.viewMode === 'years') return `${props.yearPageStart} – ${props.yearPageStart + 11}`
if (props.viewMode === 'months') return `${props.currentYear}`
const name = monthsLong[props.currentMonth]
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
})
const prevLabel = computed(() =>
props.viewMode === 'days' ? 'Mois précédent'
: props.viewMode === 'months' ? 'Année précédente'
: 'Période précédente',
)
const nextLabel = computed(() =>
props.viewMode === 'days' ? 'Mois suivant'
: props.viewMode === 'months' ? 'Année suivante'
: 'Période suivante',
)
(Supprimer les anciens :aria-label ternaires inline du template — remplacés par prevLabel/nextLabel.)
- Step 2: Lint the file
Run: npx eslint app/components/malio/date/internal/CalendarHeader.vue
Expected: aucune erreur.
- Step 3: Commit
git add app/components/malio/date/internal/CalendarHeader.vue
git commit -m "feat : header contextuel jours/mois/années (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 5: Composant YearPicker.vue + tests
Files:
- Create:
app/components/malio/date/internal/YearPicker.vue - Test:
app/components/malio/date/internal/YearPicker.test.ts
Interfaces:
-
Consumes:
isYearInRange(Task 1). -
Produces: composant
<YearPicker :page-start :selected-year? :min? :max? @select="(year:number)=>…" />. Rend 12 boutonsdata-test="year"avecdata-year. Conteneurdata-test="year-picker". -
Step 1: Write the failing test
Créer app/components/malio/date/internal/YearPicker.test.ts :
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import YearPicker from './YearPicker.vue'
const mountPicker = (props: {pageStart: number, selectedYear?: number, min?: string, max?: string}) =>
mount(YearPicker, {props})
describe('MalioDateYearPicker', () => {
it('renders 12 years from pageStart', () => {
const wrapper = mountPicker({pageStart: 2021})
const years = wrapper.findAll('[data-test="year"]')
expect(years).toHaveLength(12)
expect(years[0].attributes('data-year')).toBe('2021')
expect(years[11].attributes('data-year')).toBe('2032')
})
it('emits select with the clicked year', async () => {
const wrapper = mountPicker({pageStart: 2021})
await wrapper.get('[data-test="year"][data-year="2026"]').trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual([2026])
})
it('disables years outside [min, max] and does not emit', async () => {
const wrapper = mountPicker({pageStart: 2021, min: '2025-01-01', max: '2027-12-31'})
const out = wrapper.get('[data-test="year"][data-year="2024"]')
expect(out.attributes('disabled')).toBeDefined()
await out.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
})
it('highlights the selected year', () => {
const wrapper = mountPicker({pageStart: 2021, selectedYear: 2026})
const span = wrapper.get('[data-test="year"][data-year="2026"] span')
expect(span.classes()).toContain('bg-m-primary')
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run app/components/malio/date/internal/YearPicker.test.ts
Expected: FAIL — impossible de résoudre ./YearPicker.vue.
- Step 3: Write the component
Créer app/components/malio/date/internal/YearPicker.vue :
<template>
<div
data-test="year-picker"
class="grid grid-cols-3 gap-3"
>
<button
v-for="year in years"
:key="year"
type="button"
data-test="year"
:data-year="year"
:disabled="!isYearInRange(year, min, max)"
:aria-disabled="!isYearInRange(year, min, max)"
class="flex h-[45px] w-full items-center justify-center"
:class="isYearInRange(year, min, max) ? 'cursor-pointer' : 'cursor-not-allowed'"
@click="emit('select', year)"
>
<span
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
:class="year === selectedYear
? 'bg-m-primary text-white'
: isYearInRange(year, min, max)
? 'text-black hover:bg-m-primary/10'
: 'text-m-muted/30'"
>
{{ year }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {isYearInRange} from '../composables/dateFormat'
defineOptions({name: 'MalioDateYearPicker'})
const props = defineProps<{
pageStart: number
selectedYear?: number
min?: string
max?: string
}>()
const emit = defineEmits<{(e: 'select', year: number): void}>()
const years = computed(() => Array.from({length: 12}, (_, i) => props.pageStart + i))
</script>
Note :
@clickémet toujours, mais un<button disabled>ne déclenche pas l'événement clic dans jsdom/navigateur — le test « does not emit » le valide.
- Step 4: Run test to verify it passes
Run: npx vitest run app/components/malio/date/internal/YearPicker.test.ts
Expected: PASS.
- Step 5: Commit
git add app/components/malio/date/internal/YearPicker.vue app/components/malio/date/internal/YearPicker.test.ts
git commit -m "feat : composant YearPicker avec bornage min/max (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 6: Bornage min/max du MonthPicker + tests
Files:
- Modify:
app/components/malio/date/internal/MonthPicker.vue - Test (create):
app/components/malio/date/internal/MonthPicker.test.ts
Interfaces:
-
Consumes:
isMonthInRange(Task 1). -
Produces:
<MonthPicker :selected-month? :current-year :min? :max? @select="(month:number)=>…" />.currentYeardevient requis (nécessaire pour borner les mois). -
Step 1: Write the failing test
Créer app/components/malio/date/internal/MonthPicker.test.ts :
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import MonthPicker from './MonthPicker.vue'
const mountPicker = (props: {currentYear: number, selectedMonth?: number, min?: string, max?: string}) =>
mount(MonthPicker, {props})
describe('MalioDateMonthPicker', () => {
it('renders 12 months', () => {
const wrapper = mountPicker({currentYear: 2026})
expect(wrapper.findAll('[data-test="month"]')).toHaveLength(12)
})
it('emits select with the clicked month index', async () => {
const wrapper = mountPicker({currentYear: 2026})
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual([0])
})
it('disables months before min in the current year and does not emit', async () => {
const wrapper = mountPicker({currentYear: 2026, min: '2026-05-01'})
const april = wrapper.get('[data-test="month"][data-month="3"]')
expect(april.attributes('disabled')).toBeDefined()
await april.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
})
it('disables months after max in the current year', () => {
const wrapper = mountPicker({currentYear: 2026, max: '2026-05-31'})
expect(wrapper.get('[data-test="month"][data-month="5"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-test="month"][data-month="4"]').attributes('disabled')).toBeUndefined()
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run app/components/malio/date/internal/MonthPicker.test.ts
Expected: FAIL — le mois avril n'est pas désactivé (disabled undefined).
- Step 3: Update the component
Remplacer MonthPicker.vue par :
<template>
<div
data-test="month-picker"
class="grid grid-cols-3 gap-3"
>
<button
v-for="(name, index) in months"
:key="name"
type="button"
data-test="month"
:data-month="index"
:disabled="!isMonthInRange(currentYear, index, min, max)"
:aria-disabled="!isMonthInRange(currentYear, index, min, max)"
class="flex h-[45px] w-full items-center justify-center"
:class="isMonthInRange(currentYear, index, min, max) ? 'cursor-pointer' : 'cursor-not-allowed'"
@click="emit('select', index)"
>
<span
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
:class="index === selectedMonth
? 'bg-m-primary text-white'
: isMonthInRange(currentYear, index, min, max)
? 'text-black hover:bg-m-primary/10'
: 'text-m-muted/30'"
>
{{ name }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
import {isMonthInRange} from '../composables/dateFormat'
defineOptions({name: 'MalioDateMonthPicker'})
defineProps<{
currentYear: number
selectedMonth?: number
min?: string
max?: string
}>()
const emit = defineEmits<{(e: 'select', month: number): void}>()
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
</script>
- Step 4: Run test to verify it passes
Run: npx vitest run app/components/malio/date/internal/MonthPicker.test.ts
Expected: PASS.
- Step 5: Commit
git add app/components/malio/date/internal/MonthPicker.vue app/components/malio/date/internal/MonthPicker.test.ts
git commit -m "feat : bornage min/max du MonthPicker (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 7: Câblage CalendarField.vue
Files:
- Modify:
app/components/malio/date/internal/CalendarField.vue
Interfaces:
- Consumes:
useCalendarPopover(goToHigherView),useCalendarView(yearPageStart,selectYear),CalendarHeader(propyearPageStart),MonthPicker(propscurrentYear/min/max),YearPicker(Task 5). - Produces:
CalendarFieldaccepte les propsmin?: stringetmax?: string; rend les 3 vues.
Vérifié via l'e2e en Task 8 (pas de test colocalisé propre à
CalendarField). Steps = implémentation + lint, le cycle test rouge/vert vit en Task 8.
- Step 1: Add min/max props
Dans le bloc defineProps, ajouter min et max (après reserveMessageSpace?: boolean) :
reserveMessageSpace?: boolean
min?: string
max?: string
Et dans withDefaults({...}), après reserveMessageSpace: true, :
reserveMessageSpace: true,
min: undefined,
max: undefined,
- Step 2: Import YearPicker and update composable destructuring
Après import MonthPicker from './MonthPicker.vue' :
import YearPicker from './YearPicker.vue'
Remplacer les deux lignes de destructuration (≈218-219) :
const {isOpen, viewMode, open, close: closePopover, goToHigherView} = useCalendarPopover(root)
const {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso} = useCalendarView(viewMode)
- Step 3: Update the popover template
Remplacer le bloc <CalendarHeader> + <slot> + <MonthPicker> (≈85-103) par :
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
:year-page-start="yearPageStart"
@prev="goToPrev"
@next="goToNext"
@toggle-view="goToHigherView"
/>
<slot
v-if="viewMode === 'days'"
:current-month="currentMonth"
:current-year="currentYear"
:close="closePopover"
/>
<MonthPicker
v-else-if="viewMode === 'months'"
:selected-month="currentMonth"
:current-year="currentYear"
:min="min"
:max="max"
@select="onSelectMonth"
/>
<YearPicker
v-else
:page-start="yearPageStart"
:selected-year="currentYear"
:min="min"
:max="max"
@select="onSelectYear"
/>
- Step 4: Update handlers
goToHigherView ne sert qu'au header (zoom arrière). À la sélection, on redescend d'un niveau. useCalendarPopover n'expose pas de setter de vue, mais viewMode est un ref déstructuré et donc directement mutable. Remplacer onSelectMonth (≈324-327) par les deux handlers suivants :
const onSelectMonth = (m: number) => {
selectMonth(m)
viewMode.value = 'days'
}
const onSelectYear = (y: number) => {
selectYear(y)
viewMode.value = 'months'
}
- Step 5: Lint
Run: npx eslint app/components/malio/date/internal/CalendarField.vue
Expected: aucune erreur (notamment, viewMode est bien mutable car c'est un ref).
- Step 6: Commit
git add app/components/malio/date/internal/CalendarField.vue
git commit -m "feat : intégration du sélecteur d'année dans CalendarField (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 8: Binder min/max chez les consommateurs + e2e
Files:
- Modify:
app/components/malio/date/Date.vue - Modify:
app/components/malio/date/DateRange.vue - Modify:
app/components/malio/date/DateTime.vue - Modify:
app/components/malio/date/DateWeek.vue - Test:
app/components/malio/date/Date.test.ts
Interfaces:
-
Consumes:
CalendarFieldpropsmin/max(Task 7). -
Produces: les 4 composants propagent leurs bornes au popover. API publique inchangée.
-
Step 1: Write the failing e2e test
Dans Date.test.ts, à l'intérieur du describe('vue mois', …) (ou un nouveau describe('vue années')), ajouter :
describe('vue années', () => {
it('opens the year picker on second header toggle', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> mois
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> années
expect(wrapper.find('[data-test="year-picker"]').exists()).toBe(true)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2021 – 2032')
})
it('navigates days -> months -> years -> months -> days', 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-toggle"]').trigger('click')
await wrapper.get('[data-test="year"][data-year="2024"]').trigger('click') // -> mois 2024
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2024')
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click') // -> jours
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2024')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('paginates the year window with chevrons', 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-toggle"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2033 – 2044')
})
it('greys out years outside [min, max]', async () => {
const wrapper = mountDate({min: '2025-01-01', max: '2027-12-31'})
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
expect(wrapper.get('[data-test="year"][data-year="2024"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-test="year"][data-year="2026"]').attributes('disabled')).toBeUndefined()
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run app/components/malio/date/Date.test.ts
Expected: FAIL — year-picker introuvable (Date.vue ne passe pas encore min/max à CalendarField).
- Step 3: Bind min/max on each consumer's
<CalendarField>
Dans Date.vue, DateRange.vue, DateWeek.vue : ajouter sur la balise racine <CalendarField …> (au même niveau que les autres props, ex. après :readonly="readonly") :
:min="min"
:max="max"
Dans DateTime.vue : ajouter (les bornes y sont des YYYY-MM-DDTHH:MM, tronquer à la date) :
:min="min?.slice(0, 10)"
:max="max?.slice(0, 10)"
- Step 4: Run test to verify it passes
Run: npx vitest run app/components/malio/date/Date.test.ts
Expected: PASS (anciens + nouveaux).
- Step 5: Run the whole date suite (non-régression)
Run: npx vitest run app/components/malio/date/
Expected: PASS — Date, DateRange, DateTime, DateWeek, composables, pickers. (En cas de timeout flaky WSL2, relancer ; voir Global Constraints.)
- Step 6: Commit
git add app/components/malio/date/Date.vue app/components/malio/date/DateRange.vue app/components/malio/date/DateTime.vue app/components/malio/date/DateWeek.vue app/components/malio/date/Date.test.ts
git commit -m "feat : propage min/max au popover + e2e sélecteur d'année (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 9: Documentation (COMPONENTS.md, CHANGELOG.md)
Files:
- Modify:
COMPONENTS.md - Modify:
CHANGELOG.md
Interfaces:
-
Consumes: comportement livré aux tasks 1-8.
-
Produces: doc à jour (convention projet : maj manuelle).
-
Step 1: Locate the date section in COMPONENTS.md
Run: grep -nE "MalioDate|Date|calendrier|sélecteur" COMPONENTS.md | head -20
Expected: repère la section calendrier/date. Si aucune entrée, l'ajouter à la suite des autres composants date.
- Step 2: Document the 3-level navigation
Ajouter (dans la section du calendrier date) une description du 3ᵉ niveau :
Le calendrier propose trois niveaux de navigation : jours → clic sur l'en-tête →
sélecteur de mois → nouveau clic sur l'en-tête → sélecteur d'année (grille de 12 ans,
chevrons par pas de 12 ans). Les props `min`/`max` grisent les mois et les années hors
plage. Sélectionner une année revient au sélecteur de mois, sélectionner un mois revient
à la grille de jours.
- Step 3: Add a CHANGELOG entry
Sous la section non publiée (ou en tête, selon le format existant — vérifier head -20 CHANGELOG.md) :
- Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau) et
grisage des mois/années hors `min`/`max`.
- Step 4: Commit
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs : sélecteur d'année dans le calendrier (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Notes de vérification finale
- Suite ciblée complète :
npx vitest run app/components/malio/date/ - Lint global :
npm run lint - Vérif manuelle navigateur : proposer à l'utilisateur (ne pas lancer le MCP Chrome sans accord — coût tokens). Parcours : ouvrir un
MalioDate, cliquer le header 2× → grille d'années, paginer, choisir une année puis un mois, et tester un champ avecmin/maxpour voir le grisage.