Files
malio-layer-ui/docs/superpowers/specs/2026-06-22-date-year-picker-design.md
T
tristan 41010060ff 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>
2026-06-22 09:28:55 +00:00

7.5 KiB
Raw Blame History

Sélecteur d'année dans le calendrier (3ᵉ niveau de navigation)

Date : 2026-06-22 Statut : Design validé Composants concernés : famille date/ (Date, DateRange, DateTime, DateWeek) via le shell partagé internal/CalendarField.vue

Contexte & objectif

Aujourd'hui le calendrier a deux vues : la grille de jours (days) et le sélecteur de mois (months). En cliquant sur le libellé « Mai 2026 » du header, on bascule entre les deux (toggleView).

On ajoute un 3ᵉ niveau : depuis la vue mois, recliquer sur le header ouvre un sélecteur d'année, visuellement calqué sur le sélecteur de mois. Le tout doit fonctionner pour les 4 composants de la famille sans casser leur API publique.

Flux cible

Vue courante Header affiche Clic header → Clic sur une cellule →
days « Mai 2026 » months (jour) sélection + fermeture
months « 2026 » years days (mois choisi)
years « 2020 2031 » rien (niveau le plus haut) months (année choisie)

Décisions de design (validées)

  1. Grille d'années : 12 ans en grid-cols-3 (4 lignes), calquée sur MonthPicker, avec chevrons de pagination ±12 ans.
  2. Header contextuel : libellé adapté à la vue (voir tableau). Chevron-bas masqué en vue years (clic neutralisé).
  3. min/max respectés : le sélecteur d'année grise les années hors [min, max], ET on corrige le MonthPicker existant pour qu'il grise aussi les mois hors plage (asymétrie actuelle : aujourd'hui tous les mois sont cliquables).
  4. Cadrage de la fenêtre d'années : centrée, yearPageStart = currentYear 5 (fenêtre [courante5 … courante+6]), l'année courante apparaît ~au milieu.

Architecture

Le shell CalendarField orchestre l'input, le popover, le header et la commutation de vues. MonthPicker et le nouveau YearPicker sont rendus dans CalendarField (contrairement à la grille de jours, fournie par chaque consommateur via slot scoped).

1. Machine à états des vues — composables/useCalendarPopover.ts

  • viewMode : 'days' | 'months''days' | 'months' | 'years'.
  • Remplacer toggleView() par goToHigherView() (zoom arrière) : days → months, months → years, years → years (no-op).
  • open() / close() repartent en days (inchangé).

2. Navigation & fenêtre d'années — composables/useCalendarView.ts

  • Élargir le type viewMode à 'days' | 'months' | 'years'.
  • Nouveau ref yearPageStart.
  • watch(viewMode) : à l'entrée en years, recentrer yearPageStart = currentYear 5.
  • goToPrev / goToNext : branche yearsyearPageStart ∓ 12 (months → année ±1, days → mois ±1 : inchangés).
  • Nouveau selectYear(y)currentYear = y.

3. Header — internal/CalendarHeader.vue

  • Props : ajouter yearPageStart: number, élargir viewMode.
  • label calculé :
    • days → « Mai 2026 »
    • months → « 2026 »
    • years`${yearPageStart} ${yearPageStart + 11}`
  • Chevron-bas (mdi:chevron-down) masqué en vue years ; le bouton n'émet toggle-view que si viewMode !== 'years'.
  • aria-labels prev/next adaptés : « Période précédente/suivante » en vue years.

4. Nouveau composant — internal/YearPicker.vue

Calque de MonthPicker :

  • Props : pageStart: number, selectedYear?: number, min?: string, max?: string.
  • years = Array.from({length: 12}, (_, i) => pageStart + i).
  • Pour chaque année : disabled = !isYearInRange(year, min, max)disabled, aria-disabled, style muet (text-m-muted/30, cursor-not-allowed), pas d'émission.
  • Année sélectionnée : bg-m-primary text-white (comme le mois sélectionné).
  • Émet select(year: number).
  • defineOptions({name: 'MalioDateYearPicker'}).
  • Attributs de test : data-test="year-picker", data-test="year", data-year.

5. Correction MonthPickerinternal/MonthPicker.vue

  • Props : ajouter currentYear: number, min?: string, max?: string.
  • Pour chaque mois index : disabled = !isMonthInRange(currentYear, index, min, max) → même traitement disabled que YearPicker.

6. Helpers purs — composables/dateFormat.ts

Comparaison par préfixe ISO (les chaînes ISO se comparent lexicographiquement) :

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
}

7. Câblage — internal/CalendarField.vue + consommateurs

  • CalendarField : ajouter props min?: string, max?: string.
  • Récupérer yearPageStart, selectYear, goToHigherView des composables.
  • Template du popover :
    • <CalendarHeader … :year-page-start="yearPageStart" @toggle-view="goToHigherView" />
    • slot jours v-if="viewMode === 'days'" (inchangé)
    • <MonthPicker v-else-if="viewMode === 'months'" :selected-month :current-year :min :max @select="onSelectMonth" />
    • <YearPicker v-else :page-start="yearPageStart" :selected-year="currentYear" :min :max @select="onSelectYear" />
  • Handlers :
    • onSelectMonth(m)selectMonth(m) puis vue days.
    • onSelectYear(y)selectYear(y) puis vue months.
  • Les 4 consommateurs bindent :min / :max sur <CalendarField> (déjà disponibles comme props ISO ; DateTime tronque via .slice(0, 10)). Aucune API publique ne change.

Effets de bord & cohérence

  • month-change (émis sur [isOpen, currentMonth, currentYear]) : la pagination d'années ne touche pas currentYear → pas d'émission parasite. Sélectionner une année change currentYear → un month-change est émis (comportement attendu : le consommateur peut recharger les données).
  • Pas de navigation clavier dans les grilles (le MonthPicker actuel n'en a pas non plus) — hors scope.
  • Escape ferme le popover quelle que soit la vue (inchangé).

Plan de tests (TDD)

  • dateFormat.test.ts : isMonthInRange, isYearInRange (bornes, sans min/max, hors plage par année et par mois).
  • useCalendarPopover.test.ts : nouveau cycle goToHigherView (days→months→years→years), close() réinitialise à days.
  • useCalendarView.test.ts : nav années yearPageStart ±12, selectYear, recentrage à l'entrée en vue years.
  • MonthPicker.test.ts : mois hors plage grisés / non émis.
  • YearPicker.test.ts (nouveau) : rend 12 années depuis pageStart, année sélectionnée, années hors plage grisées, émet select.
  • Non-régression : Date.test.ts, DateRange.test.ts, DateTime.test.ts, DateWeek.test.ts doivent rester verts ; ajouter au moins un test de bout en bout du flux days → months → years → months → days dans Date.test.ts.
  • Déterminisme : vi.setSystemTime(new Date(2026, 4, 19)) comme l'existant.

Livrables doc (convention projet)

  • Maj manuelle de COMPONENTS.md (documenter le 3ᵉ niveau du calendrier).
  • Entrée CHANGELOG.md.

Non-objectifs (YAGNI)

  • Pas de sélecteur de décennie/siècle (4ᵉ niveau).
  • Pas de saisie clavier directe de l'année dans la grille.
  • Pas de navigation au clavier (flèches) dans les grilles mois/années.