Skip to content

Modale - DsfrModal

🌟 Introduction

La modale permet de concentrer l’attention de l’utilisateur exclusivement sur une tâche ou un élément d’information, sans perdre le contexte de la page en cours. Ce composant nécessite une action de l’utilisateur afin d'être ouvert ou fermé.

Le composant DsfrModal est une fenêtre modale configurable, offrant des fonctionnalités avancées telles que le piégeage de focus, l'écoute des touches d'échappement pour la fermeture, et la gestion des boutons d'action. Ce composant est conçu pour afficher des dialogues et des alertes de manière accessible et ergonomique.

🏅 La documentation sur la modale sur le DSFR

La story sur la modale sur le storybook de VueDsfr

📐 Structure

La modale par défaut permet de mettre en évidence une information qui ne nécessite pas d’action de l’utilisateur. Elle s’affiche à la suite du clic sur un bouton.

Elle se compose des éléments suivants :

  • Le bouton Fermer
  • Le titre obligatoire (prop title), avec icône, optionnelle.
  • La zone de contenu (slot par défaut), obligatoire.
  • La zode de pied de modale qui peut être rempli en utilisant le slot nommé "footer" et/ou avec des boutons (prop actions qui contient un tableau d’objets de type DsfrButtonProps)

🛠️ Props

