Skip to main content
Voir en Markdown

Navigation en barre latérale

Un panneau de navigation vertical, généralement associé à une région principale container pour former une coquille applicative. Composez-le statiquement à partir de children, ou déclarez sa forme avec groups et laissez-le se rendre comme un unique point de repère de navigation fait de vrais liens.

Propriété Description
groups Groupes de navigation libellés rendus dans la barre latérale.
rail Rend la barre latérale en rail d'icônes sous le point de rupture nommé. Omis, elle s'affiche en entier à toute largeur.
trackNavigation Recalcule le marquage de l'entrée courante côté client après une navigation dans le même document. Faux par défaut.

groups

Assembler une navigation à partir de children oblige chaque application à écrire son propre arbre de conteneurs, de titres et d'ancres, chacun avec une accessibilité un peu différente. groups déclare la forme à la place.

Propriété Description
label Titre du groupe ; accepte une clé de traduction $t:. Omis, le groupe n'énonce aucun nom de catégorie.
landmark Point de repère de navigation nommé auquel ce groupe appartient ; les groupes qui le partagent sont enveloppés dans un seul nav.
headingLevel Niveau de titre du libellé du groupe, de 2 à 6. Exige landmark.
items Entrées rédigées à la main, rendues avant celles qui sont récupérées.
source Source de lignes système pour le groupe, projetée en entrées via labelKey et hrefTemplate.

Un groupe a besoin d'au moins l'un de items et source ; n'en déclarer aucun est refusé au démarrage, parce qu'un titre sans rien dessous n'est jamais ce qui était voulu. Déclarer les deux est pris en charge et utile — les entrées rédigées viennent en premier.

source est la même liaison à une enveloppe de lignes qu'utilise un composant de données, plus deux projections qui transforment une ligne en entrée : labelKey nomme la clé de ligne qui porte le libellé, et hrefTemplate est un chemin dont les emplacements {champ} sont remplis depuis la ligne.

app.yaml
- type: sidebar
  groups:
    - label: Overview
      items:
        - { label: Home, href: /, icon: home }
        - { label: Activity, href: /activity }
    - label: Products
      items:
        - { label: All products, href: /products }
      source:
        endpoint: /api/tables/products/records
        rowsKey: records
        labelKey: name
        hrefTemplate: /products/{name}

Une entrée déclare label, un icon Lucide optionnel, un href, et les propriétés décrites plus bas — activeMatch, badge, props, showWhen, children, source et les champs de dépliage.

Marquer l'entrée courante

Une entrée peut dire comment elle se reconnaît comme la page où vous êtes. La correspondance est calculée sur le serveur, et l'entrée correspondante porte aria-current="page" — pour que « où suis-je » ait une réponse lisible par un lecteur d'écran, et pas seulement par une couleur. activeMatch: exact est la valeur par défaut et marque quand le chemin de la requête est égal à href ; prefix marque quand il est égal à href ou commence par href/.

Servez-vous de prefix sur une entrée de section pour qu'elle reste marquée tant qu'un visiteur est à l'intérieur — /tables garde sa marque sur /tables/customers. Ne l'utilisez pas sur une entrée racine : href: / en correspondance par préfixe marque toutes les pages de l'application.

Apposer une pastille

badge prend soit une chaîne littérale — un mot de statut que votre configuration peut énoncer en toute vérité, comme Beta —, soit { endpoint, valuePath } pour un compteur qu'elle ne peut pas connaître. valuePath est un chemin pointé dans la réponse, total par défaut.

Des sections dans un seul point de repère

Par défaut, un groupe portant un libellé est son propre point de repère de navigation, nommé par ce libellé. C'est juste quand les groupes sont sans rapport, et faux quand plusieurs d'entre eux sont les subdivisions d'une même navigation — un lecteur qui parcourt les points de repère rencontre alors quatre « navigation » là où vous en vouliez une seule portant quatre titres.

landmark nomme le point de repère auquel un groupe appartient ; les groupes qui partagent une valeur sont repliés dans un seul nav qui la porte comme nom accessible. headingLevel rend le libellé du groupe comme un vrai titre de ce niveau, de 2 à 6, au lieu du nom d'un point de repère. N'en déclarer aucun laisse le groupe exactement tel qu'il se rendait avant l'existence de ces propriétés.

