docs : spec sélecteur d'année dans le calendrier (3e niveau)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 :
|
||||||
|
- `<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.
|
||||||
Reference in New Issue
Block a user