Skip to content

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 croissant
  • aria-sort="descending" pour une colonne triée en ordre décroissant
  • aria-sort="none" pour les colonnes triables non actuellement triées
  • Pas d'attribut aria-sort pour les colonnes non triables

Cela permet aux lecteurs d'écran d'annoncer correctement l'état de tri de chaque colonne aux utilisateurs.

🛠️Props

NomTypeDéfautObligatoireDescription
idstringundefinedIdentifiant unique du tableau.
titlestringTitre du tableau.
headersRowArray<string | DsfrDataTableHeaderCellObject>[]Les en-têtes de votre tableau. Peut être remplacé par le slot Thead.
rowsDsfrDataTableRow[][]Les données de chaque rangée dans le tableau.
rowKeystring | numberundefinedUne clé unique pour chaque rangée, utilisée pour optimiser la mise à jour du DOM.
topActionsRowstring[]undefinedActions affichées en haut du tableau.
bottomActionsRowstring[]undefinedActions affichées en bas du tableau.
selectableRowsbooleanfalseSi true, permet la sélection des lignes via des cases à cocher.
sortableRowsboolean | string[]falseSi true, permet le tri des lignes selon chaque colonne du header. Peut être un tableau de clés pour spécifier les colonnes triables.
sortedstringundefinedClé de la colonne actuellement triée.
sortFn(a: unknown, b: unknown) => numberundefinedFonction de tri personnalisée pour les lignes du tableau.
verticalBordersbooleanfalseSi true, affiche des bordures verticales entre les colonnes.
bottomCaptionbooleanfalseSi true, affiche une légende en bas du tableau.
noCaptionbooleanfalseSi true, supprime la légende du tableau.
captionDetailstringundefinedDétails supplémentaires pour la légende du tableau.
multilineTablebooleanfalseSi true, permet le contenu multi-lignes dans les cellules.
noScrollbooleanfalseSi true, désactive le défilement horizontal du tableau.
size'sm' | 'md' | 'lg''md'Taille du tableau (petit, moyen, grand).
topBarDetailstringundefinedDétails qui concernent uniquement la selection effectuée affichés dans la barre supérieure du tableau.
topBarButtons(DsfrButtonProps & { tertiary?: undefined })[]undefinedBoutons d'action qui concernent uniquement la selection effectuée affichés dans la barre supérieure du tableau.
topBarButtonsSizeDsfrButtonGroupProps['size']undefinedTaille des boutons de la barre supérieure.
pagesPage[]undefinedListe des pages pour la pagination. Si non définie, les pages sont générées automatiquement.
paginationbooleanfalseSi true, active la pagination des lignes du tableau.
paginationOptionsnumber[][5, 10, 20]Options disponibles pour le nombre de lignes par page.
paginationAriaLabelstringundefinedAttribut aria-label pour la pagination.
paginationSelectLabelstringundefinedLabel pour le sélecteur de lignes par page.
currentPagenumber1Numéro de la page actuellement affichée.
rowsPerPagenumber10Nombre de lignes à afficher par page.
bottomActionBarClassstring | Record<string, boolean> | Array<string | Record<string, boolean>>undefinedClasse CSS pour la barre d'actions en bas du tableau.
paginationWrapperClassstring | Record<string, boolean> | Array<string | Record<string, boolean>>undefinedClasse CSS pour l'élément englobant la pagination.
tableBottomBarDetailstringundefinedDétails qui concernent l'ensemble du tableau affichés dans la barre inférieure du tableau.
tableBottomBarButtonsDsfrButtonProps[]undefinedBoutons d'action qui concernent l'ensemble du tableau affichés dans la barre inférieure du tableau.
tableBottomBarButtonsSizeDsfrButtonGroupProps['size']undefinedTaille des boutons de la barre inférieure.

📡 Events

NomPayloadDescription
update:selectionstring[]Émis lors du changement de la sélection de lignes. Utilisable avec v-model:selection.
update:current-pagenumberÉmis lors du changement du numéro de page. Utilisable avec v-model:currentPage.
update:rows-per-pagenumberÉmis lors du changement du nombre de lignes à afficher par page. Utilisable avec v-model:rowsPerPage.
update:sorted-bystring | undefinedÉmis lors du changement de l'identifiant de la colonne à trier. Utilisable avec v-model:sortedBy.
update:sorted-descbooleanÉ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 :

vue
<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

NomScopeDescription
tableTopBarRemplace l'ensemble de la barre supérieure du tableau. Permet de personnaliser complètement la structure.
tableTopBarDetailDétails/informations affichés en haut du tableau concernant la sélection effectuée.
tableTopBarSearchZone pour ajouter un composant de recherche dans la barre supérieure.
tableTopBarButtonsBoutons d'action contextuels (concernant la sélection) affichés en haut du tableau.
tableTopBarSegmentedEspace pour ajouter des contrôles segmentés.
captionDescriptionDescription/détails additionnels de la légende du tableau (<caption>).
TheadRemplace 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.
tbodyRemplace le corps du tableau (<tbody> par défaut et toutes les lignes).
cell{ colKey: string, cell: unknown }Personnalisation du rendu de chaque cellule.
tableBottomBarRemplace l'ensemble de la barre inférieure du tableau. Permet de personnaliser complètement la structure.
paginationRemplace la pagination par défaut. Utile pour intégrer un composant de pagination personnalisé.
tableBottomBarActionsBoutons d'action globaux affichés en bas du tableau (concernent l'ensemble des données).

📝 Exemples

Exemple Basique

vue
<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

vue
<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

vue
<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

vue
<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

vue
<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>
ts
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 ! 🎉