app.yaml
- type: sidebar
  groups:
    - label: Tables
      landmark: Data
      headingLevel: 2
      items: [{ label: Customers, href: /tables/customers }]
    - label: Files
      landmark: Data
      headingLevel: 2
      items: [{ label: Uploads, href: /files/uploads }]
    - label: Account
      items: [{ label: Settings, href: /settings }]

Cela fait deux points de repère : l'un nommé Data portant deux sections titrées, l'autre nommé Account par son propre libellé.

Quatre formes sont refusées au démarrage :

  • headingLevel sans landmark. Un groupe qui est son propre point de repère est déjà nommé par son libellé, et un titre qui le répète annonce deux fois les mêmes mots au même lecteur.
  • headingLevel sur un groupe sans label. Le texte du titre est le libellé : cela rendrait un titre vide — une étape de navigation par titres qui n'annonce rien. Signalé par position, puisqu'un groupe sans libellé n'a pas de nom à citer.
  • Des groupes partageant un landmark qui ne sont pas listés ensemble. La barre latérale rend les groupes dans l'ordre déclaré : un point de repère ne peut donc envelopper qu'une suite ininterrompue, et les réordonner en silence déplacerait des entrées que vous avez placées délibérément.
  • Deux points de repère de navigation partageant un nom accessible, y compris un landmark qui entre en collision avec le libellé d'un groupe qui est son propre point de repère.

headingLevel: 1 est refusé par le schéma : le titre de la page est le h1, et une section de barre latérale n'est jamais le titre principal du document à côté d'elle.

Un groupe sans libellé

Omettez label et le groupe n'énonce aucun nom de catégorie : il ne contribue ni titre ni point de repère, et ses entrées se rendent directement dans la racine de la navigation, au-dessus du premier groupe nommé. La ligne d'accueil d'une console est la sortie de toutes les sections, pas une section de plus.

Écrivez-la comme un groupe plutôt que comme un enfant link rédigé à la main, pour deux raisons. Les groupes se rendent avant tous les enfants rédigés : un enfant atterrit donc sous toute la navigation et jamais au-dessus. Et tout ce dont la ligne a besoin est hérité de la racine de la navigation — le style d'entrée partagé, le marquage de l'entrée courante avec son aria-current, les règles de ligne du rail, et le suivi qui re-marque l'entrée courante après une navigation dans le même document. Une ancre écrite à la main hors de cette racine n'obtient rien de tout cela — dans un rail, elle garde un libellé pleine largeur au milieu d'une colonne de 56 px.

Un groupe sans libellé peut tout de même puiser dans une source et déclarer un landmark. Ce qu'il ne peut pas porter, c'est headingLevel.

Nommer une ligne par des attributs

Une entrée qui ne porte rien qu'un libellé ne peut être atteinte que par ce libellé — et chaque assertion, chaque sonde analytique et chaque procédure d'exploitation se retrouve couplée à un texte d'affichage que la traduction déplacera. Un sac de propriétés donne à la ligne un nom qui ne bouge pas : items[].props atterrit sur le lien de l'entrée (ou sur son bouton bascule, quand l'entrée ne déclare pas de href), items[].childrenProps sur l'élément de liste d'un dépliage, et source.itemProps sur chaque entrée récupérée, avec des emplacements {champ} remplis depuis la ligne exactement comme ceux d'hrefTemplate — et non encodés en pourcentage, puisqu'il s'agit d'un attribut et non d'une URL.

Le rendu calcule href, class / className, aria-current, aria-expanded et aria-controls à partir des champs et de l'état de l'entrée. En déclarer l'un dans un sac de propriétés est refusé au démarrage, en nommant à la fois la clé et l'entrée, plutôt que d'être écarté en silence — ce que fait sinon un attribut à deux propriétaires.

Déplier une entrée

Une destination dont le contenu est lui-même navigable — une page d'enregistrements couvrant de nombreuses tables, une page de fichiers couvrant de nombreux buckets — coûte sinon un second clic et un chargement de page entier rien que pour découvrir ce qu'il y a dessous.

Un parent qui déclare un href reste un vrai lien vers sa propre page, avec un petit button portant aria-expanded et aria-controls à côté de lui. children porte des sous-entrées rédigées ; source les récupère, au premier dépliage et une seule fois. defaultExpanded ouvre le dépliage d'emblée, expandLabel et collapseLabel nomment la bascule (Expand {label} et Collapse {label} par défaut), et childrenProps attribue l'élément de liste.

Déclarer à la fois children et source est refusé au démarrage : seule la liste récupérée a des états de chargement, d'erreur et de vide, et une liste rédigée ne doit pas en hériter. Déclarer defaultExpanded, expandLabel, collapseLabel ou childrenProps sur une entrée qui n'a ni l'un ni l'autre est refusé aussi — il n'y a rien à déplier.

Récupérer au premier dépliage plutôt qu'au rendu garde le coût proportionnel : une barre latérale de dix dépliages ne fait dix requêtes que si le lecteur les ouvre tous les dix. Une liste récupérée dit dans lequel de ses trois états elle se trouve, avec un texte que vous pouvez surcharger sur la sourceloadingLabel (Loading…), errorLabel (Couldn't load the list.) et emptyLabel (No items.).

