From a5328de11360aa42792a4f060af72d4e094ab5e8 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 22 Jun 2026 09:46:07 +0200 Subject: [PATCH] =?UTF-8?q?docs=20:=20spec=20s=C3=A9lecteur=20d'ann=C3=A9e?= =?UTF-8?q?=20dans=20le=20calendrier=20(3e=20niveau)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-22-date-year-picker-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-date-year-picker-design.md diff --git a/docs/superpowers/specs/2026-06-22-date-year-picker-design.md b/docs/superpowers/specs/2026-06-22-date-year-picker-design.md new file mode 100644 index 0000000..9afdb74 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-date-year-picker-design.md @@ -0,0 +1,158 @@ +# 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 `[courante−5 … 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 `years` → `yearPageStart ∓ 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 `MonthPicker` — `internal/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) : + +```ts +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 : + - `` + - slot jours `v-if="viewMode === 'days'"` (inchangé) + - `` + - `` +- Handlers : + - `onSelectMonth(m)` → `selectMonth(m)` puis vue `days`. + - `onSelectYear(y)` → `selectYear(y)` puis vue `months`. +- Les 4 consommateurs bindent `:min` / `:max` sur `` (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.