From 28705c8285568d0c1584d7f3b3a1191adce0cbe2 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 22 Jun 2026 09:29:59 +0000 Subject: [PATCH] fix: date year picker (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié --------- Co-authored-by: admin malio Co-authored-by: THOLOT DECHENE Matthieu Co-authored-by: matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/84 Co-authored-by: tristan Co-committed-by: tristan --- CHANGELOG.md | 1 + COMPONENTS.md | 2 + app/components/malio/date/Date.test.ts | 61 ++ app/components/malio/date/Date.vue | 2 + app/components/malio/date/DateRange.vue | 2 + app/components/malio/date/DateTime.vue | 2 + app/components/malio/date/DateWeek.vue | 2 + .../malio/date/composables/dateFormat.test.ts | 34 +- .../malio/date/composables/dateFormat.ts | 13 + .../composables/useCalendarPopover.test.ts | 12 +- .../date/composables/useCalendarPopover.ts | 11 +- .../date/composables/useCalendarView.test.ts | 25 +- .../malio/date/composables/useCalendarView.ts | 25 +- .../malio/date/internal/CalendarField.vue | 31 +- .../malio/date/internal/CalendarHeader.vue | 19 +- .../malio/date/internal/MonthPicker.test.ts | 33 + .../malio/date/internal/MonthPicker.vue | 16 +- .../malio/date/internal/YearPicker.test.ts | 36 + .../malio/date/internal/YearPicker.vue | 48 + .../plans/2026-06-22-date-year-picker.md | 934 ++++++++++++++++++ .../2026-06-22-date-year-picker-design.md | 158 +++ 21 files changed, 1443 insertions(+), 24 deletions(-) create mode 100644 app/components/malio/date/internal/MonthPicker.test.ts create mode 100644 app/components/malio/date/internal/YearPicker.test.ts create mode 100644 app/components/malio/date/internal/YearPicker.vue create mode 100644 docs/superpowers/plans/2026-06-22-date-year-picker.md create mode 100644 docs/superpowers/specs/2026-06-22-date-year-picker-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9cb34..9a115ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-43] CalendarField : la croix d'effacement réinitialise désormais la saisie clavier même après une date invalide (le `v-model` restant `null`, le champ se vidait pas). * [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`. * [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée). +* Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau de navigation — jours → mois → années) et grisage des mois et années hors `min`/`max`. ### Changed * Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.) diff --git a/COMPONENTS.md b/COMPONENTS.md index c453986..1dbe850 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -505,6 +505,8 @@ 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 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). L'event `update:valid` remonte l'état de validité de la saisie au parent (`true` = vide ou date valide dans les bornes ; `false` = saisie malformée ou hors `min`/`max`). Il est émis **dès le montage** (état d'un champ pré-rempli connu sans interaction) puis à chaque transition. Il permet d'agréger la validité des champs date dans la gate de submit d'un formulaire — une saisie invalide n'émettant pas `modelValue`, c'est le seul signal disponible côté parent. La validité ne couvre **pas** l'obligation `required` (un champ vide reste valide), qui reste à la charge du parent. diff --git a/app/components/malio/date/Date.test.ts b/app/components/malio/date/Date.test.ts index 0ada59b..1ea2cb6 100644 --- a/app/components/malio/date/Date.test.ts +++ b/app/components/malio/date/Date.test.ts @@ -185,6 +185,67 @@ describe('MalioDate', () => { }) }) + describe('vue années', () => { + 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) + // 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 () => { + 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="year"][data-year="2024"]').trigger('click') // -> mois 2024 + expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true) + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2024') + await wrapper.get('[data-test="month"][data-month="0"]').trigger('click') // -> jours + expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false) + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2024') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + 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') // 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 () => { + const wrapper = mountDate({min: '2025-01-01', max: '2027-12-31'}) + 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') + expect(wrapper.get('[data-test="year"][data-year="2024"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-test="year"][data-year="2026"]').attributes('disabled')).toBeUndefined() + }) + }) + describe('vue mois', () => { it('switches to month view on header toggle', async () => { const wrapper = mountDate() diff --git a/app/components/malio/date/Date.vue b/app/components/malio/date/Date.vue index d131b04..6c8bf0f 100644 --- a/app/components/malio/date/Date.vue +++ b/app/components/malio/date/Date.vue @@ -9,6 +9,8 @@ :required="required" :disabled="disabled" :readonly="readonly" + :min="min" + :max="max" :hint="hint" :error="mergedError" :success="success" diff --git a/app/components/malio/date/DateRange.vue b/app/components/malio/date/DateRange.vue index ecd488a..e50a38c 100644 --- a/app/components/malio/date/DateRange.vue +++ b/app/components/malio/date/DateRange.vue @@ -9,6 +9,8 @@ :required="required" :disabled="disabled" :readonly="readonly" + :min="min" + :max="max" :hint="hint" :error="error" :success="success" diff --git a/app/components/malio/date/DateTime.vue b/app/components/malio/date/DateTime.vue index 57ae540..bccd21b 100644 --- a/app/components/malio/date/DateTime.vue +++ b/app/components/malio/date/DateTime.vue @@ -9,6 +9,8 @@ :required="required" :disabled="disabled" :readonly="readonly" + :min="min?.slice(0, 10)" + :max="max?.slice(0, 10)" :hint="hint" :error="mergedError" :success="success" diff --git a/app/components/malio/date/DateWeek.vue b/app/components/malio/date/DateWeek.vue index 4230e08..b8dae46 100644 --- a/app/components/malio/date/DateWeek.vue +++ b/app/components/malio/date/DateWeek.vue @@ -9,6 +9,8 @@ :required="required" :disabled="disabled" :readonly="readonly" + :min="min" + :max="max" :hint="hint" :error="error" :success="success" diff --git a/app/components/malio/date/composables/dateFormat.test.ts b/app/components/malio/date/composables/dateFormat.test.ts index 744c0d3..15691d1 100644 --- a/app/components/malio/date/composables/dateFormat.test.ts +++ b/app/components/malio/date/composables/dateFormat.test.ts @@ -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) + }) + }) }) diff --git a/app/components/malio/date/composables/dateFormat.ts b/app/components/malio/date/composables/dateFormat.ts index fc0bf1d..6475308 100644 --- a/app/components/malio/date/composables/dateFormat.ts +++ b/app/components/malio/date/composables/dateFormat.ts @@ -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 +} diff --git a/app/components/malio/date/composables/useCalendarPopover.test.ts b/app/components/malio/date/composables/useCalendarPopover.test.ts index 206b5f4..e8a5c25 100644 --- a/app/components/malio/date/composables/useCalendarPopover.test.ts +++ b/app/components/malio/date/composables/useCalendarPopover.test.ts @@ -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') diff --git a/app/components/malio/date/composables/useCalendarPopover.ts b/app/components/malio/date/composables/useCalendarPopover.ts index db179cc..22debad 100644 --- a/app/components/malio/date/composables/useCalendarPopover.ts +++ b/app/components/malio/date/composables/useCalendarPopover.ts @@ -2,7 +2,7 @@ import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue' export function useCalendarPopover(rootRef: Ref) { 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) { 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) { onMounted(() => document.addEventListener('mousedown', onMouseDown)) onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown)) - return {isOpen, viewMode, open, close, toggleView} + 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 1aa0d99..411a7e7 100644 --- a/app/components/malio/date/composables/useCalendarView.test.ts +++ b/app/components/malio/date/composables/useCalendarView.test.ts @@ -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) + }) }) diff --git a/app/components/malio/date/composables/useCalendarView.ts b/app/components/malio/date/composables/useCalendarView.ts index f0097b4..a600b08 100644 --- a/app/components/malio/date/composables/useCalendarView.ts +++ b/app/components/malio/date/composables/useCalendarView.ts @@ -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} } diff --git a/app/components/malio/date/internal/CalendarField.vue b/app/components/malio/date/internal/CalendarField.vue index 4d8af60..a0b00c7 100644 --- a/app/components/malio/date/internal/CalendarField.vue +++ b/app/components/malio/date/internal/CalendarField.vue @@ -88,7 +88,7 @@ :current-year="currentYear" @prev="goToPrev" @next="goToNext" - @toggle-view="toggleView" + @toggle-view="cycleView" /> + @@ -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(() => diff --git a/app/components/malio/date/internal/CalendarHeader.vue b/app/components/malio/date/internal/CalendarHeader.vue index be9109e..185cd99 100644 --- a/app/components/malio/date/internal/CalendarHeader.vue +++ b/app/components/malio/date/internal/CalendarHeader.vue @@ -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')" > () @@ -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', +) diff --git a/app/components/malio/date/internal/MonthPicker.test.ts b/app/components/malio/date/internal/MonthPicker.test.ts new file mode 100644 index 0000000..be9379c --- /dev/null +++ b/app/components/malio/date/internal/MonthPicker.test.ts @@ -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() + }) +}) diff --git a/app/components/malio/date/internal/MonthPicker.vue b/app/components/malio/date/internal/MonthPicker.vue index 2b525a1..ab16781 100644 --- a/app/components/malio/date/internal/MonthPicker.vue +++ b/app/components/malio/date/internal/MonthPicker.vue @@ -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)" > {{ name }} @@ -25,9 +30,16 @@ diff --git a/docs/superpowers/plans/2026-06-22-date-year-picker.md b/docs/superpowers/plans/2026-06-22-date-year-picker.md new file mode 100644 index 0000000..06f38bf --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-date-year-picker.md @@ -0,0 +1,934 @@ +# Sélecteur d'année dans le calendrier — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ajouter un 3ᵉ niveau de navigation au calendrier de la famille `date/` : depuis la vue mois, recliquer sur le header ouvre un sélecteur d'année calqué sur le sélecteur de mois, avec respect des bornes `min`/`max`. + +**Architecture:** Le shell partagé `internal/CalendarField.vue` orchestre l'input, le popover, le header (`CalendarHeader`) et la commutation entre les vues `days` / `months` / `years`. `MonthPicker` et le nouveau `YearPicker` sont rendus dans `CalendarField` ; la grille de jours reste fournie par chaque consommateur via slot scoped. La logique d'état vit dans deux composables (`useCalendarPopover`, `useCalendarView`) et les bornes dans des helpers purs (`dateFormat.ts`). + +**Tech Stack:** Nuxt 4 layer, Vue 3 ` +``` + +> Note : `@click` émet toujours, mais un `