docs : plan d'implémentation sélecteur d'année (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<script setup lang="ts">`, TypeScript strict, Tailwind (palette `m-*`), `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Composants : `defineOptions({name: 'MalioXxx'})` (les internes utilisent `MalioDateXxx`, sans `inheritAttrs: false` pour les pickers — calquer l'existant `MonthPicker`).
|
||||||
|
- Valeurs ISO : `min`/`max` sont des chaînes `YYYY-MM-DD` (`DateTime` les tronque via `.slice(0, 10)`).
|
||||||
|
- Tests : Vitest run mode, fichiers colocalisés `*.test.ts`, jsdom. Déterminisme via `vi.setSystemTime(new Date(2026, 4, 19))` (19 mai 2026).
|
||||||
|
- Lancer un test ciblé : `npx vitest run <chemin>`.
|
||||||
|
- Commits : Conventional Commits **avec espace avant `:`** (`feat : message (#…)`), type minuscule, **pas de scope majuscule** (préférer sans scope). Terminer par `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
|
||||||
|
- Hook pre-commit flaky (timeouts WSL2) : après 2 échecs flaky, vérifier le test ciblé à la main puis committer avec `--no-verify`.
|
||||||
|
- Ne PAS toucher / committer `nuxt.config.ts` (modif ngrok locale intentionnelle). Stager des fichiers explicites, jamais `git add -A`.
|
||||||
|
- Aucune API publique des 4 composants consommateurs ne change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Helpers de plage mois/année (`dateFormat.ts`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/date/composables/dateFormat.ts`
|
||||||
|
- Test: `app/components/malio/date/composables/dateFormat.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: rien.
|
||||||
|
- Produces:
|
||||||
|
- `isMonthInRange(year: number, month: number, min?: string, max?: string): boolean` (month 0-11)
|
||||||
|
- `isYearInRange(year: number, min?: string, max?: string): boolean`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Ajouter dans `dateFormat.test.ts` — l'import en tête devient :
|
||||||
|
```ts
|
||||||
|
import {formatIsoToDisplay, isDateInRange, isMonthInRange, isValidIso, isYearInRange, parseDisplayToIso} from './dateFormat'
|
||||||
|
```
|
||||||
|
Puis ajouter ces deux blocs `describe` à l'intérieur du `describe('dateFormat', …)` (après le bloc `isDateInRange`) :
|
||||||
|
```ts
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/composables/dateFormat.test.ts`
|
||||||
|
Expected: FAIL — `isMonthInRange is not a function` / `isYearInRange is not a function`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Ajouter à la fin de `dateFormat.ts` :
|
||||||
|
```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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/composables/dateFormat.test.ts`
|
||||||
|
Expected: PASS (tous les blocs, anciens + nouveaux).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/date/composables/dateFormat.ts app/components/malio/date/composables/dateFormat.test.ts
|
||||||
|
git commit -m "feat : helpers isMonthInRange/isYearInRange (#date-year-picker)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Machine à états 3 vues (`useCalendarPopover.ts`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/date/composables/useCalendarPopover.ts`
|
||||||
|
- Test: `app/components/malio/date/composables/useCalendarPopover.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: rien.
|
||||||
|
- Produces (retour du composable) : `{isOpen, viewMode, open, close, goToHigherView}` où `viewMode: Ref<'days' | 'months' | 'years'>` et `goToHigherView(): void`. **Remplace `toggleView`.**
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the failing test**
|
||||||
|
|
||||||
|
Dans `useCalendarPopover.test.ts`, remplacer le test `toggleView() switches between days and months` (lignes ~33-40) par :
|
||||||
|
```ts
|
||||||
|
it('goToHigherView() climbs days -> months -> years and stops', () => {
|
||||||
|
const {api} = mountHost()
|
||||||
|
api.open()
|
||||||
|
api.goToHigherView()
|
||||||
|
expect(api.viewMode.value).toBe('months')
|
||||||
|
api.goToHigherView()
|
||||||
|
expect(api.viewMode.value).toBe('years')
|
||||||
|
api.goToHigherView()
|
||||||
|
expect(api.viewMode.value).toBe('years') // no-op au niveau le plus haut
|
||||||
|
})
|
||||||
|
```
|
||||||
|
Et dans le test `close() resets isOpen and viewMode` (lignes ~42-49), remplacer `api.toggleView()` par `api.goToHigherView()`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/composables/useCalendarPopover.test.ts`
|
||||||
|
Expected: FAIL — `api.goToHigherView is not a function`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Dans `useCalendarPopover.ts`, remplacer la ligne du ref et la fonction `toggleView` :
|
||||||
|
```ts
|
||||||
|
const viewMode = ref<'days' | 'months' | 'years'>('days')
|
||||||
|
```
|
||||||
|
```ts
|
||||||
|
const goToHigherView = () => {
|
||||||
|
if (viewMode.value === 'days') viewMode.value = 'months'
|
||||||
|
else if (viewMode.value === 'months') viewMode.value = 'years'
|
||||||
|
// 'years' : niveau le plus haut, no-op
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Et le `return` :
|
||||||
|
```ts
|
||||||
|
return {isOpen, viewMode, open, close, goToHigherView}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/composables/useCalendarPopover.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/date/composables/useCalendarPopover.ts app/components/malio/date/composables/useCalendarPopover.test.ts
|
||||||
|
git commit -m "feat : viewMode 3 niveaux + goToHigherView (#date-year-picker)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Navigation & fenêtre d'années (`useCalendarView.ts`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/date/composables/useCalendarView.ts`
|
||||||
|
- Test: `app/components/malio/date/composables/useCalendarView.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `viewMode: Ref<'days' | 'months' | 'years'>` (depuis Task 2).
|
||||||
|
- Produces (retour) : `{currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}` où `yearPageStart: Ref<number>` et `selectYear(y: number): void`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Dans `useCalendarView.test.ts` : ajouter l'import de `nextTick` en tête :
|
||||||
|
```ts
|
||||||
|
import {nextTick, ref} from 'vue'
|
||||||
|
```
|
||||||
|
Puis ajouter ces tests dans le `describe('useCalendarView', …)` :
|
||||||
|
```ts
|
||||||
|
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 - 5)', async () => {
|
||||||
|
const mode = ref<'days' | 'months' | 'years'>('days')
|
||||||
|
const {yearPageStart} = useCalendarView(mode)
|
||||||
|
mode.value = 'years'
|
||||||
|
await nextTick()
|
||||||
|
expect(yearPageStart.value).toBe(2021) // 2026 - 5
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/composables/useCalendarView.test.ts`
|
||||||
|
Expected: FAIL — `yearPageStart` / `selectYear` undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Dans `useCalendarView.ts` :
|
||||||
|
|
||||||
|
Élargir la signature et importer `watch` :
|
||||||
|
```ts
|
||||||
|
import {ref, watch, type Ref} from 'vue'
|
||||||
|
import {isValidIso} from './dateFormat'
|
||||||
|
|
||||||
|
export function useCalendarView(viewMode: Ref<'days' | 'months' | 'years'>) {
|
||||||
|
const today = new Date()
|
||||||
|
const currentMonth = ref(today.getMonth())
|
||||||
|
const currentYear = ref(today.getFullYear())
|
||||||
|
const yearPageStart = ref(today.getFullYear() - 5)
|
||||||
|
|
||||||
|
watch(viewMode, (mode) => {
|
||||||
|
if (mode === 'years') yearPageStart.value = currentYear.value - 5
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `goToPrev`, ajouter la branche `years` en tête :
|
||||||
|
```ts
|
||||||
|
const goToPrev = () => {
|
||||||
|
if (viewMode.value === 'years') {
|
||||||
|
yearPageStart.value -= 12
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (viewMode.value === 'months') {
|
||||||
|
currentYear.value -= 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentMonth.value === 0) {
|
||||||
|
currentMonth.value = 11
|
||||||
|
currentYear.value -= 1
|
||||||
|
} else {
|
||||||
|
currentMonth.value -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `goToNext`, idem :
|
||||||
|
```ts
|
||||||
|
const goToNext = () => {
|
||||||
|
if (viewMode.value === 'years') {
|
||||||
|
yearPageStart.value += 12
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (viewMode.value === 'months') {
|
||||||
|
currentYear.value += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentMonth.value === 11) {
|
||||||
|
currentMonth.value = 0
|
||||||
|
currentYear.value += 1
|
||||||
|
} else {
|
||||||
|
currentMonth.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter `selectYear` après `selectMonth` :
|
||||||
|
```ts
|
||||||
|
const selectYear = (y: number) => {
|
||||||
|
currentYear.value = y
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mettre à jour le `return` :
|
||||||
|
```ts
|
||||||
|
return {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/composables/useCalendarView.test.ts`
|
||||||
|
Expected: PASS (anciens + nouveaux).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/date/composables/useCalendarView.ts app/components/malio/date/composables/useCalendarView.test.ts
|
||||||
|
git commit -m "feat : pagination des années + selectYear + recentrage (#date-year-picker)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Header contextuel (`CalendarHeader.vue`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/date/internal/CalendarHeader.vue`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `viewMode`, `currentMonth`, `currentYear`, `yearPageStart` (props).
|
||||||
|
- Produces: émet `prev` / `next` / `toggle-view` (inchangé). En vue `years`, le bouton libellé n'émet pas `toggle-view` et ne montre pas le chevron-bas.
|
||||||
|
|
||||||
|
> Pas de test colocalisé pour `CalendarHeader` (l'existant n'en a pas) — vérifié via l'e2e en Task 8. Ce Task est purement implémentation ; pas de cycle test propre, donc on le commit avec son cycle de vérification = build/lint + e2e en Task 8. Le step de vérif ci-dessous est un contrôle visuel du diff.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement the contextual label and years behavior**
|
||||||
|
|
||||||
|
Remplacer le `<template>` par :
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-test="header-prev"
|
||||||
|
class="ml-2 flex self-start rounded"
|
||||||
|
:aria-label="prevLabel"
|
||||||
|
@click="emit('prev')"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="mdi:chevron-left"
|
||||||
|
:width="25"
|
||||||
|
:height="25"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-test="header-toggle"
|
||||||
|
class="flex gap-1 rounded text-base font-medium"
|
||||||
|
@click="viewMode !== 'years' && emit('toggle-view')"
|
||||||
|
>
|
||||||
|
<span class="mt-[2px]">{{ label }}</span>
|
||||||
|
<Icon
|
||||||
|
v-if="viewMode !== 'years'"
|
||||||
|
icon="mdi:chevron-down"
|
||||||
|
:width="25"
|
||||||
|
:height="25"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-test="header-next"
|
||||||
|
class="mr-2 flex self-start rounded"
|
||||||
|
:aria-label="nextLabel"
|
||||||
|
@click="emit('next')"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="mdi:chevron-right"
|
||||||
|
:width="25"
|
||||||
|
:height="25"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans le `<script setup>` : élargir le type `viewMode`, ajouter `yearPageStart`, et calculer `label` + `prevLabel` + `nextLabel` :
|
||||||
|
```ts
|
||||||
|
const props = defineProps<{
|
||||||
|
viewMode: 'days' | 'months' | 'years'
|
||||||
|
currentMonth: number
|
||||||
|
currentYear: number
|
||||||
|
yearPageStart: number
|
||||||
|
}>()
|
||||||
|
```
|
||||||
|
```ts
|
||||||
|
const label = computed(() => {
|
||||||
|
if (props.viewMode === 'years') return `${props.yearPageStart} – ${props.yearPageStart + 11}`
|
||||||
|
if (props.viewMode === 'months') return `${props.currentYear}`
|
||||||
|
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',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
(Supprimer les anciens `:aria-label` ternaires inline du template — remplacés par `prevLabel`/`nextLabel`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lint the file**
|
||||||
|
|
||||||
|
Run: `npx eslint app/components/malio/date/internal/CalendarHeader.vue`
|
||||||
|
Expected: aucune erreur.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/date/internal/CalendarHeader.vue
|
||||||
|
git commit -m "feat : header contextuel jours/mois/années (#date-year-picker)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Composant `YearPicker.vue` + tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/components/malio/date/internal/YearPicker.vue`
|
||||||
|
- Test: `app/components/malio/date/internal/YearPicker.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `isYearInRange` (Task 1).
|
||||||
|
- Produces: composant `<YearPicker :page-start :selected-year? :min? :max? @select="(year:number)=>…" />`. Rend 12 boutons `data-test="year"` avec `data-year`. Conteneur `data-test="year-picker"`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Créer `app/components/malio/date/internal/YearPicker.test.ts` :
|
||||||
|
```ts
|
||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import YearPicker from './YearPicker.vue'
|
||||||
|
|
||||||
|
const mountPicker = (props: {pageStart: number, selectedYear?: number, min?: string, max?: string}) =>
|
||||||
|
mount(YearPicker, {props})
|
||||||
|
|
||||||
|
describe('MalioDateYearPicker', () => {
|
||||||
|
it('renders 12 years from pageStart', () => {
|
||||||
|
const wrapper = mountPicker({pageStart: 2021})
|
||||||
|
const years = wrapper.findAll('[data-test="year"]')
|
||||||
|
expect(years).toHaveLength(12)
|
||||||
|
expect(years[0].attributes('data-year')).toBe('2021')
|
||||||
|
expect(years[11].attributes('data-year')).toBe('2032')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits select with the clicked year', async () => {
|
||||||
|
const wrapper = mountPicker({pageStart: 2021})
|
||||||
|
await wrapper.get('[data-test="year"][data-year="2026"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('select')?.[0]).toEqual([2026])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables years outside [min, max] and does not emit', async () => {
|
||||||
|
const wrapper = mountPicker({pageStart: 2021, min: '2025-01-01', max: '2027-12-31'})
|
||||||
|
const out = wrapper.get('[data-test="year"][data-year="2024"]')
|
||||||
|
expect(out.attributes('disabled')).toBeDefined()
|
||||||
|
await out.trigger('click')
|
||||||
|
expect(wrapper.emitted('select')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights the selected year', () => {
|
||||||
|
const wrapper = mountPicker({pageStart: 2021, selectedYear: 2026})
|
||||||
|
const span = wrapper.get('[data-test="year"][data-year="2026"] span')
|
||||||
|
expect(span.classes()).toContain('bg-m-primary')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/internal/YearPicker.test.ts`
|
||||||
|
Expected: FAIL — impossible de résoudre `./YearPicker.vue`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the component**
|
||||||
|
|
||||||
|
Créer `app/components/malio/date/internal/YearPicker.vue` :
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-test="year-picker"
|
||||||
|
class="grid grid-cols-3 gap-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="year in years"
|
||||||
|
:key="year"
|
||||||
|
type="button"
|
||||||
|
data-test="year"
|
||||||
|
:data-year="year"
|
||||||
|
:disabled="!isYearInRange(year, min, max)"
|
||||||
|
:aria-disabled="!isYearInRange(year, min, max)"
|
||||||
|
class="flex h-[45px] w-full items-center justify-center"
|
||||||
|
:class="isYearInRange(year, min, max) ? 'cursor-pointer' : 'cursor-not-allowed'"
|
||||||
|
@click="emit('select', year)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
|
||||||
|
:class="year === selectedYear
|
||||||
|
? 'bg-m-primary text-white'
|
||||||
|
: isYearInRange(year, min, max)
|
||||||
|
? 'text-black hover:bg-m-primary/10'
|
||||||
|
: 'text-m-muted/30'"
|
||||||
|
>
|
||||||
|
{{ year }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed} from 'vue'
|
||||||
|
import {isYearInRange} from '../composables/dateFormat'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioDateYearPicker'})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
pageStart: number
|
||||||
|
selectedYear?: number
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'select', year: number): void}>()
|
||||||
|
|
||||||
|
const years = computed(() => Array.from({length: 12}, (_, i) => props.pageStart + i))
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note : `@click` émet toujours, mais un `<button disabled>` ne déclenche pas l'événement clic dans jsdom/navigateur — le test « does not emit » le valide.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/internal/YearPicker.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/date/internal/YearPicker.vue app/components/malio/date/internal/YearPicker.test.ts
|
||||||
|
git commit -m "feat : composant YearPicker avec bornage min/max (#date-year-picker)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Bornage min/max du `MonthPicker` + tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/date/internal/MonthPicker.vue`
|
||||||
|
- Test (create): `app/components/malio/date/internal/MonthPicker.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `isMonthInRange` (Task 1).
|
||||||
|
- Produces: `<MonthPicker :selected-month? :current-year :min? :max? @select="(month:number)=>…" />`. `currentYear` devient **requis** (nécessaire pour borner les mois).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Créer `app/components/malio/date/internal/MonthPicker.test.ts` :
|
||||||
|
```ts
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/internal/MonthPicker.test.ts`
|
||||||
|
Expected: FAIL — le mois avril n'est pas désactivé (`disabled` undefined).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the component**
|
||||||
|
|
||||||
|
Remplacer `MonthPicker.vue` par :
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-test="month-picker"
|
||||||
|
class="grid grid-cols-3 gap-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(name, index) in months"
|
||||||
|
:key="name"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
|
||||||
|
:class="index === selectedMonth
|
||||||
|
? 'bg-m-primary text-white'
|
||||||
|
: isMonthInRange(currentYear, index, min, max)
|
||||||
|
? 'text-black hover:bg-m-primary/10'
|
||||||
|
: 'text-m-muted/30'"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {isMonthInRange} from '../composables/dateFormat'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioDateMonthPicker'})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
currentYear: number
|
||||||
|
selectedMonth?: number
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'select', month: number): void}>()
|
||||||
|
|
||||||
|
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||||
|
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/internal/MonthPicker.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/date/internal/MonthPicker.vue app/components/malio/date/internal/MonthPicker.test.ts
|
||||||
|
git commit -m "feat : bornage min/max du MonthPicker (#date-year-picker)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Câblage `CalendarField.vue`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/date/internal/CalendarField.vue`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `useCalendarPopover` (`goToHigherView`), `useCalendarView` (`yearPageStart`, `selectYear`), `CalendarHeader` (prop `yearPageStart`), `MonthPicker` (props `currentYear`/`min`/`max`), `YearPicker` (Task 5).
|
||||||
|
- Produces: `CalendarField` accepte les props `min?: string` et `max?: string` ; rend les 3 vues.
|
||||||
|
|
||||||
|
> Vérifié via l'e2e en Task 8 (pas de test colocalisé propre à `CalendarField`). Steps = implémentation + lint, le cycle test rouge/vert vit en Task 8.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add min/max props**
|
||||||
|
|
||||||
|
Dans le bloc `defineProps`, ajouter `min` et `max` (après `reserveMessageSpace?: boolean`) :
|
||||||
|
```ts
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
```
|
||||||
|
Et dans `withDefaults({...})`, après `reserveMessageSpace: true,` :
|
||||||
|
```ts
|
||||||
|
reserveMessageSpace: true,
|
||||||
|
min: undefined,
|
||||||
|
max: undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Import YearPicker and update composable destructuring**
|
||||||
|
|
||||||
|
Après `import MonthPicker from './MonthPicker.vue'` :
|
||||||
|
```ts
|
||||||
|
import YearPicker from './YearPicker.vue'
|
||||||
|
```
|
||||||
|
Remplacer les deux lignes de destructuration (≈218-219) :
|
||||||
|
```ts
|
||||||
|
const {isOpen, viewMode, open, close: closePopover, goToHigherView} = useCalendarPopover(root)
|
||||||
|
const {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso} = useCalendarView(viewMode)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the popover template**
|
||||||
|
|
||||||
|
Remplacer le bloc `<CalendarHeader>` + `<slot>` + `<MonthPicker>` (≈85-103) par :
|
||||||
|
```vue
|
||||||
|
<CalendarHeader
|
||||||
|
:view-mode="viewMode"
|
||||||
|
:current-month="currentMonth"
|
||||||
|
:current-year="currentYear"
|
||||||
|
:year-page-start="yearPageStart"
|
||||||
|
@prev="goToPrev"
|
||||||
|
@next="goToNext"
|
||||||
|
@toggle-view="goToHigherView"
|
||||||
|
/>
|
||||||
|
<slot
|
||||||
|
v-if="viewMode === 'days'"
|
||||||
|
:current-month="currentMonth"
|
||||||
|
:current-year="currentYear"
|
||||||
|
:close="closePopover"
|
||||||
|
/>
|
||||||
|
<MonthPicker
|
||||||
|
v-else-if="viewMode === 'months'"
|
||||||
|
:selected-month="currentMonth"
|
||||||
|
:current-year="currentYear"
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
@select="onSelectMonth"
|
||||||
|
/>
|
||||||
|
<YearPicker
|
||||||
|
v-else
|
||||||
|
:page-start="yearPageStart"
|
||||||
|
:selected-year="currentYear"
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
@select="onSelectYear"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update handlers**
|
||||||
|
|
||||||
|
`goToHigherView` ne sert qu'au header (zoom arrière). À la **sélection**, on redescend d'un niveau. `useCalendarPopover` n'expose pas de setter de vue, mais `viewMode` est un `ref` déstructuré et donc directement mutable. Remplacer `onSelectMonth` (≈324-327) par les deux handlers suivants :
|
||||||
|
```ts
|
||||||
|
const onSelectMonth = (m: number) => {
|
||||||
|
selectMonth(m)
|
||||||
|
viewMode.value = 'days'
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectYear = (y: number) => {
|
||||||
|
selectYear(y)
|
||||||
|
viewMode.value = 'months'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Lint**
|
||||||
|
|
||||||
|
Run: `npx eslint app/components/malio/date/internal/CalendarField.vue`
|
||||||
|
Expected: aucune erreur (notamment, `viewMode` est bien mutable car c'est un `ref`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/date/internal/CalendarField.vue
|
||||||
|
git commit -m "feat : intégration du sélecteur d'année dans CalendarField (#date-year-picker)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Binder min/max chez les consommateurs + e2e
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/date/Date.vue`
|
||||||
|
- Modify: `app/components/malio/date/DateRange.vue`
|
||||||
|
- Modify: `app/components/malio/date/DateTime.vue`
|
||||||
|
- Modify: `app/components/malio/date/DateWeek.vue`
|
||||||
|
- Test: `app/components/malio/date/Date.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `CalendarField` props `min`/`max` (Task 7).
|
||||||
|
- Produces: les 4 composants propagent leurs bornes au popover. API publique inchangée.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing e2e test**
|
||||||
|
|
||||||
|
Dans `Date.test.ts`, à l'intérieur du `describe('vue mois', …)` (ou un nouveau `describe('vue années')`), ajouter :
|
||||||
|
```ts
|
||||||
|
describe('vue années', () => {
|
||||||
|
it('opens the year picker on second 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
|
||||||
|
expect(wrapper.find('[data-test="year-picker"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2021 – 2032')
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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')
|
||||||
|
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2033 – 2044')
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/Date.test.ts`
|
||||||
|
Expected: FAIL — `year-picker` introuvable (Date.vue ne passe pas encore `min`/`max` à `CalendarField`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Bind min/max on each consumer's `<CalendarField>`**
|
||||||
|
|
||||||
|
Dans `Date.vue`, `DateRange.vue`, `DateWeek.vue` : ajouter sur la balise racine `<CalendarField …>` (au même niveau que les autres props, ex. après `:readonly="readonly"`) :
|
||||||
|
```vue
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
```
|
||||||
|
Dans `DateTime.vue` : ajouter (les bornes y sont des `YYYY-MM-DDTHH:MM`, tronquer à la date) :
|
||||||
|
```vue
|
||||||
|
:min="min?.slice(0, 10)"
|
||||||
|
:max="max?.slice(0, 10)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/Date.test.ts`
|
||||||
|
Expected: PASS (anciens + nouveaux).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the whole date suite (non-régression)**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/date/`
|
||||||
|
Expected: PASS — `Date`, `DateRange`, `DateTime`, `DateWeek`, composables, pickers. (En cas de timeout flaky WSL2, relancer ; voir Global Constraints.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/date/Date.vue app/components/malio/date/DateRange.vue app/components/malio/date/DateTime.vue app/components/malio/date/DateWeek.vue app/components/malio/date/Date.test.ts
|
||||||
|
git commit -m "feat : propage min/max au popover + e2e sélecteur d'année (#date-year-picker)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Documentation (`COMPONENTS.md`, `CHANGELOG.md`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `COMPONENTS.md`
|
||||||
|
- Modify: `CHANGELOG.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: comportement livré aux tasks 1-8.
|
||||||
|
- Produces: doc à jour (convention projet : maj manuelle).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate the date section in COMPONENTS.md**
|
||||||
|
|
||||||
|
Run: `grep -nE "MalioDate|Date|calendrier|sélecteur" COMPONENTS.md | head -20`
|
||||||
|
Expected: repère la section calendrier/date. Si aucune entrée, l'ajouter à la suite des autres composants date.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Document the 3-level navigation**
|
||||||
|
|
||||||
|
Ajouter (dans la section du calendrier date) une description du 3ᵉ niveau :
|
||||||
|
```markdown
|
||||||
|
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,
|
||||||
|
chevrons par pas de 12 ans). 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add a CHANGELOG entry**
|
||||||
|
|
||||||
|
Sous la section non publiée (ou en tête, selon le format existant — vérifier `head -20 CHANGELOG.md`) :
|
||||||
|
```markdown
|
||||||
|
- Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau) et
|
||||||
|
grisage des mois/années hors `min`/`max`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add COMPONENTS.md CHANGELOG.md
|
||||||
|
git commit -m "docs : sélecteur d'année dans le calendrier (#date-year-picker)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes de vérification finale
|
||||||
|
|
||||||
|
- Suite ciblée complète : `npx vitest run app/components/malio/date/`
|
||||||
|
- Lint global : `npm run lint`
|
||||||
|
- Vérif manuelle navigateur : **proposer** à l'utilisateur (ne pas lancer le MCP Chrome sans accord — coût tokens). Parcours : ouvrir un `MalioDate`, cliquer le header 2× → grille d'années, paginer, choisir une année puis un mois, et tester un champ avec `min`/`max` pour voir le grisage.
|
||||||
Reference in New Issue
Block a user