Skip to content

Onglets - DsfrTabs

🌟 Introduction

Le composant onglet permet aux utilisateurs de naviguer dans différentes sections de contenu au sein d’une même page.

Le système d'onglet aide à regrouper différents contenus dans un espace limité et permet de diviser un contenu dense en sections accessibles individuellement afin de faciliter la lecture pour l'utilisateur.

🏅 La documentation sur les onglets sur le DSFR

La story sur les onglets sur le storybook de VueDsfr

📐 Structure

Chaque onglet se compose des éléments suivants :

  • un icône à gauche du titre - optionnel.
  • un titre cliquable - obligatoire ( permet d’afficher la zone de contenu qui lui est associée).

Si le nombre d’onglets dépasse la largeur du container, un scroll horizontal permet de naviguer entre les différents onglets.

🛠️ Props

NomTypeDéfautObligatoireDescription
tabContentsstring[][]Contenus (simples) des onglets.
modelValuenumber0Index de l'onglet sélectionné au chargement (existe depuis VueDsfr v6.0.0).
tabTitlesstring[][]Titres des onglets avec les id des panneaux et onglets associés.

📡 Événements

nomdonnée (payload)détail de la donnée
'update:modelValue'numberÉmis lorsqu'un onglet est sélectionné. Envoyant l'index de l'onglet sélectionné.

Important

Depuis la v6, le composant DsfrTabs déclarant la prop modelValue et émettant l’événement update:modelValue, il est recommandé d’utiliser la directive v-model. Elle contient l’index (commençant à 0) de l’onglet à afficher.

Aussi, plus besoin, depuis la v6, d’utiliser le composable useTabs(). Cf. les exemples ci-dessous.

🧩 Slots

NomDescription
tab-itemsSlot nommé pour insérer des titres d’onglets personnalisés. Si rempli, la prop tabTitles n’a aucun effet.
defaultSlot par défaut pour le contenu des onglets.

Les méthodes exposées

  • DsfrTabs#renderTabs(): permet de forcer le recalcul de la hauteur de l’onglet

Important depuis la v6

Méthodes supprimées :

  • DsfrTabs#selectFirst() : permet de sélectionner le premier onglet (raccourci de selectIndex(0))
  • DsfrTabs#selectLast() : permet de sélectionner le dernier onglet (raccourci de selectIndex(tabs.length - 1))
  • DsfrTabs#selectIndex(index: number) : permet de sélectionner un onglet particulier

Ces méthodes n’existent plus, il faut désormais utiliser directement la ref utilisée dans le v-model de DsfrTabs.

Au lieu de :

vue
<script lang="ts">
import { ref } from 'vue'

const tabs = ref<HTMLElement>()
const activeTab = ref(0)

// Quelque part dans le code :
tabs.value.selectFirst()
tabs.value.selectLast()
tabs.value.selectIndex(activeTab.value)

// (...)
</script>

<template>
  <!-- eslint-disable-next-line vue/no-unused-refs -->
  <DsfrTabs ref="tabs">
    <!-- (...) -->
  </DsfrTabs>
</template>

Utiliser :

vue
<script lang="ts">
import { ref } from 'vue'

const activeTab = ref(0)
// Quelque part dans le code :
activeTab.value = 0 // active le premier onglet
activeTab.value = n // active l’onglet n
activeTab.value = tabTitles.length - 1 // active le dernier onglet

// (...)
</script>

<template>
  <DsfrTabs v-model="activeTab">
    <!-- (...) -->
  </DsfrTabs>
</template>

📝 Exemples

  1. Onglets Simples :
vue
<script lang="ts" setup>
import { ref } from 'vue'

import DsfrTabs from '../DsfrTabs.vue'

const tabListName = 'Liste d’onglet'
const title1 = 'Titre 1'
const tabTitles = [
  { title: title1, icon: 'ri-checkbox-circle-line' },
  { title: 'Titre 2', icon: 'ri-checkbox-circle-line' },
  { title: 'Titre 3', icon: 'ri-checkbox-circle-line' },
  { title: 'Titre 4', icon: 'ri-checkbox-circle-line' },
]
const tabContents = [
  'Contenu 1 avec seulement des strings',
  'Contenu 2 avec seulement des strings',
  'Contenu 3 avec seulement des strings',
  'Contenu 4 avec seulement des strings',
]
const activeTab = ref(0)
</script>

<template>
  <div class="fr-container fr-my-2w">
    <DsfrTabs
      v-model="activeTab"
      :tab-list-name="tabListName"
      :tab-titles="tabTitles"
      :tab-contents="tabContents"
    />
  </div>
</template>
  1. Onglets Complexes :
vue
<script lang="ts" setup>
import { ref } from 'vue'

