Skip to content

Toaster (snackbar)

Le principe est d’avoir un composable (ici useToaster) qui va recevoir les messages et qui saura les gérer, c’est-à-dire en ajouter et en supprimer dans le tableau des messages accesibles par tous les utilisateurs de ce composable.

Ensuite, il faut un composant toaster (ici AppToaster) qui lira la liste de messages du composable, et qui les affichera.

Enfin, n’importe quel autre composant, composable, store, ou autre fichier, pourra ajouter des messages à la liste de message.

Le composable useToaster

Tout d’abord il faut créer le composable qui recevra et gérera les messages : il exposera la liste de message (messages), une fonction pour ajouter un message à la liste (addMessage()), et un autre pour supprimer un message de la liste (removeMessage).

ts
// use-toaster.ts
import { reactive } from 'vue'

const alphanumBase = 'abcdefghijklmnopqrstuvwyz0123456789'

const alphanum = alphanumBase.repeat(10)

const getRandomAlphaNum = () => {
  const randomIndex = Math.floor(Math.random() * alphanum.length)
  return alphanum[randomIndex]
}

const getRandomString = (length: number) => {
  return Array.from({ length })
    .map(getRandomAlphaNum).join('')
}

const getRandomHtmlId = (prefix = '', suffix = '') => {
  return (prefix ? `${prefix}-` : '') + getRandomString(5) + (suffix ? `-${suffix}` : '')
}

export type Message = {
  id?: string
  title?: string
  description: string
  type?: 'info' | 'success' | 'warning' | 'error'
  closeable?: boolean
  titleTag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
  timeout?: number
  style?: Record<string, string>
  class?: string | Record<string, string> | Array<string | Record<string, string>>
}

const timeouts: Record<string, number> = {}
const messages: Message[] = reactive([])

const useToaster = (defaultTimeout = 10000) => {
  function removeMessage (id: string) {
    const index = messages.findIndex(message => message.id === id)
    clearTimeout(timeouts[id])
    if (index === -1) {
      return
    }
    messages.splice(index, 1)
  }

  function addMessage (message: Message) {
    if (message.id && timeouts[message.id]) {
      removeMessage(message.id)
    }
    message.id ??= getRandomHtmlId('toaster')
    message.titleTag ??= 'h3'
    message.closeable ??= true
    message.type ??= 'info'
    message.timeout ??= defaultTimeout
    messages.push({ ...message, description: `${message.description}` })
    timeouts[message.id] = window.setTimeout(() => removeMessage(message.id as string), message.timeout)
  }

  function addSuccessMessage (message: Message | string) {
    const msg = typeof message === 'string' ? { description: message } : message
    addMessage({
      ...msg,
      type: 'success',
    })
  }
  function addErrorMessage (message: Message | string) {
    const msg = typeof message === 'string' ? { description: message } : message
    addMessage({
      ...msg,
      type: 'error',
    })
  }

  return {
    messages,
    addMessage,
    removeMessage,
    addSuccessMessage,
    addErrorMessage,
  }
}

export default useToaster

Le composant AppToaster

Ensuite, il faut créer le composant qui lira les messages depuis ce composable.

vue
<script lang="ts" setup>
import type { Message } from '../composables/use-toaster'

defineProps<{ messages: Message[] }>()

const emit = defineEmits<{
  closeMessage: [id: string]
}>()

const close = (id: string) => emit('closeMessage', id)
</script>

<template>
  <div class="toaster-container">
    <TransitionGroup
      mode="out-in"
      name="list"
      tag="div"
      class="toasters"
    >
      <template
        v-for="message in messages"
        :key="message.id"
      >
        <DsfrAlert
          class="app-alert"
          v-bind="message"
          @close="close(message.id as string)"
        />
      </template>
    </TransitionGroup>
  </div>
</template>

<style scoped>
.toaster-container {
  pointer-events: none;
  position: fixed;
  bottom: 1rem;
  width: 100%;
  z-index: 1750; /* To be on top of .fr-modal which has z-index: 1750 */
}
.toasters {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.app-alert {
  background-color: var(--grey-1000-50);
  width: 90%;
  pointer-events: all;
}

.list-move, /* apply transition to moving elements */
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateY(30px);
}

/* ensure leaving items are taken out of layout flow so that moving
   animations can be calculated correctly. */
.list-leave-active {
  position: fixed;
}
</style>

Ajouter ce composant AppToaster dans App.vue

Ce composant AppToaster sera ajouté une seule fois dans l’application : dans le composant principal App.vue, à la toute fin (pour qu’il soit au dessus de tous les autres).

vue
<script setup lang="ts">
import { ref } from 'vue'
// (...)
import AppToaster from '@/components/AppToaster.vue' // Import du composant AppToaster
// (...)
</script>

<template>
  <DsfrHeader
    v-model="searchQuery"
    :service-title="serviceTitle"
    :service-description="serviceDescription"
    :logo-text="logoText"
    :quick-links="quickLinks"
    show-search
  />

  <div class="fr-container">
    <router-view />
  </div>

  <AppToaster
    :messages="toaster.messages"
    @close-message="toaster.removeMessage($event)"
  />
</template>

Utilisation dans une app

Enfin, depuis n’importe quel fichier, composant ou non, il est possible d’ajouter des messages simplement en utilisant la fonction addMessage() du composable :

ts
import useToaster from './composables/useToaster' // Import du composable useToaster()

const toaster = useToaster() // Récupération du toaster depuis le composable

// (...)

toaster.addMessage({ // Ajout d’un message...
  title: 'Message 1',
  description: 'Description 1',
  type: 'info', // ...de type info...
  closeable: true, // ...que l’utilisateur peut fermer...
  titleTag: 'h3',
  timeout: 6000, // ...qui disparaîtra après 6 secondes
})

// (...)