7b838c60ca
| 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>
980 lines
33 KiB
Markdown
980 lines
33 KiB
Markdown
# 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).
|