Files
malio-layer-ui/docs/superpowers/plans/2026-05-26-modal.md
T
2026-05-26 08:33:32 +02:00

33 KiB
Raw Blame History

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 :

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 :

<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
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 :

<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 :

  {
    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
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 :

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

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