PropriétéTypeDescriptionValeur par défautObligatoire
titlestringTitre de la modale.
modalIdstringIdentifiant unique pour la modale.getRandomId('modal', 'dialog')
openedbooleanIndique si la modale est ouverte.false
actionsDsfrButtonProps[]Liste des boutons d'action pour le pied de page de la modale.[]
isAlertbooleanSpécifie si la modale est une alerte (rôle "alertdialog" si true) ou non (le rôle sera alors "dialog").false
origin{ focus: () => void }Référence à l'élément d'origine pour redonner le focus après fermeture.{ focus() {} }
iconstringNom de l'icône à afficher dans le titre de la modale.undefined
size'sm' | 'md' | 'lg' | 'xl'Taille de la modale.'md'
closeButtonLabelstringLabel du bouton de fermeture˘.'Fermer'
closeButtonTitlestringTitre pour le bouton de fermeture (pour l'accessibilité).'Fermer la fenêtre modale'

📡 Événements

  • close : Événement émis lorsque la modale est fermée.

🧩 Slots

  • default : Slot pour le contenu principal de la modale.
  • footer : Slot pour le pied de page de la modale, contenant les boutons d'action supplémentaires.

📝 Exemples

Modale simple

vue
<script setup lang="ts">
import { ref } from 'vue'

import DsfrModal from '../DsfrModal.vue'

import DsfrButton from '@/components/DsfrButton/DsfrButton.vue'

const opened = ref(false)
const title = 'Titre de la modale'
const isAlert = ref(false)
const icon = ref('ri-checkbox-circle-line')
</script>

<template>
  <div class="fr-container fr-my-2v">
    <DsfrButton @click="opened = true">
      Ouvrir la modale
    </DsfrButton>
    <DsfrModal
      v-model:opened="opened"
      :title="title"
      :icon="icon"
      :is-alert="isAlert"
      @close="opened = false"
    >
      <template #default>
        <p>Contenu de la modale (slot par défaut)</p>
      </template>
    </DsfrModal>
  </div>
</template>

N.B.

la modale apparaît ici en bas de l’écran parce que l’iframe qui les contient est contenu dans une largeur correspondant à un appareil mobile. Sur un écran plus large, la modale apparaît au milieu de l’écran.

Modale avec actions

vue
<script setup lang="ts">
import { ref } from 'vue'

import DsfrModal from '../DsfrModal.vue'

import DsfrButton, { type DsfrButtonProps } from '@/components/DsfrButton/DsfrButton.vue'

const opened = ref(false)
const title = 'Titre de la modale'
const isAlert = ref(false)
const icon = ref('ri-checkbox-circle-line')
const validated = ref<boolean>()

const actions: DsfrButtonProps[] = [
  {
    label: 'Valider',
    onClick () {
      validated.value = true
      opened.value = false
    },
  },
  {
    label: 'Non, merci',
    secondary: true,
    onClick () {
      validated.value = false
      opened.value = false
    },
  },
  {
    label: 'Annuler',
    tertiary: true,
    onClick () {
      opened.value = false
    },
  },
]
</script>

<template>
  <div class="fr-container fr-my-2v">
    <DsfrButton @click="opened = true">
      Ouvrir la modale
    </DsfrButton>

    <p
      v-if="validated !== undefined"
      class="fr-my-2v"
    >
      Veut des patates : {{ validated ? 'Oui' : 'Non' }}
    </p>
    <DsfrModal
      v-model:opened="opened"
      :title="title"
      :icon="icon"
      :is-alert="isAlert"
      :actions="actions"
      @close="opened = false"
    >
      <template #default>
        <p>Êtes-vous sur de vouloir des patates ?</p>
      </template>
    </DsfrModal>
  </div>
</template>

Modale pour changer le thème

vue
<script setup lang="ts">
import darkThemeSvg from '@gouvfr/dsfr/dist/artwork/pictograms/environment/moon.svg'
import lightThemeSvg from '@gouvfr/dsfr/dist/artwork/pictograms/environment/sun.svg'
import systemThemeSvg from '@gouvfr/dsfr/dist/artwork/pictograms/system/system.svg'
import { onMounted, reactive, ref, watchEffect } from 'vue'

import DsfrModal from '../DsfrModal.vue'

import type { Preferences, UseSchemeResult } from '@/composables/index'
import DsfrRadioButtonSet from '@/components/DsfrRadioButton/DsfrRadioButtonSet.vue'
import { useScheme } from '@/composables/index'
import DsfrButton from '@/components/DsfrButton/DsfrButton.vue'

const isThemeModalOpen = ref(false)

const preferences: Preferences = reactive({
  theme: 'light',
  scheme: 'light',
})

onMounted(() => {
  const { theme, scheme, setScheme } = useScheme() as UseSchemeResult
  preferences.scheme = scheme.value

  watchEffect(() => { preferences.theme = theme.value })

  watchEffect(() => setScheme(preferences.scheme))
})

const options = [
  {
    label: 'Thème clair',
    value: 'light',
    svgPath: lightThemeSvg,
  },
  {
    label: 'Thème sombre',
    value: 'dark',
    svgPath: darkThemeSvg,
  },
  {
    label: 'Thème système',
    value: 'system',
    hint: 'Utilise les paramètres système',
    svgPath: systemThemeSvg,
  },
]
</script>

<template>
  <div class="fr-container fr-my-2v">
    <DsfrButton
      @click="isThemeModalOpen = true"
    >
      Changer le thème
    </DsfrButton>

    <DsfrModal
      :opened="isThemeModalOpen"
      title="Changer le thème"
      @close="isThemeModalOpen = false"
    >
      <DsfrRadioButtonSet
        v-model="preferences.scheme"
        :options="options"
        name="theme-selector"
        legend="Choisissez un thème pour personnaliser l’apparence du site."
      />
    </DsfrModal>
  </div>
</template>

N.B.

la modale apparaît ici en bas de l’écran et avec les boutons d’actions verticaux parce que l’iframe qui les contient est contenu dans une largeur correspondant à un appareil mobile. Sur un écran plus large, la modale apparaît au milieu de l’écran et les boutons sont par défaut distribués horizontalement.

⚙️ Code source du composant

vue
<script lang="ts" setup>
import { FocusTrap } from 'focus-trap-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'

import DsfrButtonGroup from '../DsfrButton/DsfrButtonGroup.vue'
import VIcon from '../VIcon/VIcon.vue'

import type { DsfrModalProps } from './DsfrModal.types'

import { getRandomId } from '@/utils/random-utils'

export type { DsfrModalProps }

const props = withDefaults(defineProps<DsfrModalProps>(), {
  modalId: () => getRandomId('modal', 'dialog'),
  actions: () => [],
  origin: () => ({ focus () {} }),
  icon: undefined,
  size: 'md',
  closeButtonLabel: 'Fermer',
  closeButtonTitle: 'Fermer la fenêtre modale',
})

const emit = defineEmits<{ (e: 'close'): void }>()

const closeIfEscape = ($event: KeyboardEvent) => {
  if ($event.key === 'Escape') {
    close()
  }
}

const role = computed(() => {
  return props.isAlert ? 'alertdialog' : 'dialog'
})

const closeBtn = ref<HTMLButtonElement | null>(null)
const modal = ref()
watch(() => props.opened, (newValue) => {
  if (newValue) {
    modal.value?.showModal()
    setTimeout(() => {
      closeBtn.value?.focus()
    }, 100)
  } else {
    modal.value?.close()
  }
  setAppropriateClassOnBody(newValue)
})

function setAppropriateClassOnBody (on: boolean) {
  if (typeof window !== 'undefined') {
    document.body.classList.toggle('modal-open', on)
  }
}

onMounted(() => {
  startListeningToEscape()
  setAppropriateClassOnBody(props.opened)
})

onBeforeUnmount(() => {
  stopListeningToEscape()
  setAppropriateClassOnBody(false)
})

function startListeningToEscape () {
  document.addEventListener('keydown', closeIfEscape)
}

function stopListeningToEscape () {
  document.removeEventListener('keydown', closeIfEscape)
}

async function close () {
  await nextTick()
  props.origin?.focus()
  emit('close')
}

const dsfrIcon = computed(() => typeof props.icon === 'string' && props.icon.startsWith('fr-icon-'))
const defaultScale = 2
const iconProps = computed(() => dsfrIcon.value
  ? undefined
  : typeof props.icon === 'string'
    ? { name: props.icon, scale: defaultScale }
    : { scale: defaultScale, ...(props.icon ?? {}) },
)
</script>

<template>
  <FocusTrap
    v-if="opened"
  >
    <dialog
      id="fr-modal-1"
      ref="modal"
      :aria-labelledby="modalId"
      :role="role"
      class="fr-modal"
      :class="{ 'fr-modal--opened': opened }"
      :open="opened"
    >
      <div class="fr-container fr-container--fluid fr-container-md">
        <div class="fr-grid-row fr-grid-row--center">
          <div
            class="fr-col-12"
            :class="{
              'fr-col-md-8': size === 'lg',
              'fr-col-md-6': size === 'md',
              'fr-col-md-4': size === 'sm',
            }"
          >
            <div class="fr-modal__body">
              <div class="fr-modal__header">
                <button
                  ref="closeBtn"
                  class="fr-btn fr-btn--close"
                  :title="closeButtonTitle"
                  aria-controls="fr-modal-1"
                  type="button"
                  @click="close()"
                >
                  <span>
                    {{ closeButtonLabel }}
                  </span>
                </button>
              </div>
              <div class="fr-modal__content">
                <h1
                  :id="modalId"
                  class="fr-modal__title"
                >
                  <span
                    v-if="dsfrIcon || iconProps"
                    :class="{
                      [String(icon)]: dsfrIcon,
                    }"
                  >
                    <VIcon
                      v-if="icon && iconProps"
                      v-bind="iconProps"
                    />
                  </span>
                  {{ title }}
                </h1>
                <!-- @slot Slot par défaut pour le contenu de la liste. Sera dans `<ul class="fr-modal__title">` -->
                <slot />
              </div>
              <div
                v-if="actions?.length || $slots.footer"
                class="fr-modal__footer"
              >
                <!-- @slot Slot pour le pied-de-page de la modale `<ul class="fr-modal__footer">` -->
                <slot name="footer" />
                <DsfrButtonGroup
                  v-if="actions?.length"
                  align="right"
                  :buttons="actions"
                  inline-layout-when="large"
                  reverse
                />
              </div>
            </div>
          </div>
        </div>
      </div>
    </dialog>
  </FocusTrap>
