From 7b838c60caa1573a700f862d34a047b8d6bdbdf2 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 26 May 2026 07:36:13 +0000 Subject: [PATCH] =?UTF-8?q?[#MUI-36]=20Cr=C3=A9ation=20d'un=20composant=20?= =?UTF-8?q?modal=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | 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: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/53 Co-authored-by: tristan Co-committed-by: tristan --- .playground/pages/composant/modal/modal.vue | 74 ++ .playground/playground.nav.ts | 1 + CHANGELOG.md | 1 + COMPONENTS.md | 52 + app/components/malio/modal/Modal.test.ts | 320 ++++++ app/components/malio/modal/Modal.vue | 279 +++++ app/story/modal/modal.story.vue | 70 ++ docs/superpowers/plans/2026-05-26-modal.md | 979 ++++++++++++++++++ .../specs/2026-05-26-modal-design.md | 109 ++ 9 files changed, 1885 insertions(+) create mode 100644 .playground/pages/composant/modal/modal.vue create mode 100644 app/components/malio/modal/Modal.test.ts create mode 100644 app/components/malio/modal/Modal.vue create mode 100644 app/story/modal/modal.story.vue create mode 100644 docs/superpowers/plans/2026-05-26-modal.md create mode 100644 docs/superpowers/specs/2026-05-26-modal-design.md diff --git a/.playground/pages/composant/modal/modal.vue b/.playground/pages/composant/modal/modal.vue new file mode 100644 index 0000000..4e23c5d --- /dev/null +++ b/.playground/pages/composant/modal/modal.vue @@ -0,0 +1,74 @@ + + + diff --git a/.playground/playground.nav.ts b/.playground/playground.nav.ts index 4c2c976..8d9e458 100644 --- a/.playground/playground.nav.ts +++ b/.playground/playground.nav.ts @@ -52,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'}, ], }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 50620d0..a5e9180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Liste des évolutions de la librairie Malio layer UI * [#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`. diff --git a/COMPONENTS.md b/COMPONENTS.md index 5a03fbb..15bf628 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -779,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 + + +

Contenu de la modal

+
+ + + + + + + + + + + +

Fermeture via la croix uniquement

+
+``` + +--- + ## MalioDataTable Tableau de données presentational avec pagination, filtres par slots et lignes cliquables. diff --git a/app/components/malio/modal/Modal.test.ts b/app/components/malio/modal/Modal.test.ts new file mode 100644 index 0000000..60f9a1d --- /dev/null +++ b/app/components/malio/modal/Modal.test.ts @@ -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 + +function mountComponent(props: ModalProps = {}, slots?: Record) { + 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: '

Contenu

' }, + ) + 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: '

Titre

' }, + ) + 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: '

Titre

' }, + ) + 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: '

Titre

' }, + ) + 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: '' }, + ) + 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: 'pied' }, + ) + 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: '' }, + 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: '' }, + 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: '' }, + 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: '' }, + 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('') + }) +}) diff --git a/app/components/malio/modal/Modal.vue b/app/components/malio/modal/Modal.vue new file mode 100644 index 0000000..2800e9a --- /dev/null +++ b/app/components/malio/modal/Modal.vue @@ -0,0 +1,279 @@ + + + + + + + diff --git a/app/story/modal/modal.story.vue b/app/story/modal/modal.story.vue new file mode 100644 index 0000000..c79f0df --- /dev/null +++ b/app/story/modal/modal.story.vue @@ -0,0 +1,70 @@ + + + diff --git a/docs/superpowers/plans/2026-05-26-modal.md b/docs/superpowers/plans/2026-05-26-modal.md new file mode 100644 index 0000000..ff280c9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-modal.md @@ -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 ``. 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 ` + + + + +``` + +- [ ] **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) " +``` +(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 + + + +``` + +- [ ] **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) " +``` + +--- + +### 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 + + + +``` + +- [ ] **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) " +``` + +--- + +### 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 + + +

Contenu de la modal

+
+ + + + + + + + + + + +

Fermeture via la croix uniquement

+
+``` + +--- +``` + +- [ ] **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) " +``` + +--- + +## 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). 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).