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.
Le composant n'intègre pas de Teleport par défaut. Vous pouvez envelopper DsfrModal dans un Teleport côté application (par exemple vers body) selon vos contraintes de structure, de stacking context ou d'intégration. Ce choix est volontaire pour laisser un maximum de latitude au développeur.
🏅 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 (propactionsqui contient un tableau d’objets de typeDsfrButtonProps)
🛠️ Props
| Propriété | Type | Description | Valeur par défaut | Obligatoire |
|---|---|---|---|---|
title | string | Titre de la modale. | ✅ | |
modalId | string | Identifiant unique pour la modale. | useRandomId('modal', 'dialog') | |
opened | boolean | Indique si la modale est ouverte. | false | |
actions | DsfrButtonProps[] | Liste des boutons d'action pour le pied de page de la modale. | [] | |
isAlert | boolean | Spécifie si la modale est une alerte (si true: rôle "alertdialog" en cas d'actions ou rôle "alert" si la modale n'est qu'informative) ou non (si false: pas besoin de rôle car la balise est "<dialog>"). | false | |
origin | { focus: () => void } | Référence à l'élément d'origine pour redonner le focus après fermeture. | { focus() {} } | |
icon | string | Nom de l'icône à afficher dans le titre de la modale. | undefined | |
size | 'sm' | 'md' | 'lg' | 'xl' | Taille de la modale. | 'md' | |
closeButtonLabel | string | Label du bouton de fermeture˘. | 'Fermer' | |
closeButtonTitle | string | Titre pour le bouton de fermeture (pour l'accessibilité). | 'Fermer la fenêtre modale' | |
disableOutsideInteraction | boolean | Désactive la fermeture de la modale au clic en dehors de son contenu (overlay). | false |
Lorsque disableOutsideInteraction vaut true, la modale ne se ferme pas lors d'un clic à l'extérieur de .fr-modal__body. La fermeture reste possible via le bouton de fermeture, les actions de votre interface, ou Escape (sauf comportement spécifique applicatif).
📡É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
<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
:opened
:title
:icon
:is-alert
@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
<script setup lang="ts">
import type { DsfrButtonProps } from '@/components/DsfrButton/DsfrButton.vue'
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')
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
:opened
:title
:icon
:is-alert
:actions
@close="opened = false"
>
<template #default>
<p>Êtes-vous sur de vouloir des patates ?</p>
<p><em>Ajout de Lorem Ipsum pour le comportement du scroll et affichage de la classe fr-scroll-divider</em></p>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Ad atque debitis laudantium expedita, inventore corporis ut magnam explicabo eum eveniet rerum repellendus fugiat porro, itaque illum voluptas labore soluta provident?</p>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Ad atque debitis laudantium expedita, inventore corporis ut magnam explicabo eum eveniet rerum repellendus fugiat porro, itaque illum voluptas labore soluta provident?</p>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Ad atque debitis laudantium expedita, inventore corporis ut magnam explicabo eum eveniet rerum repellendus fugiat porro, itaque illum voluptas labore soluta provident?</p>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Ad atque debitis laudantium expedita, inventore corporis ut magnam explicabo eum eveniet rerum repellendus fugiat porro, itaque illum voluptas labore soluta provident?</p>
</template>
</DsfrModal>
</div>
</template>Modale avec interaction exterieure désactivée
<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 = 'Modale avec interaction extérieure désactivée'
const icon = ref('ri-checkbox-circle-line')
const disableOutsideInteraction = ref(true)
</script>
<template>
<div class="fr-container fr-my-2v">
<DsfrButton @click="opened = true">
Ouvrir la modale
</DsfrButton>
<DsfrModal
:opened
:title
:icon
:disable-outside-interaction
@close="opened = false"
>
<template #default>
<p>Modale sans fermeture au clic sur l'arrière-plan</p>
</template>
</DsfrModal>
</div>
</template>Modale pour changer le thème
<script setup lang="ts">
import type { Preferences, UseSchemeResult } from '@/composables/index'
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 DsfrButton from '@/components/DsfrButton/DsfrButton.vue'
import DsfrRadioButtonSet from '@/components/DsfrRadioButton/DsfrRadioButtonSet.vue'
import { useScheme } from '@/composables/index'
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
<script lang="ts" setup>
import type { DsfrModalProps } from './DsfrModal.types'
import { FocusTrap } from 'focus-trap-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import DsfrButtonGroup from '../DsfrButton/DsfrButtonGroup.vue'
import VIcon from '../VIcon/VIcon.vue'
import { useRandomId } from '@/utils/random-utils'
export type { DsfrModalProps }
const props = withDefaults(defineProps<DsfrModalProps>(), {
modalId: () => useRandomId('modal', 'dialog'),
actions: () => [],
origin: () => ({ focus () {} }),
icon: undefined,
size: 'md',
closeButtonLabel: 'Fermer',
closeButtonTitle: 'Fermer la fenêtre modale',
})
const emit = defineEmits<{ (e: 'close'): void }>()
defineSlots<{
/**
* Slot par défaut pour le contenu de la modale.
* Sera dans `<div class="fr-modal__content">`
*/
default: () => any
/**
* Slot pour le pied-de-page de la modale.
* Sera dans `<div class="fr-modal__footer">`
*/
footer?: () => any
}>()
const closeIfEscape = ($event: KeyboardEvent) => {
if (!props.opened) {
return
}
if ($event.key === 'Escape') {
close()
}
}
function closeIfOutside (event: MouseEvent) {
if (props.isAlert || props.disableOutsideInteraction) {
return
}
if (!(event.target as HTMLElement).closest('.fr-modal__body')) {
close()
}
}
const role = computed(() => {
if (!props.isAlert) {
return undefined
}
if (props.actions.length) {
return 'alertdialog'
}
return 'alert'
})
const closeButtonId = computed(() => `${props.modalId}-close-button`)
const closeBtn = useTemplateRef('closeBtn')
const modal = useTemplateRef('modal')
const getCloseButton = () => closeBtn.value ?? `#${closeButtonId.value}`
const isTestEnvironment = import.meta.env.MODE === 'test' || import.meta.env.VITEST
const tabbableOptions = isTestEnvironment
? { displayCheck: 'none' as const }
: undefined
const modalBody = useTemplateRef<HTMLElement>('modalBody')
const modalContent = useTemplateRef<HTMLElement>('modalContent')
const hasScrollDivider = ref(false)
function updateScrollDivider () {
if (!modalBody.value || !modalContent.value) {
hasScrollDivider.value = false
return
}
const hasVerticalScroll = modalBody.value.scrollHeight > modalBody.value.clientHeight
const isAtBottom = modalBody.value.scrollTop + modalBody.value.clientHeight >= modalBody.value.scrollHeight - 1
hasScrollDivider.value = hasVerticalScroll && !isAtBottom
}
watch(() => props.opened, (newValue) => {
if (newValue) {
modal.value?.showModal()
nextTick(() => {
updateScrollDivider()
})
setTimeout(() => {
closeBtn.value?.focus()
}, 100)
} else {
modal.value?.close()
hasScrollDivider.value = false
}
setAppropriateClassOnBody(newValue)
})
function setAppropriateClassOnBody (on: boolean) {
if (typeof window !== 'undefined') {
document.body.classList.toggle('modal-open', on)
}
}
onMounted(() => {
startListeningToEscape()
setAppropriateClassOnBody(props.opened)
window.addEventListener('resize', updateScrollDivider)
})
onBeforeUnmount(() => {
stopListeningToEscape()
setAppropriateClassOnBody(false)
window.removeEventListener('resize', updateScrollDivider)
})
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 }
: props.icon && typeof props.icon === 'object'
? { scale: defaultScale, ...(props.icon as Record<string, any>) }
: undefined,
)
</script>
<template>
<FocusTrap
v-if="opened"
:initial-focus="getCloseButton"
:fallback-focus="getCloseButton"
:tabbable-options="tabbableOptions"
>
<dialog
id="fr-modal-1"
ref="modal"
aria-modal="true"
:aria-labelledby="modalId"
:role="role"
class="fr-modal"
:class="{ 'fr-modal--opened': opened }"
:open="opened"
@click="closeIfOutside"
>
<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
ref="modalBody"
class="fr-modal__body"
:class="{ 'fr-scroll-divider': hasScrollDivider }"
@scroll="updateScrollDivider"
>
<div
class="fr-modal__header"
>
<button
:id="closeButtonId"
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
ref="modalContent"
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>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
disableOutsideInteraction?: boolean
}import type VIcon from '../VIcon/VIcon.vue'
import type { ButtonHTMLAttributes } from '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
}