Skip to content

Onglets - DsfrTabs

🌟 Introduction

Bonjour les artistes du code ! Voici DsfrTabs, le composant d'onglets Vue qui va révolutionner votre façon de présenter des contenus séparés mais cohérents. Avec sa gestion dynamique des onglets et son contenu personnalisable, vous êtes sur le point de donner à vos utilisateurs une expérience de navigation intuitive et élégante. Préparez-vous à plonger dans un monde où chaque onglet raconte sa propre histoire !

🏅 La documentation sur les onglets sur le DSFR

La story sur les onglets sur le storybook de VueDsfr

🛠️ Props

NomTypeDéfautObligatoireDescription
tabContentsstring[][]Contenus des onglets.
initialSelectedIndexnumber0Index de l'onglet sélectionné au chargement.
tabTitlesstring[][]Titres des onglets avec les id des panneaux et onglets associés.

📡 Les Événements

nomdonnée (payload)détail de la donnée
'select-tab 'stringÉmis lorsqu'un onglet est sélectionné. Envoyant l'index de l'onglet sélectionné.

🧩 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
  • DsfrTabs#selectIndex(): permet d’indiquer quel onglet doit être sélectionné (commence à 0)
  • 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))

📝 Exemples

  1. Onglets Simples :
vue
<script lang="ts" setup>
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 initialSelectedIndex = 0
</script>

<template>
  <div class="fr-container fr-my-2w">
    <DsfrTabs
      :tab-list-name="tabListName"
      :tab-titles="tabTitles"
      :tab-contents="tabContents"
      :initial-selected-index="initialSelectedIndex"
    />
  </div>
</template>
  1. Onglets Complexes :
vue
<script lang="ts" setup>
import { ref } from 'vue'
import DsfrButton from '../../DsfrButton/DsfrButton.vue'
import DsfrTabs from '../DsfrTabs.vue'
import DsfrTabContent from '../DsfrTabContent.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 initialSelectedIndex = 0

const asc = ref(true)
const selectedTabIndex = ref(initialSelectedIndex)

const selectTab = (idx: number) => {
  asc.value = selectedTabIndex.value < idx
  selectedTabIndex.value = idx
}
</script>

<template>
  <div class="fr-container fr-my-2w">
    <DsfrTabs
      ref="tabs"
      :tab-list-name="tabListName"
      :tab-titles="tabTitles"
      :initial-selected-index="initialSelectedIndex"
      @select-tab="selectTab"
    >
      <DsfrTabContent
        panel-id="tab-content-0"
        tab-id="tab-0"
        :selected="selectedTabIndex === 0"
        :asc="asc"
      >
        <div>Contenu 1 avec d'<em>autres composants</em></div>
      </DsfrTabContent>

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

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

      <DsfrTabContent
        panel-id="tab-content-3"
        tab-id="tab-3"
        :selected="selectedTabIndex === 3"
        :asc="asc"
      >
        <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>
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Labore explicabo voluptates, pariatur excepturi ad sint voluptatum vero molestias aut qui beatae. Porro laudantium, saepe consequuntur voluptatem magni earum labore veniam.</p>
          <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Illo quo nisi explicabo corrupti assumenda! Eaque quod, perspiciatis facere molestias nihil eum beatae commodi laudantium possimus qui error veniam enim at!</p>
        </div>
      </DsfrTabContent>
    </DsfrTabs>
    <div style="display: flex; gap: 1rem; margin-block: 1rem;">
      <DsfrButton
        label="Activer le 1er onglet"
        @click="() => { $refs.tabs.selectFirst() }"
      />
      <DsfrButton
        label="Activer le 2è onglet"
        @click="() => { $refs.tabs.selectIndex(1) }"
      />
      <DsfrButton
        label="Activer le 3è onglet"
        @click="() => { $refs.tabs.selectIndex(2) }"
      />
      <DsfrButton
        label="Activer le dernier onglet"
        @click="() => { $refs.tabs.selectLast() }"
      />
    </div>
  </div>
</template>

⚙️ Code source des composants

