feat : en-tête « Mois Année » constant + année courante centrée (2e ligne/2e col) + cycle de vues

- l'en-tête affiche toujours « Mois Année » avec chevron bas dans les 3 vues
- le clic sur l'en-tête cycle jours -> mois -> années -> jours (goToHigherView -> cycleView)
- la grille d'années cale l'année courante en index 4 (yearPageStart = courante - 4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 11:28:20 +02:00
parent 0ec63e774d
commit b07e001006
8 changed files with 46 additions and 28 deletions
+1 -1
View File
@@ -505,7 +505,7 @@ Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
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 pour paginer 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.
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 avec l'année courante centrée en 2ᵉ ligne / 2ᵉ colonne, chevrons pour paginer par pas de 12 ans) → un clic de plus revient aux **jours** (cycle). L'en-tête affiche toujours « Mois Année » avec un chevron bas, quelle que soit la vue. 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.
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La saisie est **bornée par champ** (1er *et* 2e chiffre) : jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`, si bien qu'une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut pas être tapée. Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, dépassement `min`/`max`) restent captées par la validation, en filet de sécurité. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format `JJ/MM/AAAA` en gris et se remplit au fur et à mesure de la saisie (caractères tapés en noir, reste du gabarit en gris).
+24 -6
View File
@@ -186,13 +186,29 @@ describe('MalioDate', () => {
})
describe('vue années', () => {
it('opens the year picker on second header toggle', async () => {
it('opens the year picker on second header toggle, current year centered (2nd row/2nd col)', 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')
// Le libellé reste « Mois Année » dans toutes les vues.
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
// Année courante (2026) en 2e ligne / 2e colonne d'une grille 3 colonnes = index 4.
const years = wrapper.findAll('[data-test="year"]')
expect(years[4].attributes('data-year')).toBe('2026')
expect(years[0].attributes('data-year')).toBe('2022')
})
it('cycles back to day view on third 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
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> jours
expect(wrapper.find('[data-test="year-picker"]').exists()).toBe(false)
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
})
it('navigates days -> months -> years -> months -> days', async () => {
@@ -209,13 +225,15 @@ describe('MalioDate', () => {
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('paginates the year window with chevrons', async () => {
it('paginates the year window by 12 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')
await wrapper.get('[data-test="header-toggle"]').trigger('click') // années : fenêtre 20222033
await wrapper.get('[data-test="header-next"]').trigger('click') // +12 -> 20342045
const years = wrapper.findAll('[data-test="year"]')
expect(years[0].attributes('data-year')).toBe('2034')
expect(years[11].attributes('data-year')).toBe('2045')
})
it('greys out years outside [min, max]', async () => {
@@ -30,21 +30,21 @@ describe('useCalendarPopover', () => {
expect(api.viewMode.value).toBe('days')
})
it('goToHigherView() climbs days -> months -> years and stops', () => {
it('cycleView() cycles days -> months -> years -> days', () => {
const {api} = mountHost()
api.open()
api.goToHigherView()
api.cycleView()
expect(api.viewMode.value).toBe('months')
api.goToHigherView()
api.cycleView()
expect(api.viewMode.value).toBe('years')
api.goToHigherView()
expect(api.viewMode.value).toBe('years') // no-op au niveau le plus haut
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.goToHigherView()
api.cycleView()
api.close()
expect(api.isOpen.value).toBe(false)
expect(api.viewMode.value).toBe('days')
@@ -12,10 +12,11 @@ export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
isOpen.value = false
viewMode.value = 'days'
}
const goToHigherView = () => {
// 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'
// 'years' : niveau le plus haut, no-op
else viewMode.value = 'days'
}
const onMouseDown = (event: MouseEvent) => {
@@ -26,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, goToHigherView}
return {isOpen, viewMode, open, close, cycleView}
}
@@ -81,11 +81,11 @@ describe('useCalendarView', () => {
expect(currentYear.value).toBe(2030)
})
it('recenters the year page on entering years view (current - 5)', async () => {
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(2021) // 2026 - 5
expect(yearPageStart.value).toBe(2022) // 2026 - 4 (année courante en 2e ligne / 2e col)
})
})
@@ -5,10 +5,12 @@ 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)
// 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 - 5
if (mode === 'years') yearPageStart.value = currentYear.value - 4
})
const goToPrev = () => {
@@ -86,10 +86,9 @@
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
:year-page-start="yearPageStart"
@prev="goToPrev"
@next="goToNext"
@toggle-view="goToHigherView"
@toggle-view="cycleView"
/>
<slot
v-if="viewMode === 'days'"
@@ -232,7 +231,7 @@ watch(() => props.displayValue, (value) => {
draft.value = value
})
const {isOpen, viewMode, open, close: closePopover, goToHigherView} = useCalendarPopover(root)
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}`)
@@ -18,11 +18,10 @@
type="button"
data-test="header-toggle"
class="flex gap-1 rounded text-base font-medium"
@click="viewMode !== 'years' && emit('toggle-view')"
@click="emit('toggle-view')"
>
<span class="mt-[2px]">{{ label }}</span>
<Icon
v-if="viewMode !== 'years'"
icon="mdi:chevron-down"
:width="25"
:height="25"
@@ -55,7 +54,6 @@ const props = defineProps<{
viewMode: 'days' | 'months' | 'years'
currentMonth: number
currentYear: number
yearPageStart: number
}>()
const emit = defineEmits<{
@@ -65,9 +63,9 @@ 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(() => {
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}`
})