1363 lines
43 KiB
Markdown
1363 lines
43 KiB
Markdown
# 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 `<MalioDateRange>` (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 `<script setup lang="ts">`, TypeScript strict, Tailwind (`m-*`), `tailwind-merge`, `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
|
|
|
|
**Référence spec :** `docs/superpowers/specs/2026-05-20-daterange-design.md`
|
|
|
|
---
|
|
|
|
## Conventions partagées
|
|
|
|
**`data-test` / attributs** (ancrage tests) :
|
|
|
|
| attribut | élément |
|
|
|---|---|
|
|
| `date-input`, `calendar-icon`, `clear`, `popover` | shell `CalendarField` (déjà existants, préservés) |
|
|
| `header-prev`, `header-next`, `header-toggle` | `CalendarHeader` (inchangé) |
|
|
| `month` (+`data-month`) | `MonthPicker` (inchangé) |
|
|
| `day` (+`data-iso`) | `MonthGrid` (existant) |
|
|
| `data-range-role` | **nouveau** sur chaque bouton jour : `none`/`single`/`start`/`end`/`in-range` |
|
|
| `week-number` | colonne semaine (inchangé) |
|
|
|
|
**Commande de test ciblée :** `npx vitest run <chemin>` (le hook pre-commit lance toute la suite ; en cas de timeout flaky, réessayer, puis `--no-verify` après 2 échecs).
|
|
|
|
**Ordre des tâches** (du bas vers le haut, chaque tâche commit-able) :
|
|
1. `dateRange.ts` (purs) → 2. `useCalendarView.ts` → 3. `CalendarField.vue` (shell) → 4. refacto `Date.vue` (Date.test.ts vert) → 5. extension `MonthGrid.vue` → 6. `DateRange.vue` + tests → 7. story + playground.
|
|
|
|
---
|
|
|
|
## Task 1 : Helpers purs `dateRange.ts`
|
|
|
|
**Files:**
|
|
- Create: `app/components/malio/date/composables/dateRange.ts`
|
|
- Test: `app/components/malio/date/composables/dateRange.test.ts`
|
|
|
|
- [ ] **Step 1 : Écrire les tests (échouent)**
|
|
|
|
```ts
|
|
import {describe, expect, it} from 'vitest'
|
|
import {dayRangeRole, normalizeRange, resolveRangeBounds} from './dateRange'
|
|
|
|
describe('dateRange', () => {
|
|
describe('normalizeRange', () => {
|
|
it('keeps an already ordered pair', () => {
|
|
expect(normalizeRange('2026-05-19', '2026-05-25')).toEqual({start: '2026-05-19', end: '2026-05-25'})
|
|
})
|
|
it('swaps a reversed pair', () => {
|
|
expect(normalizeRange('2026-05-25', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-25'})
|
|
})
|
|
it('handles an equal pair', () => {
|
|
expect(normalizeRange('2026-05-19', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-19'})
|
|
})
|
|
})
|
|
|
|
describe('resolveRangeBounds', () => {
|
|
it('returns null without a start', () => {
|
|
expect(resolveRangeBounds(null, null, null)).toBeNull()
|
|
})
|
|
it('returns a single-point range when only start is set', () => {
|
|
expect(resolveRangeBounds('2026-05-19', null, null)).toEqual({lo: '2026-05-19', hi: '2026-05-19'})
|
|
})
|
|
it('orders start and committed end', () => {
|
|
expect(resolveRangeBounds('2026-05-19', '2026-05-25', null)).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
|
|
})
|
|
it('uses preview when end is not set', () => {
|
|
expect(resolveRangeBounds('2026-05-19', null, '2026-05-22')).toEqual({lo: '2026-05-19', hi: '2026-05-22'})
|
|
})
|
|
it('inverts when preview is before start', () => {
|
|
expect(resolveRangeBounds('2026-05-19', null, '2026-05-10')).toEqual({lo: '2026-05-10', hi: '2026-05-19'})
|
|
})
|
|
it('prioritises committed end over preview', () => {
|
|
expect(resolveRangeBounds('2026-05-19', '2026-05-25', '2026-05-30')).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
|
|
})
|
|
})
|
|
|
|
describe('dayRangeRole', () => {
|
|
const bounds = {lo: '2026-05-19', hi: '2026-05-25'}
|
|
it('returns none without bounds', () => {
|
|
expect(dayRangeRole('2026-05-20', null)).toBe('none')
|
|
})
|
|
it('returns single when lo === hi and matches', () => {
|
|
expect(dayRangeRole('2026-05-19', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('single')
|
|
expect(dayRangeRole('2026-05-20', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('none')
|
|
})
|
|
it('returns start, end and in-range', () => {
|
|
expect(dayRangeRole('2026-05-19', bounds)).toBe('start')
|
|
expect(dayRangeRole('2026-05-25', bounds)).toBe('end')
|
|
expect(dayRangeRole('2026-05-22', bounds)).toBe('in-range')
|
|
})
|
|
it('returns none outside the bounds', () => {
|
|
expect(dayRangeRole('2026-05-10', bounds)).toBe('none')
|
|
expect(dayRangeRole('2026-05-30', bounds)).toBe('none')
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2 : Lancer, vérifier l'échec** — `npx vitest run app/components/malio/date/composables/dateRange.test.ts` → FAIL (import non résolu)
|
|
|
|
- [ ] **Step 3 : Implémenter**
|
|
|
|
```ts
|
|
// app/components/malio/date/composables/dateRange.ts
|
|
export type DateRangeValue = {start: string; end: string}
|
|
|
|
export function normalizeRange(a: string, b: string): DateRangeValue {
|
|
return a <= b ? {start: a, end: b} : {start: b, end: a}
|
|
}
|
|
|
|
export function resolveRangeBounds(
|
|
start: string | null,
|
|
end: string | null,
|
|
preview: string | null,
|
|
): {lo: string; hi: string} | null {
|
|
if (!start) return null
|
|
const other = end ?? preview
|
|
if (!other) return {lo: start, hi: start}
|
|
return start <= other ? {lo: start, hi: other} : {lo: other, hi: start}
|
|
}
|
|
|
|
export type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
|
|
|
|
export function dayRangeRole(
|
|
iso: string,
|
|
bounds: {lo: string; hi: string} | null,
|
|
): DayRangeRole {
|
|
if (!bounds) return 'none'
|
|
const {lo, hi} = bounds
|
|
if (lo === hi) return iso === lo ? 'single' : 'none'
|
|
if (iso === lo) return 'start'
|
|
if (iso === hi) return 'end'
|
|
if (iso > lo && iso < hi) return 'in-range'
|
|
return 'none'
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4 : Lancer, vérifier le succès** — `npx vitest run app/components/malio/date/composables/dateRange.test.ts` → PASS
|
|
|
|
- [ ] **Step 5 : Commit**
|
|
|
|
```bash
|
|
git add app/components/malio/date/composables/dateRange.ts app/components/malio/date/composables/dateRange.test.ts
|
|
git commit -m "feat : helpers purs de plage de dates (#MUI-33)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2 : Composable `useCalendarView.ts`
|
|
|
|
**Files:**
|
|
- Create: `app/components/malio/date/composables/useCalendarView.ts`
|
|
- Test: `app/components/malio/date/composables/useCalendarView.test.ts`
|
|
|
|
- [ ] **Step 1 : Écrire les tests (échouent)**
|
|
|
|
```ts
|
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
import {ref} from 'vue'
|
|
import {useCalendarView} from './useCalendarView'
|
|
|
|
describe('useCalendarView', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
|
})
|
|
afterEach(() => vi.useRealTimers())
|
|
|
|
it('initialises to the current month and year', () => {
|
|
const {currentMonth, currentYear} = useCalendarView(ref('days'))
|
|
expect(currentMonth.value).toBe(4)
|
|
expect(currentYear.value).toBe(2026)
|
|
})
|
|
|
|
it('goToNext advances the month in days view', () => {
|
|
const {currentMonth, goToNext} = useCalendarView(ref('days'))
|
|
goToNext()
|
|
expect(currentMonth.value).toBe(5)
|
|
})
|
|
|
|
it('rolls December to January and bumps the year', () => {
|
|
const view = ref<'days' | 'months'>('days')
|
|
const {currentMonth, currentYear, goToNext} = useCalendarView(view)
|
|
currentMonth.value = 11
|
|
goToNext()
|
|
expect(currentMonth.value).toBe(0)
|
|
expect(currentYear.value).toBe(2027)
|
|
})
|
|
|
|
it('rolls January to December backwards', () => {
|
|
const {currentMonth, currentYear, goToPrev} = useCalendarView(ref('days'))
|
|
currentMonth.value = 0
|
|
goToPrev()
|
|
expect(currentMonth.value).toBe(11)
|
|
expect(currentYear.value).toBe(2025)
|
|
})
|
|
|
|
it('navigates the year in months view', () => {
|
|
const {currentYear, goToNext, goToPrev} = useCalendarView(ref('months'))
|
|
goToNext()
|
|
expect(currentYear.value).toBe(2027)
|
|
goToPrev()
|
|
expect(currentYear.value).toBe(2026)
|
|
})
|
|
|
|
it('selectMonth sets the current month', () => {
|
|
const {currentMonth, selectMonth} = useCalendarView(ref('days'))
|
|
selectMonth(0)
|
|
expect(currentMonth.value).toBe(0)
|
|
})
|
|
|
|
it('syncToIso sets month/year from a valid ISO', () => {
|
|
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
|
|
syncToIso('2025-12-25')
|
|
expect(currentMonth.value).toBe(11)
|
|
expect(currentYear.value).toBe(2025)
|
|
})
|
|
|
|
it('syncToIso falls back to today for null/invalid', () => {
|
|
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
|
|
syncToIso('2025-12-25')
|
|
syncToIso(null)
|
|
expect(currentMonth.value).toBe(4)
|
|
expect(currentYear.value).toBe(2026)
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2 : Lancer, vérifier l'échec** — `npx vitest run app/components/malio/date/composables/useCalendarView.test.ts` → FAIL
|
|
|
|
- [ ] **Step 3 : Implémenter**
|
|
|
|
```ts
|
|
// app/components/malio/date/composables/useCalendarView.ts
|
|
import {ref, type Ref} from 'vue'
|
|
import {isValidIso} from './dateFormat'
|
|
|
|
export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
|
const today = new Date()
|
|
const currentMonth = ref(today.getMonth())
|
|
const currentYear = ref(today.getFullYear())
|
|
|
|
const goToPrev = () => {
|
|
if (viewMode.value === 'months') {
|
|
currentYear.value -= 1
|
|
return
|
|
}
|
|
if (currentMonth.value === 0) {
|
|
currentMonth.value = 11
|
|
currentYear.value -= 1
|
|
} else {
|
|
currentMonth.value -= 1
|
|
}
|
|
}
|
|
|
|
const goToNext = () => {
|
|
if (viewMode.value === 'months') {
|
|
currentYear.value += 1
|
|
return
|
|
}
|
|
if (currentMonth.value === 11) {
|
|
currentMonth.value = 0
|
|
currentYear.value += 1
|
|
} else {
|
|
currentMonth.value += 1
|
|
}
|
|
}
|
|
|
|
const selectMonth = (m: number) => {
|
|
currentMonth.value = m
|
|
}
|
|
|
|
const syncToIso = (iso: string | null) => {
|
|
if (iso && isValidIso(iso)) {
|
|
currentMonth.value = Number(iso.slice(5, 7)) - 1
|
|
currentYear.value = Number(iso.slice(0, 4))
|
|
} else {
|
|
const now = new Date()
|
|
currentMonth.value = now.getMonth()
|
|
currentYear.value = now.getFullYear()
|
|
}
|
|
}
|
|
|
|
return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4 : Lancer, vérifier le succès** — PASS
|
|
|
|
- [ ] **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 : composable de navigation mois/année du calendrier (#MUI-33)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3 : Shell `CalendarField.vue`
|
|
|
|
**Files:**
|
|
- Create: `app/components/malio/date/internal/CalendarField.vue`
|
|
|
|
Pas de test dédié — couvert indirectement par `Date.test.ts` (Task 4) et `DateRange.test.ts` (Task 6).
|
|
|
|
- [ ] **Step 1 : Créer le composant**
|
|
|
|
```vue
|
|
<!-- app/components/malio/date/internal/CalendarField.vue -->
|
|
<template>
|
|
<div>
|
|
<div
|
|
ref="root"
|
|
:class="mergedGroupClass"
|
|
>
|
|
<input
|
|
:id="inputId"
|
|
:name="name"
|
|
data-test="date-input"
|
|
readonly
|
|
autocomplete="off"
|
|
:class="mergedInputClass"
|
|
:required="required"
|
|
:disabled="disabled"
|
|
:value="displayValue"
|
|
:aria-invalid="!!error"
|
|
:aria-describedby="describedBy"
|
|
:aria-expanded="isOpen"
|
|
aria-haspopup="dialog"
|
|
v-bind="attrs"
|
|
placeholder="_"
|
|
type="text"
|
|
@click="onFieldClick"
|
|
>
|
|
|
|
<label
|
|
v-if="label"
|
|
:for="inputId"
|
|
:class="mergedLabelClass"
|
|
>
|
|
{{ label }}
|
|
</label>
|
|
|
|
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
|
<button
|
|
v-if="showClear"
|
|
type="button"
|
|
data-test="clear"
|
|
class="text-m-muted hover:text-black"
|
|
aria-label="Effacer la date"
|
|
@click.stop="emit('clear')"
|
|
>
|
|
<Icon
|
|
icon="mdi:close"
|
|
:width="16"
|
|
:height="16"
|
|
/>
|
|
</button>
|
|
<Icon
|
|
data-test="calendar-icon"
|
|
icon="mdi:calendar-blank"
|
|
:width="24"
|
|
:height="24"
|
|
:class="iconStateClass"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="isOpen"
|
|
data-test="popover"
|
|
role="dialog"
|
|
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
|
>
|
|
<CalendarHeader
|
|
:view-mode="viewMode"
|
|
:current-month="currentMonth"
|
|
:current-year="currentYear"
|
|
@prev="goToPrev"
|
|
@next="goToNext"
|
|
@toggle-view="toggleView"
|
|
/>
|
|
<slot
|
|
v-if="viewMode === 'days'"
|
|
:current-month="currentMonth"
|
|
:current-year="currentYear"
|
|
:close="closePopover"
|
|
/>
|
|
<MonthPicker
|
|
v-else
|
|
:selected-month="currentMonth"
|
|
@select="onSelectMonth"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<p
|
|
v-if="hint || hasError || hasSuccess"
|
|
:id="`${inputId}-describedby`"
|
|
:class="[
|
|
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
|
'mt-1 ml-[2px] text-xs',
|
|
]"
|
|
>
|
|
{{ error || success || hint }}
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
|
import {Icon} from '@iconify/vue'
|
|
import {twMerge} from 'tailwind-merge'
|
|
import CalendarHeader from './CalendarHeader.vue'
|
|
import MonthPicker from './MonthPicker.vue'
|
|
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
|
import {useCalendarView} from '../composables/useCalendarView'
|
|
|
|
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
displayValue: string
|
|
syncTo: string | null
|
|
id?: string
|
|
name?: string
|
|
label?: string
|
|
placeholder?: string
|
|
required?: boolean
|
|
disabled?: boolean
|
|
readonly?: boolean
|
|
hint?: string
|
|
error?: string
|
|
success?: string
|
|
clearable?: boolean
|
|
inputClass?: string
|
|
labelClass?: string
|
|
groupClass?: string
|
|
}>(),
|
|
{
|
|
id: '',
|
|
name: '',
|
|
label: '',
|
|
placeholder: 'JJ/MM/AAAA',
|
|
required: false,
|
|
disabled: false,
|
|
readonly: false,
|
|
hint: '',
|
|
error: '',
|
|
success: '',
|
|
clearable: true,
|
|
inputClass: '',
|
|
labelClass: '',
|
|
groupClass: '',
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
|
|
|
|
const attrs = useAttrs()
|
|
const generatedId = useId()
|
|
const root = ref<HTMLElement | null>(null)
|
|
|
|
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
|
|
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
|
|
|
|
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
|
const hasError = computed(() => !!props.error)
|
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
|
const isFilled = computed(() => props.displayValue.length > 0)
|
|
const showClear = computed(() =>
|
|
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
|
)
|
|
const describedBy = computed(() =>
|
|
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
|
|
)
|
|
|
|
watch(isOpen, (value) => {
|
|
if (!value) emit('close')
|
|
})
|
|
|
|
const onFieldClick = () => {
|
|
if (props.disabled || props.readonly) return
|
|
if (isOpen.value) {
|
|
closePopover()
|
|
return
|
|
}
|
|
syncToIso(props.syncTo)
|
|
open()
|
|
}
|
|
|
|
watch(() => props.syncTo, (value) => {
|
|
if (isOpen.value) syncToIso(value)
|
|
})
|
|
|
|
const onSelectMonth = (m: number) => {
|
|
selectMonth(m)
|
|
toggleView()
|
|
}
|
|
|
|
const mergedGroupClass = computed(() =>
|
|
twMerge('relative flex h-12 w-full items-center', props.groupClass),
|
|
)
|
|
|
|
const mergedInputClass = computed(() =>
|
|
twMerge(
|
|
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
|
|
isFilled.value ? 'border-black' : 'border-m-muted',
|
|
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
|
|
hasError.value
|
|
? 'border-m-danger'
|
|
: hasSuccess.value
|
|
? 'border-m-success'
|
|
: 'focus:border-m-primary',
|
|
isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
|
props.inputClass,
|
|
),
|
|
)
|
|
|
|
const mergedLabelClass = computed(() =>
|
|
twMerge(
|
|
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
|
|
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
|
|
hasError.value
|
|
? 'text-m-danger'
|
|
: hasSuccess.value
|
|
? 'text-m-success'
|
|
: isOpen.value
|
|
? 'text-m-primary'
|
|
: 'peer-placeholder-shown:text-m-muted text-black',
|
|
props.labelClass,
|
|
),
|
|
)
|
|
|
|
const iconStateClass = computed(() => {
|
|
if (hasError.value) return 'text-m-danger'
|
|
if (hasSuccess.value) return 'text-m-success'
|
|
if (isOpen.value) return 'text-m-primary'
|
|
if (isFilled.value) return 'text-black'
|
|
return 'text-m-muted'
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.floating-label {
|
|
background: white;
|
|
padding: 0 0.25rem;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **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
|
|
<!-- app/components/malio/date/Date.vue -->
|
|
<template>
|
|
<CalendarField
|
|
:display-value="displayValue"
|
|
:sync-to="modelValue ?? null"
|
|
:id="id"
|
|
:name="name"
|
|
:label="label"
|
|
:placeholder="placeholder"
|
|
:required="required"
|
|
:disabled="disabled"
|
|
:readonly="readonly"
|
|
:hint="hint"
|
|
:error="error"
|
|
:success="success"
|
|
:clearable="clearable"
|
|
:input-class="inputClass"
|
|
:label-class="labelClass"
|
|
:group-class="groupClass"
|
|
v-bind="$attrs"
|
|
@clear="emit('update:modelValue', null)"
|
|
>
|
|
<template #default="{ currentMonth, currentYear, close }">
|
|
<MonthGrid
|
|
:month="currentMonth"
|
|
:year="currentYear"
|
|
:selected-date="modelValue ?? null"
|
|
:min="min"
|
|
:max="max"
|
|
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
|
/>
|
|
</template>
|
|
</CalendarField>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, watch} from 'vue'
|
|
import CalendarField from './internal/CalendarField.vue'
|
|
import MonthGrid from './internal/MonthGrid.vue'
|
|
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
|
|
|
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
id?: string
|
|
name?: string
|
|
label?: string
|
|
modelValue?: string | null
|
|
placeholder?: string
|
|
required?: boolean
|
|
disabled?: boolean
|
|
readonly?: boolean
|
|
hint?: string
|
|
error?: string
|
|
success?: string
|
|
min?: string
|
|
max?: string
|
|
clearable?: boolean
|
|
inputClass?: string
|
|
labelClass?: string
|
|
groupClass?: string
|
|
}>(),
|
|
{
|
|
id: '',
|
|
name: '',
|
|
label: '',
|
|
modelValue: undefined,
|
|
placeholder: 'JJ/MM/AAAA',
|
|
required: false,
|
|
disabled: false,
|
|
readonly: false,
|
|
hint: '',
|
|
error: '',
|
|
success: '',
|
|
min: undefined,
|
|
max: undefined,
|
|
clearable: true,
|
|
inputClass: '',
|
|
labelClass: '',
|
|
groupClass: '',
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
|
|
|
|
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
|
|
|
watch(() => props.modelValue, (val) => {
|
|
if (val && !isValidIso(val) && import.meta.dev) {
|
|
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
|
|
}
|
|
})
|
|
</script>
|
|
```
|
|
|
|
- [ ] **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
|
|
<!-- app/components/malio/date/internal/MonthGrid.vue -->
|
|
<template>
|
|
<div
|
|
data-test="month-grid"
|
|
@mouseleave="emit('hover', null)"
|
|
>
|
|
<div class="grid grid-cols-[auto_repeat(7,minmax(0,1fr))]">
|
|
<div class="mr-[12px] flex h-8 w-[35px] items-center justify-center text-[14px] font-medium opacity-[60%]">
|
|
S
|
|
</div>
|
|
<div
|
|
v-for="d in dayLabels"
|
|
:key="d"
|
|
class="flex h-8 items-center justify-center text-[14px] font-medium opacity-[60%]"
|
|
>
|
|
{{ d }}
|
|
</div>
|
|
|
|
<template
|
|
v-for="(week, wIndex) in weeks"
|
|
:key="week.days[0].isoDate"
|
|
>
|
|
<div
|
|
data-test="week-number"
|
|
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center bg-m-primary-light p-[10px] text-sm"
|
|
:class="[
|
|
week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60',
|
|
wIndex === 0 ? 'rounded-t-md' : '',
|
|
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
|
|
]"
|
|
>
|
|
{{ week.weekNumber }}
|
|
</div>
|
|
<button
|
|
v-for="cell in week.days"
|
|
:key="cell.isoDate"
|
|
type="button"
|
|
data-test="day"
|
|
:data-iso="cell.isoDate"
|
|
:data-range-role="roleOf(cell)"
|
|
:disabled="!inRange(cell.isoDate)"
|
|
:aria-label="ariaLabel(cell)"
|
|
:aria-disabled="!inRange(cell.isoDate)"
|
|
class="relative flex h-[45px] w-full items-center justify-center"
|
|
:class="inRange(cell.isoDate) ? 'cursor-pointer' : 'cursor-not-allowed'"
|
|
@click="onSelect(cell.isoDate)"
|
|
@mouseenter="emit('hover', cell.isoDate)"
|
|
>
|
|
<span
|
|
v-if="roleOf(cell) === 'in-range'"
|
|
class="absolute inset-0 bg-m-primary-light"
|
|
/>
|
|
<span
|
|
v-else-if="roleOf(cell) === 'start'"
|
|
class="absolute inset-y-0 left-1/2 right-0 bg-m-primary-light"
|
|
/>
|
|
<span
|
|
v-else-if="roleOf(cell) === 'end'"
|
|
class="absolute inset-y-0 left-0 right-1/2 bg-m-primary-light"
|
|
/>
|
|
<span
|
|
class="relative flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium transition-colors duration-100"
|
|
:class="cellClass(cell)"
|
|
>
|
|
{{ cell.day }}
|
|
</span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, toRef} from 'vue'
|
|
import {useMonthMatrix, type DayCell} from '../composables/useMonthMatrix'
|
|
import {isDateInRange} from '../composables/dateFormat'
|
|
import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composables/dateRange'
|
|
|
|
defineOptions({name: 'MalioDateMonthGrid'})
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
month: number
|
|
year: number
|
|
selectedDate?: string | null
|
|
rangeStart?: string | null
|
|
rangeEnd?: string | null
|
|
previewDate?: string | null
|
|
min?: string
|
|
max?: string
|
|
}>(),
|
|
{
|
|
selectedDate: null,
|
|
rangeStart: undefined,
|
|
rangeEnd: undefined,
|
|
previewDate: undefined,
|
|
min: undefined,
|
|
max: undefined,
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'select', iso: string): void
|
|
(e: 'hover', iso: string | null): void
|
|
}>()
|
|
|
|
const dayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
|
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
|
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
|
|
|
|
const {weeks} = useMonthMatrix(toRef(props, 'month'), toRef(props, 'year'))
|
|
|
|
const inRange = (iso: string) => isDateInRange(iso, props.min, props.max)
|
|
|
|
const isRangeMode = computed(() => props.rangeStart !== undefined)
|
|
const bounds = computed(() =>
|
|
isRangeMode.value
|
|
? resolveRangeBounds(props.rangeStart ?? null, props.rangeEnd ?? null, props.previewDate ?? null)
|
|
: null,
|
|
)
|
|
|
|
const roleOf = (cell: DayCell): DayRangeRole => {
|
|
if (isRangeMode.value) return dayRangeRole(cell.isoDate, bounds.value)
|
|
return props.selectedDate === cell.isoDate ? 'single' : 'none'
|
|
}
|
|
|
|
const ariaLabel = (cell: DayCell) => {
|
|
const [, m, d] = cell.isoDate.split('-')
|
|
return `${Number(d)} ${monthsLong[Number(m) - 1]} ${cell.isoDate.slice(0, 4)}`
|
|
}
|
|
|
|
const cellClass = (cell: DayCell) => {
|
|
if (!inRange(cell.isoDate)) return 'text-m-muted/30'
|
|
const role = roleOf(cell)
|
|
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
|
|
if (role === 'in-range') return 'text-black'
|
|
const parts = ['hover:bg-m-primary/10']
|
|
if (cell.isToday) parts.push('border border-m-primary text-m-primary')
|
|
else if (cell.isCurrentMonth) parts.push('text-black')
|
|
else parts.push('opacity-[60%]')
|
|
return parts.join(' ')
|
|
}
|
|
|
|
const onSelect = (iso: string) => {
|
|
if (!inRange(iso)) return
|
|
emit('select', iso)
|
|
}
|
|
</script>
|
|
```
|
|
|
|
- [ ] **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
|
|
<!-- app/components/malio/date/DateRange.vue -->
|
|
<template>
|
|
<CalendarField
|
|
:display-value="displayValue"
|
|
:sync-to="validRange?.start ?? null"
|
|
:id="id"
|
|
:name="name"
|
|
:label="label"
|
|
:placeholder="placeholder"
|
|
:required="required"
|
|
:disabled="disabled"
|
|
:readonly="readonly"
|
|
:hint="hint"
|
|
:error="error"
|
|
:success="success"
|
|
:clearable="clearable"
|
|
:input-class="inputClass"
|
|
:label-class="labelClass"
|
|
:group-class="groupClass"
|
|
@clear="onClear"
|
|
@close="onClose"
|
|
>
|
|
<template #default="{ currentMonth, currentYear, close }">
|
|
<MonthGrid
|
|
:month="currentMonth"
|
|
:year="currentYear"
|
|
:range-start="rangeStart"
|
|
:range-end="rangeEnd"
|
|
:preview-date="previewDate"
|
|
:min="min"
|
|
:max="max"
|
|
@select="(iso) => onSelectDay(iso, close)"
|
|
@hover="onHover"
|
|
/>
|
|
</template>
|
|
</CalendarField>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, ref} from 'vue'
|
|
import CalendarField from './internal/CalendarField.vue'
|
|
import MonthGrid from './internal/MonthGrid.vue'
|
|
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
|
import {normalizeRange, type DateRangeValue} from './composables/dateRange'
|
|
|
|
defineOptions({name: 'MalioDateRange', inheritAttrs: false})
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
id?: string
|
|
name?: string
|
|
label?: string
|
|
modelValue?: DateRangeValue | null
|
|
placeholder?: string
|
|
required?: boolean
|
|
disabled?: boolean
|
|
readonly?: boolean
|
|
hint?: string
|
|
error?: string
|
|
success?: string
|
|
min?: string
|
|
max?: string
|
|
clearable?: boolean
|
|
inputClass?: string
|
|
labelClass?: string
|
|
groupClass?: string
|
|
}>(),
|
|
{
|
|
id: '',
|
|
name: '',
|
|
label: '',
|
|
modelValue: undefined,
|
|
placeholder: 'JJ/MM/AAAA',
|
|
required: false,
|
|
disabled: false,
|
|
readonly: false,
|
|
hint: '',
|
|
error: '',
|
|
success: '',
|
|
min: undefined,
|
|
max: undefined,
|
|
clearable: true,
|
|
inputClass: '',
|
|
labelClass: '',
|
|
groupClass: '',
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{(e: 'update:modelValue', value: DateRangeValue | null): void}>()
|
|
|
|
const pendingStart = ref<string | null>(null)
|
|
const hoverDate = ref<string | null>(null)
|
|
const isSelecting = computed(() => pendingStart.value !== null)
|
|
|
|
const validRange = computed<DateRangeValue | null>(() => {
|
|
const v = props.modelValue
|
|
if (v && isValidIso(v.start) && isValidIso(v.end)) return v
|
|
return null
|
|
})
|
|
|
|
const rangeStart = computed(() =>
|
|
isSelecting.value ? pendingStart.value : (validRange.value?.start ?? null),
|
|
)
|
|
const rangeEnd = computed(() =>
|
|
isSelecting.value ? null : (validRange.value?.end ?? null),
|
|
)
|
|
const previewDate = computed(() => (isSelecting.value ? hoverDate.value : null))
|
|
|
|
const displayValue = computed(() => {
|
|
if (isSelecting.value || !validRange.value) return ''
|
|
return `${formatIsoToDisplay(validRange.value.start)} - ${formatIsoToDisplay(validRange.value.end)}`
|
|
})
|
|
|
|
const onSelectDay = (iso: string, close: () => void) => {
|
|
if (pendingStart.value === null) {
|
|
pendingStart.value = iso
|
|
hoverDate.value = null
|
|
return
|
|
}
|
|
emit('update:modelValue', normalizeRange(pendingStart.value, iso))
|
|
pendingStart.value = null
|
|
hoverDate.value = null
|
|
close()
|
|
}
|
|
|
|
const onHover = (iso: string | null) => {
|
|
if (isSelecting.value) hoverDate.value = iso
|
|
}
|
|
|
|
const onClose = () => {
|
|
pendingStart.value = null
|
|
hoverDate.value = null
|
|
}
|
|
|
|
const onClear = () => {
|
|
emit('update:modelValue', null)
|
|
pendingStart.value = null
|
|
hoverDate.value = null
|
|
}
|
|
</script>
|
|
```
|
|
|
|
- [ ] **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<DateRangeProps>
|
|
const mountRange = (props: DateRangeProps = {}) =>
|
|
mount(DateRangeForTest, {props, attachTo: document.body})
|
|
|
|
const openAndClickDays = async (wrapper: ReturnType<typeof mountRange>, 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
|
|
<!-- app/story/date/dateRange.story.vue -->
|
|
<template>
|
|
<Story title="Date/DateRange">
|
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
|
<div class="rounded-lg border p-4">
|
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
|
<MalioDateRange
|
|
v-model="simpleValue"
|
|
label="Période"
|
|
/>
|
|
</div>
|
|
|
|
<div class="rounded-lg border p-4">
|
|
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
|
<MalioDateRange
|
|
v-model="initialValue"
|
|
label="Séjour"
|
|
/>
|
|
</div>
|
|
|
|
<div class="rounded-lg border p-4">
|
|
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
|
|
<MalioDateRange
|
|
v-model="boundedValue"
|
|
label="Plage bornée"
|
|
:min="todayIso"
|
|
:max="maxIso"
|
|
hint="Entre aujourd'hui et +30 jours"
|
|
/>
|
|
</div>
|
|
|
|
<div class="rounded-lg border p-4">
|
|
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
|
<MalioDateRange
|
|
v-model="initialValue"
|
|
label="Période verrouillée"
|
|
:clearable="false"
|
|
/>
|
|
</div>
|
|
|
|
<div class="rounded-lg border p-4">
|
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
|
<MalioDateRange
|
|
v-model="initialValue"
|
|
label="Désactivé"
|
|
disabled
|
|
/>
|
|
</div>
|
|
|
|
<div class="rounded-lg border p-4">
|
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
|
<MalioDateRange
|
|
v-model="errorValue"
|
|
label="Période"
|
|
error="Période invalide"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Story>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {ref} from 'vue'
|
|
import MalioDateRange from '../../components/malio/date/DateRange.vue'
|
|
|
|
type RangeValue = {start: string; end: string}
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
const now = new Date()
|
|
const todayIso = toIso(now)
|
|
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
|
|
|
const simpleValue = ref<RangeValue | null>(null)
|
|
const initialValue = ref<RangeValue | null>({start: todayIso, end: maxIso})
|
|
const boundedValue = ref<RangeValue | null>(null)
|
|
const errorValue = ref<RangeValue | null>(null)
|
|
</script>
|
|
```
|
|
|
|
- [ ] **Step 2 : Créer la page playground**
|
|
|
|
```vue
|
|
<!-- .playground/pages/composant/date/dateRange.vue -->
|
|
<template>
|
|
<div class="max-w-md space-y-6">
|
|
<h1 class="text-2xl font-bold">MalioDateRange</h1>
|
|
|
|
<MalioDateRange
|
|
v-model="value"
|
|
label="Période"
|
|
hint="Clique deux fois pour définir une plage"
|
|
/>
|
|
|
|
<div class="rounded border p-3 text-sm">
|
|
<p>Début : <code>{{ value?.start ?? 'null' }}</code></p>
|
|
<p>Fin : <code>{{ value?.end ?? 'null' }}</code></p>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
class="rounded bg-m-primary px-3 py-1.5 text-white"
|
|
@click="value = {start: '2026-12-20', end: '2026-12-31'}"
|
|
>
|
|
Forcer 20→31/12/2026
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded border px-3 py-1.5"
|
|
@click="value = null"
|
|
>
|
|
Réinitialiser
|
|
</button>
|
|
</div>
|
|
|
|
<MalioDateRange
|
|
v-model="bounded"
|
|
label="Plage bornée"
|
|
:min="todayIso"
|
|
:max="maxIso"
|
|
hint="Entre aujourd'hui et +30 jours"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {ref} from 'vue'
|
|
|
|
type RangeValue = {start: string; end: string}
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
const now = new Date()
|
|
const todayIso = toIso(now)
|
|
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
|
|
|
const value = ref<RangeValue | null>(null)
|
|
const bounded = ref<RangeValue | null>(null)
|
|
</script>
|
|
```
|
|
|
|
- [ ] **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.
|