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
<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 :
<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 + onSubmit | ouvre le formulaire de la page |
onCreate | remplace l'ouverture du formulaire vide |
onEdit | remplace l'ouverture du formulaire prérempli |
onView | remplace l'ouverture du détail |
detail: false | retire 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
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églage | Effet |
|---|---|
label, icon | change l'apparence de l'action |
permission | la retire si la session n'a pas le droit |
hidden(row) | la retire ligne par ligne |
disabled(row) | la laisse visible mais inerte |
confirm | ajoute, remplace ou retire la confirmation |
false | retire 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 :
<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.
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
/>| Besoin | Réglage |
|---|---|
| d'autres tailles | pageSizeOptions={[25, 50, 100]} — la taille en vigueur y est ajoutée si elle manque |
| retirer le choix | ne pas fournir onPageSizeChange |
| un autre libellé | pageSizeLabel, ou la clé perPage du SiaProvider |
| une autre forme | renderPageSize={({ 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 barre | Disposition |
|---|---|
| au-delà de 52rem | une ligne : navigation à gauche, réglages à droite |
| 34 à 52rem | réglages sur la ligne suivante : total à gauche, contrôles à droite |
| sous 34rem | tout 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
| Prop | Valeurs |
|---|---|
density | compact, default, comfortable |
striped, bordered | le fond alterné, le cadre |
layout | auto, fixed — fixed tient les largeurs déclarées |
stickyHeader + maxHeight | l'en-tête figé pendant le défilement |
selectable + selectionActions | la sélection et sa barre |
inlineActionsLimit | au-delà, les actions passent en menu |
actionsDisplay | icon garde la colonne étroite |
showViewToggle, responsive | les 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
| Avant | Maintenant |
|---|---|
CrudPage actions={<Button/>} | headerActions |
| — | operations personnalise les quatre opérations |
CrudPage children seul | columns + data, children reste possible |
DataTable align: "left" | "right" | "start" | "end" |
DataTable getRowKey requis | facultatif — 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é :
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=2devenaitpage=%222%22, et la page retombait à 1 au rechargement.createTanStackRouterStoragepasse 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.
useTableQueryn'é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 :
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.
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 :
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 composantUne 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.