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:
+1
-1
@@ -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.
|
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).
|
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).
|
||||||
|
|
||||||
|
|||||||
@@ -186,13 +186,29 @@ describe('MalioDate', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('vue années', () => {
|
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()
|
const wrapper = mountDate()
|
||||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
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') // -> mois
|
||||||
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> années
|
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> années
|
||||||
expect(wrapper.find('[data-test="year-picker"]').exists()).toBe(true)
|
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 () => {
|
it('navigates days -> months -> years -> months -> days', async () => {
|
||||||
@@ -209,13 +225,15 @@ describe('MalioDate', () => {
|
|||||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
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()
|
const wrapper = mountDate()
|
||||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
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-toggle"]').trigger('click')
|
await wrapper.get('[data-test="header-toggle"]').trigger('click') // années : fenêtre 2022–2033
|
||||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
await wrapper.get('[data-test="header-next"]').trigger('click') // +12 -> 2034–2045
|
||||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2033 – 2044')
|
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 () => {
|
it('greys out years outside [min, max]', async () => {
|
||||||
|
|||||||
@@ -30,21 +30,21 @@ describe('useCalendarPopover', () => {
|
|||||||
expect(api.viewMode.value).toBe('days')
|
expect(api.viewMode.value).toBe('days')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goToHigherView() climbs days -> months -> years and stops', () => {
|
it('cycleView() cycles days -> months -> years -> days', () => {
|
||||||
const {api} = mountHost()
|
const {api} = mountHost()
|
||||||
api.open()
|
api.open()
|
||||||
api.goToHigherView()
|
api.cycleView()
|
||||||
expect(api.viewMode.value).toBe('months')
|
expect(api.viewMode.value).toBe('months')
|
||||||
api.goToHigherView()
|
api.cycleView()
|
||||||
expect(api.viewMode.value).toBe('years')
|
expect(api.viewMode.value).toBe('years')
|
||||||
api.goToHigherView()
|
api.cycleView()
|
||||||
expect(api.viewMode.value).toBe('years') // no-op au niveau le plus haut
|
expect(api.viewMode.value).toBe('days') // boucle vers le bas depuis 'years'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('close() resets isOpen and viewMode', () => {
|
it('close() resets isOpen and viewMode', () => {
|
||||||
const {api} = mountHost()
|
const {api} = mountHost()
|
||||||
api.open()
|
api.open()
|
||||||
api.goToHigherView()
|
api.cycleView()
|
||||||
api.close()
|
api.close()
|
||||||
expect(api.isOpen.value).toBe(false)
|
expect(api.isOpen.value).toBe(false)
|
||||||
expect(api.viewMode.value).toBe('days')
|
expect(api.viewMode.value).toBe('days')
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
|
|||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
viewMode.value = 'days'
|
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'
|
if (viewMode.value === 'days') viewMode.value = 'months'
|
||||||
else if (viewMode.value === 'months') viewMode.value = 'years'
|
else if (viewMode.value === 'months') viewMode.value = 'years'
|
||||||
// 'years' : niveau le plus haut, no-op
|
else viewMode.value = 'days'
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMouseDown = (event: MouseEvent) => {
|
const onMouseDown = (event: MouseEvent) => {
|
||||||
@@ -26,5 +27,5 @@ export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
|
|||||||
onMounted(() => document.addEventListener('mousedown', onMouseDown))
|
onMounted(() => document.addEventListener('mousedown', onMouseDown))
|
||||||
onBeforeUnmount(() => document.removeEventListener('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)
|
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 mode = ref<'days' | 'months' | 'years'>('days')
|
||||||
const {yearPageStart} = useCalendarView(mode)
|
const {yearPageStart} = useCalendarView(mode)
|
||||||
mode.value = 'years'
|
mode.value = 'years'
|
||||||
await nextTick()
|
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 today = new Date()
|
||||||
const currentMonth = ref(today.getMonth())
|
const currentMonth = ref(today.getMonth())
|
||||||
const currentYear = ref(today.getFullYear())
|
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) => {
|
watch(viewMode, (mode) => {
|
||||||
if (mode === 'years') yearPageStart.value = currentYear.value - 5
|
if (mode === 'years') yearPageStart.value = currentYear.value - 4
|
||||||
})
|
})
|
||||||
|
|
||||||
const goToPrev = () => {
|
const goToPrev = () => {
|
||||||
|
|||||||
@@ -86,10 +86,9 @@
|
|||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
:current-month="currentMonth"
|
:current-month="currentMonth"
|
||||||
:current-year="currentYear"
|
:current-year="currentYear"
|
||||||
:year-page-start="yearPageStart"
|
|
||||||
@prev="goToPrev"
|
@prev="goToPrev"
|
||||||
@next="goToNext"
|
@next="goToNext"
|
||||||
@toggle-view="goToHigherView"
|
@toggle-view="cycleView"
|
||||||
/>
|
/>
|
||||||
<slot
|
<slot
|
||||||
v-if="viewMode === 'days'"
|
v-if="viewMode === 'days'"
|
||||||
@@ -232,7 +231,7 @@ watch(() => props.displayValue, (value) => {
|
|||||||
draft.value = 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 {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso} = useCalendarView(viewMode)
|
||||||
|
|
||||||
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
||||||
|
|||||||
@@ -18,11 +18,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
data-test="header-toggle"
|
data-test="header-toggle"
|
||||||
class="flex gap-1 rounded text-base font-medium"
|
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>
|
<span class="mt-[2px]">{{ label }}</span>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="viewMode !== 'years'"
|
|
||||||
icon="mdi:chevron-down"
|
icon="mdi:chevron-down"
|
||||||
:width="25"
|
:width="25"
|
||||||
:height="25"
|
:height="25"
|
||||||
@@ -55,7 +54,6 @@ const props = defineProps<{
|
|||||||
viewMode: 'days' | 'months' | 'years'
|
viewMode: 'days' | 'months' | 'years'
|
||||||
currentMonth: number
|
currentMonth: number
|
||||||
currentYear: number
|
currentYear: number
|
||||||
yearPageStart: number
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -65,9 +63,9 @@ const emit = defineEmits<{
|
|||||||
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
|
'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 label = computed(() => {
|
||||||
if (props.viewMode === 'years') return `${props.yearPageStart} – ${props.yearPageStart + 11}`
|
|
||||||
if (props.viewMode === 'months') return `${props.currentYear}`
|
|
||||||
const name = monthsLong[props.currentMonth]
|
const name = monthsLong[props.currentMonth]
|
||||||
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
|
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user