vue
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, reactive } from 'vue'

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

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

import type { DsfrTabsProps } from './DsfrTabs.types'

export type { DsfrTabsProps }

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

const emit = defineEmits<{ (e: 'selectTab', payload: number): void }>()

const selectedIndex = ref(props.initialSelectedIndex || 0)
const generatedIds: Record<string, string> = reactive({})
const asc = ref(true)
const resizeObserver = ref<ResizeObserver | null>(null)
const $el = ref<HTMLElement | null>(null)
const tablist = ref<HTMLUListElement | null>(null)

const isSelected = (idx: number) => {
  return selectedIndex.value === idx
}

/*
 * 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 (selectedIndex.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')[selectedIndex.value]
  if (!selectedTab || !(selectedTab as HTMLElement).offsetHeight) {
    return
  }
  const selectedTabHeight = (selectedTab as HTMLElement).offsetHeight

  $el.value?.style.setProperty('--tabs-height', `${tablistHeight + selectedTabHeight}px`)
}

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

const selectIndex = async (idx: number) => {
  asc.value = idx > selectedIndex.value
  selectedIndex.value = idx
  emit('selectTab', idx)
}
const selectPrevious = async () => {
  const newIndex = selectedIndex.value === 0 ? props.tabTitles.length - 1 : selectedIndex.value - 1
  await selectIndex(newIndex)
}
const selectNext = async () => {
  const newIndex = selectedIndex.value === props.tabTitles.length - 1 ? 0 : selectedIndex.value + 1
  await selectIndex(newIndex)
}
const selectFirst = async () => {
  await selectIndex(0)
}
const selectLast = async () => {
  await selectIndex(props.tabTitles.length - 1)
}

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,
  selectIndex,
  selectFirst,
  selectLast,
})
</script>

<template>
  <div
    ref="$el"
    class="fr-tabs"
  >
    <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)"
          :selected="isSelected(index)"
          @click="selectIndex(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)"
      :selected="isSelected(index)"
      :asc="asc"
    >
      <p>
        {{ tabContent }}
      </p>
    </DsfrTabContent>

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

import type { DsfrTabContentProps } from './DsfrTabs.types'

export type { DsfrTabContentProps }

const props = defineProps<DsfrTabContentProps>()

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

<template>
  <Transition
    name="slide-fade"
    mode="in-out"
  >
    <div
      v-show="selected"
      :id="panelId"
      class="fr-tabs__panel"
      :class="{
        'fr-tabs__panel--selected': selected,
      }"
      role="tabpanel"
      :aria-labelledby="tabId"
      :tabindex="selected ? 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 { ref, watch } from 'vue'
import { OhVueIcon as VIcon } from 'oh-vue-icons'

import type { DsfrTabItemProps } from './DsfrTabs.types'

export type { DsfrTabItemProps }

const props = withDefaults(defineProps<DsfrTabItemProps>(), {
  icon: undefined,
})

const emit = defineEmits<{
  (e: 'click', payload: MouseEvent): void
  (e: 'next'): void
  (e: 'previous'): void
  (e: 'first'): void
  (e: 'last'): void
}>()

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

watch(() => props.selected, (newValue) => {
  if (newValue) {
    button.value?.focus()
  }
})

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
  // @ts-expect-error 2769
  emit(keyToEventDict[key])
}
</script>

<template>
  <li
    role="presentation"
  >
    <button
      :id="tabId"
      ref="button"
      :data-testid="`test-${tabId}`"
      class="fr-tabs__tab"
      :tabindex="selected ? 0 : -1"
      role="tab"
      type="button"
      :aria-selected="selected"
      :aria-controls="panelId"
      @click.prevent="$emit('click', $event)"
      @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
  selected?: boolean
  icon?: string
}

export type DsfrTabContentProps = {
  asc?: boolean
  selected?: boolean
  panelId: string
  tabId: string
}

export type DsfrTabsProps = {
  tabListName: string
  tabTitles: (Partial<DsfrTabItemProps> & { title: string })[]
  tabContents?: string[]
  initialSelectedIndex?: number
}