Compare commits

..

6 Commits

Author SHA1 Message Date
tristan 7b838c60ca [#MUI-36] Création d'un composant modal (#53)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #53
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-26 07:36:13 +00:00
tristan 9551816bf8 Merge branch 'main' into develop
# Conflicts:
#	.playground/playground.nav.ts
#	CHANGELOG.md
2026-05-22 09:59:06 +02:00
tristan 7ac097e7f0 [#MUI-33] Développer le composant Datepicker (#50)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #50
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-22 07:56:07 +00:00
tristan 7ca5c5f4c5 fix: refonte du composant Drawer (#51)
Release / release (push) Successful in 1m25s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #51
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-22 07:05:16 +00:00
tristan bc813190c6 Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	CHANGELOG.md
2026-05-22 09:03:49 +02:00
tristan f3a18ace1d feat: composant saisie assistée, composant téléphone et composant mail (#47)
Release / release (push) Successful in 1m12s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #47
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-13 07:01:30 +00:00
17 changed files with 3375 additions and 0 deletions
@@ -0,0 +1,68 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateTime</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateTime
v-model="value"
label="Date et heure du rendez-vous"
hint="Choisis un jour puis une heure"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ value ?? '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 = '2026-12-25T09:30:00'"
>
Forcer le 25/12/2026 09:30
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateTime
v-model="erpValue"
label="Date et heure du rendez-vous"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateTime
v-model="bounded"
label="Créneau borné"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>('2026-05-20T14:30:00')
</script>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import MalioButton from "../../../../app/components/malio/button/Button.vue";
const modalBase = ref(false)
const modalForm = ref(false)
const modalLong = ref(false)
const modalNoDismiss = ref(false)
</script>
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Modal simple</h2>
<MalioButton label="Ouvrir" @click="modalBase = true" />
<MalioModal v-model="modalBase" headerClass="py-7 px-[25px]" footerClass="flex justify-center pt-8">
<template #header>
<h2 class="text-[24px] font-bold text-black">Marquer comme vu ?</h2>
</template>
<template #footer>
<MalioButton label="Valider"/>
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="modalForm = true" />
<MalioModal v-model="modalForm" modal-class="max-w-lg">
<template #header>
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
<MalioInputText label="Email" />
</div>
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="modalForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="modalForm = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Contenu long (body scrollable)</h2>
<MalioButton label="Ouvrir" variant="tertiary" @click="modalLong = true" />
<MalioModal v-model="modalLong">
<template #header>
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
</template>
<div class="flex flex-col gap-4">
<p v-for="n in 20" :key="n" class="text-m-text">
Paragraphe {{ n }} contenu long pour forcer le scroll interne ; le header et le footer restent fixes.
</p>
</div>
<template #footer>
<MalioButton label="Accepter" button-class="w-full" @click="modalLong = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
<MalioButton label="Ouvrir" variant="danger" @click="modalNoDismiss = true" />
<MalioModal v-model="modalNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
</template>
<p class="text-m-text">Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
</MalioModal>
</div>
</div>
</template>
+3
View File
@@ -32,6 +32,7 @@ export const navSections: SidebarSection[] = [
{label: 'Date', to: '/composant/date/date'},
{label: 'Plage de dates', to: '/composant/date/dateRange'},
{label: 'Semaine', to: '/composant/date/dateWeek'},
{label: 'Date & heure', to: '/composant/date/datetime'},
{label: 'Heure', to: '/composant/time/time'},
],
},
@@ -51,6 +52,7 @@ export const navSections: SidebarSection[] = [
items: [
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
{label: 'Drawer', to: '/composant/drawer/drawer'},
{label: 'Modal', to: '/composant/modal/modal'},
{label: 'Onglets', to: '/composant/tab/tabList'},
],
},
@@ -65,6 +67,7 @@ export const navSections: SidebarSection[] = [
label: 'DIVERS',
icon: 'mdi:dots-horizontal',
items: [
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
{label: 'Formulaire client', to: '/composant/form/client'},
],
+2
View File
@@ -32,6 +32,8 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
* [#MUI-34] Revoir le système de playground
* [#MUI-33] Développer le composant Datepicker
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
### Changed
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
+189
View File
@@ -442,6 +442,106 @@ Bouton radio (à utiliser en groupe avec le même `name`).
---
## MalioDate
Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Requis |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` (jours antérieurs désactivés) |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` (jours postérieurs désactivés) |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioDate v-model="date" label="Date de naissance" />
<!-- date === "2026-05-20" -->
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
```
---
## MalioDateRange
Sélecteur de **plage de dates** (date de début → date de fin) dans un seul champ. Cliquer un premier jour démarre la plage, le second la termine ; un survol prévisualise la plage.
La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"`), ou `null`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `{ start: string; end: string } \| null` | `undefined` | Plage de dates ISO (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Requis |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: { start: string; end: string } | null)`
```vue
<MalioDateRange v-model="periode" label="Période de séjour" />
<!-- periode === { start: "2026-05-20", end: "2026-05-27" } -->
```
---
## MalioDateWeek
Sélecteur de **semaine ISO** : cliquer un jour (ou un numéro de semaine) sélectionne la semaine entière.
La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2026-W21"`), ou `null`. Le champ affiche `Semaine W (JJ/MM → JJ/MM/AAAA)`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Semaine ISO `"YYYY-Www"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Requis |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioDateWeek v-model="semaine" label="Semaine de livraison" />
<!-- semaine === "2026-W21" -->
```
---
## MalioTime
Sélecteur d'heure.
@@ -463,6 +563,43 @@ Sélecteur d'heure.
---
## MalioDateTime
Champ unique combinant **date et heure** dans un popover (grille de calendrier + sélecteur d'heure sous la grille).
> ⚠️ **Version intérimaire** : le sélecteur d'heure est un `<input type="time">` natif, en attendant la maquette d'un sélecteur d'heure dédié. Le bloc heure est isolé pour être remplacé sans impact sur le reste.
La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:MM:00"` (heure murale locale). Symfony (`DateTimeNormalizer`) parse ce format et applique son fuseau configuré côté back — pas de gestion de fuseau côté front.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date + heure ISO naïve `"YYYY-MM-DDTHH:MM:00"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
| `required` | `boolean` | `false` | Requis |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Borne min (datetime ou date ; borne la grille sur la partie date) |
| `max` | `string` | `undefined` | Borne max (idem) |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
```vue
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
<!-- rdv === "2026-05-20T14:30:00" -->
```
---
## MalioButton
Bouton d'action avec 4 variantes visuelles et icône optionnelle.
@@ -642,6 +779,58 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
---
## MalioModal
Boîte de dialogue modale centrée avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs modals. Structure : header fixe, body scrollable (`max-h-[85vh]`), footer fixe.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
| `modalClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-lg` (twMerge) |
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS footer fixe (twMerge) |
**Events :** `update:modelValue(value: boolean)`, `close()`
**Slots :**
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable).
- `footer` — actions (boutons). Rendu en bas, fixe, séparé par une bordure. N'apparaît que si le slot est fourni.
```vue
<MalioModal v-model="isOpen">
<template #header>
<h2 class="text-[24px] font-bold">Détails</h2>
</template>
<p>Contenu de la modal</p>
</MalioModal>
<!-- Largeur custom + footer d'actions -->
<MalioModal v-model="isOpen" modal-class="max-w-lg">
<template #header><h2>Nouveau contact</h2></template>
<MalioInputText label="Nom" />
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="isOpen = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="isOpen = false" />
</template>
</MalioModal>
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
<MalioModal v-model="isOpen" :dismissable="false" :close-on-escape="false">
<template #header><h2>Action requise</h2></template>
<p>Fermeture via la croix uniquement</p>
</MalioModal>
```
---
## MalioDataTable
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
+120
View File
@@ -0,0 +1,120 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateTime_ from './DateTime.vue'
type DateTimeProps = {
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
}
const DateTimeForTest = DateTime_ as DefineComponent<DateTimeProps>
const mountDateTime = (props: DateTimeProps = {}) =>
mount(DateTimeForTest, {props, attachTo: document.body})
describe('MalioDateTime', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('affiche le label et l\'icône calendrier', () => {
const wrapper = mountDateTime({label: 'Rendez-vous'})
expect(wrapper.get('label').text()).toBe('Rendez-vous')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('affiche la valeur formatée date + heure dans le champ', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('20/05/2026 14:30')
})
})
describe('popover', () => {
it('ouvre la grille et l\'input heure au clic', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
expect(wrapper.find('[data-test="time-input"]').exists()).toBe(true)
})
})
describe('sélection', () => {
it('émet le jour à 00:00 et garde le popover ouvert', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T00:00:00'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('applique l\'heure réglée avant le clic du jour', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="time-input"]').setValue('09:15')
// pas d'émission tant qu'aucun jour n'est choisi
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
})
it('met à jour l\'heure quand une date est déjà choisie', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="time-input"]').setValue('08:45')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
})
it('initialise l\'input heure depuis la valeur', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const time = wrapper.get('[data-test="time-input"]').element as HTMLInputElement
expect(time.value).toBe('14:30')
})
})
describe('bornes min/max', () => {
it('désactive les jours hors bornes (datetime borné sur la date)', async () => {
const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
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)
})
})
describe('effacement', () => {
it('émet null au clic sur la croix', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
})
describe('accessibilité', () => {
it('positionne aria-invalid et describedby sur erreur', () => {
const wrapper = mountDateTime({error: 'Date requise'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Date requise')
})
})
})
+134
View File
@@ -0,0 +1,134 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="datePart"
: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="onClear"
>
<template #default="{ currentMonth, currentYear }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="datePart"
:min="min?.slice(0, 10)"
:max="max?.slice(0, 10)"
@select="onSelectDay"
/>
<!-- Bloc heure intérimaire : input natif, isolé pour remplacement futur par le sélecteur dédié. -->
<div class="mt-[26px] flex-col items-center gap-2">
<input
:id="timeInputId"
data-test="time-input"
type="time"
:value="timeValue"
class="w-full border border-m-muted bg-white px-2 py-1 text-base outline-none focus:border-m-primary"
@input="onTimeInput"
>
</div>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref, useId, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat'
defineOptions({name: 'MalioDateTime', 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 HH:MM',
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 generatedId = useId()
const timeInputId = computed(() => `${props.id || `malio-datetime-${generatedId}`}-time`)
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
const pendingTime = ref('')
const parts = computed(() => splitDateTime(props.modelValue ?? null))
const datePart = computed(() => parts.value.date)
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
const timeValue = computed(() => parts.value.time || pendingTime.value)
function onSelectDay(iso: string) {
const time = parts.value.time || pendingTime.value || '00:00'
emit('update:modelValue', composeDateTime(iso, time))
}
function onTimeInput(e: Event) {
const value = (e.target as HTMLInputElement).value
if (!value) return
if (datePart.value) {
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
pendingTime.value = value
}
}
function onClear() {
pendingTime.value = ''
emit('update:modelValue', null)
}
watch(() => props.modelValue, (val) => {
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
}
})
</script>
@@ -0,0 +1,61 @@
import {describe, expect, it} from 'vitest'
import {
composeDateTime,
formatIsoDateTimeToDisplay,
isValidIsoDateTime,
splitDateTime,
} from './datetimeFormat'
describe('datetimeFormat', () => {
describe('isValidIsoDateTime', () => {
it('accepte un datetime ISO complet valide', () => {
expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true)
expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true)
expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true)
})
it('rejette une date seule, des composants invalides ou une chaîne vide', () => {
expect(isValidIsoDateTime('2026-05-20')).toBe(false)
expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false)
expect(isValidIsoDateTime('')).toBe(false)
})
})
describe('formatIsoDateTimeToDisplay', () => {
it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => {
expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30')
})
it('renvoie une chaîne vide pour nul ou invalide', () => {
expect(formatIsoDateTimeToDisplay(null)).toBe('')
expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('')
expect(formatIsoDateTimeToDisplay('nope')).toBe('')
})
})
describe('splitDateTime', () => {
it('découpe un datetime valide', () => {
expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'})
})
it('renvoie date null et time vide pour nul, date seule ou invalide', () => {
expect(splitDateTime(null)).toEqual({date: null, time: ''})
expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''})
expect(splitDateTime('nope')).toEqual({date: null, time: ''})
})
})
describe('composeDateTime', () => {
it('recompose un datetime ISO avec secondes à 00', () => {
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
})
it('utilise 00:00 quand l\'heure est vide', () => {
expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00')
})
})
})
@@ -0,0 +1,33 @@
import {isValidIso} from './dateFormat'
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
export function isValidIsoDateTime(s: string): boolean {
const m = DATETIME_RE.exec(s)
if (!m) return false
const [, date, hh, mm, ss] = m
if (!isValidIso(date)) return false
const h = Number(hh)
const min = Number(mm)
const sec = Number(ss)
return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
}
export function formatIsoDateTimeToDisplay(s: string | null): string {
if (!s || !isValidIsoDateTime(s)) return ''
const [date, time] = s.split('T')
const [y, mo, d] = date.split('-')
const [hh, mm] = time.split(':')
return `${d}/${mo}/${y} ${hh}:${mm}`
}
export function splitDateTime(s: string | null): {date: string | null; time: string} {
if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''}
const [date, time] = s.split('T')
return {date, time: time.slice(0, 5)}
}
export function composeDateTime(date: string, time: string): string {
const t = time || '00:00'
return `${date}T${t}:00`
}
+320
View File
@@ -0,0 +1,320 @@
import { afterEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import Modal from './Modal.vue'
type ModalProps = {
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}
const ModalForTest = Modal as DefineComponent<ModalProps>
function mountComponent(props: ModalProps = {}, slots?: Record<string, string>) {
return mount(ModalForTest, {
props,
slots,
global: { stubs: { Teleport: true } },
})
}
describe('MalioModal', () => {
enableAutoUnmount(afterEach)
afterEach(() => {
document.body.style.overflow = ''
})
it('does not render when modelValue is false', () => {
const wrapper = mountComponent({ modelValue: false })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('renders the panel when modelValue is true', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
})
it('centers the modal (items-center justify-center)', () => {
const wrapper = mountComponent({ modelValue: true })
const root = wrapper.find('.fixed')
expect(root.classes()).toContain('items-center')
expect(root.classes()).toContain('justify-center')
})
it('renders default slot in the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ default: '<p data-test="content">Contenu</p>' },
)
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
})
it('works in uncontrolled mode (defaults closed)', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('uses custom id when provided', () => {
const wrapper = mountComponent({ modelValue: true, id: 'my-modal' })
expect(wrapper.find('.fixed').attributes('id')).toBe('my-modal')
})
it('generates an id when not provided', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-modal-/)
})
it('has role="dialog" and aria-modal on the panel', () => {
const wrapper = mountComponent({ modelValue: true })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('role')).toBe('dialog')
expect(panel.attributes('aria-modal')).toBe('true')
})
it('applies modalClass to the panel', () => {
const wrapper = mountComponent({ modelValue: true, modalClass: 'max-w-2xl' })
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
})
it('renders the #header slot inside the header bar', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ header: '<h2 data-test="title">Titre</h2>' },
)
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
})
it('renders the header bar when showClose is true even without #header', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
})
it('does not render the header bar when no #header and showClose is false', () => {
const wrapper = mountComponent({ modelValue: true, showClose: false })
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
})
it('shows the close button by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
})
it('hides the close button when showClose is false', () => {
const wrapper = mountComponent(
{ modelValue: true, showClose: false },
{ header: '<h2>Titre</h2>' },
)
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
})
it('close button renders mdi:cancel-bold icon', () => {
const wrapper = mountComponent({ modelValue: true })
const icon = wrapper.findComponent(IconifyIcon)
expect(icon.props('icon')).toBe('mdi:cancel-bold')
})
it('close button has aria-label "Fermer"', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
})
it('emits update:modelValue false and close on close button click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="close-button"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('sets aria-labelledby to the header id when #header is provided', () => {
const wrapper = mountComponent(
{ modelValue: true, id: 'test-modal' },
{ header: '<h2>Titre</h2>' },
)
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-labelledby')).toBe('test-modal-header')
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-modal-header')
})
it('sets aria-label from ariaLabel when no #header is provided', () => {
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Boîte de dialogue' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-label')).toBe('Boîte de dialogue')
expect(panel.attributes('aria-labelledby')).toBeUndefined()
})
it('applies headerClass to the header bar', () => {
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
})
it('renders the #footer slot in a footer pinned below the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ footer: '<button data-test="save">Enregistrer</button>' },
)
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
})
it('does not render the footer when no #footer slot', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
})
it('applies bodyClass to the body', () => {
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
})
it('applies footerClass to the footer', () => {
const wrapper = mountComponent(
{ modelValue: true, footerClass: 'justify-end' },
{ footer: '<span>pied</span>' },
)
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
})
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on backdrop click when dismissable is false', async () => {
const wrapper = mountComponent({ modelValue: true, dismissable: false })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('applies overlayClass to the backdrop', () => {
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
})
it('closes on Escape key when closeOnEscape is true', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on Escape when closeOnEscape is false', async () => {
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('locks body scroll when opened and restores it when closed', async () => {
const wrapper = mountComponent({ modelValue: false })
expect(document.body.style.overflow).toBe('')
await wrapper.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
it('moves focus into the panel when opened', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: false, showClose: false },
slots: { default: '<button data-test="first">OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="first"]').element
expect(document.activeElement).toBe(first)
wrapper.unmount()
})
it('restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
expect(document.activeElement).toBe(trigger)
const wrapper = mount(ModalForTest, {
props: { modelValue: false },
slots: { default: '<button>OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
await wrapper.setProps({ modelValue: false })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
trigger.remove()
})
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
last.focus()
expect(document.activeElement).toBe(last)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
wrapper.unmount()
})
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
first.focus()
expect(document.activeElement).toBe(first)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
wrapper.unmount()
})
it('does not release body scroll-lock when one stacked modal closes while another is still open', async () => {
const wrapperA = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
const wrapperB = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapperA.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('hidden')
await wrapperA.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
})
+279
View File
@@ -0,0 +1,279 @@
<template>
<Teleport to="body">
<Transition
name="modal"
appear
@after-leave="isRendered = false"
>
<div
v-if="isRendered && isOpen"
:id="componentId"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
v-bind="attrs"
>
<div
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
data-test="backdrop"
@click="onBackdropClick"
/>
<div
ref="panelRef"
:class="twMerge(
'relative z-50 flex max-h-[85vh] w-full max-w-md flex-col rounded-malio bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]',
modalClass,
)"
role="dialog"
aria-modal="true"
:aria-labelledby="hasHeader ? headerId : undefined"
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
tabindex="-1"
data-test="panel"
@keydown="onKeydown"
>
<div
v-if="hasHeader || showClose"
:class="twMerge('flex shrink-0 items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
data-test="header"
>
<div
:id="headerId"
class="min-w-0 flex-1"
data-test="header-content"
>
<slot name="header" />
</div>
<button
v-if="showClose"
type="button"
aria-label="Fermer"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
data-test="close-button"
@click="close"
>
<IconifyIcon
icon="mdi:cancel-bold"
:width="16"
:height="16"
/>
</button>
</div>
<div
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
data-test="body"
>
<slot />
</div>
<div
v-if="$slots.footer"
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
data-test="footer"
>
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
useSlots,
watch,
} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioModal', inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}>(),
{
id: '',
modelValue: undefined,
showClose: true,
dismissable: true,
closeOnEscape: true,
ariaLabel: '',
modalClass: '',
overlayClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-modal-${generatedId}`)
const slots = useSlots()
const headerId = computed(() => `${componentId.value}-header`)
const hasHeader = computed(() => !!slots.header)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false)
const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const isRendered = ref(isOpen.value)
const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
// Per-instance flag: true while this modal holds a scroll-lock count slot.
let lockedByThisInstance = false
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
),
).filter((el) => el.tabIndex !== -1)
}
function onOpen() {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
if (!lockedByThisInstance) {
lockedByThisInstance = true
openModalCount++
if (openModalCount === 1) {
document.body.style.overflow = 'hidden'
}
}
nextTick(() => {
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
;(focusable[0] ?? panel).focus()
})
}
function onClose() {
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
previouslyFocused?.focus?.()
previouslyFocused = null
}
watch(isOpen, (val) => {
if (val) {
isRendered.value = true
onOpen()
}
else {
onClose()
}
})
onMounted(() => {
if (isOpen.value) onOpen()
})
onBeforeUnmount(() => {
// If this instance is still holding a scroll-lock slot, release it.
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
})
function onBackdropClick() {
if (props.dismissable) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEscape) {
e.stopPropagation()
close()
return
}
if (e.key !== 'Tab') return
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
if (focusable.length === 0) {
e.preventDefault()
panel.focus()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
}
else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
function close() {
if (!isControlled.value) localValue.value = false
emit('update:modelValue', false)
emit('close')
}
</script>
<script lang="ts">
// Shared across all MalioModal instances: only the last open modal releases the body scroll-lock.
let openModalCount = 0
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active > div:last-child,
.modal-leave-active > div:last-child {
transition: transform 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child,
.modal-leave-to > div:last-child {
transform: scale(0.95);
}
</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<Story title="Date/DateTime">
<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>
<MalioDateTime
v-model="simpleValue"
label="Date et heure"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateTime
v-model="initialValue"
label="Rendez-vous"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateTime
v-model="boundedValue"
label="Créneau"
: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">Erreur</h2>
<MalioDateTime
v-model="errorValue"
label="Date limite"
error="Date et heure requises"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateTime
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
<MalioDateTime
v-model="initialValue"
label="Lecture seule"
readonly
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateTime from '../../components/malio/date/DateTime.vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>('2026-05-20T14:30:00')
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
</script>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({ name: 'ModalStory' })
const showBase = ref(false)
const showForm = ref(false)
const showNoDismiss = ref(false)
</script>
<template>
<Story title="Overlay/Modal">
<Variant title="Simple">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showBase = true"
>
Ouvrir
</button>
<MalioModal v-model="showBase">
<template #header>
<h2 class="text-xl font-bold">Détails</h2>
</template>
<p>Contenu simple de la modal.</p>
</MalioModal>
</div>
</Variant>
<Variant title="Avec footer d'actions">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showForm = true"
>
Ouvrir le formulaire
</button>
<MalioModal v-model="showForm" modal-class="max-w-lg">
<template #header>
<h2 class="text-xl font-bold">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
</div>
<template #footer>
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
</template>
</MalioModal>
</div>
</Variant>
<Variant title="Non dismissable">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showNoDismiss = true"
>
Ouvrir
</button>
<MalioModal v-model="showNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-xl font-bold">Action requise</h2>
</template>
<p>Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
</MalioModal>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,712 @@
# MalioDateTime Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ajouter un composant `MalioDateTime` (date + heure dans un seul champ) à la famille temporelle de `@malio/layer-ui`, en version intérimaire avec `<input type="time">` natif.
**Architecture:** Fine enveloppe autour du shell `internal/CalendarField.vue` (comme `MalioDate`). Le slot popover contient `MonthGrid` (jour) + un `<input type="time">` (heure) sous la grille. La valeur émise est l'ISO naïf `"YYYY-MM-DDTHH:MM:00"`. Logique de découpe/recomposition dans un nouveau composable `datetimeFormat.ts`.
**Tech Stack:** Vue 3 `<script setup lang="ts">`, TypeScript strict, Tailwind (`m-*`), `tailwind-merge`, Vitest + @vue/test-utils (jsdom).
**Conventions (rappel) :** Conventional Commits **avec espace avant `:`**, type minuscule, suffixe ticket `(#MUI-33)`, pas de préfixe `[#...]`. Terminer par `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`. Le hook pre-commit est flaky (timeouts WSL2) → après vérification ciblée `npx vitest run <chemin>`, committer avec `--no-verify`.
**Réfs spec :** `docs/superpowers/specs/2026-05-22-datetime-design.md`.
---
### Task 1 : Composable `datetimeFormat.ts`
**Files:**
- Create: `app/components/malio/date/composables/datetimeFormat.ts`
- Test: `app/components/malio/date/composables/datetimeFormat.test.ts`
- [ ] **Step 1 : Écrire les tests qui échouent**
Créer `app/components/malio/date/composables/datetimeFormat.test.ts` :
```ts
import {describe, expect, it} from 'vitest'
import {
composeDateTime,
formatIsoDateTimeToDisplay,
isValidIsoDateTime,
splitDateTime,
} from './datetimeFormat'
describe('datetimeFormat', () => {
describe('isValidIsoDateTime', () => {
it('accepte un datetime ISO complet valide', () => {
expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true)
expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true)
expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true)
})
it('rejette une date seule, des composants invalides ou une chaîne vide', () => {
expect(isValidIsoDateTime('2026-05-20')).toBe(false)
expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false)
expect(isValidIsoDateTime('')).toBe(false)
})
})
describe('formatIsoDateTimeToDisplay', () => {
it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => {
expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30')
})
it('renvoie une chaîne vide pour nul ou invalide', () => {
expect(formatIsoDateTimeToDisplay(null)).toBe('')
expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('')
expect(formatIsoDateTimeToDisplay('nope')).toBe('')
})
})
describe('splitDateTime', () => {
it('découpe un datetime valide', () => {
expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'})
})
it('renvoie date null et time vide pour nul, date seule ou invalide', () => {
expect(splitDateTime(null)).toEqual({date: null, time: ''})
expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''})
expect(splitDateTime('nope')).toEqual({date: null, time: ''})
})
})
describe('composeDateTime', () => {
it('recompose un datetime ISO avec secondes à 00', () => {
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
})
it('utilise 00:00 quand l\\'heure est vide', () => {
expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00')
})
})
})
```
- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent**
Run: `npx vitest run app/components/malio/date/composables/datetimeFormat.test.ts`
Expected: FAIL (module `./datetimeFormat` introuvable).
- [ ] **Step 3 : Écrire l'implémentation**
Créer `app/components/malio/date/composables/datetimeFormat.ts` :
```ts
import {isValidIso} from './dateFormat'
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
export function isValidIsoDateTime(s: string): boolean {
const m = DATETIME_RE.exec(s)
if (!m) return false
const [, date, hh, mm, ss] = m
if (!isValidIso(date)) return false
const h = Number(hh)
const min = Number(mm)
const sec = Number(ss)
return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
}
export function formatIsoDateTimeToDisplay(s: string | null): string {
if (!s || !isValidIsoDateTime(s)) return ''
const [date, time] = s.split('T')
const [y, mo, d] = date.split('-')
const [hh, mm] = time.split(':')
return `${d}/${mo}/${y} ${hh}:${mm}`
}
export function splitDateTime(s: string | null): {date: string | null; time: string} {
if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''}
const [date, time] = s.split('T')
return {date, time: time.slice(0, 5)}
}
export function composeDateTime(date: string, time: string): string {
const t = time || '00:00'
return `${date}T${t}:00`
}
```
- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent**
Run: `npx vitest run app/components/malio/date/composables/datetimeFormat.test.ts`
Expected: PASS (tous verts).
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/composables/datetimeFormat.ts app/components/malio/date/composables/datetimeFormat.test.ts
git commit --no-verify -m "feat : composable datetimeFormat pour MalioDateTime (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 2 : Composant `DateTime.vue`
**Files:**
- Create: `app/components/malio/date/DateTime.vue`
- Test: `app/components/malio/date/DateTime.test.ts`
- [ ] **Step 1 : Écrire les tests qui échouent**
Créer `app/components/malio/date/DateTime.test.ts` :
```ts
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateTime_ from './DateTime.vue'
type DateTimeProps = {
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
}
const DateTimeForTest = DateTime_ as DefineComponent<DateTimeProps>
const mountDateTime = (props: DateTimeProps = {}) =>
mount(DateTimeForTest, {props, attachTo: document.body})
describe('MalioDateTime', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('affiche le label et l\\'icône calendrier', () => {
const wrapper = mountDateTime({label: 'Rendez-vous'})
expect(wrapper.get('label').text()).toBe('Rendez-vous')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('affiche la valeur formatée date + heure dans le champ', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('20/05/2026 14:30')
})
})
describe('popover', () => {
it('ouvre la grille et l\\'input heure au clic', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
expect(wrapper.find('[data-test="time-input"]').exists()).toBe(true)
})
})
describe('sélection', () => {
it('émet le jour à 00:00 et garde le popover ouvert', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T00:00:00'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('applique l\\'heure réglée avant le clic du jour', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
const time = wrapper.get('[data-test="time-input"]')
;(time.element as HTMLInputElement).value = '09:15'
await time.trigger('input')
// pas d'émission tant qu'aucun jour n'est choisi
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
})
it('met à jour l\\'heure quand une date est déjà choisie', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const time = wrapper.get('[data-test="time-input"]')
;(time.element as HTMLInputElement).value = '08:45'
await time.trigger('input')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
})
it('initialise l\\'input heure depuis la valeur', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const time = wrapper.get('[data-test="time-input"]').element as HTMLInputElement
expect(time.value).toBe('14:30')
})
})
describe('bornes min/max', () => {
it('désactive les jours hors bornes (datetime borné sur la date)', async () => {
const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
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)
})
})
describe('effacement', () => {
it('émet null au clic sur la croix', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
})
describe('accessibilité', () => {
it('positionne aria-invalid et describedby sur erreur', () => {
const wrapper = mountDateTime({error: 'Date requise'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Date requise')
})
})
})
```
- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent**
Run: `npx vitest run app/components/malio/date/DateTime.test.ts`
Expected: FAIL (`DateTime.vue` introuvable).
- [ ] **Step 3 : Écrire l'implémentation**
Créer `app/components/malio/date/DateTime.vue` :
```vue
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="datePart"
: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="onClear"
>
<template #default="{ currentMonth, currentYear }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="datePart"
:min="min?.slice(0, 10)"
:max="max?.slice(0, 10)"
@select="onSelectDay"
/>
<!-- Bloc heure intérimaire : input natif, isolé pour remplacement futur par le sélecteur dédié. -->
<div class="mt-[10px] flex items-center gap-2 border-t border-m-muted/30 pt-[10px]">
<label
:for="timeInputId"
class="text-sm font-medium text-m-muted"
>
Heure
</label>
<input
:id="timeInputId"
data-test="time-input"
type="time"
:value="timeValue"
class="rounded-md border border-m-muted bg-white px-2 py-1 text-base outline-none focus:border-m-primary"
@input="onTimeInput"
>
</div>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref, useId, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat'
defineOptions({name: 'MalioDateTime', 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 HH:MM',
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 generatedId = useId()
const timeInputId = computed(() => `${props.id || `malio-datetime-${generatedId}`}-time`)
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
const pendingTime = ref('')
const parts = computed(() => splitDateTime(props.modelValue ?? null))
const datePart = computed(() => parts.value.date)
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
const timeValue = computed(() => parts.value.time || pendingTime.value)
function onSelectDay(iso: string) {
const time = parts.value.time || pendingTime.value || '00:00'
emit('update:modelValue', composeDateTime(iso, time))
}
function onTimeInput(e: Event) {
const value = (e.target as HTMLInputElement).value
if (!value) return
if (datePart.value) {
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
pendingTime.value = value
}
}
function onClear() {
pendingTime.value = ''
emit('update:modelValue', null)
}
watch(() => props.modelValue, (val) => {
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
}
})
</script>
```
- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent**
Run: `npx vitest run app/components/malio/date/DateTime.test.ts`
Expected: PASS (tous verts).
Note : si `@input` ne déclenche pas `value` correctement en jsdom, utiliser `await time.setValue('09:15')` à la place du couple `.value =` + `.trigger('input')` dans les tests.
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/DateTime.vue app/components/malio/date/DateTime.test.ts
git commit --no-verify -m "feat : composant MalioDateTime (date + heure) (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3 : Page playground + entrée nav
**Files:**
- Create: `.playground/pages/composant/date/datetime.vue`
- Modify: `.playground/playground.nav.ts` (section `DATES & HEURES`)
- [ ] **Step 1 : Créer la page playground**
Créer `.playground/pages/composant/date/datetime.vue` :
```vue
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateTime</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">Simple</h2>
<MalioDateTime
v-model="value"
label="Date et heure du rendez-vous"
hint="Choisis un jour puis une heure"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
</div>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">Valeur initiale + bornes</h2>
<MalioDateTime
v-model="bounded"
label="Créneau"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ bounded ?? 'null' }}</code></p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<string | null>(null)
const bounded = ref<string | null>('2026-05-20T14:30:00')
</script>
```
- [ ] **Step 2 : Ajouter l'entrée nav**
Dans `.playground/playground.nav.ts`, section `DATES & HEURES`, ajouter l'item après `Semaine` :
```ts
{label: 'Date & heure', to: '/composant/date/datetime'},
```
Le bloc devient :
```ts
items: [
{label: 'Date', to: '/composant/date/date'},
{label: 'Plage de dates', to: '/composant/date/dateRange'},
{label: 'Semaine', to: '/composant/date/dateWeek'},
{label: 'Date & heure', to: '/composant/date/datetime'},
{label: 'Heure', to: '/composant/time/time'},
],
```
- [ ] **Step 3 : Vérifier le lint**
Run: `npm run lint`
Expected: PASS (pas d'erreur sur les fichiers playground).
- [ ] **Step 4 : Commit**
```bash
git add .playground/pages/composant/date/datetime.vue .playground/playground.nav.ts
git commit --no-verify -m "feat : page playground MalioDateTime (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 4 : Story Histoire
**Files:**
- Create: `app/story/date/dateTime.story.vue`
- [ ] **Step 1 : Créer la story**
Créer `app/story/date/dateTime.story.vue` (nom de fichier camelCase pour éviter `vue/multi-word-component-names`) :
```vue
<template>
<Story title="Date/DateTime">
<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>
<MalioDateTime
v-model="simpleValue"
label="Date et heure"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateTime
v-model="initialValue"
label="Rendez-vous"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateTime
v-model="boundedValue"
label="Créneau"
: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">Erreur</h2>
<MalioDateTime
v-model="errorValue"
label="Date limite"
error="Date et heure requises"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateTime
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
<MalioDateTime
v-model="initialValue"
label="Lecture seule"
readonly
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateTime from '../../components/malio/date/DateTime.vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>('2026-05-20T14:30:00')
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
</script>
```
- [ ] **Step 2 : Vérifier le lint**
Run: `npm run lint`
Expected: PASS.
- [ ] **Step 3 : Commit**
```bash
git add app/story/date/dateTime.story.vue
git commit --no-verify -m "docs : story Histoire pour MalioDateTime (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 5 : Documentation (COMPONENTS.md + CHANGELOG.md)
**Files:**
- Modify: `COMPONENTS.md`
- Modify: `CHANGELOG.md`
- [ ] **Step 1 : Ajouter la section dans COMPONENTS.md**
Ouvrir `COMPONENTS.md`, repérer la section `MalioDate` (ou la famille date) et ajouter une section `MalioDateTime` calquée dessus, documentant :
- Description : champ unique date + heure, popover (grille + sélecteur d'heure), version intérimaire avec `<input type="time">` natif.
- `modelValue` : `string | null`, format `"YYYY-MM-DDTHH:MM:00"` (ISO naïf sans fuseau ; Symfony applique son fuseau).
- Table des props identique à `MalioDate` (ajouter la note sur `min`/`max` bornant la date).
- Émission `update:modelValue`.
- Exemple d'usage :
```vue
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
<!-- rdv === "2026-05-20T14:30:00" -->
```
- Note : le sélecteur d'heure est intérimaire et sera remplacé par un composant dédié (maquette à venir).
Respecter le style et la structure exacts des sections existantes (titres, tableaux markdown).
- [ ] **Step 2 : Ajouter l'entrée CHANGELOG.md**
Dans `CHANGELOG.md`, sous `## [0.0.0]``### Added`, ajouter après la ligne `[#MUI-33] Développer le composant Datepicker` :
```
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
```
- [ ] **Step 3 : Commit**
```bash
git add COMPONENTS.md CHANGELOG.md
git commit --no-verify -m "docs : documente MalioDateTime (COMPONENTS + CHANGELOG) (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Vérification finale
- [ ] `npx vitest run app/components/malio/date/` → toute la famille date verte.
- [ ] `npm run lint` → pas d'erreur.
- [ ] Revue finale du composant (cohérence avec la famille date, isolation du bloc heure pour remplacement futur).
+979
View File
@@ -0,0 +1,979 @@
# MalioModal Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ajouter un composant `MalioModal` (dialogue modal centré) autonome à `@malio/layer-ui`, sans modifier le Drawer existant.
**Architecture:** Composant Vue SFC unique `app/components/malio/modal/Modal.vue`, auto-importé comme `<MalioModal>`. Réimplémente sa propre logique (Teleport, focus-trap, scroll-lock partagé via compteur module-level, pattern contrôlé/non-contrôlé, transition fade+scale) en s'inspirant du Drawer. Structure : header fixe / body scrollable / footer fixe.
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Tailwind (tokens `m-*`), `tailwind-merge`, `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom), Histoire.
**Spec:** `docs/superpowers/specs/2026-05-26-modal-design.md`
**Conventions projet à respecter :**
- Commits Conventional **avec espace avant les `:`** : `feat : … (#MUI-36)`, `docs : …`, `test : …`. Type en minuscules, pas de préfixe `[#…]`. Finir par la ligne `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`.
- Le hook pre-commit lance lint + ~595 tests et **time out de façon flaky** sous WSL2. Pattern : réessayer ; après 2 échecs flaky (échecs différents d'un run à l'autre), vérifier les tests ciblés à la main (`npx vitest run <chemin>`) puis committer avec `--no-verify`.
- Story : nom de fichier sous un dossier (`story/modal/modal.story.vue`) ; `defineOptions({ name: 'ModalStory' })` pour éviter `vue/multi-word-component-names`.
**File Structure:**
- Create `app/components/malio/modal/Modal.vue` — le composant (≈ taille du Drawer).
- Create `app/components/malio/modal/Modal.test.ts` — tests colocalisés.
- Create `.playground/pages/composant/modal/modal.vue` — page de démo (route `/composant/modal/modal`).
- Modify `.playground/playground.nav.ts` — ajout de l'entrée nav dans la section `NAVIGATION`.
- Create `app/story/modal/modal.story.vue` — story Histoire.
- Modify `COMPONENTS.md` — section `## MalioModal` (insérée après la section `## MalioDrawer`).
- Modify `CHANGELOG.md` — ligne sous `### Added`.
---
### Task 1: Composant MalioModal + suite de tests (cycle TDD)
**Files:**
- Create: `app/components/malio/modal/Modal.test.ts`
- Create: `app/components/malio/modal/Modal.vue`
- [ ] **Step 1: Écrire la suite de tests qui échoue**
Create `app/components/malio/modal/Modal.test.ts` :
```ts
import { afterEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import Modal from './Modal.vue'
type ModalProps = {
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}
const ModalForTest = Modal as DefineComponent<ModalProps>
function mountComponent(props: ModalProps = {}, slots?: Record<string, string>) {
return mount(ModalForTest, {
props,
slots,
global: { stubs: { Teleport: true } },
})
}
describe('MalioModal', () => {
enableAutoUnmount(afterEach)
afterEach(() => {
document.body.style.overflow = ''
})
it('does not render when modelValue is false', () => {
const wrapper = mountComponent({ modelValue: false })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('renders the panel when modelValue is true', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
})
it('centers the modal (items-center justify-center)', () => {
const wrapper = mountComponent({ modelValue: true })
const root = wrapper.find('.fixed')
expect(root.classes()).toContain('items-center')
expect(root.classes()).toContain('justify-center')
})
it('renders default slot in the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ default: '<p data-test="content">Contenu</p>' },
)
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
})
it('works in uncontrolled mode (defaults closed)', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('uses custom id when provided', () => {
const wrapper = mountComponent({ modelValue: true, id: 'my-modal' })
expect(wrapper.find('.fixed').attributes('id')).toBe('my-modal')
})
it('generates an id when not provided', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-modal-/)
})
it('has role="dialog" and aria-modal on the panel', () => {
const wrapper = mountComponent({ modelValue: true })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('role')).toBe('dialog')
expect(panel.attributes('aria-modal')).toBe('true')
})
it('applies modalClass to the panel', () => {
const wrapper = mountComponent({ modelValue: true, modalClass: 'max-w-2xl' })
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
})
it('renders the #header slot inside the header bar', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ header: '<h2 data-test="title">Titre</h2>' },
)
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
})
it('renders the header bar when showClose is true even without #header', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
})
it('does not render the header bar when no #header and showClose is false', () => {
const wrapper = mountComponent({ modelValue: true, showClose: false })
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
})
it('shows the close button by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
})
it('hides the close button when showClose is false', () => {
const wrapper = mountComponent(
{ modelValue: true, showClose: false },
{ header: '<h2>Titre</h2>' },
)
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
})
it('close button renders mdi:cancel-bold icon', () => {
const wrapper = mountComponent({ modelValue: true })
const icon = wrapper.findComponent(IconifyIcon)
expect(icon.props('icon')).toBe('mdi:cancel-bold')
})
it('close button has aria-label "Fermer"', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
})
it('emits update:modelValue false and close on close button click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="close-button"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('sets aria-labelledby to the header id when #header is provided', () => {
const wrapper = mountComponent(
{ modelValue: true, id: 'test-modal' },
{ header: '<h2>Titre</h2>' },
)
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-labelledby')).toBe('test-modal-header')
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-modal-header')
})
it('sets aria-label from ariaLabel when no #header is provided', () => {
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Boîte de dialogue' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-label')).toBe('Boîte de dialogue')
expect(panel.attributes('aria-labelledby')).toBeUndefined()
})
it('applies headerClass to the header bar', () => {
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
})
it('renders the #footer slot in a footer pinned below the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ footer: '<button data-test="save">Enregistrer</button>' },
)
// le footer n'est PAS dans la zone scrollable (≠ Drawer)
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
})
it('does not render the footer when no #footer slot', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
})
it('applies bodyClass to the body', () => {
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
})
it('applies footerClass to the footer', () => {
const wrapper = mountComponent(
{ modelValue: true, footerClass: 'justify-end' },
{ footer: '<span>pied</span>' },
)
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
})
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on backdrop click when dismissable is false', async () => {
const wrapper = mountComponent({ modelValue: true, dismissable: false })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('applies overlayClass to the backdrop', () => {
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
})
it('closes on Escape key when closeOnEscape is true', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on Escape when closeOnEscape is false', async () => {
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('locks body scroll when opened and restores it when closed', async () => {
const wrapper = mountComponent({ modelValue: false })
expect(document.body.style.overflow).toBe('')
await wrapper.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
it('moves focus into the panel when opened', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: false, showClose: false },
slots: { default: '<button data-test="first">OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="first"]').element
expect(document.activeElement).toBe(first)
wrapper.unmount()
})
it('restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
expect(document.activeElement).toBe(trigger)
const wrapper = mount(ModalForTest, {
props: { modelValue: false },
slots: { default: '<button>OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
await wrapper.setProps({ modelValue: false })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
trigger.remove()
})
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
last.focus()
expect(document.activeElement).toBe(last)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
wrapper.unmount()
})
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
first.focus()
expect(document.activeElement).toBe(first)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
wrapper.unmount()
})
it('does not release body scroll-lock when one stacked modal closes while another is still open', async () => {
const wrapperA = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
const wrapperB = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapperA.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('hidden')
await wrapperA.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
})
```
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
Run: `npx vitest run app/components/malio/modal/Modal.test.ts`
Expected: FAIL — `Failed to resolve import "./Modal.vue"` (le composant n'existe pas encore).
- [ ] **Step 3: Implémenter le composant**
Create `app/components/malio/modal/Modal.vue` :
```vue
<template>
<Teleport to="body">
<Transition
name="modal"
appear
@after-leave="isRendered = false"
>
<div
v-if="isRendered && isOpen"
:id="componentId"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
v-bind="attrs"
>
<div
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
data-test="backdrop"
@click="onBackdropClick"
/>
<div
ref="panelRef"
:class="twMerge(
'relative z-50 flex max-h-[85vh] w-full max-w-md flex-col rounded-malio bg-white',
modalClass,
)"
role="dialog"
aria-modal="true"
:aria-labelledby="hasHeader ? headerId : undefined"
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
tabindex="-1"
data-test="panel"
@keydown="onKeydown"
>
<div
v-if="hasHeader || showClose"
:class="twMerge('flex shrink-0 items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
data-test="header"
>
<div
:id="headerId"
class="min-w-0 flex-1"
data-test="header-content"
>
<slot name="header" />
</div>
<button
v-if="showClose"
type="button"
aria-label="Fermer"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
data-test="close-button"
@click="close"
>
<IconifyIcon
icon="mdi:cancel-bold"
:width="16"
:height="16"
/>
</button>
</div>
<div
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
data-test="body"
>
<slot />
</div>
<div
v-if="$slots.footer"
:class="twMerge('flex shrink-0 items-center gap-3 border-t border-m-border px-5 py-4', footerClass)"
data-test="footer"
>
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
useSlots,
watch,
} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioModal', inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}>(),
{
id: '',
modelValue: undefined,
showClose: true,
dismissable: true,
closeOnEscape: true,
ariaLabel: '',
modalClass: '',
overlayClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-modal-${generatedId}`)
const slots = useSlots()
const headerId = computed(() => `${componentId.value}-header`)
const hasHeader = computed(() => !!slots.header)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false)
const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const isRendered = ref(isOpen.value)
const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
// Per-instance flag: true while this modal holds a scroll-lock count slot.
let lockedByThisInstance = false
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
),
).filter((el) => el.tabIndex !== -1)
}
function onOpen() {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
if (!lockedByThisInstance) {
lockedByThisInstance = true
openModalCount++
if (openModalCount === 1) {
document.body.style.overflow = 'hidden'
}
}
nextTick(() => {
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
;(focusable[0] ?? panel).focus()
})
}
function onClose() {
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
previouslyFocused?.focus?.()
previouslyFocused = null
}
watch(isOpen, (val) => {
if (val) {
isRendered.value = true
onOpen()
}
else {
onClose()
}
})
onMounted(() => {
if (isOpen.value) onOpen()
})
onBeforeUnmount(() => {
// If this instance is still holding a scroll-lock slot, release it.
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
})
function onBackdropClick() {
if (props.dismissable) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEscape) {
e.stopPropagation()
close()
return
}
if (e.key !== 'Tab') return
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
if (focusable.length === 0) {
e.preventDefault()
panel.focus()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
}
else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
function close() {
if (!isControlled.value) localValue.value = false
emit('update:modelValue', false)
emit('close')
}
</script>
<script lang="ts">
// Shared across all MalioModal instances: only the last open modal releases the body scroll-lock.
let openModalCount = 0
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active > div:last-child,
.modal-leave-active > div:last-child {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child,
.modal-leave-to > div:last-child {
transform: scale(0.95);
opacity: 0;
}
</style>
```
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
Run: `npx vitest run app/components/malio/modal/Modal.test.ts`
Expected: PASS — tous les tests (≈ 32) verts.
- [ ] **Step 5: Lint**
Run: `npm run lint`
Expected: 0 erreur sur les fichiers du composant.
- [ ] **Step 6: Commit**
```bash
git add app/components/malio/modal/Modal.vue app/components/malio/modal/Modal.test.ts
git commit -m "feat : composant Modal (#MUI-36)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
(En cas de timeout flaky pre-commit, voir le pattern conventions en tête de plan : retry ×2 puis `--no-verify` après vérif ciblée `npx vitest run app/components/malio/modal/Modal.test.ts`.)
---
### Task 2: Page playground + entrée nav
**Files:**
- Create: `.playground/pages/composant/modal/modal.vue`
- Modify: `.playground/playground.nav.ts` (section `NAVIGATION`, après le Drawer)
- [ ] **Step 1: Créer la page de démo**
Create `.playground/pages/composant/modal/modal.vue` :
```vue
<script setup lang="ts">
import { ref } from 'vue'
const modalBase = ref(false)
const modalForm = ref(false)
const modalLong = ref(false)
const modalNoDismiss = ref(false)
</script>
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Modal simple</h2>
<MalioButton label="Ouvrir" @click="modalBase = true" />
<MalioModal v-model="modalBase">
<template #header>
<h2 class="text-[24px] font-bold text-black">Détails</h2>
</template>
<p class="text-m-text">Contenu de la modal. Échap, clic backdrop et croix la ferment.</p>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="modalForm = true" />
<MalioModal v-model="modalForm" modal-class="max-w-lg">
<template #header>
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
<MalioInputText label="Email" />
</div>
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="modalForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="modalForm = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Contenu long (body scrollable)</h2>
<MalioButton label="Ouvrir" variant="tertiary" @click="modalLong = true" />
<MalioModal v-model="modalLong">
<template #header>
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
</template>
<div class="flex flex-col gap-4">
<p v-for="n in 20" :key="n" class="text-m-text">
Paragraphe {{ n }} contenu long pour forcer le scroll interne ; le header et le footer restent fixes.
</p>
</div>
<template #footer>
<MalioButton label="Accepter" button-class="w-full" @click="modalLong = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
<MalioButton label="Ouvrir" variant="danger" @click="modalNoDismiss = true" />
<MalioModal v-model="modalNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
</template>
<p class="text-m-text">Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
</MalioModal>
</div>
</div>
</template>
```
- [ ] **Step 2: Ajouter l'entrée nav**
Modify `.playground/playground.nav.ts`, dans la section `NAVIGATION`, ajouter la ligne Modal juste après le Drawer :
```ts
{
label: 'NAVIGATION',
icon: 'mdi:navigation-variant',
items: [
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
{label: 'Drawer', to: '/composant/drawer/drawer'},
{label: 'Modal', to: '/composant/modal/modal'},
{label: 'Onglets', to: '/composant/tab/tabList'},
],
},
```
- [ ] **Step 3: Vérifier le lint**
Run: `npm run lint`
Expected: 0 erreur.
- [ ] **Step 4: Commit**
```bash
git add .playground/pages/composant/modal/modal.vue .playground/playground.nav.ts
git commit -m "docs : page playground Modal (#MUI-36)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Story Histoire
**Files:**
- Create: `app/story/modal/modal.story.vue`
- [ ] **Step 1: Créer la story**
Create `app/story/modal/modal.story.vue` :
```vue
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({ name: 'ModalStory' })
const showBase = ref(false)
const showForm = ref(false)
const showNoDismiss = ref(false)
</script>
<template>
<Story title="Overlay/Modal">
<Variant title="Simple">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showBase = true"
>
Ouvrir
</button>
<MalioModal v-model="showBase">
<template #header>
<h2 class="text-xl font-bold">Détails</h2>
</template>
<p>Contenu simple de la modal.</p>
</MalioModal>
</div>
</Variant>
<Variant title="Avec footer d'actions">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showForm = true"
>
Ouvrir le formulaire
</button>
<MalioModal v-model="showForm" modal-class="max-w-lg">
<template #header>
<h2 class="text-xl font-bold">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
</div>
<template #footer>
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
</template>
</MalioModal>
</div>
</Variant>
<Variant title="Non dismissable">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showNoDismiss = true"
>
Ouvrir
</button>
<MalioModal v-model="showNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-xl font-bold">Action requise</h2>
</template>
<p>Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
</MalioModal>
</div>
</Variant>
</Story>
</template>
```
- [ ] **Step 2: Vérifier le lint**
Run: `npm run lint`
Expected: 0 erreur (notamment pas de `vue/multi-word-component-names` grâce au `defineOptions`).
- [ ] **Step 3: Commit**
```bash
git add app/story/modal/modal.story.vue
git commit -m "docs : story Histoire Modal (#MUI-36)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Documentation (COMPONENTS.md + CHANGELOG.md)
**Files:**
- Modify: `COMPONENTS.md` (insérer après la section `## MalioDrawer`, juste avant `## MalioDataTable`)
- Modify: `CHANGELOG.md` (ligne sous `### Added`)
- [ ] **Step 1: Ajouter la section dans COMPONENTS.md**
Dans `COMPONENTS.md`, insérer ce bloc juste après le `---` qui clôt la section `## MalioDrawer` (et avant `## MalioDataTable`) :
```markdown
## MalioModal
Boîte de dialogue modale centrée avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs modals. Structure : header fixe, body scrollable (`max-h-[85vh]`), footer fixe.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
| `modalClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-lg` (twMerge) |
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS footer fixe (twMerge) |
**Events :** `update:modelValue(value: boolean)`, `close()`
**Slots :**
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable).
- `footer` — actions (boutons). Rendu en bas, fixe, séparé par une bordure. N'apparaît que si le slot est fourni.
```vue
<MalioModal v-model="isOpen">
<template #header>
<h2 class="text-[24px] font-bold">Détails</h2>
</template>
<p>Contenu de la modal</p>
</MalioModal>
<!-- Largeur custom + footer d'actions -->
<MalioModal v-model="isOpen" modal-class="max-w-lg">
<template #header><h2>Nouveau contact</h2></template>
<MalioInputText label="Nom" />
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="isOpen = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="isOpen = false" />
</template>
</MalioModal>
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
<MalioModal v-model="isOpen" :dismissable="false" :close-on-escape="false">
<template #header><h2>Action requise</h2></template>
<p>Fermeture via la croix uniquement</p>
</MalioModal>
```
---
```
- [ ] **Step 2: Ajouter l'entrée CHANGELOG**
Dans `CHANGELOG.md`, sous `### Added`, ajouter en dernière ligne de la liste (après la ligne DateTime) :
```markdown
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
```
- [ ] **Step 3: Commit**
```bash
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs : documentation du composant Modal (#MUI-36)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Vérification finale
- [ ] `npx vitest run app/components/malio/modal/Modal.test.ts` → tous verts.
- [ ] `npm run lint` → 0 erreur.
- [ ] `npm run dev` → la page `/composant/modal/modal` s'affiche, l'entrée « Modal » est dans la nav sous NAVIGATION, les 4 démos fonctionnent (ouverture, fermeture backdrop/Échap/croix, scroll interne, non-dismissable).
@@ -0,0 +1,146 @@
# MalioDateTime — Design (version intérimaire)
**Date :** 2026-05-22
**Ticket :** #MUI-33 (famille Datepicker)
**Statut :** validé, prêt pour le plan d'implémentation
## Objectif
Ajouter un composant `MalioDateTime` à la famille temporelle de `@malio/layer-ui`, permettant de saisir **une date ET une heure** dans un seul champ avec popover.
Cette version est **intérimaire** : le sélecteur d'heure utilise un `<input type="time">` natif, le temps que la maquette du sélecteur d'heure dédié soit fournie. Le bloc heure est volontairement isolé pour pouvoir être remplacé sans toucher au reste.
## Valeur du modèle
`modelValue: string | null` au format **ISO naïf sans fuseau** : `"YYYY-MM-DDTHH:MM:00"` (ex. `"2026-05-20T14:30:00"`).
- Heure murale locale : un picker n'a pas de notion de fuseau.
- Symfony (`DateTimeNormalizer`, RFC 3339) parse ce format et applique son fuseau configuré → zéro bug TZ/DST côté front.
- Les secondes sont toujours `00` (le natif `type="time"` par défaut n'expose pas les secondes).
- Cohérent avec `MalioDate` (`YYYY-MM-DD`) et `MalioTime` (`HH:MM`).
## Architecture
Fine enveloppe autour du shell partagé `internal/CalendarField.vue`, comme `MalioDate` / `MalioDateRange` / `MalioDateWeek`.
```
MalioDateTime (Date/DateTime.vue)
└─ CalendarField (champ + popover + header + navigation mois)
slot #default={ currentMonth, currentYear, close }
├─ MonthGrid (sélection du jour)
└─ <input type="time"> (sélection de l'heure, sous la grille)
```
Le `close` du slot n'est **pas** appelé sur sélection (contrairement à `MalioDate`) : on a besoin de date ET heure, la fermeture se fait au clic extérieur (déjà gérée par `CalendarField` via `useCalendarPopover`).
## Props
Identiques à `MalioDate` :
| Prop | Type | Défaut | Note |
|---|---|---|---|
| `id` | `string` | `''` | |
| `name` | `string` | `''` | |
| `label` | `string` | `''` | |
| `modelValue` | `string \| null` | `undefined` | `"YYYY-MM-DDTHH:MM:00"` |
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | |
| `required` | `boolean` | `false` | |
| `disabled` | `boolean` | `false` | |
| `readonly` | `boolean` | `false` | |
| `hint` | `string` | `''` | |
| `error` | `string` | `''` | |
| `success` | `string` | `''` | |
| `min` | `string` | `undefined` | datetime ou date ; on borne la grille avec la partie date |
| `max` | `string` | `undefined` | idem |
| `clearable` | `boolean` | `true` | |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | |
**Émissions :** `update:modelValue: string | null`.
## Affichage du champ
`displayValue` = `formatIsoDateTimeToDisplay(modelValue)``"JJ/MM/AAAA HH:MM"` (ex. `20/05/2026 14:30`). Chaîne vide si `modelValue` nul ou invalide.
## Flux de sélection
État : `modelValue` est la source de vérité. Une ref locale `pendingTime: string` (`'HH:MM'` ou `''`) sert de pont quand l'heure est réglée avant qu'un jour soit choisi.
- **Partie date courante** = `splitDateTime(modelValue).date` (ou `null`).
- **Valeur de l'`<input type="time">`** = `splitDateTime(modelValue).time` si présent, sinon `pendingTime`.
Interactions :
1. **Clic sur un jour `iso`** :
- `heureEffective` = partie heure de `modelValue`, sinon `pendingTime`, sinon `'00:00'`.
- émet `composeDateTime(iso, heureEffective)``"iso T heure:00"`.
- le popover **reste ouvert**.
2. **Changement de l'`<input type="time">` (`hhmm`)** :
- si une partie date existe → émet `composeDateTime(datePart, hhmm)`.
- sinon → stocke `hhmm` dans `pendingTime` (pas d'émission tant qu'aucun jour n'est choisi).
- si `hhmm` est vidé (`''`) et qu'une date existe → on garde la date avec `00:00` ? **Non** : on n'émet rien sur vidage, on conserve la dernière valeur émise (le natif renvoie rarement `''` une fois rempli ; cas négligé pour l'intérim).
3. **Effacer (croix)** : émet `null`, `pendingTime = ''`.
Bornes `min`/`max` : passées à `MonthGrid` après slice de la partie date (`min?.slice(0, 10)`). Pas de borne sur l'heure dans cette version.
## Composable `composables/datetimeFormat.ts`
Réutilise `isValidIso` de `dateFormat.ts` pour valider la partie date.
```ts
// "YYYY-MM-DDTHH:MM:00" complet et valide ?
export function isValidIsoDateTime(s: string): boolean
// "YYYY-MM-DDTHH:MM:00" -> "JJ/MM/AAAA HH:MM" (chaîne vide si invalide/nul)
export function formatIsoDateTimeToDisplay(s: string | null): string
// "YYYY-MM-DDTHH:MM:00" -> { date: "YYYY-MM-DD" | null, time: "HH:MM" | '' }
export function splitDateTime(s: string | null): { date: string | null; time: string }
// (date "YYYY-MM-DD", time "HH:MM") -> "YYYY-MM-DDTHH:MM:00"
export function composeDateTime(date: string, time: string): string
```
Règles :
- `isValidIsoDateTime` : regex `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$`, partie date valide via `isValidIso`, heure `0023`, minutes `0059`, secondes `0059`.
- `splitDateTime` : si `s` nul ou ne matche pas le motif datetime → `{ date: null, time: '' }`. Sinon découpe sur `T`, renvoie la date et `HH:MM` (5 premiers car. de la partie heure).
- `composeDateTime(date, time)` : `${date}T${time}:00`. Si `time` vide → `${date}T00:00:00`.
- `formatIsoDateTimeToDisplay` : si `!isValidIsoDateTime``''`. Sinon `${dd}/${mm}/${yyyy} ${hh}:${min}`.
## Tests
Colocalisés, Vitest + @vue/test-utils (jsdom), `vi.setSystemTime(new Date(2026, 4, 19))` pour déterminisme.
**`datetimeFormat.test.ts`** (helpers purs) :
- `isValidIsoDateTime` : accepte `"2026-05-20T14:30:00"` ; rejette `"2026-05-20"`, `"2026-13-01T00:00:00"`, `"2026-05-20T24:00:00"`, `"2026-05-20T14:60:00"`, `""`, format sans secondes.
- `formatIsoDateTimeToDisplay` : `"2026-05-20T14:30:00"``"20/05/2026 14:30"` ; nul/invalide → `''`.
- `splitDateTime` : `"2026-05-20T14:30:00"``{ date: "2026-05-20", time: "14:30" }` ; `null``{ date: null, time: '' }` ; `"2026-05-20"` (date seule, non datetime) → `{ date: null, time: '' }`.
- `composeDateTime` : `("2026-05-20", "14:30")``"2026-05-20T14:30:00"` ; `("2026-05-20", "")``"2026-05-20T00:00:00"`.
**`DateTime.test.ts`** (composant) :
- Rendu : champ présent, placeholder `JJ/MM/AAAA HH:MM`, `displayValue` correct quand `modelValue` fourni.
- Ouverture popover au clic → `MonthGrid` + `input[type=time]` visibles.
- Clic sur un jour sans heure → émet `"<iso>T00:00:00"`, popover reste ouvert.
- Clic sur un jour avec `pendingTime` réglé d'abord → émet `"<iso>T<pendingTime>:00"`.
- Changement de l'heure avec date déjà choisie → émet `"<date>T<nouvelleHeure>:00"`.
- Changement de l'heure sans date → **aucune émission**.
- `clearable` : croix émet `null`.
- `min`/`max` datetime → jours hors bornes désactivés dans la grille.
- Accessibilité : label lié, `aria-invalid` sur erreur.
## Livrables
1. `app/components/malio/date/composables/datetimeFormat.ts`
2. `app/components/malio/date/composables/datetimeFormat.test.ts`
3. `app/components/malio/date/DateTime.vue`
4. `app/components/malio/date/DateTime.test.ts`
5. Page playground `.playground/pages/composant/date/datetime.vue` + entrée nav (`playground.nav.ts`)
6. Story `app/story/date/dateTime.story.vue`
7. `COMPONENTS.md` (section MalioDateTime)
8. `CHANGELOG.md` (ligne sous `### Added`)
## Hors périmètre (intérim)
- Sélecteur d'heure dédié / maquette → itération ultérieure ; on remplacera le bloc `<input type="time">` isolé.
- Bornes horaires (min/max sur l'heure).
- Pas de minutes configurable (granularité native du navigateur).
- Secondes dans l'UI.
@@ -0,0 +1,109 @@
# Design — `MalioModal`
Date : 2026-05-26
Statut : validé
## Objectif
Ajouter un composant `MalioModal` à `@malio/layer-ui` : un dialogue modal centré sur fond
assombri. Périmètre initial = **shell générique seul** (pas de variante confirmation/alerte).
Le consommateur place ce qu'il veut dans les slots.
## Décisions clés
- **Composant autonome** : la Modal réimplémente sa propre logique en s'inspirant du Drawer,
**sans modifier le Drawer existant** (zéro risque de régression). Un éventuel refactor vers
un composable partagé Drawer/Modal pourra se faire plus tard.
- **Largeur unique + override** : une largeur par défaut (`max-w-md`), ajustable par le
consommateur via la prop `modalClass` (pas de prop `size`).
- **Footer fixe en bas** : header fixe en haut, body scrollable au milieu (`max-h-[85vh]`),
footer fixe en bas séparé par une bordure — structure modale classique (≠ Drawer où le footer
est dans la zone scrollable).
- **Front volontairement simple** pour cette première version.
## Emplacement & livrables
- `app/components/malio/modal/Modal.vue`
- `app/components/malio/modal/Modal.test.ts` (colocalisé, jsdom)
- Page playground `.playground/pages/composant/modal/modal.vue`
- Entrée nav : `{label: 'Modal', to: '/composant/modal/modal'}` ajoutée dans la section
**NAVIGATION** de `.playground/playground.nav.ts`, juste après le Drawer
- Story Histoire `app/story/modal.story.vue`
- Mise à jour `COMPONENTS.md` (API) et `CHANGELOG.md` (`### Added`)
## Structure (template)
```
Teleport to body
└ Transition (fade overlay + fade/scale du panneau)
└ div fixed inset-0 z-50 flex items-center justify-center p-4 ← centre la modal
├ div backdrop (bg-black/40, @click → si dismissable)
└ div panel (role=dialog, aria-modal, w-full max-w-md max-h-[85vh], flex flex-col)
├ header (slot #header + bouton fermer) ← shrink-0, rendu si slot OU showClose
├ body (slot par défaut) ← flex-1 overflow-y-auto
└ footer (slot #footer, bordure haute) ← shrink-0, rendu si slot #footer présent
```
## API
### Props
| Prop | Type | Défaut | Rôle |
|------|------|--------|------|
| `id` | `string` | `''` | id du composant (sinon généré via `useId`) |
| `modelValue` | `boolean` | `undefined` | ouverture (contrôlé) ; fallback `localValue` interne si non fourni |
| `showClose` | `boolean` | `true` | affiche le bouton de fermeture (croix) dans le header |
| `dismissable` | `boolean` | `true` | clic sur le backdrop ferme la modal |
| `closeOnEscape` | `boolean` | `true` | touche Échap ferme la modal |
| `ariaLabel` | `string` | `''` | label ARIA si pas de slot header |
| `modalClass` | `string` | `''` | override du panneau (ex. largeur `max-w-lg`) |
| `overlayClass` | `string` | `''` | override du backdrop |
| `headerClass` | `string` | `''` | override du header |
| `bodyClass` | `string` | `''` | override du body |
| `footerClass` | `string` | `''` | override du footer |
Mêmes props que le Drawer, **sans `side`** ; `drawerClass``modalClass`.
### Events
- `update:modelValue(value: boolean)` — pour le `v-model`
- `close()` — émis à chaque fermeture (croix, backdrop, Échap, fermeture programmatique)
### Slots
- `#header` — contenu du header (titre…). Si absent **et** `showClose=false`, header non rendu.
- *(défaut)* — corps de la modal (zone scrollable)
- `#footer` — pied (boutons d'action). Rendu uniquement si le slot est fourni.
## Comportements repris du Drawer
- **Teleport** vers `<body>`.
- **Focus-trap** : focus initial sur le 1er élément focusable (sinon le panneau) ; boucle
Tab / Shift+Tab ; restauration du focus précédent à la fermeture.
- **Scroll-lock partagé** : compteur module-level (`openModalCount`) — `overflow:hidden` sur
`<body>` tant qu'au moins une instance est ouverte, libéré par la dernière (gère aussi
`onBeforeUnmount`).
- **Pattern contrôlé / non-contrôlé** : `isControlled = computed(() => modelValue !== undefined)`
avec `localValue` en fallback ; `isRendered` pour démonter après la transition de sortie.
- **`defineOptions({ name: 'MalioModal', inheritAttrs: false })`** + `v-bind="attrs"` sur le
conteneur.
- **Transition** : fade de l'overlay + fade léger **et scale** (`scale-95 → scale-100`) du
panneau (remplace le translate latéral du Drawer).
## Accessibilité
`role="dialog"`, `aria-modal="true"`, `aria-labelledby` vers l'id du header si présent (sinon
`aria-label`), bouton fermer avec `aria-label="Fermer"`.
## Tests (Vitest + @vue/test-utils, jsdom, colocalisés)
- Rendu conditionnel (fermé → non rendu ; ouvert → panneau + backdrop).
- `v-model` / contrôlé vs non-contrôlé.
- Fermeture : croix, backdrop (selon `dismissable`), Échap (selon `closeOnEscape`) ; events
`update:modelValue(false)` + `close`.
- Slots header / défaut / footer (footer rendu seulement si fourni ; header rendu si slot OU
`showClose`).
- Accessibilité : `role`, `aria-modal`, `aria-labelledby`/`aria-label`.
- Focus-trap : focus initial, boucle Tab/Shift+Tab.
- Scroll-lock : `overflow:hidden` à l'ouverture, libéré à la fermeture (et avec instances
multiples).