@etic-insa/ui
v0.6.0
Published
Bibliothèque de composants React des applications ETIC, construite sur shadcn et Base UI.
Readme
@etic-insa/ui
Bibliothèque de composants React des applications ETIC (ETIC Members, ETIC Docs…), construite sur shadcn/ui (style Nova, couleur de base Neutral) et Base UI, avec Tailwind CSS 4, les icônes lucide et la police Geist.
Utiliser la bibliothèque dans une application
npm i @etic-insa/uiPrérequis (dépendances pairs) : React 19.2 ou plus récent (useEffectEvent), Tailwind CSS 4
(tailwindcss et @tailwindcss/vite) et lucide-react (les icônes passées aux composants,
icone={UsersIcon}, viennent de l'application). recharts 3.8 ou plus récent seulement pour les
graphiques (@etic-insa/ui/charts, voir Graphiques).
QueryBoundary accepte tel quel le résultat de useQuery (TanStack Query), sans que la
bibliothèque dépende de TanStack Query : il ne lit que la forme de la requête.
/* CSS principal de l'application */
@import "tailwindcss";
@import "@etic-insa/ui/styles.css";
/* Tailwind doit lire les classes des composants ; chemin relatif à ce fichier */
@source "../node_modules/@etic-insa/ui/dist";
/* Couleur d'accent de l'application : les quatre variables, en clair ET en sombre. */
:root {
--primary: #2f5fdb;
--primary-foreground: #ffffff;
--primary-text: #2f5fdb;
--ring: #2f5fdb;
}
.dark {
--primary: #6d92f2;
--primary-foreground: #0e1116;
--primary-text: #6d92f2;
--ring: #6d92f2;
}Thème et couleur d'accent
La bibliothèque fournit tout le thème (neutres, rayons, police, couleurs destructive, success
et warning, clair et sombre) sauf la couleur d'accent. Chaque application remplit, après
l'import de styles.css, en :root et en .dark, quatre variables :
| Variable | Rôle | Exigence |
| ---------------------- | ------------------------------------ | -------------------------------- |
| --primary | aplats : bouton principal, sélection | — |
| --primary-foreground | texte posé sur --primary | ≥ 4,5:1 sur --primary |
| --primary-text | l'accent employé comme texte ou lien | ≥ 4,5:1 sur le fond et --muted |
| --ring | anneau de focus | ≥ 3:1 sur le fond et --muted |
Un accent clair (orange, jaune, vert pâle) ne supporte ni texte blanc ni rôle de texte : il prend
un --primary-foreground foncé et un --primary-text plus sombre que lui.
Les couleurs appartiennent à chaque application : la bibliothèque n'en livre aucune et ne
connaît pas les applications. atelier/accents.css contient seulement
des exemples, aux couleurs des applications ETIC, pour voir les composants avec un vrai accent
dans l'atelier ; npm test vérifie que ces exemples tiennent les exigences ci-dessus. Il n'est
pas publié et n'est pas la référence d'une application.
Dans les classes, l'accent comme texte s'écrit text-primary-text, jamais text-primary. Les
composants shadcn d'origine emploient text-primary (variante link du bouton et du badge,
liens survolés) : c'est corrigé dans src/components/ui/, et npm test échoue si
text-primary y réapparaît.
Le thème sombre s'active par la classe dark sur <html>.
Les neutres (fonds, panneaux, bordures, textes) sont ceux de la couleur de base zinc de
shadcn (palette zinc de Tailwind 4, gris froid presque neutre), communs à toute la suite : une
application ne les redéfinit pas. Ce sont exactement les valeurs du preset, contraste faible
compris (texte secondaire zinc-500 : 4,39:1 sur --muted en clair), sauf les écarts décidés
pour ETIC : --page, barre latérale blanche en clair, --control-border, couleurs
sémantiques. src/theme.test.ts compare chaque jeton au registre et vérifie les contrastes.
Le fond de la page elle-même est un jeton ajouté, --page (classe bg-page) : zinc-50 en
clair, si bien que cartes et menus blancs s'en détachent (en sombre, il vaut
--background et les cartes sont déjà plus claires). Le body et le contenu d'AppShell
le prennent ; un conteneur pleine page d'une application aussi, plutôt que bg-background,
qui reste la surface blanche de shadcn (onglet actif, bouton outline, calendrier…).
color-scheme suit le thème (light, dark) : barres de défilement et contrôles natifs
compris.
Un tableau se détache de la surface qui le porte : son cadre est en bg-tableau (jeton
ajouté), qui vaut --card sur la page et --tableau-encart (jeton ajouté, un cran plus
sombre que la carte en clair, plus clair en sombre) dans une surface surélevée — toute surface de la bibliothèque peinte en bg-card ou bg-popover : Card
(donc SectionCard), Alert, dialogue, AlertDialog, tiroir (Sheet), popover, menu
déroulant, liste d'un Select ou d'un Combobox, SaveBar, barre d'actions groupées, barre
latérale, Disclosure variante="carte". La liste de ces surfaces est tenue une fois dans
styles.css (règle qui pose --tableau-fond, héritée) ; le cadre de DataTable la suit sans
rien à passer. Un cadre de tableau propre à une application prend bg-tableau plutôt que
bg-card. Dans une surface, l'écart de fond est le même, discret, dans les deux thèmes (1,05:1,
le filet --border le rend net) : le tableau se détache de sa carte sans s'y creuser. Les lignes gardent le
survol et la sélection de shadcn (bg-muted/50, bg-muted).
Thème clair, sombre ou système
appliquerTheme(theme) pose la classe dark sur <html> pour "dark", la retire pour
"light" et, pour "system", suit prefers-color-scheme, y compris quand le système change.
La préférence vient du compte (theme de @etic-insa/auth) ; useThemeApplique la peint,
la mémorise dans ce navigateur (localStorage, clé "etic-theme") et suit un changement
fait dans un autre onglet. Une valeur null ou inconnue vaut "system". Le rappel est
obligatoire : sans lui, l'état de l'application garderait l'ancien thème et le rechoisir ne
changerait rien.
import { useThemeApplique } from "@etic-insa/ui"
// Une fois, près de la racine : thème du compte, undefined tant qu'il n'est pas chargé.
useThemeApplique(me?.preferences?.theme, (theme) => {
/* autre onglet : aligner l'état local sans rappeler le serveur */
})Hors de React (atelier, page sans compte), appliquerTheme seul : il ne mémorise rien.
Pour que la première image soit déjà dans le bon thème, avant le chargement du compte,
l'application charge le script de démarrage du paquet dans <head>, avant le bundle, sans
type="module", depuis sa propre origine (une CSP script-src 'self' l'admet) :
<script src="/theme-boot.js"></script>Vite ne regroupe pas un script classique : le copier dans public/ avant dev et build
(copie ignorée dans git) :
"scripts": {
"theme-boot": "node -e \"const fs = require('fs'); fs.mkdirSync('public', { recursive: true }); fs.copyFileSync(require.resolve('@etic-insa/ui/theme-boot.js'), 'public/theme-boot.js')\"",
"predev": "npm run theme-boot",
"prebuild": "npm run theme-boot"
}Il relit le thème mémorisé ; sans thème mémorisé, il suit le système.
import { Button } from "@etic-insa/ui"
export function Export() {
return (
<Button variant="outline" className="w-full">
Exporter
</Button>
)
}Coquille d'application
AppShell pose la mise en page commune : barre latérale de shadcn (marque, rubriques, menu du
compte, thème), repliable en colonne d'icônes sur ordinateur (rail ou Ctrl+B, état gardé dans le
cookie sidebar_state de shadcn), qui devient un tiroir ouvert par une barre du haut sous
1 025 px (téléphones et tablettes). Il ne connaît ni le routeur ni l'authentification : l'application donne les liens et
leur état actif, le compte et le thème, et son composant de lien.
import { AppShell } from "@etic-insa/ui"
import { Link, Outlet, useLocation } from "react-router-dom"
import { UsersIcon } from "lucide-react"
const LienRouteur = ({ href, ...props }: React.ComponentProps<"a">) => (
<Link to={href!} {...props} />
)
export function MiseEnPage() {
const { pathname } = useLocation()
return (
<AppShell
application={{ nom: "ETIC Docs", logo: <img src={logo} alt="" /> }}
sections={[
{
titre: "Études",
liens: [
{
libelle: "Études",
href: "/etudes",
icone: UsersIcon,
actif: pathname.startsWith("/etudes"),
},
],
},
]}
compte={{ nom: "Ada Lovelace", initiales: "AL", onSeDeconnecter }}
theme={{ valeur: theme, onValeurChange: changerTheme }}
lien={LienRouteur}
>
<Outlet />
</AppShell>
)
}
// Dans chaque page
export function Groupes() {
return (
<PageContainer>
<PageHeader titre="Groupes" />
<SectionCard titre="Liste">…</SectionCard>
</PageContainer>
)
}Le thème est une entrée du menu du compte (« Thème : Sombre », qui ouvre Clair / Système /
Sombre), comme dans le nav-user de shadcn : le pied de la barre ne porte que le compte, sur
toute sa largeur. Dans le tiroir mobile, trop étroit pour un sous-menu, les trois choix sont
dans le menu. Sans compte, il garde son propre bouton. onValeurChange peut renvoyer une
promesse : rejetée, elle affiche « Thème non enregistré » quelques secondes. La déconnexion (onSeDeconnecter) est toujours confirmée ; une entrée du
menu du compte est un lien (href) ou une action (onChoisir). La coquille fournit l'élément
<main> : les pages n'en ajoutent pas. Elle ouvre la tabulation par un lien « Aller au
contenu », visible au focus, qui met le focus sur ce <main>. Sur ordinateur, un bouton de
l'en-tête replie la colonne (Ctrl+B aussi, sauf dans un champ de saisie, où il reste au
gras d'un éditeur). Sur mobile, la page réserve en haut la hauteur de la barre collante
(scroll-padding-top) : une ancre ne s'arrête pas dessous. ThemeMenu existe aussi seul
(page de connexion…) ; son className va sur le bouton.
Le composant de lien du routeur se donne une fois, autour de l'application :
<LienProvider lien={LienRouteur}>, où LienRouteur est une référence stable, définie
au niveau du module (un composant écrit en ligne serait nouveau à chaque rendu, et React
remonterait tous les liens). AppShell, DetailHeader, ButtonLink et
DataTableCellLink le prennent alors par défaut, sans lien={…} à chaque appel ; leur
prop lien reste prioritaire. Téléchargement ou adresse externe : lien="a" (un vrai
<a>, rechargement complet). Sans fournisseur ni prop, un <a>. useLien(lien?) rend le
même choix aux liens propres de l'application (const Lien = useLien()) ; le type
LienComposant le décrit (AppShellLienComposant, son ancien nom, est déprécié).
bandeau (un ErrorBanner qui concerne toute l'application…) s'affiche au-dessus du
contenu avec les marges latérales de PageContainer : l'application le passe nu. Un bandeau
qui ne rend rien (ErrorBanner sans erreur) ne laisse pas d'espace vide. Il prend toute la
largeur du contenu, car la coquille ignore la largeur de la page : sur grand écran, il
déborde d'une page étroite (largeur="md"). Il sert donc aux messages de toute
l'application ; un message propre à une page se place dans son PageContainer.
PageContainer est le conteneur de chaque page dans AppShell : marges (16 px sur
téléphone, 24 puis 32 px au-delà), espacement vertical régulier entre les blocs (gap-6),
et largeur maximale centrée (md, lg, xl, ou full par défaut). Les pages ne posent
pas leurs propres marges.
Retours et mise en page
Compositions des primitives shadcn pour les états d'une page et les actions courantes. Elles ne
connaissent ni l'API ni le routeur : l'application fournit ses fonctions (envoi, suppression,
chargement d'une image) et, au besoin, son composant de lien (lien, comme pour AppShell).
| Composant | Rôle |
| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ConfirmDialog | Confirmation (AlertDialog) : titre qui nomme l'action, description qui nomme ce qu'elle touche, saisie pour exiger de taper une valeur non affichée (action critique). L'erreur de l'action s'affiche dans le dialogue, qui reste ouvert. |
| FormDialog | Formulaire dans un Dialog : pied « Annuler / Enregistrer », curseur dans le premier champ à remplir, état d'envoi (champs figés), erreur en tête, taille sm à xl, verrouEnvoi pour dire pourquoi l'envoi est impossible. Les contraintes des champs (required, type, pattern…) sont vérifiées sans la bulle du navigateur : la liste des champs à corriger s'affiche en français. |
| DraftResumeDialog | Ouverture d'un utilitaire à brouillon (compagnon de useDraftAutosave) : « Quitter », libelleRepartir (suppression du brouillon, onRepartir attendue, bouton occupé, erreur affichée, comme erreur venue d'ailleurs) et « Reprendre au même point » si reprenable. Sans croix ; Échap quitte, jamais pendant la suppression. |
| UnsavedChangesDialog, useUnsavedChangesWarning | Garde « modifications non enregistrées », sans routeur : useUnsavedChangesWarning(modifie) (booléen, ou fonction lue au moment de quitter) retient la fermeture de l'onglet ; le dialogue confirme une navigation retenue par l'application (blocage : state, proceed, reset, la forme du useBlocker de react-router). |
| PageContainer | Conteneur de page dans AppShell : marges, largeur maximale, espacement entre les blocs. |
| PageHeader, DetailHeader | En-tête de page (<h1>, description, actions) ; en-tête de fiche avec fil d'Ariane (chemin), badges, méta, actions et menu « Plus d'actions » (actionsSecondaires : onChoisir, destructif, verrou affiché sous le libellé, l'entrée DropdownMenuAction des autres menus), compact quand la fiche répète déjà son nom. |
| SectionCard, DetailList, DetailItem | Carte de section titrée (<h2>) ; liste <dl> de paires libellé / valeur, « — » pour une valeur absente (null, undefined, false, chaîne vide). disposition : grille (défaut, autant de colonnes de 14rem que la largeur en permet, quatre au plus : une liste courte laisse de l'air à droite sans tenir dans un coin), pile (une seule colonne, libellé au-dessus de la valeur : carte étroite, dialogue, valeurs longues), ligne (métadonnées secondaires : « libellé : valeur » en petit et en discret, l'une après l'autre). |
| ErrorBanner | Alert destructive : message, code, détail technique replié (même circulaire), « Réessayer » (onReessayer, occupé pendant enCours). Lit toute erreur ayant un message (et code, details) ; voir ErrorBannerProvider pour masquer le code. |
| ErrorBoundary | Filet sous le rendu : message, détail technique, « Réessayer », « Retour à l'accueil » ; cleReinitialisation (la route) l'efface. Le focus perdu avec la partie cassée passe sur le message. |
| EmptyState, Loading, SkeletonTable, SkeletonCard, SkeletonPage | État vide (Empty dans un cadre pointillé, icone lucide : icone={InboxIcon}) ; attente annoncée (Spinner) ; squelettes de tableau, de carte et de fiche, un seul statut « Chargement… » chacun. |
| QueryBoundary | Chargement, erreur (avec « Réessayer »), vide et données d'une requête ; accepte tel quel le résultat de useQuery. L'échec d'une actualisation garde les données, montées, sous le bandeau d'erreur ; pendant une nouvelle tentative, le bandeau reste, son bouton occupé. Sans enveloppe : rendu dans le parent, qui règle l'espacement. |
| QueryBoundary introuvable | Fiche : introuvable (texte ou nœud, avec un lien de retour) remplace le bandeau quand l'erreur porte un status 404 (ErreurApi de @etic-insa/auth, ou même forme), sans « Réessayer ». Les autres échecs gardent le bandeau : une panne n'est jamais annoncée « introuvable ». |
| QueryErrorBanner | Le bandeau d'erreur de QueryBoundary, seul (requete : isError, error, isFetching, refetch), pour une page qui place elle-même ses données ; rien tant que la requête n'a pas échoué. Accepte titre, description, className d'ErrorBanner. |
| Disclosure, DisclosureTrigger | Section repliable à chevron tournant, fermée par défaut : titre, compteur en pastille, variante lien (en ligne), discret (fantôme estompé) ou carte (bloc encadré), defautOuvert ou ouvert / onOuvertChange. DisclosureTrigger seul pour un Collapsible composé à la main. |
| CenteredScreen, MessageScreen | Écran hors de la coquille (connexion, page introuvable, vérification de session) : le <main> de la page, contenu centré sur toute la hauteur, fond estompé ; écran qui ne dit qu'une chose (page introuvable, lien expiré) : icone en pastille, titre (<h1>), description, actions. |
| StatTile, StatGrid, StatusBadge | Chiffre clé d'un résumé (href : toute la tuile mène au détail, avec un chevron ; lien du routeur, ou celui du LienProvider) ; rangée de tuiles (colonnes de largeurMin au moins, 190 px par défaut, une seule sur téléphone) ; badge d'état à pastille. ton (type StatusTon) : neutral, info, success, warning, destructive ; il colore aussi Progress (8 px avec size="lg"), ProgressRing, VerdictPanel, le segment choisi d'un SegmentedControl et un IconToggle actif. |
| VerdictPanel | Verdict d'une page de supervision, lu avant le détail : ton (success tout va bien, warning à surveiller, destructive en panne), titre (la phrase qui dit l'état), detail, action (un bouton, qui passe sous le texte quand la place manque). Carte au liseré du ton ; signe du ton en pastille (coche, triangle, croix, i, trait), caché aux lecteurs d'écran, icone pour un autre. |
| ProgressRing | Jauge circulaire d'une ressource qui se remplit (mémoire, disque, quota), le pendant rond de Progress, en SVG sans bibliothèque de graphiques : valeur de 0 à 100 (bornée ; null, piste vide), ton de l'arc (l'accent par défaut), libelle lu par les lecteurs d'écran (« Mémoire : 45 % »), children au centre (le pourcentage), caché à ces lecteurs. Taille par className (size-28 par défaut). |
| RemovableBadge, ExplainedBadge | Badge de 28 px (size="lg", aussi pour les badges sans croix de la même liste) terminé par une croix « Retirer » (onRetirer, libelleRetirer, enCours, verrou) ; seul le premier élément du libellé se coupe, un marqueur qui le suit (cadenas) reste entier et centré ; badge dont l'explication s'ouvre au toucher, au clic, au survol et au focus (un Toggletip) et se lit après lui (render : un autre porteur, <time> par exemple). |
| Toggletip | Explication d'un élément qui n'est pas une action (date, sigle) : bulle sur Popover, ouverte au toucher, au clic, au survol et au focus, fermée au toucher ailleurs, à Échap ou quand le focus part ; clic sans effet sur un parent cliquable ; lue après le contenu (texteLu) ; dans un badge, la bulle part de son contour. Un bouton qui agit garde son infobulle (TooltipButton). |
| SaveBar | Pied d'un formulaire de page : « Enregistrer » (soumet le formulaire, ou onEnregistrer), occupé mais focalisable pendant enCours ; « Annuler » avec onAnnuler ; statut annoncé : « Enregistré » (enregistre), « Modifications non enregistrées » (modifie). collante : pied d'un long formulaire, sur une carte opaque alignée sur celles de la page, collé en bas de l'écran tant qu'il y a des modifications (modifie) ou un envoi en cours, à sa place sinon ; masqueeAuRepos ne l'affiche qu'avec quelque chose à dire. Les deux boutons ne se séparent jamais : au téléphone, le statut passe à la ligne ou se replie. Après « Annuler », le focus passe à « Enregistrer ». retablirDefauts (onRetablir, masque) : le ↶ général d'un formulaire à valeurs par défaut, à l'autre bout des boutons. |
| ResetButton | Retour à la valeur par défaut : icône ↶ seule, « Rétablir la valeur par défaut : 6 » en infobulle (valeur, telle qu'elle s'affiche), jamais de texte « Rétablir 6 » sous le champ. Dans un FormField, passé à son action : à droite du contrôle, nommé avec le libellé du champ, focus rendu au contrôle au clic (dans un groupe, à l'option rétablie). Toujours rendu, masque (invisible, sa place gardée) quand la valeur est déjà celle par défaut. Hors d'un FormField (FieldSet) : libelleId dit quel libellé citer, cible qui reçoit le focus. Sans valeur : « Rétablir toutes les valeurs par défaut ». Voir « Valeurs enregistrées et valeurs par défaut » plus bas. |
| CopyButton, DeleteButton, IconToggle | Copie avec retour « Copié ! », ou « Copie impossible » si le navigateur refuse (onCopie, onEchec) ; corbeille qui ouvre une confirmation ; bouton à icône qui pose ou retire un marqueur (Toggle, aria-pressed, onActifChange, enCours occupé sans perdre le focus ; actif, en couleur de son ton, warning par défaut). Tous ont une infobulle, et verrou pour dire pourquoi l'action est impossible. |
| TooltipButton | Button avec infobulle et état verrou (désactivé mais focalisable, la raison en infobulle) : la règle pour tout bouton à icône seule, que l'infobulle nomme (aria-label, sauf nom donné). destructif : gris, rouge au survol seulement (corbeille, retrait dans une liste). |
| ButtonLink | Lien à l'allure de bouton (variant, size de Button), annoncé comme un lien ; lien : le composant de lien du routeur, par défaut celui du LienProvider (<a> sans lui ; lien="a" force un rechargement complet). |
| AuthAvatar, AuthImage, useAuthObjectUrl | Image servie par une adresse authentifiée : l'application passe charger(src, signal) (type AuthImageCharger) qui renvoie le Blob ; l'avatar garde les initiales tant que la photo manque. |
| useAuthObjectUrlEtat, secoursEchec, diametre | État complet d'une image authentifiée (url, enCours, echec, erreur) pour dire « indisponible » plutôt que charger sans fin ; secoursEchec d'AuthImage en cas d'échec ; diametre (px) d'AuthAvatar et d'Avatar, initiales proportionnées (leur graisse suit la racine). |
ConfirmDialog et FormDialog s'ouvrent par un declencheur, ou sont contrôlés (ouvert,
onOuvertChange). DeleteButton est son propre déclencheur ; ouvert et onOuvertChange
permettent de suivre sa confirmation. Leur action (onConfirmer, onEnvoyer, onSupprimer)
peut renvoyer une promesse : le dialogue l'attend (boutons occupés mais focalisables, Échap sans
effet), se ferme si elle aboutit, affiche l'erreur si elle échoue. Avec TanStack Query, passer
mutateAsync. Si l'action renvoie false (ou une promesse résolue à false), le dialogue
reste ouvert sans erreur : suppression en deux temps, où la page change sa question (la
description de ConfirmDialog est une région polie : la nouvelle est lue).
Hors de ces dialogues, un bouton qui lance une action longue passe enCours au Button (ou au
TooltipButton) : la roue remplace ses icônes, libelleEnCours (« Suppression… ») son texte,
et il reste focalisable (aria-busy, clics ignorés, curseur d'attente, pas de survol). Un
dialogue composé à la main s'ouvre normalement mais refuse de se fermer pendant l'action avec
onOpenChange={fermerSaufEnCours(enCours, setOuvert)}. Un lien dans
une phrase ou une cellule : <Button variant="link" size="inline">. SpinnerDecoratif est
la roue muette, à côté d'un texte qui dit déjà l'attente. Un CopyButton placé dans un
InputGroupAddon passe à 24 px, comme le bouton de shadcn dans un groupe (sauf size donnée).
Un envoi de FormDialog refusé par les contraintes marque chaque champ fautif
(aria-invalid, retiré dès qu'il est corrigé) et le nomme par son libellé, y compris une
liste (Select, SearchSelect…) dont l'entrée cachée porte la valeur : le libellé de son
FormField, et le focus va à son contrôle visible.
Options de ConfirmDialog : verrou (confirmation impossible, et pourquoi : bouton focalisable,
raison en infobulle et en description), libelleAnnuler (« Revenir au tableau »), taille
(sm par défaut, md ou lg pour un récapitulatif), elements et libelleElements (liste
nommée des éléments concernés, qui défile au-delà d'une dizaine de lignes), onAnnuler
(appelé quand la personne renonce : « Annuler » ou Échap ; jamais après une
réussite, alors que onOuvertChange(false) suit les deux). Si saisie.attendu change pendant
l'ouverture (le nombre d'éléments a changé côté serveur), la saisie est vidée, sans remonter le
dialogue : pas de key à forcer. FormDialog prend libelleAnnuler (« Plus tard ») et
actionSecondaire (libelle, onChoisir, destructif) : une troisième sortie discrète en
tête du pied (« Se déconnecter »), indisponible pendant l'envoi, qui ne ferme pas le dialogue
d'elle-même ; si onChoisir renvoie une promesse, le dialogue l'attend comme un envoi (ce
bouton en cours, le reste figé, erreur en tête), et destructif la rend grise, rouge au
survol (comme TooltipButton). modifie (la saisie diffère de l'état initial) garde
FormDialog d'une perte : Échap, clic sur le voile, croix ou « Annuler » posent d'abord, en
tête du formulaire, « Abandonner la saisie ? » (« Continuer la saisie », qui reçoit le focus, ou
« Abandonner ») au lieu d'ouvrir un second dialogue ; un envoi réussi ferme sans rien demander.
Les boutons occupés de ces dialogues, de « Réessayer »
(ErrorBanner), de FileUploadButton et d'IconToggle sont tous le Button en cours. Sans champ où l'on tape, il s'ouvre sur le premier contrôle actif et affiché (case,
liste, choix segmenté… ; un panneau replié est ignoré). Tous les dialogues plafonnent leur
hauteur à l'écran et gardent le focus : Tab et Maj+Tab en font le tour sans en sortir. Dans
FormDialog, ConfirmDialog et DraftResumeDialog, seul le corps défile : titre, croix et
pied restent visibles. Un dialogue composé à la main obtient la même chose en plaçant son
contenu dans DialogBody (aussi dans un AlertDialogContent) ; sans lui, tout le contenu
défile.
// Garde « modifications non enregistrées » avec react-router (routeur « data »).
useUnsavedChangesWarning(modifie)
const blocage = useBlocker(
({ currentLocation, nextLocation }) =>
modifie && currentLocation.pathname !== nextLocation.pathname
)
<UnsavedChangesDialog blocage={blocage} />Une action qui fait disparaître le déclencheur (ligne supprimée) passe finalFocus (un ref
ou une fonction, comme le finalFocus de Base UI) : le focus va à la ligne suivante ou au
titre de la liste au lieu de tomber sur la page.
<DeleteButton
libelle="ce groupe"
confirmation={{ description: `Le groupe « ${groupe.nom} » sera supprimé définitivement.` }}
verrou={groupe.critique ? "Groupe critique : suppression impossible" : undefined}
finalFocus={titreListe}
onSupprimer={() => supprimer.mutateAsync(groupe.id)}
/>
// Suppression en deux temps : le serveur répond 409 « confirmation requise ».
const [conflit, setConflit] = useState(false)
<ConfirmDialog
titre="Supprimer le groupe"
description={conflit ? "14 rattachements seront supprimés aussi." : "Le groupe sera supprimé."}
libelleConfirmer={conflit ? "Supprimer quand même" : "Supprimer"}
onOuvertChange={(ouvert) => !ouvert && setConflit(false)}
onConfirmer={async () => {
try {
await supprimer.mutateAsync({ id, confirmer: conflit })
} catch (erreur) {
if (conflit || !confirmationRequise(erreur)) throw erreur
setConflit(true)
return false
}
}}
declencheur={<Button variant="destructive">Supprimer</Button>}
/>
<QueryBoundary requete={useQuery(...)} vide="Aucun groupe.">
{(groupes) => <TableGroupes groupes={groupes} />}
</QueryBoundary>Affichage des erreurs
Une application dont les codes d'erreur ne parlent qu'aux développeurs (not_found…) les
masque une fois, à la racine : tous les bandeaux de la bibliothèque suivent
ErrorBannerProvider (ErrorBanner, QueryErrorBanner, QueryBoundary, le bandeau de
DataTable, l'erreur de ConfirmDialog, de FormDialog et de DraftResumeDialog).
L'erreur elle-même n'est pas réécrite : un 404 reste « introuvable » pour QueryBoundary.
decrire remplace la lecture de l'erreur (decrireErreur, exportée : message, code,
détail) pour une règle propre à l'application ; une fonction définie au niveau du module.
// Un détail à taire pour une famille d'erreurs.
const decrire = (erreur: unknown) => {
const lu = decrireErreur(erreur)
return estPanneExterne(erreur) ? { ...lu, detail: undefined } : lu
}
export function Racine() {
return (
<ErrorBannerProvider masquerCode decrire={decrire}>
<App />
</ErrorBannerProvider>
)
}BoutonReessayer (« Réessayer » des bandeaux, size sm ou default) et DetailTechnique
(détail replié sous « Détails », libelle au besoin) servent aussi hors d'un bandeau. La
colonne du texte d'un bandeau est bornée : une chaîne sans espace (adresse, identifiant) se
coupe au lieu de passer sous « Réessayer ».
Primitives shadcn ajoutées pour ces usages : Alert a, en plus de destructive, les variantes
warning, info et success (couleur --info dans le thème, contrastes vérifiés) ; info et
success, messages d'état, portent role="status" (annoncés sans interrompre), les autres
role="alert" comme dans shadcn. Slider (Base UI) se nomme par aria-labelledby ou
aria-label (relayé à chaque curseur) et prend une valeur simple (value={1.5}, un curseur)
ou un tableau (une plage, dont les curseurs s'annoncent « minimum » et « maximum »).
Utilitaires : cn(...classes) fusionne des classes Tailwind (la dernière l'emporte en cas de
conflit, comme clsx + tailwind-merge) ; useIsMobile() vaut vrai sous le seuil mobile de
la suite, MOBILE_MEDIA_QUERY ((width < 1025px)), dès le premier rendu.
Mouvement réduit : sous prefers-reduced-motion: reduce, styles.css neutralise animations
et transitions (squelettes, ouverture des dialogues et des menus, tiroir, repli de la barre) ;
seule la roue de chargement tourne encore.
Formulaires
Compositions des primitives Field, Combobox, Popover + Calendar, ToggleGroup et
Checkbox de shadcn. Les champs sont contrôlés : valeur et onValeurChange
(valeurs et onValeursChange pour CheckboxList) ; les fichiers n'ont pas de valeur, ils
arrivent à onChoisir. Désactiver : disabled, comme en HTML. Règle : toujours le
composant shadcn le plus adapté à l'usage plutôt qu'un contrôle natif ou minimal (une longue
liste prend un Combobox avec recherche).
Dans les composites (DatePicker, PhoneInput, SearchSelect), className va à
l'enveloppe (InputGroup ou ButtonGroup) et les autres attributs (ref, onBlur,
autoComplete, data-*…) au champ de saisie. Avec name, DatePicker et PhoneInput
soumettent leur valeur (date ISO 2026-03-05, numéro E.164) par un champ caché, et non le
texte affiché ; SearchSelect soumet idElement(valeur).
FormField: libellé (astérisque siobligatoire), contrôle,descriptioneterreur(texte ou liste), reliés au contrôle parhtmlFor,aria-describedbyetaria-invalid. L'erreur n'est pas une alerte (role="alert"de shadcn retiré) : cinq champs fautifs feraient cinq annonces. Un astérisque implique un champ requis. Règle, sur le modèle duuseFormField()du Form de shadcn :- l'enfant direct reçoit toujours
id,aria-describedbyetaria-invalid; autour d'unInputGroup, c'est son champ (InputGroupInput,InputGroupTextarea) qui les reçoit ; - s'il est un champ natif ou une primitive shadcn de saisie (
Input,Textarea,NativeSelect, champ d'InputGroup), il reçoitrequired; sinonaria-required(déclencheur deSelect,ComboboxInput…) ; - un contrôle composite lit le contexte du champ,
useFormField(), et pose lui-même ces attributs et un vrairequiredsur son élément de saisie : c'est ce que fontDatePicker,PhoneInputetSearchSelect, et ce que doit faire un composant de l'application ; - un groupe (
CheckboxList,SegmentedControl) n'a nirequirednatif ni droit àaria-required(role="group") : il cite le libellé du champ pararia-labelledby={champ.libelleId}(un<label for>ne nomme pas un groupe) et, s'il est obligatoire, une mention masquée, ce qui donne le nom « Groupes obligatoire ». Il prend aussirequiredhors d'unFormField. La vérification reste à l'application.
useFormField()renvoie{ id, libelleId, descriptionId, erreurId, describedBy, obligatoire, invalide }, ounullhors d'unFormField.RequiredMarkexiste seul.FormSectionregroupe des champs sous un titre (FieldSet+FieldLegend).action: un bouton à droite du contrôle, sur sa ligne (unResetButton). À passer toujours, masqué au besoin : la ligne ajoutée ou retirée pendant la frappe ferait perdre le focus au contrôle. Le contrôle prend la place restante, dans la limite de sa largeur maximale.- l'enfant direct reçoit toujours
Valeurs enregistrées et valeurs par défaut
Règle commune aux formulaires de page (profil, paramètres) :
- « Enregistrer » est commun à toute la page, une seule
SaveBar, avec à ses côtés « Annuler », qui remet chaque champ à sa valeur enregistrée (onAnnuler, passé tant qu'il y a des modifications). - Un champ qui a une valeur par défaut porte un
ResetButton(sonaction), visible dès que la valeur s'en écarte, enregistrée ou non (en un fondu bref) ; l'infobulle dit la valeur rétablie. La page ajoute le bouton général,retablirDefautsde laSaveBar, visible seulement quand une valeur enregistrée s'écarte de son défaut (et que la saisie ne les vaut pas déjà toutes) : quand seule la saisie s'en écarte, « Annuler » fait la même chose. - Rétablir ne fait que remplir le formulaire : rien n'est écrit avant « Enregistrer », qui confirme ; « Annuler » revient encore à l'enregistré. Pas d'autre confirmation.
- Aucun texte « Rétablir 8 Mo » ni « Défaut : 8 Mo » sous les champs : la valeur par défaut n'est dite que par l'infobulle du bouton.
<FormField
libelle="Copies conservées"
action={
<ResetButton
valeur={String(defauts.copies)}
masque={saisie.copies === defauts.copies}
onRetablir={() => modifier("copies", defauts.copies)}
/>
}
>
<Input className="max-w-24" value={saisie.copies} onChange={…} />
</FormField>
<SaveBar
collante
modifie={modifie}
desactive={!modifie}
onAnnuler={modifie ? annuler : undefined}
retablirDefauts={{ onRetablir: toutRetablir, masque: toutAuDefaut }}
/>DatePicker: le Date Picker de shadcn avec saisie au clavier (jj/mm/aaaa, ou les chiffres seuls05032026pour le pavé numérique de l'iPhone ; lue en quittant le champ ou sur Entrée), calendrier en français navigable aux flèches, bornesdateMin/dateMax, listes du mois et de l'année (listesMoisAnnee, pour une date de naissance), bouton d'effacement. Valeur :Date. Une saisie illisible ou hors bornes dit pourquoi sous le champ, bloque l'envoi d'un formulaire natif, etonValeurChange(undefined, { invalide: true })prévient l'application (à bloquer avant l'envoi).moisInitial: mois montré à l'ouverture tant qu'aucune date n'est choisie (une date de naissance s'ouvre ainsi vingt ans plus tôt, pas sur le mois courant), et où il revient quand la date est effacée. Fermé au clavier ou par un jour choisi, le calendrier rend le focus au champ (un Échap de plus ferme le dialogue englobant, sans infobulle à fermer d'abord) ; après un clic ailleurs, le focus reste où l'on a cliqué.PhoneInput: sélecteur de pays (Combobox : drapeau et indicatif, recherche sur le nom du pays ou l'indicatif) et numéro, lus et validés par libphonenumber-js.onValeurChange(valeur, { valide, pays })reçoit l'E.164 (+33612345678) dès que la longueur est possible ; un numéro tapé avec « + » choisit le pays. En erreur (aria-invaliddu numéro), le groupe est traité comme un seul champ : bordure rouge sur le sélecteur de pays comme sur le numéro, un seul halo autour des deux.formaterTelephone(numero, { paysParDefaut = "FR" })écrit un numéro pour l'affichage hors d'un champ (fiche, liste), sans drapeau : national pour le pays par défaut (06 55 18 92 45), international pour tout autre, pour que son pays se lise (+44 20 7946 0958), tel quel s'il est illisible. LePhoneInput, lui, écrit toujours le format national, le pays étant montré par son drapeau. Chaque appel relit le numéro : dans une longue liste, mémoïser le résultat.SearchSelect: choix dans une longue liste cherchée par l'application.rechercher(texte, signal)renvoie une promesse (texte vide : premières suggestions) ;libelleElement,idElementetrendreOptiondécrivent un élément. Anti-rebond, chargement (la liste reste vide tant que la réponse ne correspond pas à la saisie : un ancien résultat ne se choisit pas), échec avec « Réessayer » (l'erreur va àonErreur, ou à la console ; au clavier, Entrée dans le champ relance la recherche, même identique), liste vide (vide) et effacement sont gérés. Sans élément choisi, le texte tapé reste quand la liste se ferme (Entrée, Tab), et la recherche reprend à la réouverture.formva, commename, à l'entrée cachée qui porte la valeur. La liste s'aligne sur le cadre du champ, loupe comprise (comme toute liste d'unComboboxInput).Select(shadcn) : la liste s'ouvre sous le champ, alignée à gauche, sans jamais le recouvrir (alignItemWithTriggeretalignrestent réglables surSelectContent). Elle prend la largeur de ses options, au moins celle du champ et au plus l'espace disponible ; une option plus longue encore passe à la ligne au lieu d'être coupée. Dans le champ, une valeur trop longue finit par « … ». Les options vont dans unSelectGroup, comme chez shadcn ; posées directement dansSelectContent, elles ont la même marge. Sans option (y compris unSelectGroupvide), la liste dit « Aucune option. » (videdeSelectContent), dans une zone de statut annoncée aux lecteurs d'écran.SelectItemprend unedescriptionsous son libellé, reliée à l'option : la raison d'une option figée (disabled), qui reste lisible quand le libellé s'estompe ; le libellé passe alors à la ligne plutôt que d'être coupé.FileUploadButtonetFileDropZone: bouton, ou zone de glisser-déposer avec bouton, sur react-dropzone. Types (accepter={{ "text/csv": [".csv"] }}) et taille (tailleMax, en octets) vérifiés, refus en français : àonRefuserpour les deux, et affichés dans la zone (après le bouton s'il n'y a pas d'onRefuser, en rouge, sur une ligne entière dans une rangéeflex-wrap; sans refus, rien d'autre que le bouton n'occupe le flux, ce qui le laisse dans une barre d'outils ou unButtonGroup). PendantenCours, le bouton reste focalisé, indisponible, et l'envoi est annoncé.FileUploadButtonrelaie les props deButton(id,aria-*: il fonctionne dans unFormField).SegmentedControl: choix exclusif en boutons joints (ToggleGroup), sans désélection ; le segment choisi prend, en plus du fondbg-muted, un liseré intérieur, ou la couleur dutonde son option (celle de l'état que produit le choix). L'infobulled'une option nomme un segment à icône seule, sans masquer un libellé texte.CheckboxList: plusieurs valeurs parmi une liste de cases (CheckboxGroupde Base UI) ; ladescriptiond'une option est reliée à sa case. Une case figée n'estompe que sa ligne.useDraftAutosave({ contenu, enregistrer }): sauvegarde automatique d'un brouillon là où l'application le range (API ou stockage du navigateur) — anti-rebond, sans doublon, une écriture à la fois, version pour un verrou optimiste,erreuretreessayer(), alerte à la fermeture de l'onglet ;enregistrerMaintenant()avant de quitter. Démonté (navigation interne), il range aussitôt l'écriture en attente ;annuler()(avant de supprimer le brouillon) l'abandonne, elle et celles en file. Un brouillon chargé (versionInitiale> 0) n'est pas réécrit au montage ;enregistrerreçoit le contenu tel quel (sans passer par JSON) ; l'objet renvoyé est stable.marquerEnregistre(contenu): appelé dans le même gestionnaire que la mise à jour du contenu, passer le nouveau.
<FormField libelle="Téléphone" obligatoire erreur={erreurs.telephone}>
<PhoneInput valeur={telephone} onValeurChange={setTelephone} />
</FormField>
<FormField libelle="Taille maximale">
<InputGroup>
<InputGroupInput inputMode="numeric" />
<InputGroupAddon align="inline-end">
<InputGroupText>Mo</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormField>// Contrôle de l'application dans un FormField
function ChampCode() {
const champ = useFormField()
return (
<input
id={champ?.id}
required={champ?.obligatoire}
aria-describedby={champ?.describedBy}
aria-invalid={champ?.invalide || undefined}
/>
)
}Tableau de données
La famille DataTable suit le guide Data Table de shadcn
sur TanStack Table v9 : les colonnes se déclarent avec
l'assistant typé, useDataTable crée l'instance, les composants la reçoivent en table.
| Composant | Rôle |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| useDataTable | instance TanStack : tri, filtre et pagination (côté client, ou serveur avec triManuel), colonnes, sélection, dépliage |
| DataTable | la Table de shadcn ; état vide, lignes fantômes du chargement, bandeau d'erreur (requete), clic de ligne, détail déplié |
| DataTableColumnHeader | en-tête triable : un clic trie par ordre croissant, le suivant inverse le sens |
| dataTableSelectionColumn | colonne de cases ; Maj+clic sélectionne une plage ; raison d'une case verrouillée |
| dataTableExpandColumn | colonne du chevron qui déplie une ligne (aria-expanded, aria-controls) |
| dataTableActionsColumn | colonne des commandes d'une ligne : en-tête lu mais invisible, commandes à droite |
| DataTableCellLink | titre d'une ligne : vrai lien (href, routeur par lien) ou bouton |
| DataTableRowActions | menu « … » d'une ligne |
| DropdownMenuAction | entrée d'un menu d'actions : libelle, icone, destructif, description ; verrou grise l'entrée et affiche sa raison sous le libellé |
| DataTableViewOptions | bouton « Colonnes » : afficher, masquer, réordonner (glisser-déposer, clavier compris) |
| DataTableToolbar | recherche, filtres (DataTableSelectFilter), compteur (DataTableCount), commandes |
| DataTablePagination | pagination par décalage (offset/limit, total connu), ou celle du tableau (table) |
| DataTableBulkActions | barre d'actions groupées, collante en bas, avec la sélection ; actions moins fréquentes dans « … » (actionsSecondaires) ; croix et Échap désélectionnent |
| useColumnPrefs | colonnes affichées et leur ordre, gardés dans le navigateur par liste et par personne |
| usePersistedState | useState gardé dans le navigateur (filtre choisi…), sous la portée PorteeStockageLocal |
import { keepPreviousData, useQuery } from "@tanstack/react-query"
import {
DataTable,
DataTableColumnHeader,
DataTablePagination,
DataTableRowActions,
DataTableToolbar,
DataTableViewOptions,
createDataTableColumnHelper,
dataTableSelectionColumn,
useDataTable,
type SortingState,
} from "@etic-insa/ui"
const aide = createDataTableColumnHelper<Etude>()
// Hors du composant (ou mémorisées) : des colonnes recréées à chaque rendu recalculent tout.
const colonnes = aide.columns([
dataTableSelectionColumn<Etude>({
libelleLigne: (e) => `Sélectionner ${e.titre}`,
}),
aide.accessor("titre", {
header: ({ column }) => (
<DataTableColumnHeader column={column} libelle="Titre" />
),
meta: { libelle: "Titre" }, // nom dans le sélecteur de colonnes et l'annonce du tri
}),
aide.display({
id: "actions",
enableHiding: false,
cell: ({ row }) => (
// Un nom propre à la ligne : sinon, une liste de boutons « Actions » indiscernables.
<DataTableRowActions libelle={`Actions pour ${row.original.titre}`}>
{/* DropdownMenuItem… */}
</DataTableRowActions>
),
}),
])
export function Etudes() {
const [tri, setTri] = useState<SortingState>([{ id: "titre", desc: false }])
const [decalage, setDecalage] = useState(0)
const [limite, setLimite] = useState(25)
const { data, isFetching } = useQuery({
queryKey: ["etudes", tri, decalage, limite], // le serveur trie et pagine
queryFn: () => api.etudes({ tri, decalage, limite }),
// Garde la page affichée pendant le chargement de la suivante : le compteur
// et la pagination ne retombent pas à 0, la hauteur ne saute pas.
placeholderData: keepPreviousData,
})
const table = useDataTable({
donnees: data?.items ?? AUCUNE, // référence stable, pas un nouveau [] à chaque rendu
colonnes,
idLigne: (e) => e.id,
tri,
onTriChange: setTri,
triManuel: true,
})
return (
<>
<DataTableToolbar
compteur={{
total: data?.total ?? 0,
decalage,
nombre: data?.items.length ?? 0,
}}
>
<DataTableViewOptions table={table} />
</DataTableToolbar>
<DataTable
table={table}
libelle="Études"
enChargement={isFetching}
lignesChargement={limite}
/>
<DataTablePagination
total={data?.total ?? 0}
decalage={decalage}
limite={limite}
onDecalageChange={setDecalage}
onLimiteChange={setLimite}
/>
</>
)
}Chaque partie de l'état (tri, visibiliteColonnes, ordreColonnes, selection,
lignesDepliees) est contrôlée si l'application la fournit avec son on…Change (pour la
garder, en localStorage par exemple), interne sinon ; etatInitial donne la valeur de départ
d'une partie interne. La sélection se limite par défaut aux lignes affichées (changer de page
ou de filtre la réduit) ; conserverSelection la garde ; table.options.meta.deselectionner(ids)
en retire quelques lignes (refus d'une action groupée pour certaines). Sur mobile, le tableau
garde ses colonnes et son cadre défile horizontalement. Une colonne se masque par « Colonnes »,
pas depuis son en-tête ; la dernière colonne de données affichée ne se masque pas.
donnees et colonnes doivent être des références stables. idLigne peut s'écrire en ligne :
il n'est relu que quand donnees change. Une ligne ne se redessine que si ses données, sa
sélection, son dépliage ou les colonnes changent (cocher une case ne redessine pas les 1 000
autres) : une cellule qui affiche un autre état le lit elle-même (hook, contexte).
Accessibilité : le tri choisi, le chargement et « tous sélectionnés » sont annoncés par le
tableau ; DataTableCount est une zone de statut (le nouveau compte après une recherche ou un
filtre est annoncé), qui n'ajoute la tranche affichée (« · 26–50 affichées », feminin) que si
la liste tient sur plusieurs pages ; DataTablePagination annonce la page atteinte. Quand un contrôle disparaît
sous le focus (« Réinitialiser », la croix de la recherche, le bandeau ou la barre de sélection),
le focus passe à un élément stable (champ de recherche, premier filtre, case d'en-tête). Le clic
de ligne (onActiverLigne) est un raccourci à la souris : une cellule doit aussi offrir un lien
ou un bouton pour le clavier (DataTableCellLink). Un bouton passé dans vide (« Réinitialiser
les filtres ») disparaît avec l'état vide : l'application rend d'abord le focus au champ de
recherche. Quand une ligne qui avait le focus disparaît (suppression par un DeleteButton, un
menu « … » ou un dialogue de l'application ouvert depuis elle, liste rechargée), DataTable
rend le focus à la même cellule de la ligne suivante (ou précédente), sinon au tableau ; pour un
tableau rendu à la main, focaliserTableau(cadre) donne la même cible. size règle la taille
du DeleteButton (icon-lg pour la corbeille dans un en-tête de page ; avec avecTexte,
l'en-tête l'agrandit seul). La recherche de DataTableToolbar porte NO_AUTOFILL ;
recherche.attributs lui ajoute des attributs.
L'en-tête du tableau ne se surligne pas au survol (seules les lignes de données le font). Quand le tableau se vide (filtre sans résultat, page suivante en chargement), ses colonnes gardent leur largeur. La colonne de sélection a la largeur de la zone de ses cases (40 px) ; une case verrouillée reste lisible (fond gris, ou gris foncé cochée) au lieu d'être estompée.
Sélectionner tous les éléments
La case d'en-tête (« Sélectionner la pa
