Files
malio-layer-ui/docs/superpowers/specs/2026-05-20-daterange-design.md
2026-05-20 11:44:31 +02:00

12 KiB
Raw Blame History

MalioDateRange — Design Spec

Composant de sélection d'une période (date début / date fin) via un champ + popover calendrier. Deuxième brique de la famille temporelle, construite sur un shell partagé extrait de MalioDate.

Ticket : MUI-33 (suite) Branche : feature/MUI-33-developper-le-composant-datepicker Spec liée : docs/superpowers/specs/2026-05-19-datepicker-design.md

Contexte & roadmap

MalioDate (sélection simple) est déjà livré. Suivront DateWeek et DateTime. Les 4 composants partagent le même champ + popover + header + vue mois ; seule la sélection diffère. On extrait donc un shell réutilisable CalendarField (Approche 3 retenue parce que la famille comptera 4 variantes — la duplication de coquille ×4 et la maintenance front en parallèle sont le vrai risque).

Périmètre

Sélection d'une période sur un seul mois affiché (popover = largeur du champ, comme Date). Visuellement identique à Date, sauf :

  • on sélectionne deux dates (début/fin)
  • les jours entre les bornes ont un fond bg-m-primary-light (bleu clair)
  • les bornes (start/end) gardent le cercle plein bg-m-primary
  • aperçu au survol (hover preview) de la plage pendant la sélection

Inclus : sélection 2 clics, auto-inversion, hover preview, surlignage de plage (demi-barre aux bornes), bornes min/max, effacement, vue mois conservée.

Reporté : deux mois côte à côte, ajustement de la borne la plus proche au 3e clic (on garde le reset standard), saisie clavier, navigation clavier.

Architecture (Approche 3 — shell partagé)

app/components/malio/date/
  Date.vue                       # ENVELOPPE (refacto) — sélection simple
  DateRange.vue                  # ENVELOPPE (nouveau) — sélection période
  Date.test.ts                   # inchangé (filet de sécurité du refacto)
  DateRange.test.ts              # nouveau
  internal/
    CalendarField.vue            # NOUVEAU — shell : champ + popover + header + MonthPicker
    CalendarHeader.vue           # inchangé
    MonthGrid.vue                # étendu : props range + émission hover + data-range-role
    MonthPicker.vue              # inchangé
  composables/
    useCalendarView.ts           # NOUVEAU — état mois/année + navigation (extrait de Date.vue)
    useCalendarView.test.ts
    useCalendarPopover.ts        # inchangé
    dateRange.ts                 # NOUVEAU — helpers purs de plage
    dateRange.test.ts
    dateFormat.ts                # inchangé
    useMonthMatrix.ts            # inchangé
app/story/date/dateRange.story.vue
.playground/pages/composant/date/dateRange.vue

Flux :

DateRange.vue (enveloppe)
 ├─ état de sélection range ({start,end} + pendingStart + hoverDate)
 ├─ displayValue ("19/05/2026 - 25/05/2026")
 └─ <CalendarField :display-value :sync-to=start ... @clear @close>
        ├─ champ + popover (useCalendarPopover) + navigation (useCalendarView)
        ├─ header + MonthPicker (viewMode='months')
        └─ <slot :current-month :current-year :close>   ← viewMode='days'
               └─ <MonthGrid> mode range (rangeStart/rangeEnd/previewDate, @select, @hover)

CalendarField ne connaît rien de la sélection : il gère champ, ouverture, navigation, et expose { currentMonth, currentYear, close } au slot. Chaque enveloppe branche son MonthGrid et décide quand appeler close().

useCalendarView.ts

function useCalendarView(viewMode: Ref<'days' | 'months'>): {
  currentMonth: Ref<number>          // 0-11
  currentYear: Ref<number>
  goToPrev: () => void               // viewMode==='months' ? année-1 : mois-1 (roulement déc↔jan)
  goToNext: () => void               // idem +1
  selectMonth: (m: number) => void   // currentMonth = m
  syncToIso: (iso: string | null) => void  // mois/année depuis un ISO valide, sinon mois courant
}

