diff --git a/docs/superpowers/specs/2026-05-26-modal-design.md b/docs/superpowers/specs/2026-05-26-modal-design.md new file mode 100644 index 0000000..23e129c --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-modal-design.md @@ -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 ``. +- **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).