# MalioDateRange + shell partagé — 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:** Composant `` (sélection période, hover preview, surlignage demi-barre) bâti sur un shell `CalendarField` extrait de `MalioDate`, sans changer l'API publique de `Date`. **Architecture:** Extraction d'un shell `CalendarField.vue` (champ + popover + header + vue mois + navigation) consommé par `Date` et `DateRange` via slot scoped. `MonthGrid` étendu pour le mode plage. Logique pure dans `dateRange.ts` + `useCalendarView.ts`. **Tech Stack:** Nuxt 4 layer, Vue 3 ` ``` - [ ] **Step 2 : Lint** — `npx eslint app/components/malio/date/internal/CalendarField.vue` → 0 erreur - [ ] **Step 3 : Commit** ```bash git add app/components/malio/date/internal/CalendarField.vue git commit -m "feat : shell CalendarField partagé (champ + popover + navigation) (#MUI-33)" ``` --- ## Task 4 : Refacto `Date.vue` en enveloppe **Files:** - Modify: `app/components/malio/date/Date.vue` (remplacement complet du fichier) - Test: `app/components/malio/date/Date.test.ts` (inchangé — doit rester vert) - [ ] **Step 1 : Remplacer `Date.vue` par l'enveloppe** ```vue ``` - [ ] **Step 2 : Lancer Date.test.ts, vérifier qu'il reste vert** Run: `npx vitest run app/components/malio/date/Date.test.ts` Expected: PASS (21 tests). Si un test casse → régression de refacto à corriger dans `CalendarField`/`Date` (ne pas adapter le test). - [ ] **Step 3 : Lint** — `npx eslint app/components/malio/date/Date.vue` → 0 erreur - [ ] **Step 4 : Commit** ```bash git add app/components/malio/date/Date.vue git commit -m "refactor : Date.vue devient une enveloppe du shell CalendarField (#MUI-33)" ``` --- ## Task 5 : Extension `MonthGrid.vue` (mode plage) **Files:** - Modify: `app/components/malio/date/internal/MonthGrid.vue` Couvert par `DateRange.test.ts` (Task 6) pour le mode plage et `Date.test.ts` pour le mode simple. - [ ] **Step 1 : Remplacer `MonthGrid.vue`** (ajout props range, hover, data-range-role, double couche) ```vue ``` - [ ] **Step 2 : Vérifier la non-régression simple** — `npx vitest run app/components/malio/date/Date.test.ts` → PASS (21) - [ ] **Step 3 : Lint** — `npx eslint app/components/malio/date/internal/MonthGrid.vue` → 0 erreur - [ ] **Step 4 : Commit** ```bash git add app/components/malio/date/internal/MonthGrid.vue git commit -m "feat : MonthGrid mode plage (surlignage demi-barre + hover + data-range-role) (#MUI-33)" ``` --- ## Task 6 : `DateRange.vue` + tests d'intégration **Files:** - Create: `app/components/malio/date/DateRange.vue` - Test: `app/components/malio/date/DateRange.test.ts` - [ ] **Step 1 : Créer `DateRange.vue`** ```vue ``` - [ ] **Step 2 : Écrire `DateRange.test.ts`** ```ts import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {mount} from '@vue/test-utils' import type {DefineComponent} from 'vue' import DateRange from './DateRange.vue' type RangeValue = {start: string; end: string} type DateRangeProps = { modelValue?: RangeValue | null label?: string disabled?: boolean readonly?: boolean error?: string min?: string max?: string clearable?: boolean } const DateRangeForTest = DateRange as DefineComponent const mountRange = (props: DateRangeProps = {}) => mount(DateRangeForTest, {props, attachTo: document.body}) const openAndClickDays = async (wrapper: ReturnType, isos: string[]) => { await wrapper.get('[data-test="date-input"]').trigger('click') for (const iso of isos) { await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click') } } describe('MalioDateRange', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 }) afterEach(() => vi.useRealTimers()) it('renders the label and calendar icon', () => { const wrapper = mountRange({label: 'Période'}) expect(wrapper.get('label').text()).toBe('Période') expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true) }) it('displays the formatted range when modelValue is set', () => { const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement expect(input.value).toBe('19/05/2026 - 25/05/2026') }) it('shows an empty field without a value', () => { const wrapper = mountRange() const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement expect(input.value).toBe('') }) it('opens on the start month when a range is set', async () => { const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}}) await wrapper.get('[data-test="date-input"]').trigger('click') expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025') }) it('does not emit on the first click', async () => { const wrapper = mountRange() await openAndClickDays(wrapper, ['2026-05-19']) expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) }) it('emits the range and closes on the second click', async () => { const wrapper = mountRange() await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25']) expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}]) expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) }) it('auto-inverts when the second click is before the first', async () => { const wrapper = mountRange() await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19']) expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}]) }) it('allows a single-day range', async () => { const wrapper = mountRange() await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19']) expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}]) }) it('restarts a new range on the third click', async () => { const wrapper = mountRange() await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25']) // complete await wrapper.get('[data-test="date-input"]').trigger('click') // reopen await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click') // 3rd click = new start expect(wrapper.emitted('update:modelValue')).toHaveLength(1) // still only the first range await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}]) }) it('previews the range on hover while selecting', async () => { const wrapper = mountRange() await wrapper.get('[data-test="date-input"]').trigger('click') await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') // start await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter') // hover const between = wrapper.get('[data-test="day"][data-iso="2026-05-20"]') expect(between.attributes('data-range-role')).toBe('in-range') }) it('does not preview before selecting', async () => { const wrapper = mountRange() await wrapper.get('[data-test="date-input"]').trigger('click') await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter') const day = wrapper.get('[data-test="day"][data-iso="2026-05-20"]') expect(day.attributes('data-range-role')).toBe('none') }) it('marks start, end and in-range roles for a committed range', async () => { const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) await wrapper.get('[data-test="date-input"]').trigger('click') expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start') expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end') expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range') }) it('cancels an in-progress selection on outside click', async () => { const wrapper = mountRange() await openAndClickDays(wrapper, ['2026-05-19']) // only start chosen document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})) await wrapper.vm.$nextTick() expect(wrapper.emitted('update:modelValue')).toBeUndefined() // reopening: no pending start (role none for the previously clicked day) await wrapper.get('[data-test="date-input"]').trigger('click') expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none') }) it('emits null on clear', async () => { const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) await wrapper.get('[data-test="clear"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) }) it('disables days outside min/max', async () => { const wrapper = mountRange({min: '2026-05-10', max: '2026-05-20'}) await wrapper.get('[data-test="date-input"]').trigger('click') const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]') expect((outside.element as HTMLButtonElement).disabled).toBe(true) await outside.trigger('click') expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) it('sets aria-invalid on error', () => { const wrapper = mountRange({error: 'Période requise'}) expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true') expect(wrapper.text()).toContain('Période requise') }) it('does not open when disabled', async () => { const wrapper = mountRange({disabled: true}) await wrapper.get('[data-test="date-input"]').trigger('click') expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) }) }) ``` - [ ] **Step 3 : Lancer, vérifier le succès** — `npx vitest run app/components/malio/date/DateRange.test.ts` → PASS (~18). Ajuster l'implémentation si besoin (tests = contrat). - [ ] **Step 4 : Lint + suite date complète** Run: `npx eslint app/components/malio/date/ && npx vitest run app/components/malio/date/` Expected: 0 erreur lint ; tous verts (dateFormat, useMonthMatrix, useCalendarPopover, useCalendarView, dateRange, Date, DateRange). - [ ] **Step 5 : Commit** ```bash git add app/components/malio/date/DateRange.vue app/components/malio/date/DateRange.test.ts git commit -m "feat : composant MalioDateRange (sélection période) (#MUI-33)" ``` --- ## Task 7 : Story + playground **Files:** - Create: `app/story/date/dateRange.story.vue` - Create: `.playground/pages/composant/date/dateRange.vue` - [ ] **Step 1 : Créer la story** ```vue ``` - [ ] **Step 2 : Créer la page playground** ```vue ``` - [ ] **Step 3 : Vérification visuelle** — `npm run dev` → menu "Date" → page DateRange : sélection 2 clics, hover preview, demi-barres aux bornes, reset 3e clic, bornes. Et `npm run story:dev`. - [ ] **Step 4 : Lint** — `npx eslint app/story/date/dateRange.story.vue .playground/pages/composant/date/dateRange.vue` → 0 erreur - [ ] **Step 5 : Commit** ```bash git add app/story/date/dateRange.story.vue .playground/pages/composant/date/dateRange.vue git commit -m "feat : story et page playground de MalioDateRange (#MUI-33)" ``` --- ## Self-Review (effectuée à l'écriture) **Couverture spec :** shell `CalendarField` ✓ (T3), `useCalendarView` ✓ (T2), `dateRange.ts` ✓ (T1), `MonthGrid` range+hover+data-range-role+demi-barre ✓ (T5), refacto `Date` ✓ (T4, Date.test.ts vert), `DateRange` API + machine à états (1er/2e clic, auto-inversion, single-day, 3e clic reset, hover preview, close annule, clear) ✓ (T6), min/max ✓ (T5/T6), displayValue vide pendant sélection ✓ (T6), story+playground ✓ (T7). Reportés (2 mois, ajustement borne proche, clavier) : non planifiés, conforme. **Placeholders :** aucun ; tout le code est complet. **Cohérence des types :** `DateRangeValue {start,end}` défini en T1, réutilisé en T6. `DayRangeRole` T1 → T5. `resolveRangeBounds(start,end,preview)` / `dayRangeRole(iso,bounds)` cohérents T1↔T5. `useCalendarView(viewMode)` renvoie `{currentMonth,currentYear,goToPrev,goToNext,selectMonth,syncToIso}` T2 → consommés tels quels en T3. `CalendarField` props `displayValue`/`syncTo` + events `clear`/`close` + slot `{currentMonth,currentYear,close}` T3 → consommés en T4 (Date) et T6 (DateRange). `MonthGrid` mode plage activé par `rangeStart !== undefined` ; `Date` ne passe pas `rangeStart` (reste single) — cohérent T4↔T5. **Écart assumé :** `MonthGrid` gagne `data-range-role` (testabilité + debug), documenté dans la spec.