Skip to content

La page de liste et son tableau ​

Ce qu'ils étaient ​

CrudPage tenait en neuf lignes : un titre, un emplacement libre, une pagination. Aucune des quatre opérations qui donnent son nom au composant n'y était. Chaque écran les réécrivait, ce qui produit quatre variantes qui divergent — celle qui oublie la confirmation, celle qui ne vérifie pas les droits, celle dont le bouton reste actif pendant l'appel.

DataTable affichait des colonnes et déléguait son tri. Ni sélection, ni actions de ligne, ni mode cartes.

Les quatre opérations marchent d'emblée ​

tsx
<CrudPage
  title="Factures"
  columns={COLONNES}
  data={factures}
  getRowKey={(f) => f.id}
  onCreate={() => ouvrir("creation")}
  onView={(f) => ouvrir("fiche", f.id)}
  onEdit={(f) => ouvrir("edition", f.id)}
  onDelete={(f) => api.supprimer(f.id)}
/>

Fournir le gestionnaire suffit. Ne pas le fournir retire l'action. Il n'y a pas de liste de boutons à composer, pas de colonne d'actions à déclarer.

Les boîtes s'ouvrent d'elles-mêmes ​

Un gestionnaire n'est pas obligatoire. En déclarant les champs, la page ouvre son propre formulaire — vide pour une création, prérempli pour une modification — et sa propre vue de détail :

tsx
<CrudPage
  title="Factures"
  columns={COLONNES}
  data={factures}
  fields={[
    { name: "reference", label: "Référence", required: true },
    { name: "client", label: "Client", required: true },
  ]}
  onSubmit={(valeurs, { mode, row }) => api.enregistrer(mode, row, valeurs)}
  onDelete={(f) => api.supprimer(f.id)}
/>

onCreate, onEdit et onView restent la porte de sortie : fournis, ils prennent le pas sur la boîte et mènent où l'on veut — une page dédiée, un assistant en plusieurs étapes.

DéclaréCe que l'action fait
fields + onSubmitouvre le formulaire de la page
onCreateremplace l'ouverture du formulaire vide
onEditremplace l'ouverture du formulaire prérempli
onViewremplace l'ouverture du détail
detail: falseretire la consultation
detail: (row) => …remplace le contenu du détail

Les boîtes n'inventent rien : Modal pour la boîte, Form pour la saisie, Descriptions pour la lecture. Ce qu'elles apportent est de savoir laquelle ouvrir, avec quelles valeurs, et de refermer une fois l'envoi passé.

La vue de détail est dérivée des colonnes, qui disent déjà quoi montrer et comment : un montant y est formaté, un statut y est une pastille. Redemander la même chose sous une autre forme ferait deux descriptions du même objet.

Pour n'écrire la description qu'une seule fois — colonnes, champs, détail, validation, recherche et droits compris — voir Déclarer une ressource.

Personnaliser sans repartir de zéro ​

tsx
operations={{
  create: { label: "Nouvelle facture", permission: "facture.creer" },
  delete: {
    permission: "facture.supprimer",
    hidden: (f) => f.statut === "payee",
    confirm: { title: "Supprimer cette facture ?", destructive: true },
  },
  edit: false,
}}
RéglageEffet
label, iconchange l'apparence de l'action
permissionla retire si la session n'a pas le droit
hidden(row)la retire ligne par ligne
disabled(row)la laisse visible mais inerte
confirmajoute, remplace ou retire la confirmation
falseretire l'action, même si son gestionnaire existe

Ce dernier point sert plus qu'on ne croit : le gestionnaire peut être branché ailleurs — un raccourci clavier, un menu contextuel — sans que le bouton de ligne apparaisse.

extraRowActions ajoute des actions propres au métier, avant « Supprimer » : la destruction reste la dernière action de la ligne, là où on ne la vise pas en voulant autre chose. onBulkDelete active la sélection et sa barre d'actions groupées.

Un onSubmit refusé garde la boîte ouverte : qu'il rende des erreurs par champ ou qu'il lève une HttpError, les erreurs s'affichent sous les champs, le reste dans une alerte — voir Les erreurs du serveur.

Un bouton qui ouvre un formulaire que le serveur refusera est pire qu'un bouton absent. Mais cacher n'est pas protéger : le serveur reste seul juge.

L'état vide, et les filtres ​

empty.icon pose une icône sur l'état vide du tableau — CrudPage la transmet par table — et emptyStateProps règle tout le reste d'EmptyState :

tsx
<DataTable empty={{ title: "Aucun projet", icon: <FolderIcon /> }} … />

Dans FiltersBar, les filtres se rangent en ligne à la largeur filterWidth (12rem par défaut) et passent à la ligne quand la place manque. Deux Select de filtre s'empilaient : leur conteneur ne grandissait pas.

