Skip to content

useScheme

Ce composable permet de gérer simplement le thème du site. Vous trouverez ci-dessous des exemples d’utilisation.

Exemple complet est disponible sur Stackblitz.

Utilisation basique

html
<script>
import { useScheme } from '@gouvminint/vue-dsfr'

export default {
  mounted () {
    useScheme() // useScheme utilise `window` et modifie `document`, il faut donc être sûr d'être côté client
  }
}
</script>
html
<script setup>
import { onMounted } from 'vue'
import { useScheme } from '@gouvminint/vue-dsfr'

onMounted(useScheme) // useScheme utilise `window` et modifie `document`, il faut donc être sûr d'être côté client
</script>

Ceci va récupérer la préférence au niveau de l’OS de l’utilisateur et appliquer le bon thème en fonction de celui-ci.

Utilisation avancée (Script setup TypeScript)

vue
<script setup>
import { onMounted, reactive, watchEffect } from 'vue'

import { DsfrButton, useScheme } from '../../index'

const preferences = reactive({
  theme: undefined,
  scheme: undefined,
})

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

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

  watchEffect(() => setScheme(preferences.scheme))
})
</script>

<template>
  <div class="fr-container">
    <p>
      Thème courant : {{ preferences.theme }} ('light' ou 'dark')
    </p>
    <p>
      Scheme courant : {{ preferences.scheme }} ('system', 'light', ou 'dark')
    </p>
    <p style="display: flex; gap: 0.5rem">
      <DsfrButton @click="preferences.scheme = 'system'">
        System
      </DsfrButton>
      <DsfrButton @click="preferences.scheme = 'light'">
        Light
      </DsfrButton>
      <DsfrButton @click="preferences.scheme = 'dark'">
        Dark
      </DsfrButton>
    </p>
  </div>
</template>
ts
import { computed, ref, watchEffect } from 'vue'
import type { ComputedRef } from 'vue'

const PREFERS_DARK_MEDIA_QUERY = '(prefers-color-scheme: dark)'
const DEFAULT_COLOR_SCHEME_LS_KEY = 'vue-dsfr-scheme'
let localStorageKey: string
export const LIGHT_SCHEME = 'light'
export const DARK_SCHEME = 'dark'
export const SYSTEM_SCHEME = 'system'
const DEFAULT_DATA_THEME_ATTRIBUTE = 'data-fr-theme'

export type Preferences = {
  theme: 'light' | 'dark'
  scheme: 'light' | 'dark' | 'system'
}

/**
 * @property {(scheme: string) => void} setScheme - Fonction pour mettre à jour le scheme
 * @property {string} scheme - Scheme courant
 * @property {string} theme - Thème courant en fonction du scheme
 */
export declare type UseSchemeResult = {
  setScheme: (scheme: string) => void
  scheme: ComputedRef<string>
  theme: ComputedRef<string>
}

/**
 * @property {string=} scheme? - Scheme souhaité (`'system'` par défaut)
 * @property {string=} dataThemeAttribute? - Nom complet de l’attribut qui contiendra la valeur du thème (`'data-fr-theme'` par défaut)
 */
export declare type UseThemeOptions = {
  scheme?: string
  dataThemeAttribute?: string
  localStorageKey?: string
}

const getProperSchemeValue = (desiredScheme: string): string => {
  const scheme = desiredScheme ?? (localStorage.getItem(localStorageKey) || SYSTEM_SCHEME)
  return [LIGHT_SCHEME, DARK_SCHEME, SYSTEM_SCHEME].includes(scheme)
    ? scheme
    : SYSTEM_SCHEME
}

const getThemeMatchingScheme = (scheme: string, mediaQuery: MediaQueryList): string => {
  scheme = getProperSchemeValue(scheme)
  if (scheme === SYSTEM_SCHEME) {
    return mediaQuery?.matches ? DARK_SCHEME : LIGHT_SCHEME
  }
  return scheme
}

/**
 * Permet de gérer le thème selon le scheme donné.
 * Si dans les options, `scheme` vaut 'system', le thème sera celui du système,
 * si `scheme` vaut `'light'`, le theme sera clair,
 * et s’il vaut `'dark'`, le thème sera sombre.
 *
 * @param {UseThemeOptions=} options - Peut contenir les clés `scheme` pour le scheme voulu, `dataThemeAttribute` pour l’attribut
 *                   qui contiendra la valeur de scheme, et `localStorageKey` pour personnaliser la clé utilisée dans stockage local.
 *
 * @return {UseSchemeResult} Objet contenant la fonction `setScheme` pour changer le scheme, et les
 *          propriétés calculés (réactives et en lecture seule) `theme` et `scheme`.
 */
export const useScheme = (options?: UseThemeOptions): UseSchemeResult | undefined => {
  if (typeof window === 'undefined') {
    return
  }
  localStorageKey = options?.localStorageKey ?? DEFAULT_COLOR_SCHEME_LS_KEY

  const opts = {
    scheme: localStorage.getItem(localStorageKey) || SYSTEM_SCHEME,
    dataThemeAttribute: DEFAULT_DATA_THEME_ATTRIBUTE,
    ...options,
  }

  const mediaQuery =
    window.matchMedia && window.matchMedia(PREFERS_DARK_MEDIA_QUERY)

  const scheme = ref(getProperSchemeValue(opts.scheme))

  localStorage.setItem(localStorageKey, scheme.value)

  const theme = ref(getThemeMatchingScheme(scheme.value, mediaQuery))
  const force = ref(scheme.value !== SYSTEM_SCHEME)

  watchEffect(() => {
    document.body.parentElement?.setAttribute(
      opts.dataThemeAttribute || DEFAULT_DATA_THEME_ATTRIBUTE,
      theme.value,
    )
  })

  mediaQuery?.addEventListener('change', (event) => {
    if (force.value) {
      return
    }
    if (event.matches) {
      theme.value = DARK_SCHEME
    } else {
      theme.value = LIGHT_SCHEME
    }
  })

  if (!force.value && mediaQuery?.matches) {
    theme.value = DARK_SCHEME
  }

  const setScheme = (newScheme: string): void => {
    scheme.value = getProperSchemeValue(newScheme)
    localStorage.setItem(localStorageKey, scheme.value)
    if ([LIGHT_SCHEME, DARK_SCHEME].includes(scheme.value)) {
      theme.value = scheme.value
      force.value = true
      return
    }
    theme.value = getThemeMatchingScheme(scheme.value, mediaQuery)
    force.value = false
  }

  const target = document.documentElement
  const observerOptions = {
    subtree: false,
    childList: false,
    attributes: true,
  }

  const observer = new MutationObserver((mutationList /* , observer */) => {
    for (const mutation of mutationList) {
      if (mutation.type === 'attributes' && mutation.attributeName === 'data-fr-theme') {
        const newScheme = (mutation.target as HTMLElement).getAttribute(mutation.attributeName) as 'light' | 'dark'
        if (newScheme !== scheme.value) {
          setScheme(newScheme)
        }
      }
    }
  })

  observer.observe(target, observerOptions)

  window.addEventListener('unload', () => observer.disconnect())

  setScheme(scheme.value)

  return {
    setScheme,
    theme: computed(() => theme.value),
    scheme: computed(() => scheme.value),
  }
}