From b07e00100654459f3a39d492e3067d16f91ef66a Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 22 Jun 2026 11:28:20 +0200 Subject: [PATCH] =?UTF-8?q?feat=20:=20en-t=C3=AAte=20=C2=AB=20Mois=20Ann?= =?UTF-8?q?=C3=A9e=20=C2=BB=20constant=20+=20ann=C3=A9e=20courante=20centr?= =?UTF-8?q?=C3=A9e=20(2e=20ligne/2e=20col)=20+=20cycle=20de=20vues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- COMPONENTS.md | 2 +- app/components/malio/date/Date.test.ts | 30 +++++++++++++++---- .../composables/useCalendarPopover.test.ts | 12 ++++---- .../date/composables/useCalendarPopover.ts | 7 +++-- .../date/composables/useCalendarView.test.ts | 4 +-- .../malio/date/composables/useCalendarView.ts | 6 ++-- .../malio/date/internal/CalendarField.vue | 5 ++-- .../malio/date/internal/CalendarHeader.vue | 8 ++--- 8 files changed, 46 insertions(+), 28 deletions(-) diff --git a/COMPONENTS.md b/COMPONENTS.md index cfca55e..1dbe850 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -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). diff --git a/app/components/malio/date/Date.test.ts b/app/components/malio/date/Date.test.ts index e289250..1ea2cb6 100644 --- a/app/components/malio/date/Date.test.ts +++ b/app/components/malio/date/Date.test.ts @@ -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 2022–2033 + await wrapper.get('[data-test="header-next"]').trigger('click') // +12 -> 2034–2045 + 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 () => { diff --git a/app/components/malio/date/composables/useCalendarPopover.test.ts b/app/components/malio/date/composables/useCalendarPopover.test.ts index a44c5c2..e8a5c25 100644 --- a/app/components/malio/date/composables/useCalendarPopover.test.ts +++ b/app/components/malio/date/composables/useCalendarPopover.test.ts @@ -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') diff --git a/app/components/malio/date/composables/useCalendarPopover.ts b/app/components/malio/date/composables/useCalendarPopover.ts index 9715a18..22debad 100644 --- a/app/components/malio/date/composables/useCalendarPopover.ts +++ b/app/components/malio/date/composables/useCalendarPopover.ts @@ -12,10 +12,11 @@ export function useCalendarPopover(rootRef: Ref) { 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) { onMounted(() => document.addEventListener('mousedown', onMouseDown)) onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown)) - return {isOpen, viewMode, open, close, goToHigherView} + return {isOpen, viewMode, open, close, cycleView} } diff --git a/app/components/malio/date/composables/useCalendarView.test.ts b/app/components/malio/date/composables/useCalendarView.test.ts index 36267a1..411a7e7 100644 --- a/app/components/malio/date/composables/useCalendarView.test.ts +++ b/app/components/malio/date/composables/useCalendarView.test.ts @@ -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) }) }) diff --git a/app/components/malio/date/composables/useCalendarView.ts b/app/components/malio/date/composables/useCalendarView.ts index 3bc0794..a600b08 100644 --- a/app/components/malio/date/composables/useCalendarView.ts +++ b/app/components/malio/date/composables/useCalendarView.ts @@ -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 = () => { diff --git a/app/components/malio/date/internal/CalendarField.vue b/app/components/malio/date/internal/CalendarField.vue index 1837c42..a0b00c7 100644 --- a/app/components/malio/date/internal/CalendarField.vue +++ b/app/components/malio/date/internal/CalendarField.vue @@ -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" /> 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}`) diff --git a/app/components/malio/date/internal/CalendarHeader.vue b/app/components/malio/date/internal/CalendarHeader.vue index d931f96..185cd99 100644 --- a/app/components/malio/date/internal/CalendarHeader.vue +++ b/app/components/malio/date/internal/CalendarHeader.vue @@ -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')" > {{ label }} () 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}` })