import DsfrButton from '../../DsfrButton/DsfrButton.vue'
import DsfrTabContent from '../DsfrTabContent.vue'
import DsfrTabItem from '../DsfrTabItem.vue'
import DsfrTabs from '../DsfrTabs.vue'

const tabListName = 'Liste d’onglet'
const title1 = 'Titre 1'
const tabTitles = [
  { title: title1, icon: 'ri-checkbox-circle-line', tabId: 'tab-0', panelId: 'tab-content-0' },
  { title: 'Titre 2', icon: 'ri-checkbox-circle-line', tabId: 'tab-1', panelId: 'tab-content-1' },
  { title: 'Titre 3', icon: 'ri-checkbox-circle-line', tabId: 'tab-2', panelId: 'tab-content-2' },
  { title: 'Titre 4', icon: 'ri-checkbox-circle-line', tabId: 'tab-3', panelId: 'tab-content-3' },
]

const activeTab = ref(0)
const selectPrevious = async () => {
  const newIndex = activeTab.value === 0 ? tabTitles.length - 1 : activeTab.value - 1
  activeTab.value = newIndex
}
const selectNext = async () => {
  const newIndex = activeTab.value === tabTitles.length - 1 ? 0 : activeTab.value + 1
  activeTab.value = newIndex
}
const selectFirst = async () => {
  activeTab.value = 0
}
const selectLast = async () => {
  activeTab.value = tabTitles.length - 1
}
</script>

<template>
  <div class="fr-container fr-my-2w">
    <DsfrTabs
      v-model="activeTab"
      :tab-list-name="tabListName"
    >
      <template #tab-items>
        <DsfrTabItem
          v-for="(tab, index) of tabTitles"
          :key="tab.tabId"
          :tab-id="tab.tabId"
          :panel-id="tab.panelId"
          :icon="tab.icon"
          @click="activeTab = index"
          @next="selectNext()"
          @previous="selectPrevious()"
          @first="selectFirst()"
          @last="selectLast()"
        >
          {{ tab.title }}
        </DsfrTabItem>
      </template>
      <DsfrTabContent
        panel-id="tab-content-0"
        tab-id="tab-0"
      >
        <div>Contenu 1 avec d'<em>autres composants</em></div>
      </DsfrTabContent>

      <DsfrTabContent
        panel-id="tab-content-1"
        tab-id="tab-1"
      >
        <div>Contenu 2 avec d'<strong>autres composants</strong></div>
      </DsfrTabContent>

      <DsfrTabContent
        panel-id="tab-content-2"
        tab-id="tab-2"
      >
        <div>Contenu 3 avec d'<em><strong>autres composants</strong></em></div>
      </DsfrTabContent>

      <DsfrTabContent
        panel-id="tab-content-3"
        tab-id="tab-3"
      >
        <div>
          <p>Contenu 4 avec beaucoup de contenus</p>
          <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Vitae fugit sit et eos a officiis adipisci nulla repellat cupiditate? Assumenda, explicabo ullam laboriosam ex sit corporis enim illum a itaque.</p>
          <p>Lorem, ipsum dolor sit amet consectetur adipisicing elit. Quasi animi quis quos consectetur alias delectus recusandae sunt quisquam incidunt provident quidem, at voluptatibus id, molestias et? Temporibus perspiciatis aut voluptates.</p>
          <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Quibusdam obcaecati at delectus iusto possimus! Molestiae, iusto veritatis. Nostrum magni officiis autem, in ullam aliquid, mollitia, commodi architecto vitae omnis vero.</p>
        </div>
      </DsfrTabContent>
    </DsfrTabs>
    <div style="display: flex; gap: 1rem; margin-block: 1rem;">
      <DsfrButton
        label="Activer le 1er onglet"
        :disabled="activeTab === 0"
        @click="activeTab = 0"
      />
      <DsfrButton
        label="Activer le 2è onglet"
        :disabled="activeTab === 1"
        @click="activeTab = 1"
      />
      <DsfrButton
        label="Activer le 3è onglet"
        :disabled="activeTab === 2"
        @click="activeTab = 2"
      />
      <DsfrButton
        label="Activer le dernier onglet"
        :disabled="activeTab === tabTitles.length - 1"
        @click="activeTab = tabTitles.length - 1"
      />
    </div>
  </div>
</template>

⚙️ Code source des composants

vue
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, provide, reactive, ref, type Ref, watch } from 'vue'

import { getRandomId } from '../../utils/random-utils'

import DsfrTabContent from './DsfrTabContent.vue'
import DsfrTabItem from './DsfrTabItem.vue'
import { registerTabKey } from './injection-key'
import type { DsfrTabsProps } from './DsfrTabs.types'

export type { DsfrTabsProps }

