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. 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 tout neuf et a besoin de vos retours. Son API ne devrait pas changer, elle devrait s’étoffer dans les prochaines semaines ou les prochains mois.
Si vous avez des propositions, 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.
Props 🛠️
Nom | Type | Défaut | Obligatoire | Description |
---|---|---|---|---|
title | string | ✅ | Les en-têtes de votre tableau. | |
headers | Array<string> | [] | Les en-têtes de votre tableau. | |
rows | Array<DsfrDataTableRowProps | string[] | DsfrDataTableCellProps[]> | [] | Les données de chaque rangée dans le tableau. | |
rowKey | string | Function | undefined | Une clé unique pour chaque rangée, utilisée pour optimiser la mise à jour du DOM. | |
currentPage | number | 1 | La page actuelle dans la pagination 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 | Obligatoire | Clé de la colonne actuellement triée. | |
sortFn | (a: unknown, b: unknown) => number | defaultSortFn | 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. | |
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. | |
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> | string[] | undefined | Classe CSS pour la barre d'actions en bas du tableau. | |
paginationWrapperClass | string | Record<string, boolean> | string[] | undefined | Classe CSS pour l'élément englobant la pagination. |
Events 📡
Nom | Payload | Description |
---|---|---|
update:current-page | Émis lors du changement du numéro de page (dans le composant pagination intégré). | |
update:rows-per-page | Émis lors du changement du nombre de lignes à afficher par page (dans le composant pagination intégré). | |
update:selection | Émis lors du changement de la sélection de lignes. | |
update:sorted-by | Émis lors du changement de l’identifiant de la colonne à trier. | |
update:sorted-desc | Émis lors du changement du sens de tri. |
Vous pouvez donc utiliser v-model
pour :
- selection
- rowsPerPage
- currentPage
- sortedBy
- sortedDesc
🧩 Slots
- Slot par défaut: Utilisé pour le corps du tableau. Par défaut, il affiche les rangées de données via
DsfrDataTableRow
. header
: Permet de personnaliser le rendu des en-têtes de colonne.cell
: Permet de personnaliser le contenu des cellules.pagination
: Permet de personnaliser la pagination affichée sous le tableau.
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"
:rows="rows"
/>
</div>
</template>
<style scoped>
</style>
Exemple Complexe
<script lang="ts" setup>
import { ref } from 'vue'
import DsfrDataTable from '../DsfrDataTable.vue'
import type { DsfrDataTableProps } from '../DsfrDataTable.types'
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="0"
>
<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 { ref } from 'vue'
import DsfrDataTable from '../DsfrDataTable.vue'
import type { DsfrDataTableProps } from '../DsfrDataTable.types'
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)
</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
:rows-per-page="2"
:pagination-options="[1, 2, 3]"
bottom-action-bar-class="bottom-action-bar-class"
pagination-wrapper-class="pagination-wrapper-class"
sorted="id"
:sortable-rows="['id']"
>
<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>
</template>
<style scoped>
:deep(.bottom-action-bar-class) {
width: 860px;
}
:deep(.pagination-wrapper-class) {
width: 860px;
}
</style>
⚙️ Code source du composant
<script lang="ts" setup>
import { computed, ref } from 'vue'
import DsfrPagination from '../DsfrPagination/DsfrPagination.vue'
import VIcon from '../VIcon/VIcon.vue'
import { getRandomId } from '@/utils/random-utils'
export type Page = { href?: string, label: string, title: string }
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
pages?: Page[]
pagination?: boolean
paginationOptions?: number[]
currentPage?: number
rowsPerPage?: number
bottomActionBarClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>
paginationWrapperClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>
}
const props = withDefaults(defineProps<DsfrDataTableProps>(), {
id: () => getRandomId('table'),
topActionsRow: () => [],
bottomActionsRow: () => [],
currentPage: 0,
rowsPerPage: 10,
rowKey: 0,
paginationOptions: () => [
5,
10,
20,
],
})
const emit = defineEmits<{
'update:current-page': [page: number]
}>()
const selection = defineModel<string[]>('selection', { default: [] })
const rowsPerPage = defineModel<number>('rowsPerPage', { default: 10 })
const currentPage = defineModel<number>('currentPage', { default: 1 })
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
}
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 finalRows = computed(() => {
const rowKeys = props.headersRow.map((header) => {
if (typeof header !== 'object') {
return header
}
return header.key
})
const rows = sortedRows.value.map((row) => {
if (Array.isArray(row)) {
return row
}
return rowKeys.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)
}
selection.value!.length = 0
}
const wholeSelection = ref(false)
function checkSelection () {
wholeSelection.value = selection.value.length === finalRows.value.length
}
function onPaginationOptionsChange () {
emit('update:current-page', 0)
wholeSelection.value = false
selection.value.length = 0
}
function copyToClipboard (text: string) {
navigator.clipboard.writeText(text)
}
</script>
<template>
<div
class="fr-table"
>
<div class="fr-table__wrapper">
<div class="fr-table__container">
<div class="fr-table__content">
<table :id="id">
<caption>
{{ title }}
</caption>
<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"
@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)"
>
<div
: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>
<span v-if="sortedBy !== ((header as DsfrDataTableHeaderCellObject).key ?? header) && (sortableRows === true || (Array.isArray(sortableRows) && sortableRows.includes((header as DsfrDataTableHeaderCellObject).key ?? header)))">
<VIcon
name="ri-sort-asc"
color="var(--grey-625-425)"
/>
</span>
<span v-else-if="sortedBy === ((header as DsfrDataTableHeaderCellObject).key ?? header)">
<VIcon :name="sortedDesc ? 'ri-sort-desc' : 'ri-sort-asc'" />
</span>
</div>
</th>
</tr>
</thead>
<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">
<!-- @vue-expect-error TS2538 -->
<input
:id="`row-select-${id}-${idx}`"
v-model="selection"
:value="rows[idx][rowKey] ?? `row-${idx}`"
type="checkbox"
@change="checkSelection()"
>
<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>
</tbody>
</table>
</div>
</div>
</div>
<div
:class="bottomActionBarClass"
>
<slot name="pagination">
<template
v-if="pagination && !$slots.pagination"
>
<div
class="flex justify-between items-center"
:class="paginationWrapperClass"
>
<div class="flex gap-2 items-center">
<label
class="fr-label"
for="pagination-options"
>
Résultats par page :
</label>
<select
id="pagination-options"
v-model="rowsPerPage"
class="fr-select"
@change="onPaginationOptionsChange()"
>
<option
value=""
:selected="!paginationOptions.includes(rowsPerPage)"
disabled="true"
hidden="hidden"
>
Sélectionner une option
</option>
<option
v-for="(option, idx) in paginationOptions"
:key="idx"
:value="option"
:selected="+option === rowsPerPage"
>
{{ option }}
</option>
</select>
</div>
<div class="flex ml-1">
<span class="self-center">Page {{ currentPage + 1 }} sur {{ pageCount }}</span>
</div>
<DsfrPagination
v-model:current-page="currentPage"
:pages="pages"
/>
</div>
</template>
</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;
}
</style>
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
sorted: string
sortFn?: (a: unknown, b: unknown) => number
verticalBorders?: boolean
bottomCaption?: boolean
noCaption?: boolean
pages?: Page[]
pagination?: boolean
paginationOptions?: number[]
currentPage?: number
rowsPerPage?: number
bottomActionBarClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>
paginationWrapperClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>
}
C'est tout, amis développeurs ! Avec DsfrDataTable, donnez vie à vos données comme jamais auparavant ! 🎉