Files
malio-layer-ui/docs/superpowers/specs/2026-05-26-modal-design.md
T
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

5.1 KiB

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 ; drawerClassmodalClass.

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).