const props = withDefaults(defineProps<DsfrTabsProps>(), {
  tabContents: () => [],
  tabTitles: () => [],
  modelValue: 0,
})

const emit = defineEmits<{
  'update:modelValue': [tabIndex: number]
}>()

const asc = ref(false)
const activeTab = computed({
  get: () => props.modelValue,
  set (tabIndex: number) {
    emit('update:modelValue', tabIndex)
  },
})
const tabs = ref(new Map<number, string>())
const currentIndex = ref(0)
provide(registerTabKey, (tabId: Ref<string>) => {
  const asc = ref(true)
  watch(activeTab, (newIndex, lastIndex) => {
    asc.value = newIndex > lastIndex
  })

  if ([...tabs.value.values()].includes(tabId.value)) {
    return { isVisible: computed(() => tabs.value.get(activeTab.value) === tabId.value), asc }
  }
  const myIndex = currentIndex.value++
  tabs.value.set(myIndex, tabId.value)

  const isVisible = computed(() => myIndex === activeTab.value)

  watch(tabId, () => {
    tabs.value.set(myIndex, tabId.value)
  })

  onUnmounted(() => {
    tabs.value.delete(myIndex)
  })

  return { isVisible }
})

const $el = ref<HTMLElement | null>(null)
const tablist = ref<HTMLUListElement | null>(null)

const generatedIds: Record<string, string> = reactive({})
const getIdFromIndex = (idx: number) => {
  if (generatedIds[idx]) {
    return generatedIds[idx]
  }
  const id = getRandomId('tab')
  generatedIds[idx] = id
  return id
}

const selectPrevious = async () => {
  const newIndex = activeTab.value === 0 ? props.tabTitles.length - 1 : activeTab.value - 1
  asc.value = false
  activeTab.value = newIndex
}
const selectNext = async () => {
  const newIndex = activeTab.value === props.tabTitles.length - 1 ? 0 : activeTab.value + 1
  asc.value = true
  activeTab.value = newIndex
}
const selectFirst = async () => {
  activeTab.value = 0
}
const selectLast = async () => {
  activeTab.value = props.tabTitles.length - 1
}

const tabsStyle = ref({ '--tabs-height': '100px' })

/*
* Need to reimplement tab-height calc
* @see https://github.com/GouvernementFR/dsfr/blob/main/src/component/tab/script/tab/tabs-group.js#L117
*/
const renderTabs = () => {
  if (activeTab.value < 0) {
    return
  }
  if (!tablist.value || !tablist.value.offsetHeight) {
    return
  }
  const tablistHeight = tablist.value.offsetHeight
  // Need to manually select tabs-content in case of manual slot filling
  const selectedTab = $el.value?.querySelectorAll('.fr-tabs__panel')[activeTab.value]
  if (!selectedTab || !(selectedTab as HTMLElement).offsetHeight) {
    return
  }
  const selectedTabHeight = (selectedTab as HTMLElement).offsetHeight
  tabsStyle.value['--tabs-height'] = `${tablistHeight + selectedTabHeight}px`
}

const resizeObserver = ref<ResizeObserver | null>(null)
onMounted(() => {
  /*
  * Need to use a resize-observer as tab-content height can
    * change according to its inner components.
    */
  if (window.ResizeObserver) {
    resizeObserver.value = new window.ResizeObserver(() => {
      renderTabs()
    })
  }

  $el.value?.querySelectorAll('.fr-tabs__panel').forEach((element) => {
    if (element) {
      resizeObserver.value?.observe(element)
    }
  })
})

onUnmounted(() => {
  $el.value?.querySelectorAll('.fr-tabs__panel').forEach((element) => {
    if (element) {
      resizeObserver.value?.unobserve(element)
    }
  })
})

defineExpose({
  renderTabs,
  selectFirst,
  selectLast,
})
</script>

