La coquille d'application
Ce qu'elle était
Six lignes : une grille à deux colonnes, un bouton de repli écrit ☰ en caractère littéral, et trois emplacements libres. Elle ne réutilisait ni la navigation, ni le tiroir, ni les droits. Chaque application repartait de zéro dès qu'elle voulait un rail, un mobile ou un menu filtré.
Ce qu'elle assemble
Elle n'invente rien : la navigation est le Sidebar, le tiroir est le Drawer, les droits viennent de @sia-ui/headless. Ce qu'elle apporte est l'assemblage, et une seule source de vérité — ni la barre ni les onglets n'ont leur propre idée de ce qui est visible, ni de ce qui est actif. Deux filtrages séparés finiraient par diverger, et personne ne saurait lequel a raison.
Le routeur tient en une chaîne
C'est le point qui décide de tout le reste. La coquille n'importe aucun routeur; elle en reçoit l'adresse.
const { pathname } = useLocation(); // React Router
const pathname = usePathname(); // Next.js
const [pathname] = useLocation(); // wouter
<AppShell
nav={NAV}
currentPath={pathname}
renderLink={({ item, children, props }) => (
<Link to={item.href!} {...props}>
{children}
</Link>
)}
/>;De cette chaîne, elle tire trois choses que les applications réécrivent d'ordinaire à la main :
L'entrée courante, par le préfixe le plus long. Une page /factures/12/lignes marque Factures; l'égalité stricte casserait dès la première page de détail. La frontière de segment est respectée : /factures ne couvre pas /facturation.
La branche ouverte — la rubrique qui contient la page s'ouvre d'elle-même, y compris en arrivant par un lien profond ou le bouton retour.
La fermeture du tiroir mobile. C'est ce qu'on oublie toujours : on touche une entrée, la page change derrière, et le tiroir reste ouvert par-dessus. Le changement d'adresse suffit à le détecter — aucun abonnement au routeur.
renderLink est à stabiliser avec useCallback : les nœuds de navigation sont mémoïsés, et une fonction recréée à chaque rendu annule cette mémoïsation.
Sans routeur du tout, la coquille rend des <a href> ordinaires et activeKey peut être fourni à la main.
Trois états, pas un booléen
sidebarState | Ce que ça donne |
|---|---|
expanded | la barre déployée |
collapsed | le rail à icônes — les sous-menus sortent en volet |
hidden | plus de barre du tout |
collapsible dit ce que fait le bouton : icon alterne déployé et rail, offcanvas alterne déployé et masqué, false retire le bouton. « Réduite » et « masquée » sont deux intentions différentes, et une application qui veut l'une ne veut généralement pas l'autre.
Les entrées du Sidebar masquent déjà leur libellé lorsqu'elles reçoivent collapsed. Pour la marque et le pied de navigation, les primitives de AppShell font la même chose sans CSS applicatif :
<AppShell
brand={
<AppShellBrand
icon={<Logo />}
name="SIA Gestion"
description="Administration"
/>
}
sidebarFooter={
<AppShellRailItem
as="button"
icon={<LogoutIcon />}
label="Se déconnecter"
/>
}
nav={NAV}
>
{children}
</AppShell>AppShellBrand conserve l'icône et masque le nom ainsi que la description. AppShellRailItem fait de même avec son libellé et peut rendre un div, un button ou un lien a. Pour une composition entièrement personnalisée, l'en-tête et le pied reçoivent toujours data-collapsed en mode rail : ce crochet reste disponible, mais il n'est plus nécessaire dans le cas courant.
Le passage vers le CSS est volontairement simple : AppShell calcule l'état collapsed, le transmet au Sidebar, puis pose l'attribut data-collapsed sur les conteneurs de brand et sidebarFooter. Les styles du package ciblent alors [data-collapsed] .sia-shell-brand__copy et [data-collapsed] .sia-shell-rail-item__label. Le CSS ne devine donc aucun état React ; il réagit à un attribut présent dans le DOM.
Sous le seuil mobile
mobileNav | Ce que ça donne |
|---|---|
drawer (défaut) | la barre complète en tiroir |
tabs | une barre d'onglets basse |
both | les deux |
Le tiroir utilise une surface opaque adaptée au thème. Il affiche un en-tête Navigation et un bouton de fermeture explicite ; le clic sur l'arrière-plan et la touche Échap restent également disponibles.
Il entre avec une animation depuis le côté configuré par side : de gauche à droite avec side="left", et de droite à gauche avec side="right". Une className personnalisée complète toujours les classes internes du Drawer ; elle ne doit jamais les remplacer, car elles portent sa surface, sa taille et son mouvement.
Cette limite à deux côtés appartient à l'AppShell, dont la navigation reste latérale. Le composant Drawer autonome accepte les quatre positions left, right, top et bottom, chacune avec son animation d'entrée et de sortie correspondante.
both n'est pas le défaut, et ce n'est pas un oubli : deux systèmes de navigation simultanés se disputent l'attention, et l'utilisateur ne sait plus lequel fait autorité.
Les onglets ne montrent que des feuilles — c'est navLeaves de @sia-ui/headless. Un onglet qui ouvrirait un sous-menu depuis une barre basse serait un piège tactile : on vise une destination, on obtient un menu. Le reste passe par « Plus », qui ouvre le tiroir.
Quatre onglets au maximum : au-delà, les cibles tactiles descendent sous les quarante-quatre pixels recommandés. L'onglet actif reçoit une pastille qui grandit sous l'icône — une simple bascule de couleur passe inaperçue sur un téléphone tenu à bout de bras, un changement de forme non.
La coquille réserve la hauteur de la barre d'onglets, sans quoi la dernière ligne d'un tableau reste inatteignable.
La largeur du contenu
contentWidth | Pour |
|---|---|
wide (défaut) | 1400 px — deux colonnes de cartes, une ligne de texte encore lisible |
narrow | 768 px — un écran qui n'est qu'un formulaire |
full | sans limite — un tableau qui a besoin de toute la place |
Décidée par la coquille plutôt qu'écran par écran : des largeurs posées page après page finissent par diverger, et personne ne sait laquelle fait foi.
La barre du haut : compte, mode, temps réel
Trois briques qui revenaient dans chaque console :
<LiveIndicator status={useRealtimeStatus(source)} />
<ColorModeToggle />
<AccountMenu
name={moi.nom}
email={moi.email}
role="Administrateur"
items={[
{ key: "profil", label: "Mon profil", icon: <UserIcon /> },
{ key: "sortir", label: "Se déconnecter", icon: <LogOutIcon />, danger: true },
]}
/>AccountMenu pose l'avatar seul en déclencheur — rond, sans le cadre d'un bouton — et ouvre un menu dont l'en-tête porte avatar, nom, adresse et rôle. Cet en-tête est une entrée { type: "header", content } de DropdownMenu : contenu libre, sauté par le clavier. L'entrée label, elle, reste un titre de section en capitales, qui n'a pas vocation à porter un profil.
LiveIndicator dit si l'écran se met à jour tout seul — en direct, connexion, hors ligne ; voir Temps réel.
Ce que la coquille ne fait pas
Elle ne rend pas le titre de la page. Un <h1> rendu par la coquille force chaque écran à passer par ses props, et un écran qui veut deux titres ou une mise en page à lui doit se battre. L'emplacement page reçoit un PageHeader — ou tout autre chose.
Elle ne protège aucune route. Le filtrage des droits masque des entrées de menu; un écran caché reste atteignable tant que le serveur ne refuse pas l'appel.
Migration depuis l'ancienne
| Avant | Maintenant |
|---|---|
logo | brand |
navigation (nœuds) | nav (données) |
header | inchangé |
defaultCollapsed | defaultSidebarState |
| — | currentPath, renderLink, can, page, mobileNav |
C'est une rupture assumée : navigation prenait des nœuds React, ce qui interdisait à la coquille de filtrer, d'aplatir pour les onglets ou de marquer l'entrée courante. Rien de tout ce qui précède n'était possible avec des enfants opaques.
Prévu
Un fil d'Ariane nourri par navPath, qui décrit déjà le chemin de la racine jusqu'à l'entrée courante — sans redire l'arborescence une seconde fois.