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
Nom | Type | Défaut | Obligatoire | Description |
---|---|---|---|---|
tabContents | string[] | [] | Contenus des onglets. | |
initialSelectedIndex | number | 0 | Index de l'onglet sélectionné au chargement. | |
tabTitles | string[] | [] | Titres des onglets avec les id des panneaux et onglets associés. |
📡 Les Événements
nom | donné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
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’ongletDsfrTabs#selectIndex()
: permet d’indiquer quel onglet doit être sélectionné (commence à 0)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)
)
📝 Exemples
- 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>
- 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
}