Files
malio-layer-ui/docs/superpowers/plans/2026-05-20-daterange.md
2026-05-20 11:49:39 +02:00

43 KiB

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)

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'échecnpx vitest run app/components/malio/date/composables/dateRange.test.ts → FAIL (import non résolu)

  • Step 3 : Implémenter

// 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èsnpx vitest run app/components/malio/date/composables/dateRange.test.ts → PASS

  • Step 5 : Commit

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)

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'échecnpx vitest run app/components/malio/date/composables/useCalendarView.test.ts → FAIL

  • Step 3 : Implémenter

// 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

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
<!-- 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 : Lintnpx eslint app/components/malio/date/internal/CalendarField.vue → 0 erreur

  • Step 3 : Commit

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

<!-- 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 : Lintnpx eslint app/components/malio/date/Date.vue → 0 erreur

  • Step 4 : Commit

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)
<!-- 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 simplenpx vitest run app/components/malio/date/Date.test.ts → PASS (21)

  • Step 3 : Lintnpx eslint app/components/malio/date/internal/MonthGrid.vue → 0 erreur

  • Step 4 : Commit

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

<!-- 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
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èsnpx 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
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

<!-- 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
<!-- .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 2031/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 visuellenpm 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 : Lintnpx eslint app/story/date/dateRange.story.vue .playground/pages/composant/date/dateRange.vue → 0 erreur

  • Step 5 : Commit

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.