expandLabel et collapseLabel doivent porter {label} ; l'un sans l'autre est refusé au démarrage, parce qu'une chaîne fixe donne le même nom accessible à tous les dépliages — c'est-à-dire exactement le « lequel est-ce ? » auquel un nom existe pour répondre.

Le dépliage qui contient la page courante est ouvert à l'arrivée, résolu sur le serveur. C'est indépendant de defaultExpanded : c'est la navigation qui répond « où suis-je », pas une valeur par défaut.

Un parent qui ne mène nulle part

Omettez href et la ligne entière devient la bascule — un seul button portant l'icône, le libellé, la pastille éventuelle et le chevron, avec un traitement au survol et aucun état sélectionné. href n'est optionnel que sur une entrée de premier niveau, et seulement sur une entrée déclarant children ou source ; les sous-entrées et les entrées feuilles en exigent toujours un.

Deux conséquences en découlent, et toutes deux sont le but plutôt qu'une limite. La ligne n'est jamais marquée comme la page courante — aria-current dit « voici la page où vous êtes », et une ligne qui ne mène nulle part ne peut jamais l'être —, si bien qu'activeMatch y est refusé. Et son nom accessible est son propre libellé, lu avec l'état qu'aria-expanded annonce déjà : expandLabel et collapseLabel sont refusés pour la même raison — sur une ligne dont le texte visible est son nom, une seconde chaîne masquerait les mots qui sont à l'écran.

Une entrée sans href, sans children et sans source est refusée au démarrage, en nommant l'entrée et en disant ce qui lui manque. Une telle ligne n'est ni un lien ni un contrôle — rendue, non cliquable, et légale dans tous les champs restants, ce qui est précisément la raison pour laquelle rien d'autre que cette règle ne peut l'attraper.

Un troisième niveau

Une sous-entrée peut porter ses propres children — un niveau de plus, et le dernier. Servez-vous-en quand la destination est une page unique qui contient de nombreuses parties nommées : un catalogue de composants réparti en une douzaine de catégories titrées, une page de réglages avec une longue colonne de sections.

Le troisième niveau est toujours ouvert et ne porte pas de bascule. Un dépliage dans un dépliage aurait besoin d'un nom accessible disant laquelle des deux imbrications il actionne, et aucune formulation brève ne le fait. Il ne prend aucun des champs de dépliage — defaultExpanded, expandLabel, collapseLabel et source restent tous un niveau au-dessus —, et un childrenProps déclaré sans children est refusé au démarrage. Un troisième niveau récupéré à distance n'est pas exprimable non plus : une liste avec des états de chargement, d'erreur et de vide a besoin d'un dépliage pour les héberger, ce que ce niveau se refuse à être.

N'afficher une entrée qu'à l'intérieur de sa propre section

showWhen est la seule garde du produit qui lise le chemin de la requête. Donnez-lui une section, et l'entrée fait partie de la navigation à ce chemin et en dessous, et est absente partout ailleurs.

section est mis en correspondance comme le fait activeMatch: prefix : le chemin de la requête lui est égal, ou commence par lui suivi de /. Une ligne portée à /design-system/ui-kit est donc toujours là sur /design-system/ui-kit/button — un lecteur qui entre dans un objet ne voit pas disparaître la navigation par laquelle il est arrivé. Elle n'a pas besoin d'être l'href d'une entrée, et elle doit commencer par /.

Une entrée sous garde est retirée du document, pas masquée. Une navigation qui expédie toutes ses lignes et en cache la plupart est une navigation dont le point de repère, l'ordre de tabulation et la lecture au lecteur d'écran contredisent tous ce qui est à l'écran.

showWhen est disponible aux trois niveaux. Associez-la à un troisième niveau, sauf si cette liste a sa place dans le cadre de chaque page — un troisième niveau toujours présent, c'est une barre latérale devenue plan du site.

