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
Nom | Type | Défaut | Obligatoire | Description |
---|---|---|---|---|
tabContents | string[] | [] | Contenus (simples) des onglets. | |
modelValue | number | 0 | Index de l'onglet sélectionné au chargement (existe depuis VueDsfr v6.0.0). | |
tabTitles | string[] | [] | Titres des onglets avec les id des panneaux et onglets associés. |
📡 Événements
nom | donné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
Nom | Description |
---|---|
tab-items | Slot nommé pour insérer des titres d’onglets personnalisés. Si rempli, la prop tabTitles n’a aucun effet. |
default | Slot 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 deselectIndex(0)
)DsfrTabs#selectLast()
: permet de sélectionner le dernier onglet (raccourci deselectIndex(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 :
<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 :
<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
- Onglets Simples :
<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>
- Onglets Complexes :
<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
<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>
<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>
<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>
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[]
}
import type { InjectionKey, Ref } from 'vue'
type RegisterTab = (title: Ref<string>) => {
isVisible: Ref<boolean>
asc?: Ref<boolean>
}
export const registerTabKey: InjectionKey<RegisterTab> = Symbol('tabs')