npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/ui

Pré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 si obligatoire), contrôle, description et erreur (texte ou liste), reliés au contrôle par htmlFor, aria-describedby et aria-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 du useFormField() du Form de shadcn :

    • l'enfant direct reçoit toujours id, aria-describedby et aria-invalid ; autour d'un InputGroup, 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çoit required ; sinon aria-required (déclencheur de Select, ComboboxInput…) ;
    • un contrôle composite lit le contexte du champ, useFormField(), et pose lui-même ces attributs et un vrai required sur son élément de saisie : c'est ce que font DatePicker, PhoneInput et SearchSelect, et ce que doit faire un composant de l'application ;
    • un groupe (CheckboxList, SegmentedControl) n'a ni required natif ni droit à aria-required (role="group") : il cite le libellé du champ par aria-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 aussi required hors d'un FormField. La vérification reste à l'application.

    useFormField() renvoie { id, libelleId, descriptionId, erreurId, describedBy, obligatoire, invalide }, ou null hors d'un FormField. RequiredMark existe seul. FormSection regroupe des champs sous un titre (FieldSet + FieldLegend). action : un bouton à droite du contrôle, sur sa ligne (un ResetButton). À 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.

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 (son action), 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, retablirDefauts de la SaveBar, 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 seuls 05032026 pour le pavé numérique de l'iPhone ; lue en quittant le champ ou sur Entrée), calendrier en français navigable aux flèches, bornes dateMin/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, et onValeurChange(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-invalid du 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. Le PhoneInput, 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, idElement et rendreOption dé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. form va, comme name, à l'entrée cachée qui porte la valeur. La liste s'aligne sur le cadre du champ, loupe comprise (comme toute liste d'un ComboboxInput).
  • Select (shadcn) : la liste s'ouvre sous le champ, alignée à gauche, sans jamais le recouvrir (alignItemWithTrigger et align restent réglables sur SelectContent). 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 un SelectGroup, comme chez shadcn ; posées directement dans SelectContent, elles ont la même marge. Sans option (y compris un SelectGroup vide), la liste dit « Aucune option. » (vide de SelectContent), dans une zone de statut annoncée aux lecteurs d'écran. SelectItem prend une description sous 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é.
  • FileUploadButton et FileDropZone : 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 : à onRefuser pour 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ée flex-wrap ; sans refus, rien d'autre que le bouton n'occupe le flux, ce qui le laisse dans une barre d'outils ou un ButtonGroup). Pendant enCours, le bouton reste focalisé, indisponible, et l'envoi est annoncé. FileUploadButton relaie les props de Button (id, aria-* : il fonctionne dans un FormField).
  • SegmentedControl : choix exclusif en boutons joints (ToggleGroup), sans désélection ; le segment choisi prend, en plus du fond bg-muted, un liseré intérieur, ou la couleur du ton de son option (celle de l'état que produit le choix). L'infobulle d'une option nomme un segment à icône seule, sans masquer un libellé texte.
  • CheckboxList : plusieurs valeurs parmi une liste de cases (CheckboxGroup de Base UI) ; la description d'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, erreur et reessayer(), 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 ; enregistrer reç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