<template>
  <div
    ref="$el"
    class="fr-tabs"
    :style="tabsStyle"
  >
    <ul
      ref="tablist"
      class="fr-tabs__list"
      role="tablist"
      :aria-label="tabListName"
    >
      <!-- @slot Slot nommé `tab-items` pour y mettre des Titres d’onglets personnalisés. S’il est rempli, la props `tabTitles° n’aura aucun effet -->
      <slot name="tab-items">
        <DsfrTabItem
          v-for="(tabTitle, index) in tabTitles"
          :key="index"
          :icon="tabTitle.icon"
          :panel-id="tabTitle.panelId || `${getIdFromIndex(index)}-panel`"
          :tab-id="tabTitle.tabId || getIdFromIndex(index)"
          @click="activeTab = index"
          @next="selectNext()"
          @previous="selectPrevious()"
          @first="selectFirst()"
          @last="selectLast()"
        >
          {{ tabTitle.title }}
        </DsfrTabItem>
      </slot>
    </ul>

    <DsfrTabContent
      v-for="(tabContent, index) in tabContents"
      :key="index"
      :panel-id="tabTitles?.[index]?.panelId || `${getIdFromIndex(index)}-panel`"
      :tab-id="tabTitles?.[index]?.tabId || getIdFromIndex(index)"
    >
      {{ tabContent }}
    </DsfrTabContent>

    <!-- @slot Slot par défaut pour le contenu des onglets -->
    <slot />
  </div>
</template>
vue
<script setup lang="ts">
import { computed, inject, toRef } from 'vue'

import { registerTabKey } from './injection-key'

export type DsfrTabContentProps = {
  panelId: string
  tabId: string
}
const props = defineProps<DsfrTabContentProps>()

const values = { true: '100%', false: '-100%' }
const useTab = inject(registerTabKey)!
const { isVisible, asc } = useTab(toRef(() => props.tabId))
// @ts-expect-error this will be fine
const translateValueFrom = computed(() => values[String(asc?.value)])
// @ts-expect-error this will be fine
const translateValueTo = computed(() => values[String(!asc?.value)])
</script>

<template>
  <Transition
    name="slide-fade"
    mode="in-out"
  >
    <div
      v-show="isVisible"
      :id="panelId"
      class="fr-tabs__panel"
      :class="{
        'fr-tabs__panel--selected': isVisible,
      }"
      role="tabpanel"
      :aria-labelledby="tabId"
      :tabindex="isVisible ? 0 : -1"
    >
      <!-- @slot Slot par défaut pour le contenu de l’onglet. Sera dans `<div class="fr-tabs__panel">` -->
      <slot />
    </div>
  </Transition>
</template>

<style scoped>
.slide-fade-enter-active {
  transition: all 0.3s ease-out;
}

.slide-fade-leave-active {
  transition: all 0.3s ease-out;
}

.slide-fade-enter-from {
  transform: translateX(v-bind(translateValueFrom));
  opacity: 0;
}
.slide-fade-leave-to {
  transform: translateX(v-bind(translateValueTo));
  opacity: 0;
}
</style>
vue
<script lang="ts" setup>
import { inject, ref, toRef } from 'vue'

import VIcon from '../VIcon/VIcon.vue'

import { registerTabKey } from './injection-key'

export type DsfrTabItemProps = {
  panelId: string
  tabId: string
  icon?: string
}
const props = withDefaults(defineProps<DsfrTabItemProps>(), {
  icon: undefined,
})

const emit = defineEmits<{
  click: [tabId: string]
  next: []
  previous: []
  first: []
  last: []
}>()

const button = ref<HTMLButtonElement | null>(null)

const keyToEventDict = {
  ArrowRight: 'next',
  ArrowLeft: 'previous',
  ArrowDown: 'next',
  ArrowUp: 'previous',
  Home: 'first',
  End: 'last',
} as const

function onKeyDown (event: KeyboardEvent) {
  const key = event?.key as keyof typeof keyToEventDict
  const eventToEmit = keyToEventDict[key]
  if (eventToEmit) {
    // @ts-expect-error 2769
    emit(eventToEmit)
  }
}

const useTab = inject(registerTabKey)!
const { isVisible } = useTab(toRef(() => props.tabId))
</script>

<template>
  <li
    role="presentation"
  >
    <button
      :id="tabId"
      ref="button"
      :data-testid="`test-${tabId}`"
      class="fr-tabs__tab"
      :tabindex="isVisible ? 0 : -1"
      role="tab"
      type="button"
      :aria-selected="isVisible"
      :aria-controls="panelId"
      @click.prevent="$emit('click', tabId)"
      @keydown="onKeyDown($event)"
    >
      <span
        v-if="icon"
        style="margin-left: -0.25rem; margin-right: 0.5rem; font-size: 0.95rem;"
      >
        <VIcon
          :name="icon"
        />
      </span>
      <!-- @slot Slot par défaut pour le contenu de l’onglet. Sera dans `<button class="fr-tabs__tab">` -->
      <slot />
    </button>
  </li>
</template>
ts
export type DsfrTabItemProps = {
  panelId: string
  tabId: string
  icon?: string
}

export type DsfrTabContentProps = {
  panelId: string
  tabId: string
}

export type DsfrTabsProps = {
  modelValue: number
  tabListName: string
  tabTitles: (Partial<DsfrTabItemProps> & { title: string })[]
  tabContents?: string[]
}
ts
import type { InjectionKey, Ref } from 'vue'

type RegisterTab = (title: Ref<string>) => {
  isVisible: Ref<boolean>
  asc?: Ref<boolean>
}

export const registerTabKey: InjectionKey<RegisterTab> = Symbol('tabs')