trackNavigation

aria-current="page" est résolu sur le serveur, ce qui est juste et suffisant pour une application dont chaque navigation est un chargement de page. Une application qui remplace sa région de contenu sur place laisse la barre latérale montée et la marque du serveur figée sur la page que le lecteur a déjà quittée — de sorte que le seul élément qui répond à « où suis-je » devient le seul élément qui a tort.

trackNavigation: true recalcule la marque côté client après une navigation dans le même document. C'est une adhésion explicite, parce que cela coûte un îlot client et qu'une application qui ne fait que des chargements de page complets n'y gagne rien.

La marque se déplace sur deux signaux. popstate — précédent et suivant du navigateur — ne demande rien à votre application. L'autre est sovrium:navigated, l'événement qu'annonce un permutateur applicatif. Mettez window.location à jour d'abord, puis émettez un CustomEvent sur document :

app.ts
history.pushState({}, '', '/products/widgets')
document.dispatchEvent(
  new CustomEvent('sovrium:navigated', { detail: { path: '/products/widgets' } })
)

Cet ordre est le contrat, pas une convention : la barre latérale lit window.location elle-même, detail.path est donc informatif, et émettre avant la mise à jour de l'emplacement recalcule la marque sur la page que le lecteur est en train de quitter.

activeMatch est réévalué côté client selon la même règle que côté serveur : une entrée en prefix garde donc sa marque quand on entre dans sa section. Un dépliage dont la section devient courante s'ouvre avec elle — mais n'est jamais rouvert après que le lecteur l'a délibérément replié.

rail — un rail d'icônes sur un écran étroit

Une barre latérale assez large pour être lue coûte à un portable environ un quart de sa largeur, et la surface qui la jouxte est généralement celle pour laquelle le lecteur est venu. rail garde la navigation présente à cette largeur tout en rendant la colonne.

app.yaml
- type: sidebar
  rail: { below: xl }
  groups:
    - label: Data
      items:
        - { label: Tables, href: /tables, icon: table }
        - { label: Files, href: /files, icon: folder }

below est une borne inférieure stricte : le rail s'applique à toute largeur inférieure au point de rupture nommé, et à partir de lui la barre latérale se rend exactement comme sans la clé. Une console qui veut un rail sur un portable et la barre complète sur un grand écran nomme donc le point de rupture où commence le grand écran — xl —, pas celui où commence le portable.

Les jetons sont ceux de responsive : sm, md, lg, xl, 2xl. mobile est refusé plutôt qu'accepté puis ignoré — c'est la base et non un point de rupture, si bien que « sous mobile » est l'intervalle vide : le rail ne s'appliquerait jamais, sur aucune page, sans rien dans les journaux.

Sous le point de rupture, la barre latérale prend une largeur fixe de 56 px, chaque entrée y centre son icône, et les libellés d'entrées, les pastilles et les titres de groupes cessent d'être peints. Donnez une icon à chaque entrée : une ligne qui n'en a pas est une colonne de 56 px de vide.

Les libellés ne sont pas supprimés. Ils restent dans le document et dans l'arbre d'accessibilité, et chaque ligne gagne une infobulle title pour qu'un lecteur au pointeur puisse encore la nommer. Une entrée dont le nom accessible changerait avec la fenêtre serait un lien qui se résout par son nom sur un grand écran et par rien sur un portable — chaque lien profond, chaque procédure et chaque test tiendrait à une largeur et échouerait silencieusement à une autre, la page paraissant correcte dans les deux cas. Chaque ligne garde aussi son arrêt de tabulation et sa marque aria-current="page".

Un rail n'est pas un tiroir. Il reste présent, reste un point de repère de navigation et reste atteignable au clavier dans l'ordre déclaré. Une application qui veut que la barre latérale quitte la mise en page sur un téléphone et revienne derrière un bouton décrit un tiroir, qui est une autre affordance avec son propre contrôle. Les deux se composent : une barre latérale peut être un rail à partir d'un point de rupture vers le bas et masquée derrière un tiroir à partir d'un point plus étroit, parce que le tiroir est le comportement du cadre et le rail celui de la navigation.

Omettre rail conserve le rendu actuel à toutes les largeurs.

Pages connexes

Dernière mise à jour 23 septembre 2026

Cette documentation a été rédigée avec de l'IA : des erreurs ou du contenu obsolète sont donc possibles. Sovrium est en bêta. Les contributions et corrections sont les bienvenues.

Construit avec Sovrium