</template>

<style scoped>
.fr-modal {
  color: var(--text-default-grey);
}
:global(body.modal-open) {
  overflow: hidden;
}
</style>
ts
import type { DsfrButtonProps } from '../DsfrButton/DsfrButton.types'

export type DsfrModalProps = {
  modalId?: string
  opened?: boolean
  actions?: DsfrButtonProps[]
  isAlert?: boolean
  origin?: { focus: () => void }
  title: string
  icon?: string
  size?: 'sm' | 'md' | 'lg' | 'xl'
  closeButtonLabel?: string
  closeButtonTitle?: string
}
ts
import type { ButtonHTMLAttributes } from 'vue'

import type VIcon from '../VIcon/VIcon.vue'

export type DsfrButtonProps = {
  disabled?: boolean
  label?: string
  secondary?: boolean
  tertiary?: boolean
  iconRight?: boolean
  iconOnly?: boolean
  noOutline?: boolean
  size?: 'sm' | 'small' | 'lg' | 'large' | 'md' | 'medium' | '' | undefined
  icon?: string | InstanceType<typeof VIcon>['$props']
  onClick?: ($event: MouseEvent) => void
}

export type DsfrButtonGroupProps = {
  buttons?: (DsfrButtonProps & ButtonHTMLAttributes)[]
  reverse?: boolean
  equisized?: boolean
  iconRight?: boolean
  align?: 'right' | 'center' | '' | undefined
  inlineLayoutWhen?: 'always' | 'never' | 'sm' | 'small' | 'lg' | 'large' | 'md' | 'medium' | '' | true | undefined
  size?: 'sm' | 'small' | 'lg' | 'large' | 'md' | 'medium' | undefined
}