Tableau - DsfrDataTable
🌟 Introduction
Le composant DsfrDataTable est un élément puissant et polyvalent pour afficher des données sous forme de tableaux dans vos applications Vue avec une intégration facile du tri et de la pagination, entre autres. Il a été enrichi pour remplacer complètement DsfrTable (qui n’est plus maintenu mais toujours présent dans la bibliothèque) et pour répondre à la version 1.14.3 du DSFR. Utilisant une combinaison de slots, de props, et d'événements personnalisés, ce composant offre une flexibilité remarquable. Plongeons dans les détails !
Prudence
Ce composant est complexe. Son API ne devrait pas changer, elle devrait s’étoffer dans les prochaines semaines ou les prochains mois.
Si vous avez des propositions, veuillez lancer une discussion avant d’ouvrir une issue.
🏅 La documentation sur le tableau sur le DSFR
La story sur le tableau de données sur le storybook de VueDsfr📐 Structure
Le composant DsfrDataTable s'utilise pour afficher des données structurées sous forme de tableau. Il prend en charge le tri des colonnes, la pagination des lignes, et l'ajout de boutons ou d'icônes pour effectuer des actions spécifiques sur les données.
Rappel de la structure du tableau DSFR : ce qui concerne la sélection se trouve au dessus du tableau. Les informations concernant le tableau complet, la pagination et les actions globales se trouvent en dessous du tableau.
Accessibilité
Le composant gère automatiquement l'attribut aria-sort sur les en-têtes de colonnes triables :
aria-sort="ascending"pour une colonne triée en ordre croissantaria-sort="descending"pour une colonne triée en ordre décroissantaria-sort="none"pour les colonnes triables non actuellement triées- Pas d'attribut
aria-sortpour les colonnes non triables
Cela permet aux lecteurs d'écran d'annoncer correctement l'état de tri de chaque colonne aux utilisateurs.
🛠️Props
| Nom | Type | Défaut | Obligatoire | Description |
|---|---|---|---|---|
id | string | undefined | Identifiant unique du tableau. | |
title | string | ✅ | Titre du tableau. | |
headersRow | Array<string | DsfrDataTableHeaderCellObject> | [] | Les en-têtes de votre tableau. Peut être remplacé par le slot Thead. | |
rows | DsfrDataTableRow[] | [] | Les données de chaque rangée dans le tableau. | |
rowKey | string | number | undefined | Une clé unique pour chaque rangée, utilisée pour optimiser la mise à jour du DOM. | |
topActionsRow | string[] | undefined | Actions affichées en haut du tableau. | |
bottomActionsRow | string[] | undefined | Actions affichées en bas du tableau. | |
selectableRows | boolean | false | Si true, permet la sélection des lignes via des cases à cocher. | |
sortableRows | boolean | string[] | false | Si true, permet le tri des lignes selon chaque colonne du header. Peut être un tableau de clés pour spécifier les colonnes triables. | |
sorted | string | undefined | Clé de la colonne actuellement triée. | |
sortFn | (a: unknown, b: unknown) => number | undefined | Fonction de tri personnalisée pour les lignes du tableau. | |
verticalBorders | boolean | false | Si true, affiche des bordures verticales entre les colonnes. | |
bottomCaption | boolean | false | Si true, affiche une légende en bas du tableau. | |
noCaption | boolean | false | Si true, supprime la légende du tableau. | |
captionDetail | string | undefined | Détails supplémentaires pour la légende du tableau. | |
multilineTable | boolean | false | Si true, permet le contenu multi-lignes dans les cellules. | |
noScroll | boolean | false | Si true, désactive le défilement horizontal du tableau. | |
size | 'sm' | 'md' | 'lg' | 'md' | Taille du tableau (petit, moyen, grand). | |
topBarDetail | string | undefined | Détails qui concernent uniquement la selection effectuée affichés dans la barre supérieure du tableau. | |
topBarButtons | (DsfrButtonProps & { tertiary?: undefined })[] | undefined | Boutons d'action qui concernent uniquement la selection effectuée affichés dans la barre supérieure du tableau. | |
topBarButtonsSize | DsfrButtonGroupProps['size'] | undefined | Taille des boutons de la barre supérieure. | |
pages | Page[] | undefined | Liste des pages pour la pagination. Si non définie, les pages sont générées automatiquement. | |
pagination | boolean | false | Si true, active la pagination des lignes du tableau. | |
paginationOptions | number[] | [5, 10, 20] | Options disponibles pour le nombre de lignes par page. | |
paginationAriaLabel | string | undefined | Attribut aria-label pour la pagination. | |
paginationSelectLabel | string | undefined | Label pour le sélecteur de lignes par page. | |
currentPage | number | 1 | Numéro de la page actuellement affichée. | |
rowsPerPage | number | 10 | Nombre de lignes à afficher par page. | |
bottomActionBarClass | string | Record<string, boolean> | Array<string | Record<string, boolean>> | undefined | Classe CSS pour la barre d'actions en bas du tableau. | |
paginationWrapperClass | string | Record<string, boolean> | Array<string | Record<string, boolean>> | undefined | Classe CSS pour l'élément englobant la pagination. | |
tableBottomBarDetail | string | undefined | Détails qui concernent l'ensemble du tableau affichés dans la barre inférieure du tableau. | |
tableBottomBarButtons | DsfrButtonProps[] | undefined | Boutons d'action qui concernent l'ensemble du tableau affichés dans la barre inférieure du tableau. | |
tableBottomBarButtonsSize | DsfrButtonGroupProps['size'] | undefined | Taille des boutons de la barre inférieure. |
📡 Events
| Nom | Payload | Description |
|---|---|---|
update:selection | string[] | Émis lors du changement de la sélection de lignes. Utilisable avec v-model:selection. |
update:current-page | number | Émis lors du changement du numéro de page. Utilisable avec v-model:currentPage. |
update:rows-per-page | number | Émis lors du changement du nombre de lignes à afficher par page. Utilisable avec v-model:rowsPerPage. |
update:sorted-by | string | undefined | Émis lors du changement de l'identifiant de la colonne à trier. Utilisable avec v-model:sortedBy. |
update:sorted-desc | boolean | Émis lors du changement du sens de tri (ascendant/descendant). Utilisable avec v-model:sortedDesc. |
Utilisation de v-model
Vous pouvez utiliser v-model pour les propriétés suivantes :
<DsfrDataTable
v-model:selection="selectedRows"
v-model:current-page="pageNum"
v-model:rows-per-page="itemsPerPage"
v-model:sorted-by="sortColumn"
v-model:sorted-desc="isDescending"
:title="tableTitle"
:headers-row="headers"
:rows="data"
/>🧩 Slots
Structure du tableau
| Nom | Scope | Description |
|---|---|---|
tableTopBar | Remplace l'ensemble de la barre supérieure du tableau. Permet de personnaliser complètement la structure. | |
tableTopBarDetail | Détails/informations affichés en haut du tableau concernant la sélection effectuée. | |
tableTopBarSearch | Zone pour ajouter un composant de recherche dans la barre supérieure. | |
tableTopBarButtons | Boutons d'action contextuels (concernant la sélection) affichés en haut du tableau. | |
tableTopBarSegmented | Espace pour ajouter des contrôles segmentés. | |
captionDescription | Description/détails additionnels de la légende du tableau (<caption>). | |
Thead | Remplace l'en-tête complet du tableau (<thead> par défaut). | |
header | { key: string, label: string } | Personnalisation du rendu de chaque en-tête de colonne. |
tbody | Remplace le corps du tableau (<tbody> par défaut et toutes les lignes). | |
cell | { colKey: string, cell: unknown } | Personnalisation du rendu de chaque cellule. |
tableBottomBar | Remplace l'ensemble de la barre inférieure du tableau. Permet de personnaliser complètement la structure. | |
pagination | Remplace la pagination par défaut. Utile pour intégrer un composant de pagination personnalisé. | |
tableBottomBarActions | Boutons d'action globaux affichés en bas du tableau (concernent l'ensemble des données). |
📝 Exemples
Exemple Basique
<script lang="ts" setup>
import DsfrDataTable from '../DsfrDataTable.vue'
const headers = [
'ID',
'Name',
'Email',
]
const rows = [
[1, 'John Doe', 'john.doe@gmail.com'],
[2, 'Jane Doe', 'jane.doe@gmail.com'],
]
</script>
<template>
<div class="fr-container fr-my-2v">
<DsfrDataTable
title="Titre du tableau (caption)"
:headers-row="headers"
no-caption
:rows="rows"
/>
</div>
</template>
<style scoped>
</style>Exemple Complexe
<script lang="ts" setup>
import type { DsfrDataTableProps } from '../DsfrDataTable.types'
import { ref } from 'vue'
import DsfrDataTable from '../DsfrDataTable.vue'
const headers: DsfrDataTableProps['headersRow'] = [
{
key: 'id',
label: 'ID',
},
{
key: 'name',
label: 'Name',
},
{
key: 'email',
label: 'Email',
},
]
const rows = [
[1, 'John Doe', 'john.doe@gmail.com'],
[2, 'Jane Doe', 'jane.doe@gmail.com'],
[3, 'James Bond', 'james.bond@mi6.gov.uk'],
]
const click = (event: MouseEvent, key: string) => {
console.warn(event, key)
}
const selection = ref<string[]>([])
</script>
<template>
<div class="fr-container fr-my-2v">
<DsfrDataTable
v-model:selection="selection"
title="Titre du tableau (caption)"
:headers-row="headers"
:rows="rows"
selectable-rows
sortable-rows
row-key="id"
vertical-borders
>
<template #header="{ key, label }">
<div @click="click($event, key)">
<em>{{ label }}</em>
</div>
</template>
<template #cell="{ colKey, cell }">
<template v-if="colKey === 'email'">
<a :href="`mailto:${cell as string}`">{{ cell }}</a>
</template>
<template v-else>
{{ cell }} <em>({{ colKey }})</em>
</template>
</template>
</DsfrDataTable>
IDs sélectionnées : {{ selection }}
</div>
</template>Exemple Plus Complexe
<script lang="ts" setup>
import type { DsfrDataTableProps } from '../DsfrDataTable.types'
import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import DsfrDataTable from '../DsfrDataTable.vue'
const headers: DsfrDataTableProps['headersRow'] = [
{
key: 'id',
label: 'ID',
},
{
key: 'name',
label: 'Name',
},
{
key: 'email',
label: 'Email',
},
]
const rows = [
{ id: 2, name: 'Jane Doe', email: 'jane.doe@gmail.com' },
{ id: 1, name: 'John Doe', email: 'john.doe@gmail.com' },
{ id: 3, name: 'James Bond', email: 'james.bond@mi6.gov.uk' },
]
const selection = ref<string[]>([])
const currentPage = ref<number>(0)
const getTopDetail = (selRef: Ref<string[]>) => {
const selectionLengthText = String(selRef.value.length)
const isPlural = selRef.value.length > 1
const plural = isPlural ? 's' : ''
let detail = 'Selectionnez des lignes pour y appliquer des actions de groupe'
if (selRef.value.length > 0) {
detail = selectionLengthText.concat(' ligne', plural, ' sélectionnée', plural)
}
return detail
}
const topBarDetail = computed(() => getTopDetail(selection))
const clicked = ref(0)
const actions = ref([]) as Ref<Array<[number, string[]]>>
const topBarButtons: DsfrDataTableProps['topBarButtons'] = [
{
label: 'Action sur la selection',
secondary: true,
onClick: () => {
clicked.value += 1
actions.value.push([clicked.value, selection.value])
},
},
]
const bottomBarButtons: DsfrDataTableProps['bottomBarButtons'] = [
{
label: 'Action globale',
secondary: false,
onClick: () => {
clicked.value += 1
actions.value.push([clicked.value, ['toutes']])
},
},
]
</script>
<template>
<div class="fr-container fr-my-2v w-[800px]">
<DsfrDataTable
v-model:selection="selection"
v-model:current-page="currentPage"
:headers-row="headers"
:rows="rows"
selectable-rows
row-key="id"
title="Titre du tableau (caption)"
pagination
:pagination-options="[1, 2, 3]"
sorted="id"
:sortable-rows="['id']"
:top-bar-detail="topBarDetail"
:top-bar-buttons="topBarButtons"
:bottom-bar-detail="`${rows.length} lignes au total`"
:bottom-bar-buttons="bottomBarButtons"
>
<template #tableTopBarDetail />
<template #header="{ label }">
<em>{{ label }}</em>
</template>
<template #cell="{ colKey, cell }">
<template v-if="colKey === 'email'">
<a :href="`mailto:${cell as string}`">{{ cell }}</a>
</template>
<template v-else>
{{ cell }} <em>({{ colKey }})</em>
</template>
</template>
</DsfrDataTable>
IDs sélectionnées : {{ selection }}
<div class="fr-mt-2v">
<strong>Actions déclenchées :</strong>
<ul>
<li
v-for="(action, index) in actions"
:key="index"
>
{{ action[0] }} action déclanchée sur les IDs : [{{ action[1].join(', ') }}]
</li>
</ul>
</div>
</div>
</template>Exemple Emploi du temps
<script lang="ts" setup>
import { getCurrentInstance } from 'vue'
import DsfrTag from '../../DsfrTag/DsfrTag.vue'
import DsfrDataTable from '../DsfrDataTable.vue'
getCurrentInstance()?.appContext.app.component('DsfrTag', DsfrTag)
const title = 'Emploi du temps complexe avec cellules fusionnées et rowspan et colspan et bordures verticales'
</script>
<template>
<DsfrDataTable
:title="title"
vertical-borders
:headers-row="[]"
:rows="[]"
>
<template #captionDescription>
(Résumé) Emploi du temps horaire des Groupes 1 et 2, le matin des jours de la semaine ouvrée (Lundi au Vendredi) :
<ul>
<li>la première colonne représente le planning de la journée de Lundi pour les groupes 1 et 2,</li>
<li>la deuxième colonne représente le planning de la journée de Mardi pour les groupes 1 et 2,</li>
<li>la troisième colonne représente le planning des journées de Mercredi et Jeudi pour le groupe 1,</li>
<li>la quatrième colonne représente le planning des journées de Mercredi et Jeudi pour le groupe 2,</li>
<li>la cinquième colonne représente le planning de la journée de Vendredi pour les groupes 1 et 2.</li>
</ul>
</template>
<template #thead>
<tr>
<th
id="complex-thead-0-col-0"
class="fr-cell--fixed"
role="columnheader"
rowspan="2"
>
Horaires
</th>
<th id="complex-thead-0-col-1">
Lundi
</th>
<th id="complex-thead-0-col-2">
Mardi
</th>
<th
id="complex-thead-0-col-3"
colspan="2"
>
Mercredi & Jeudi<br>Exemple de 2 cellules fusionnées dans le Header
</th>
<th id="complex-thead-0-col-4">
Vendredi
</th>
</tr>
<tr>
<th
id="complex-thead-1-col-0"
headers="complex-thead-0-col-1"
>
Groupes 1 & 2
</th>
<th
id="complex-thead-1-col-1"
headers="complex-thead-0-col-2"
>
Groupes 1 & 2
</th>
<th
id="complex-thead-1-col-2"
headers="complex-thead-0-col-3"
>
Groupe 1
</th>
<th
id="complex-thead-1-col-3"
headers="complex-thead-0-col-3"
>
Groupe 2
</th>
<th
id="complex-thead-1-col-4"
headers="complex-thead-0-col-4"
>
Groupes 1 & 2
</th>
</tr>
</template>
<template #tbody>
<tr
id="table-13-row-key-1"
data-row-key="1"
>
<th
id="complex-row-0"
class="fr-cell--fixed"
headers="complex-thead-0-col-0"
>
8h
</th>
<td headers="complex-row-0 complex-thead-0-col-1 complex-thead-1-col-1">
Français
</td>
<td headers="complex-row-0 complex-thead-0-col-2 complex-thead-1-col-2">
Mathématiques
</td>
<td headers="complex-row-0 complex-thead-0-col-3 complex-thead-1-col-3">
LV1
</td>
<td headers="complex-row-0 complex-thead-0-col-3 complex-thead-1-col-4">
Histoire - Géographie
</td>
<td headers="complex-row-0 complex-thead-0-col-4 complex-thead-1-col-4">
EPS
</td>
</tr>
<tr
id="table-13-row-key-2"
data-row-key="2"
>
<th
id="complex-row-1"
class="fr-cell--fixed"
headers="complex-thead-0-col-0"
>
9h
</th>
<td
colspan="5"
headers="complex-row-1 complex-thead-0-col-1 complex-thead-0-col-2 complex-thead-0-col-3 complex-thead-0-col-4 complex-thead-1-col-1 complex-thead-1-col-2 complex-thead-1-col-3 complex-thead-1-col-4 complex-thead-1-col-4"
>
Etude dirigée Exemple de colspan sur toute la ligne
</td>
</tr>
<tr
id="table-13-row-key-3"
data-row-key="3"
>
<th
id="complex-row-2"
class="fr-cell--fixed"
headers="complex-thead-0-col-0"
>
10h
</th>
<td headers="complex-row-2 complex-thead-0-col-1 complex-thead-1-col-1">
Mathématiques
</td>
<td headers="complex-row-2 complex-thead-0-col-2 complex-thead-1-col-2">
Histoire - Géographie
</td>
<td
rowspan="2"
headers="complex-row-2 complex-row-3 complex-thead-0-col-3 complex-thead-1-col-3"
>
Arts
appliqués
</td>
<td headers="complex-row-2 complex-thead-0-col-3 complex-thead-1-col-4">
LV2
</td>
<td headers="complex-row-2 complex-thead-0-col-4 complex-thead-1-col-4">
Sciences
</td>
</tr>
<tr
id="table-13-row-key-4"
data-row-key="4"
>
<th
id="complex-row-3"
class="fr-cell--fixed"
headers="complex-thead-0-col-0"
>
11h
</th>
<td headers="complex-row-3 complex-thead-0-col-1 complex-thead-1-col-1">
Français
</td>
<td headers="complex-row-3 complex-thead-0-col-2 complex-thead-1-col-2">
EPS
</td>
<td headers="complex-row-3 complex-thead-0-col-3 complex-thead-1-col-4">
Histoire - Géographie
</td>
<td headers="complex-row-3 complex-thead-0-col-4 complex-thead-1-col-4">
Physique - Chimie
</td>
</tr>
<tr
id="table-13-row-key-5"
data-row-key="5"
>
<th
id="complex-row-4"
class="fr-cell--fixed"
headers="complex-thead-0-col-0"
>
12h
</th>
<td headers="complex-row-4 complex-thead-0-col-1 complex-thead-1-col-1">
Sciences
</td>
<td headers="complex-row-4 complex-thead-0-col-2 complex-thead-1-col-2">
LV1
</td>
<td
colspan="2"
headers="complex-row-4 complex-thead-0-col-3 complex-thead-1-col-3 complex-thead-1-col-4"
>
EPS
Exemple de colspan sur 2 cellules
</td>
<td headers="complex-row-4 complex-thead-0-col-4 complex-thead-1-col-4">
LV2
</td>
</tr>
</template>
</DsfrDataTable>
</template>
<style scoped>
:deep(.info) {
color: var(--info-425-625);
background-color: var(--info-950-100);
}
:deep(.error) {
color: var(--error-425-625);
background-color: var(--error-950-100);
}
:deep(.success) {
color: var(--success-425-625);
background-color: var(--success-950-100);
}
</style>⚙️Code source du composant
<script lang="ts" setup>
import type { DsfrDataTableHeaderCell, DsfrDataTableHeaderCellObject, DsfrDataTableProps, DsfrDataTableRow } from './DsfrDataTable.types'
import type { Page } from '../DsfrPagination/DsfrPagination.types'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import DsfrButtonGroup from '../DsfrButton/DsfrButtonGroup.vue'
import DsfrPagination from '../DsfrPagination/DsfrPagination.vue'
import DsfrSelect from '../DsfrSelect/DsfrSelect.vue'
import { useRandomId } from '@/utils/random-utils'
export type { DsfrDataTableHeaderCell, DsfrDataTableHeaderCellObject, DsfrDataTableProps, DsfrDataTableRow, Page }
const props = withDefaults(defineProps<DsfrDataTableProps>(), {
id: () => useRandomId('table'),
headersRow: () => [],
rows: () => [],
topActionsRow: () => [],
bottomActionsRow: () => [],
currentPage: 0,
rowsPerPage: 10,
rowKey: 0,
paginationOptions: () => [
5,
10,
20,
],
paginationAriaLabel: 'Pagination',
paginationSelectLabel: 'Nombre de lignes par page',
})
const emit = defineEmits<{
'update:current-page': [page: number]
}>()
defineSlots<{
/** Slot pour le contenu de l'en-tête du tableau (au-dessus de la table dans div.fr-table div.fr-table__header) */
tableTopBar: () => any
/** Slot pour le détail dans l'en-tête du tableau (potentiellement premier élément de div.fr-table__header) */
tableTopBarDetail: () => any
/** Slot pour la barre de recherche dans l'en-tête du tableau (potentiellement second élément de div.fr-table__header) */
tableTopBarSearch: () => any
/** Slot pour les boutons dans l'en-tête du tableau (potentiellement troisième élément de div.fr-table__header) */
tableTopBarButtons: () => any
/** Slot pour les boutons segmentés dans l'en-tête du tableau destinés aux actions de groupe (selection) (potentiellement quatrième élément de div.fr-table__header) */
tableTopBarSegmented: () => any
/** Slot pour la description dans la légende du tableau (dans caption div.fr-table__caption__desc)*/
captionDescription: () => any
/** Slot pour le contenu de l'en-tête du tableau (`<thead>`) */
thead: () => any
/** Slot pour le contenu des cellules d'en-tête (<th>). Reçoit les props `key` et `label` */
header: { key: string | number, label: string }
/** Slot pour le contenu du corps du tableau (`<tbody>`) */
tbody: () => any
/** Slot pour le contenu personnalisé des cellules. Reçoit les props `colKey` et `cell` */
cell: { colKey: string | number, cell: string | DsfrDataTableRow }
/** Slot pour le contenu du pied de page du tableau (en dessous de la table div.fr-table__footer) */
tableBottomBar: () => any
/** Slot pour la pagination dans le pied de page du tableau (dans div.fr-table__footer */
tableBottomBarPagination: () => any
/** Slot pour les actions dans le pied de page du tableau destinés aux actions globales (ensemble du tableau) */
tableBottomBarActions: () => any
}>()
const selection = defineModel<string[]>('selection', { default: [] })
const rowsPerPage = defineModel<number>('rowsPerPage', { default: 10 })
const currentPage = defineModel<number>('currentPage', { default: 0 })
const pageCount = computed(() => Math.ceil(props.rows.length / rowsPerPage.value))
const pages = computed<Page[]>(() => props.pages ?? Array.from({ length: pageCount.value }).map((x, i) => ({ label: `${i + 1}`, title: `Page ${i + 1}`, href: `#${i + 1}` })))
const lowestLimit = computed(() => currentPage.value * rowsPerPage.value)
const highestLimit = computed(() => (currentPage.value + 1) * rowsPerPage.value)
const sortedBy = defineModel<string | undefined>('sortedBy', { default: undefined })
const sortedDesc = defineModel('sortedDesc', { default: false })
function defaultSortFn (a: string | DsfrDataTableRow, b: string | DsfrDataTableRow) {
const key = sortedBy.value ?? props.sorted
// @ts-expect-error TS7015
if (((a as DsfrDataTableRow)[key] ?? a) < ((b as DsfrDataTableRow)[key] ?? b)) {
return -1
}
// @ts-expect-error TS7015
if (((a as DsfrDataTableRow)[key] ?? a) > ((b as DsfrDataTableRow)[key] ?? b)) {
return 1
}
return 0
}
function sortBy (key: string) {
if (!props.sortableRows || (Array.isArray(props.sortableRows) && !props.sortableRows.includes(key))) {
return
}
if (sortedBy.value === key) {
if (sortedDesc.value) {
sortedBy.value = undefined
sortedDesc.value = false
return
}
sortedDesc.value = true
return
}
sortedDesc.value = false
sortedBy.value = key
}
function getAriaSort (header: DsfrDataTableHeaderCell, idx: number): 'ascending' | 'descending' | 'none' | undefined {
const headerKey = (header as DsfrDataTableHeaderCellObject).key ?? (props.rows[0] && Array.isArray(props.rows[0]) ? idx : header)
const isSortable = props.sortableRows === true || (Array.isArray(props.sortableRows) && props.sortableRows.includes(String(headerKey)))
if (!isSortable) {
return undefined
}
if (sortedBy.value === headerKey) {
return sortedDesc.value ? 'descending' : 'ascending'
}
return 'none'
}
const sortedRows = computed(() => {
const _sortedRows = sortedBy.value ? props.rows.slice().sort(props.sortFn ?? defaultSortFn) : props.rows.slice()
if (sortedDesc.value) {
_sortedRows.reverse()
}
return _sortedRows
})
const rowKeys = computed(() => props.headersRow.map((header) => {
if (typeof header !== 'object') {
return header
}
return header.key
}))
const rowKeyIndex = computed(() => rowKeys.value.findIndex(key => key === props.rowKey))
const finalRows = computed(() => {
const rows = sortedRows.value.map((row) => {
if (Array.isArray(row)) {
return row
}
return rowKeys.value.map(key => typeof row !== 'object' ? row : row[key] ?? row)
})
if (props.pagination) {
return rows.slice(lowestLimit.value, highestLimit.value)
}
return rows
})
function selectAll (bool: boolean) {
if (bool) {
const keyIndex = props.headersRow.findIndex(header => (header as DsfrDataTableHeaderCellObject).key ?? header)
selection.value = finalRows.value.map(row => row[keyIndex] as string)
} else {
selection.value.length = 0
}
}
const wholeSelection = computed(() => selection.value.length === finalRows.value.length)
function onPaginationOptionsChange () {
emit('update:current-page', 0)
selection.value.length = 0
}
function copyToClipboard (text: string) {
navigator.clipboard.writeText(text)
}
// rendu tenant compte du JS table DSFR
const captionRef = ref<HTMLTableCaptionElement | null>(null)
const containerStyle = ref({})
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
await nextTick()
if (!captionRef.value) {
return
}
const height = captionRef.value.offsetHeight
containerStyle.value = {
'--table-offset': `calc(${height}px + 1rem)`,
}
resizeObserver = new ResizeObserver(() => {
if (!captionRef.value) {
return
}
const newHeight = captionRef.value.offsetHeight
containerStyle.value = {
'--table-offset': `calc(${newHeight}px + 1rem)`,
}
})
if (captionRef.value) {
resizeObserver.observe(captionRef.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<template>
<div
class="fr-table"
:class="{ 'fr-table--sm': size === 'sm', 'fr-table--lg': size === 'lg', 'fr-table--no-caption': noCaption, 'fr-table--bordered': verticalBorders, 'fr-table--no-scroll': noScroll, 'fr-table--multiline': multilineTable, 'fr-table--caption-bottom': bottomCaption && !noCaption }"
>
<div
v-if="$slots.tableTopBar || $slots.tableTopBarDetail || $slots.tableTopBarSearch || $slots.tableTopBarButtons || $slots.tableTopBarSegmented || topBarDetail || topBarButtons"
class="fr-table__header"
>
<slot
name="tableTopBar"
>
<slot
name="tableTopBarDetail"
class="fr-table__detail"
>
<p
v-if="topBarDetail"
class="fr-table__detail"
>
{{ topBarDetail }}
</p>
</slot>
<slot name="tableTopBarSearch" />
<slot
name="tableTopBarButtons"
>
<DsfrButtonGroup
v-if="topBarButtons"
:buttons="topBarButtons"
:size="topBarButtonsSize"
/>
</slot>
<slot name="tableTopBarSegmented" />
</slot>
</div>
<div
class="fr-table__wrapper"
:style="containerStyle"
>
<div class="fr-table__container">
<div class="fr-table__content">
<table :id="id">
<caption
ref="captionRef"
>
{{ title }}
<div
v-if="captionDetail || $slots.captionDescription"
class="fr-table__caption__desc"
>
<slot name="captionDescription">
{{ captionDetail }}
</slot>
</div>
</caption>
<thead>
<slot name="thead">
<tr>
<th
v-if="selectableRows"
class="fr-cell--fixed"
role="columnheader"
>
<div class="fr-checkbox-group fr-checkbox-group--sm">
<!-- @vue-expect-error TS2538 -->
<input
:id="`table-select--${id}-all`"
:checked="wholeSelection"
type="checkbox"
@input="selectAll($event.target.checked)"
>
<label
class="fr-label"
:for="`table-select--${id}-all`"
>
Sélectionner tout
</label>
</div>
</th>
<th
v-for="(header, idx) of headersRow"
:key="typeof header === 'object' ? header.key : header"
scope="col"
v-bind="typeof header === 'object' && header.headerAttrs"
:tabindex="sortableRows ? 0 : undefined"
:aria-sort="getAriaSort(header, idx)"
>
<div
class="fr-cell-sort"
:class="{ 'sortable-header': sortableRows === true || (Array.isArray(sortableRows) && sortableRows.includes((header as DsfrDataTableHeaderCellObject).key ?? header)) }"
>
<slot
name="header"
v-bind="typeof header === 'object' ? header : { key: header, label: header }"
>
{{ typeof header === 'object' ? header.label : header }}
</slot>
<button
v-if="sortableRows === true || (Array.isArray(sortableRows) && sortableRows.includes((header as DsfrDataTableHeaderCellObject).key ?? header))"
type="button"
class="fr-btn--sort fr-btn fr-btn-sm"
:class="{ 'fr-btn--sort-asc': getAriaSort(header, idx) === 'ascending', 'fr-btn--sort-desc': getAriaSort(header, idx) === 'descending' }"
@click="sortBy((header as DsfrDataTableHeaderCellObject).key ?? (Array.isArray(rows[0]) ? idx : header))"
@keydown.enter="sortBy((header as DsfrDataTableHeaderCellObject).key ?? header)"
@keydown.space="sortBy((header as DsfrDataTableHeaderCellObject).key ?? header)"
>
Trier
</button>
</div>
</th>
</tr>
</slot>
</thead>
<tbody>
<slot name="tbody">
<tr
v-for="(row, idx) of finalRows"
:key="`row-${idx}`"
:data-row-key="idx + 1"
>
<th
v-if="selectableRows"
class="fr-cell--fixed"
role="columnheader"
>
<div class="fr-checkbox-group fr-checkbox-group--sm">
<input
:id="`row-select-${id}-${idx}`"
v-model="selection"
:value="row[rowKeyIndex] ?? `row-${idx}`"
type="checkbox"
>
<label
class="fr-label"
:for="`row-select-${id}-${idx}`"
>
Sélectionner la ligne {{ idx + 1 }}
</label>
</div>
</th>
<!-- @vue-expect-error TS2538 -->
<td
v-for="(cell, cellIdx) of row"
:key="typeof cell === 'object' ? cell[rowKey] : cell"
tabindex="0"
@keydown.ctrl.c="copyToClipboard(typeof cell === 'object' ? cell[rowKey] : cell)"
@keydown.meta.c="copyToClipboard(typeof cell === 'object' ? cell[rowKey] : cell)"
>
<slot
name="cell"
v-bind="{
colKey: typeof headersRow[cellIdx] === 'object'
? headersRow[cellIdx].key
: headersRow[cellIdx],
cell,
}"
>
<!-- @vue-expect-error TS2538 -->
{{ typeof cell === 'object' ? cell[rowKey] : cell }}
</slot>
</td>
</tr>
</slot>
</tbody>
</table>
</div>
</div>
</div>
<div
v-if="bottomActionBarClass || $slots.tableBottomBarPagination || $slots.tableBottomBar || $slots.tableBottomBarActions || bottomBarButtons || pagination || bottomBarDetail"
class="fr-table__footer"
:class="bottomActionBarClass"
>
<slot
name="tableBottomBar"
>
<slot name="tableBottomBarPagination">
<template
v-if="pagination && !$slots.tableBottomBarPagination"
>
<div class="fr-table__footer--start">
<p
v-if="bottomBarDetail"
class="fr-table__detail"
>
{{ bottomBarDetail }}
</p>
<div
class=""
:class="paginationWrapperClass"
>
<DsfrSelect
v-if="paginationOptions && paginationOptions.length > 0"
v-model="rowsPerPage"
:options="paginationOptions"
:label="paginationSelectLabel"
:hide-label="true"
:default-unselected-text="paginationSelectLabel"
@update:model-value="onPaginationOptionsChange()"
/>
</div>
<div class="fr-table__footer--middle">
<DsfrPagination
v-model:current-page="currentPage"
:pages="pages"
:aria-label="paginationAriaLabel"
@update:current-page="selection.length = 0"
/>
</div>
<div
v-if="bottomBarButtons || $slots.tableBottomBarActions"
class="fr-table__footer--end"
>
<slot name="tableBottomBarActions">
<DsfrButtonGroup
v-if="bottomBarButtons"
:buttons="bottomBarButtons"
:size="bottomBarButtonsSize"
/>
</slot>
</div>
</div>
</template>
</slot>
</slot>
</div>
</div>
</template>
<style scoped>
.flex {
display: flex;
}
.justify-between {
justify-content: space-between;
}
.items-center {
align-items: center;
}
.gap-2 {
gap: 0.5rem;
}
:deep(.fr-pagination__link) {
margin-bottom: 0 !important;
}
.sortable-header {
display: flex;
justify-content: space-between;
cursor: pointer;
}
.fr-table {
position: relative;
height: 100%;
}
.fr-table__wrapper {
position: relative;
}
.fr-table caption {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
</style>import type { DsfrButtonGroupProps, DsfrButtonProps } from '../DsfrButton/DsfrButton.types'
import type { Page } from '../DsfrPagination/DsfrPagination.types'
export type DsfrDataTableRow = (string | number | boolean | bigint | symbol)[]
| Record<string | symbol | number, unknown>
export type DsfrDataTableHeaderCellObject = { key: string, label: string, headerAttrs?: Record<string, unknown> }
export type DsfrDataTableHeaderCell = (string | DsfrDataTableHeaderCellObject)
export type DsfrDataTableProps = {
id?: string
title: string
rowKey?: string | number
headersRow?: DsfrDataTableHeaderCell[]
rows?: DsfrDataTableRow[]
topActionsRow?: string[]
bottomActionsRow?: string[]
selectableRows?: boolean
sortableRows?: boolean | string[]
sorted?: string
sortFn?: (a: unknown, b: unknown) => number
verticalBorders?: boolean
bottomCaption?: boolean
noCaption?: boolean
captionDetail?: string
multilineTable?: boolean
noScroll?: boolean
size?: 'sm' | 'md' | 'lg'
topBarDetail?: string
topBarButtons?: Omit<DsfrButtonProps, 'tertiary'>[]
topBarButtonsSize?: DsfrButtonGroupProps['size']
pages?: Page[]
pagination?: boolean
paginationOptions?: number[]
paginationAriaLabel?: string
paginationSelectLabel?: string
currentPage?: number
rowsPerPage?: number
bottomActionBarClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>
paginationWrapperClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>
bottomBarDetail?: string
bottomBarButtons?: DsfrButtonProps[]
bottomBarButtonsSize?: DsfrButtonGroupProps['size']
}C'est tout, amis développeurs ! Avec DsfrDataTable, donnez vie à vos données comme jamais auparavant ! 🎉