La pagination et la taille de page ​

Pagination propose le nombre de lignes par page dès qu'on lui donne pageSize et onPageSizeChange — CrudPage les transmet tels quels. Le choix passe par le Select du système (variante logée), pas par un contrôle à part.

tsx
const query = useTableQuery();

<Pagination
  page={query.state.page}
  totalPages={totalPages}
  onPageChange={query.setPage}
  pageSize={query.state.perPage}
  onPageSizeChange={query.setPerPage} // revient en page 1 de lui-même
/>
BesoinRéglage
d'autres taillespageSizeOptions={[25, 50, 100]} — la taille en vigueur y est ajoutée si elle manque
retirer le choixne pas fournir onPageSizeChange
un autre libellépageSizeLabel, ou la clé perPage du SiaProvider
une autre formerenderPageSize={({ pageSize, options, onChange, label }) => …}

La barre reste affichée sur une seule page quand le choix de taille est présent : après avoir choisi 100 lignes, il faut pouvoir revenir à 10. Pagination ne remonte que la nouvelle taille ; revenir en page 1 est l'affaire de qui tient l'état — setPerPage de useTableQuery le fait.

La barre se compose de deux groupes qui passent à la ligne chacun d'un seul tenant : la navigation, et les réglages (total, taille, saut direct). Le repli suit la largeur de la barre, par container queries, pas celle de l'écran — une pagination dans une carte étroite se replie comme sur un téléphone :

Largeur de la barreDisposition
au-delà de 52remune ligne : navigation à gauche, réglages à droite
34 à 52remréglages sur la ligne suivante : total à gauche, contrôles à droite
sous 34remtout centré ; sous 26rem, les flèches perdent leur libellé

Au survol, la page courante garde sa couleur, un ton plus sombre. La règle de survol commune passait devant elle par spécificité et la peignait en blanc sous un chiffre blanc.

Le tableau ​

Il n'ordonne rien lui-même : à partir de quelques centaines de lignes, trier côté client veut dire les avoir toutes téléchargées. Le tri est une intention envoyée au serveur.

La recherche fait exception, et seulement par défaut : sans onSearch, elle filtre les lignes déjà chargées — ce qui suffit tant que tout tient sur une page. Avec onSearch, le tableau cesse de filtrer et remonte la requête.

Remontée à chaque frappe par défaut — ce qui convient à un filtrage local. Pour une recherche serveur, searchDelay (sur DataTable comme sur CrudPage) attend que la frappe s'arrête : searchDelay={300} envoie une requête par recherche, pas une par lettre. Entrée et l'effacement déclenchent sans attendre. La temporisation est celle de SearchInput, pas une seconde implémentation.

La vue en cartes ​

Une carte n'est pas un tableau replié. Elle prend un titre, quelques couples libellé / valeur, et laisse le reste au tableau — recopier douze colonnes donne une fiche que personne ne lit. card: "title" | "meta" | "hidden" sur une colonne dit sa place; sans rien, la première colonne fait le titre.

La bascule est automatique sous 768 px et prime sur le choix manuel : un tableau à sept colonnes est illisible sur un téléphone, quel que soit le réglage.

Ce qui se règle ​

PropValeurs
densitycompact, default, comfortable
striped, borderedle fond alterné, le cadre
layoutauto, fixed — fixed tient les largeurs déclarées
stickyHeader + maxHeightl'en-tête figé pendant le défilement
selectable + selectionActionsla sélection et sa barre
inlineActionsLimitau-delà, les actions passent en menu
actionsDisplayicon garde la colonne étroite
showViewToggle, responsiveles modes d'affichage

CrudPage passe au tableau tout ce qui n'a pas de raccourci, via table.

Les animations, et pourquoi elles s'arrêtent là ​

Les lignes arrivent en escalier, vingt-deux millisecondes d'écart. Le décalage est plafonné à dix lignes : au-delà, l'escalier devient une attente, et personne ne regarde la vingtième ligne en premier.

Les actions de ligne s'estompent hors survol. Une colonne pleine de boutons attire l'œil plus que les données qu'elle borde. Elles restent entières au clavier — :focus-within — et sur tactile, où il n'y a pas de survol : la règle est sous @media (hover: hover).

La barre de sélection se glisse depuis le haut plutôt que d'apparaître d'un bloc, ce qui donnerait l'impression que la page a sauté.

Tout est neutralisé sous prefers-reduced-motion.

Migration ​

AvantMaintenant
CrudPage actions={<Button/>}headerActions
—operations personnalise les quatre opérations
CrudPage children seulcolumns + data, children reste possible
DataTable align: "left" | "right""start" | "end"
DataTable getRowKey requisfacultatif — l'index à défaut

