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:
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './dateFormat'
|
||||
import {formatIsoToDisplay, isDateInRange, isMonthInRange, isValidIso, isYearInRange, parseDisplayToIso} from './dateFormat'
|
||||
|
||||
describe('dateFormat', () => {
|
||||
describe('isValidIso', () => {
|
||||
@@ -59,4 +59,36 @@ describe('dateFormat', () => {
|
||||
expect(isDateInRange('2026-05-25', '2026-05-10', '2026-05-20')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,3 +24,16 @@ export function isDateInRange(iso: string, min?: string, max?: string): boolean
|
||||
if (max && iso > max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -30,19 +30,21 @@ describe('useCalendarPopover', () => {
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
})
|
||||
|
||||
it('toggleView() switches between days and months', () => {
|
||||
it('cycleView() cycles days -> months -> years -> days', () => {
|
||||
const {api} = mountHost()
|
||||
api.open()
|
||||
api.toggleView()
|
||||
api.cycleView()
|
||||
expect(api.viewMode.value).toBe('months')
|
||||
api.toggleView()
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
api.cycleView()
|
||||
expect(api.viewMode.value).toBe('years')
|
||||
api.cycleView()
|
||||
expect(api.viewMode.value).toBe('days') // boucle vers le bas depuis 'years'
|
||||
})
|
||||
|
||||
it('close() resets isOpen and viewMode', () => {
|
||||
const {api} = mountHost()
|
||||
api.open()
|
||||
api.toggleView()
|
||||
api.cycleView()
|
||||
api.close()
|
||||
expect(api.isOpen.value).toBe(false)
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 viewMode = ref<'days' | 'months' | 'years'>('days')
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true
|
||||
@@ -12,8 +12,11 @@ export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
|
||||
isOpen.value = false
|
||||
viewMode.value = 'days'
|
||||
}
|
||||
const toggleView = () => {
|
||||
viewMode.value = viewMode.value === 'days' ? 'months' : 'days'
|
||||
// Le clic sur l'en-tête fait un cycle : jours → mois → années → jours.
|
||||
const cycleView = () => {
|
||||
if (viewMode.value === 'days') viewMode.value = 'months'
|
||||
else if (viewMode.value === 'months') viewMode.value = 'years'
|
||||
else viewMode.value = 'days'
|
||||
}
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
@@ -24,5 +27,5 @@ export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
|
||||
onMounted(() => document.addEventListener('mousedown', onMouseDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
|
||||
|
||||
return {isOpen, viewMode, open, close, toggleView}
|
||||
return {isOpen, viewMode, open, close, cycleView}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {ref} from 'vue'
|
||||
import {nextTick, ref} from 'vue'
|
||||
import {useCalendarView} from './useCalendarView'
|
||||
|
||||
describe('useCalendarView', () => {
|
||||
@@ -65,4 +65,27 @@ describe('useCalendarView', () => {
|
||||
expect(currentMonth.value).toBe(4)
|
||||
expect(currentYear.value).toBe(2026)
|
||||
})
|
||||
|
||||
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 - 4)', async () => {
|
||||
const mode = ref<'days' | 'months' | 'years'>('days')
|
||||
const {yearPageStart} = useCalendarView(mode)
|
||||
mode.value = 'years'
|
||||
await nextTick()
|
||||
expect(yearPageStart.value).toBe(2022) // 2026 - 4 (année courante en 2e ligne / 2e col)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import {ref, type Ref} from 'vue'
|
||||
import {ref, watch, type Ref} from 'vue'
|
||||
import {isValidIso} from './dateFormat'
|
||||
|
||||
export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
export function useCalendarView(viewMode: Ref<'days' | 'months' | 'years'>) {
|
||||
const today = new Date()
|
||||
const currentMonth = ref(today.getMonth())
|
||||
const currentYear = ref(today.getFullYear())
|
||||
// Fenêtre de 12 ans calée pour que l'année courante tombe en 2e ligne / 2e
|
||||
// colonne d'une grille 3 colonnes (index 4) → début = année courante − 4.
|
||||
const yearPageStart = ref(today.getFullYear() - 4)
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'years') yearPageStart.value = currentYear.value - 4
|
||||
})
|
||||
|
||||
const goToPrev = () => {
|
||||
if (viewMode.value === 'years') {
|
||||
yearPageStart.value -= 12
|
||||
return
|
||||
}
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value -= 1
|
||||
return
|
||||
@@ -20,6 +31,10 @@ export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (viewMode.value === 'years') {
|
||||
yearPageStart.value += 12
|
||||
return
|
||||
}
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value += 1
|
||||
return
|
||||
@@ -36,6 +51,10 @@ export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
currentMonth.value = m
|
||||
}
|
||||
|
||||
const selectYear = (y: number) => {
|
||||
currentYear.value = y
|
||||
}
|
||||
|
||||
const syncToIso = (iso: string | null) => {
|
||||
if (iso && isValidIso(iso)) {
|
||||
currentMonth.value = Number(iso.slice(5, 7)) - 1
|
||||
@@ -47,5 +66,5 @@ export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
}
|
||||
}
|
||||
|
||||
return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso}
|
||||
return {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user