Reprend la logique onPrev/onNext/syncViewToValue actuelle de Date.vue. Pur, testable seul.

CalendarField.vue (shell)

Props :

Prop Type Défaut Description
displayValue string requis Texte affiché dans le champ ('' si rien/incomplet)
syncTo string | null requis ISO servant à caler le mois à l'ouverture
id,name,label,placeholder string '' / 'JJ/MM/AAAA' Champ
required,disabled,readonly boolean false États
hint,error,success string '' Messages
clearable boolean true Croix d'effacement
inputClass,labelClass,groupClass string '' Overrides twMerge

Events :

Event Payload Description
clear Croix cliquée → l'enveloppe met son modelValue à null
close Popover fermé (clic dehors ou programmatique) → l'enveloppe annule sa sélection en cours

Slot par défaut (scoped, rendu quand viewMode==='days') : { currentMonth: number, currentYear: number, close: () => void }.

Comportement (repris à l'identique de l'actuel Date.vue) : input readonly, label flottant (bleu à l'ouverture), icône calendrier, croix (si clearable && displayValue && !disabled && !readonly), grossissement calibré 48px, bordures/états, popover ombré largeur champ collé sous le champ, header (prev/nextuseCalendarView, toggleuseCalendarPopover.toggleView), MonthPicker en vue mois (clic mois → selectMonth + retour vue jours), syncToIso(syncTo) à l'ouverture + watch resync. isFilled dérivé de displayValue.length > 0.

dateRange.ts (helpers purs)

type DateRangeValue = { start: string; end: string }

function normalizeRange(a: string, b: string): DateRangeValue
// réordonne pour garantir start ≤ end

function resolveRangeBounds(
  start: string | null, end: string | null, preview: string | null,
): { lo: string; hi: string } | null
// pas de start → null ; end committé prioritaire, sinon preview, sinon {lo:start,hi:start}

type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
function dayRangeRole(iso: string, bounds: { lo: string; hi: string } | null): DayRangeRole

MonthGrid.vue — extension

Nouvelles props (optionnelles) : rangeStart?, rangeEnd?, previewDate? (ISO ou null). Mode plage actif dès que rangeStart est passé ; sinon mode simple (selectedDate, comportement actuel inchangé).

Nouvel event : hover payload string | nullmouseenter d'un jour → ISO, mouseleave de la grille → null.

Attribut testabilité : chaque bouton jour porte :data-range-role="role" (none/single/start/end/in-range).

Rendu d'un jour en mode plage — bouton relative superposant 2 couches :

  1. Barre de fond absolue bg-m-primary-light : in-range → pleine largeur (inset-0) ; start → moitié droite (left-1/2 right-0) ; end → moitié gauche (left-0 right-1/2) ; single/none → aucune.
  2. Cercle (span h-10 w-10) au-dessus : start/end/singlebg-m-primary blanc ; in-range → transparent, texte noir ; none → rendu simple actuel (aujourd'hui, hors-mois…).

La barre passe sous les cercles, colonnes jointives → plage continue démarrant/finissant au centre des cercles.

DateRange.vue (enveloppe)

Props : identiques à Date sauf modelValue?: { start: string; end: string } | null. (id,name,label,placeholder,required,disabled,readonly,hint,error,success,min,max,clearable,inputClass,labelClass,groupClass.)

Emit : update:modelValue{ start: string; end: string } | null.

État interne :

pendingStart = ref<string | null>(null)   // 1er clic en attente du 2e
hoverDate    = ref<string | null>(null)   // survol pour le preview
const isSelecting = computed(() => pendingStart.value !== null)

Passé au <MonthGrid> :

rangeStart  = isSelecting ? pendingStart : (modelValue?.start ?? null)
rangeEnd    = isSelecting ? null         : (modelValue?.end ?? null)
previewDate = isSelecting ? hoverDate    : null
// + :min :max :month :year (slot)

displayValue : '' pendant la sélection (1 seul jour choisi) ; "JJ/MM/AAAA - JJ/MM/AAAA" si plage complète ; '' sinon. syncTo = modelValue?.start ?? null.

Machine à états :

onSelectDay(iso):
  si pendingStart === null:                 # 1er clic (ou reset après plage complète)
      pendingStart = iso ; hoverDate = null
  sinon:                                     # 2e clic → complète
      { start, end } = normalizeRange(pendingStart, iso)   # auto-inversion
      emit('update:modelValue', { start, end })
      pendingStart = null ; hoverDate = null
      close()                                # ferme le popover (slot)

onHover(iso):                                # émis par MonthGrid
  si isSelecting: hoverDate = iso            # preview seulement pendant la sélection

onClose():                                   # CalendarField émet 'close'
  pendingStart = null ; hoverDate = null     # annule la sélection en cours, modelValue inchangé

onClear():                                   # CalendarField émet 'clear'
  emit('update:modelValue', null)
  pendingStart = null ; hoverDate = null
  • 3e clic (plage complète) : pendingStart===null → nouveau start, ancienne plage masquée pendant la sélection (rangeEnd=null), remplacée à la complétion.
  • min/max : MonthGrid désactive les jours hors bornes → les 2 clics sont contraints.
  • modelValue invalide (start/end mal formés) : traité comme null + warning dev.

Refacto Date.vue

API publique inchangée. Devient une enveloppe (~80 lignes) :

<CalendarField :display-value="displayValue" :sync-to="modelValue ?? null" ...props
  @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>

displayValue = formatIsoToDisplay(modelValue). Watch modelValue invalide → warning dev (conservé). Mode simple : pas de @close (rien à annuler), pas de @hover.

Les 21 tests de Date.test.ts doivent passer sans modification : tous les data-test sont rendus par CalendarField/MonthGrid, donc présents dans le DOM monté de Date. C'est le filet de sécurité du refacto.

Tests

dateRange.test.ts (~12)

  • normalizeRange : ordonné, inversé, égal
  • resolveRangeBounds : pas de start → null ; start seul → {lo,hi}=start ; start+end ordonné ; start+preview ; preview avant start (inversion) ; end prioritaire sur preview
  • dayRangeRole : none (pas de bornes / hors plage), single (lo===hi), start, end, in-range

useCalendarView.test.ts (~8, fake timers)

  • mois/année initiaux = aujourd'hui ; goToNext/goToPrev vue jours (+ roulement déc↔jan avec année) ; goToNext/goToPrev vue mois (année ±1) ; selectMonth ; syncToIso valide / null

Date.test.ts

Inchangé — doit rester vert (filet du refacto).

DateRange.test.ts (~18)

  • Rendu : label, icône, "19/05/2026 - 25/05/2026" si modelValue, champ vide sinon
  • Ouverture popover, vue sur le mois du start
  • 1er clic → pas d'émission ; 2e clic → émet {start,end} + ferme
  • 2e clic avant le 1er → auto-inversion (start ≤ end)
  • Même jour ×2 → {start:x, end:x}
  • 3e clic → repart sur un nouveau start (pas d'émission avant le 2e)
  • Hover pendant sélection → data-range-role="in-range" sur jours intermédiaires ; pas de preview hors sélection
  • Rôles : start/end/in-range corrects via data-range-role
  • Clic dehors pendant sélection → annulation, modelValue inchangé
  • clear → émet null
  • min/max → jours hors bornes non cliquables
  • a11y : aria-invalid sur error

Story dateRange.story.vue

Default vide, plage initiale, min/max, états (disabled/readonly/error/success), non-clearable.

Playground .playground/pages/composant/date/dateRange.vue

<MalioDateRange> standalone + affichage start → end, boutons set/reset, cas borné.

Découpage d'implémentation

  1. Helpers purs dateRange.ts + tests
  2. Composable useCalendarView.ts + tests
  3. Shell CalendarField.vue (extraction depuis Date.vue)
  4. Refacto Date.vue en enveloppe → Date.test.ts doit rester vert
  5. Extension MonthGrid.vue (range + hover + data-range-role)
  6. DateRange.vue + DateRange.test.ts
  7. Story + playground