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 (propactions
qui 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. | getRandomId('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 (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() {} } | |
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' |
📡 É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
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
<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
<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
<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>
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
}
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
}