# 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 ``. - **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 `` 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).