feat : sélecteur d'année dans le calendrier (3ᵉ niveau) (#83)

## Sélecteur d'année dans le calendrier (3ᵉ niveau de navigation)

Ajoute un 3ᵉ niveau de navigation à la famille de composants date, et corrige le bornage min/max du sélecteur de mois.

### Comportement
- Clic sur le champ → calendrier (vue **jours**)
- Clic sur l'en-tête → **sélecteur de mois**
- **Re-clic sur l'en-tête → sélecteur d'année** (grille de 12 ans, chevrons paginant par pas de 12 ans, fenêtre centrée sur l'année courante − 5)
- Clic sur une année → retour au sélecteur de mois ; clic sur un mois → retour à la grille de jours
- Les props `min`/`max` **grisent les mois ET les années** hors plage (corrige l'asymétrie : le `MonthPicker` affichait jusqu'ici tous les mois)

En-tête contextuel : « Mai 2026 » (jours) / « 2026 » (mois) / « 2020 – 2031 » (années).

### Périmètre
- Shell partagé `internal/CalendarField.vue` → bénéficie aux 4 composants publics `Date`, `DateRange`, `DateTime`, `DateWeek`
- **Aucune API publique modifiée**
- Nouveau composant `internal/YearPicker.vue` (calqué sur `MonthPicker`)
- Helpers purs `isMonthInRange` / `isYearInRange` (comparaison par préfixe ISO, bornes inclusives)
- State machine `viewMode` à 3 niveaux (`useCalendarPopover` / `useCalendarView`)

### Tests
- Suite date **246/246 verte**, ESLint propre
- Unitaires : helpers, `YearPicker`, `MonthPicker` (grisage), composables (pagination ±12, recentrage, `selectYear`)
- e2e `Date.test.ts` : flux complet jours→mois→années→mois→jours + grisage min/max

### Process
Développé en brainstorming → spec → plan → exécution TDD (un commit par étape). Spec et plan inclus sous `docs/superpowers/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #83
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #83.
This commit is contained in:
2026-06-22 09:28:55 +00:00
committed by Autin
parent b0f060f909
commit 41010060ff
21 changed files with 1443 additions and 24 deletions
@@ -88,7 +88,7 @@
:current-year="currentYear"
@prev="goToPrev"
@next="goToNext"
@toggle-view="toggleView"
@toggle-view="cycleView"
/>
<slot
v-if="viewMode === 'days'"
@@ -97,10 +97,21 @@
:close="closePopover"
/>
<MonthPicker
v-else
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"
/>
</div>
</div>
@@ -127,6 +138,7 @@ import type {MaskInputOptions} from 'maska'
import MalioRequiredMark from '../../shared/RequiredMark.vue'
import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue'
import YearPicker from './YearPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover'
import {useCalendarView} from '../composables/useCalendarView'
import {buildBoundedMask} from '../composables/maskTemplate'
@@ -157,6 +169,8 @@ const props = withDefaults(
labelClass?: string
groupClass?: string
reserveMessageSpace?: boolean
min?: string
max?: string
}>(),
{
id: '',
@@ -176,6 +190,8 @@ const props = withDefaults(
labelClass: '',
groupClass: '',
reserveMessageSpace: true,
min: undefined,
max: undefined,
},
)
@@ -215,8 +231,8 @@ watch(() => props.displayValue, (value) => {
draft.value = value
})
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
const {isOpen, viewMode, open, close: closePopover, cycleView} = useCalendarPopover(root)
const {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso} = useCalendarView(viewMode)
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
const hasError = computed(() => !!props.error)
@@ -323,7 +339,12 @@ watch(() => props.syncTo, (value) => {
const onSelectMonth = (m: number) => {
selectMonth(m)
toggleView()
viewMode.value = 'days'
}
const onSelectYear = (y: number) => {
selectYear(y)
viewMode.value = 'months'
}
const mergedGroupClass = computed(() =>
@@ -4,7 +4,7 @@
type="button"
data-test="header-prev"
class="ml-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois précédent' : 'Année précédente'"
:aria-label="prevLabel"
@click="emit('prev')"
>
<Icon
@@ -32,7 +32,7 @@
type="button"
data-test="header-next"
class="mr-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois suivant' : 'Année suivante'"
:aria-label="nextLabel"
@click="emit('next')"
>
<Icon
@@ -51,7 +51,7 @@ import {Icon} from '@iconify/vue'
defineOptions({name: 'MalioDateCalendarHeader'})
const props = defineProps<{
viewMode: 'days' | 'months'
viewMode: 'days' | 'months' | 'years'
currentMonth: number
currentYear: number
}>()
@@ -63,8 +63,21 @@ const emit = defineEmits<{
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
// Libellé constant « Mois Année » dans toutes les vues (jours/mois/années) :
// la grille affichée en dessous indique le niveau courant.
const label = computed(() => {
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',
)
</script>
@@ -0,0 +1,33 @@
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()
})
})
@@ -9,14 +9,19 @@
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'
: 'text-black hover:bg-m-primary/10'"
: isMonthInRange(currentYear, index, min, max)
? 'text-black hover:bg-m-primary/10'
: 'text-m-muted/30'"
>
{{ name }}
</span>
@@ -25,9 +30,16 @@
</template>
<script setup lang="ts">
import {isMonthInRange} from '../composables/dateFormat'
defineOptions({name: 'MalioDateMonthPicker'})
defineProps<{selectedMonth?: number}>()
defineProps<{
currentYear: number
selectedMonth?: number
min?: string
max?: string
}>()
const emit = defineEmits<{(e: 'select', month: number): void}>()
@@ -0,0 +1,36 @@
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')
})
})
@@ -0,0 +1,48 @@
<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>