Files
malio-layer-ui/docs/superpowers/specs/2026-06-22-date-year-picker-design.md
T
2026-06-22 09:46:07 +02:00

159 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `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.