actions désignait des nœuds d'en-tête. Lui donner un autre sens aurait été le pire cas de rupture : le code compile et se comporte autrement. D'où operations, qui ne peut être confondu avec rien.

useDataTableQuery et createUrlTableStorage vivent désormais dans DataTable/query.ts.

L'état du tableau, le routeur et le serveur ​

useDataTableQuery range page, taille, recherche, tri et filtres dans l'URL. Écrire directement dans window.history passe sous le nez d'un routeur qui tient ses propres paramètres : il ne voit pas le changement et le réécrase. Avec TanStack Router, l'option router branche l'adaptateur livré :

ts
const router = useRouter();
const query = useDataTableQuery({ router, serverParams: NEST });

Un adaptateur plutôt qu'une recette, parce que la recette avait deux défauts qu'on ne voit qu'à l'usage :

  • TanStack Router sérialise ses paramètres en JSON. Une chaîne qui a l'air d'un nombre y est mise entre guillemets : page=2 devenait page=%222%22, et la page retombait à 1 au rechargement. createTanStackRouterStorage passe de vrais nombres — sans perte : "007" reste une chaîne — et lit l'objet déjà décodé, jamais la chaîne brute.
  • Écrire la chaîne entière efface ce qui n'est pas au tableau. Un second tableau préfixé, un onglet dans l'URL disparaissaient à chaque tri. useTableQuery n'écrit désormais que ses propres clés (mergeTableQuery), quel que soit le stockage — l'URL du navigateur en profite aussi.

Pour un autre routeur, storage accepte tout objet TableQueryStorage (read, write, subscribe) : write reçoit la chaîne complète, clés étrangères au tableau comprises. Deux tableaux sur une page se préfixent tous les deux — sans préfixe, un tableau considère toutes les clés comme siennes.

L'URL garde toujours la même forme — page, perPage, sort=-champ — pour qu'un lien partagé reste valable. Seuls les params envoyés au serveur suivent serverParams :

ts
const NEST = {
  perPage: "limit",
  sortStyle: "split",                       // sort=createdAt&order=DESC
  orderValues: { asc: "ASC", desc: "DESC" },
} satisfies TableParamsMapping;

api.getList("/users", { queryParams: query.params });

Une liste dans une fiche ​

Les environnements d'un projet, les comptes d'un fournisseur : une liste posée dans un écran qui a déjà son <h1>. headingLevel={2} rend un titre de section — plus petit, plus près de son tableau — au lieu d'un second titre de page.

L'en-tête se replie selon la place réellement disponible, non selon la largeur de l'écran : à côté d'une barre latérale, la zone de contenu est étroite sur un grand écran. Quand ses actions ne tiennent plus à côté du titre, elles passent dessous d'un seul bloc — un bouton ne s'écrase jamais sur deux lignes.

Le journal d'activité ​

ActivityLog répond à « qui a fait quoi, sur tout le système » — AuditMeta et Timeline racontent l'histoire d'un seul objet. Tout passe par la requête : rien n'est filtré dans le navigateur, un journal se compte en milliers de lignes.

tsx
const query = useDataTableQuery({ serverParams: NEST });
const { data } = useQuery(journal.pageQuery({ query: query.params }));

<ActivityLog
  title="Activité"
  query={query}
  entries={data?.items ?? []}
  totalPages={data?.meta.totalPages}
  searchPlaceholder="Chercher un acteur ou une cible"
  actionTones={{ create: "success", delete: "danger" }}
  actionLabels={{ create: "Création", delete: "Suppression" }}
  filters={[
    { key: "actor", label: "Acteur", options: acteurs },
    { key: "action", label: "Action", options: actions },
  ]}
/>

Sans columns, les colonnes d'une ActivityEntry (at, actor, action, target, summary) s'appliquent ; activityColumns() les rend pour qui veut en ajouter une. Le compteur de filtres actifs et « Réinitialiser » viennent de FiltersBar, la taille de page de Pagination.

Des statuts qui se lisent d'un coup d'œil ​

Une table valeur → ton, déclarée une fois par domaine, sert partout où la valeur s'affiche :

tsx
const STATUTS = {
  tones: { payee: "success", attente: "warning", annulee: "danger" },
  labels: { payee: "Payée", attente: "En attente", annulee: "Annulée" },
} as const;

<StatusBadge value={facture.statut} {...STATUTS} />
toneOf(STATUTS.tones, facture.statut); // pour un autre composant

Une valeur absente de la table reste visible, en ton neutre. tones d'une ressource passe par le même toneOf.

Prévu ​

Le redimensionnement des colonnes à la souris, et leur masquage depuis un menu — deux choses qui demandent de persister une préférence par utilisateur, donc un endroit où la ranger.

Publié sous licence MIT.