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 DsfrButton from '@/components/DsfrButton/DsfrButton.vue'
import DsfrModal from '../DsfrModal.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 DsfrButton, { type DsfrButtonProps } from '@/components/DsfrButton/DsfrButton.vue'
import DsfrModal from '../DsfrModal.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>
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 { onMounted, onBeforeUnmount, computed, ref, nextTick, watch } from 'vue'
import { FocusTrap } from 'focus-trap-vue'
import { OhVueIcon as VIcon } from 'oh-vue-icons'
import DsfrButtonGroup from '../DsfrButton/DsfrButtonGroup.vue'
import { getRandomId } from '@/utils/random-utils'
import type { DsfrModalProps } from './DsfrModal.types'
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 { OhVueIcon as VIcon } from 'oh-vue-icons'
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
}