Composants

Catalogue des composants partagés de @lrvo/core/shared/components, usage, runtime et conventions.

@lrvo/core/shared/components est le catalogue de composants de la plateforme. Utiliser ces composants en priorité. Si un besoin est réutilisable à plusieurs endroits et que le composant n'existe pas, le créer ici plutôt que dans le module qui en a besoin en premier.

Import

import { Button, Table, Modal, Input } from '@lrvo/core/shared/components';

Le sous-chemin est client-safe : importable depuis un fichier 'use client'.

Actions

ComposantRôle
ButtonBouton. Sept variantes, trois tailles, états chargement et désactivé. Rend un <Link> quand href est fourni.
SplitButtonBouton principal plus chevron ouvrant un menu de variantes de la même intention. Accepte une icon comme Button. Une action d'intention opposée reste un bouton séparé.
DropdownMenuMenu déroulant à déclencheur discret, sans action par défaut. Sa place est la fin d'une ligne de tableau. Voir Menu déroulant.
FloatingActionBarBarre flottante des actions d'une sélection : médias, posts, utilisateurs, fichiers.
UnsavedChangesBarBarre flottante des formulaires pleine page. Encapsule useUnsavedChanges, l'animation de rappel et le rendu. Voir Hooks.

Saisie

ComposantRôle
InputChamp texte, avec label, description et erreur.
TextareaChamp multiligne.
Select<select> natif. À utiliser quand la liste tient à l'écran, sous une dizaine d'entrées. Pas de recherche.
ComboboxÉquivalent avec recherche. Trigger identique à Select, champ de filtrage en tête, navigation au clavier. À utiliser dès que la liste devient longue. Le filtre couvre label et value, et chaque option peut porter un hint secondaire. Le panneau est ancré dans un portail : il passe par-dessus une modale ou un conteneur défilant, bascule au-dessus du déclencheur quand le bas de l'écran manque, et sa liste rétrécit à la hauteur disponible.
SwitchInterrupteur booléen.
CheckboxCase à cocher, avec un état indéterminé pour une sélection partielle. Le clic ne remonte pas au parent, ce qui la rend utilisable dans une ligne cliquable. Voir Sélection dans Table.
TagInputSaisie de plusieurs valeurs sous forme d'étiquettes.
ColorPickerSélecteur de couleur.
IconPickerOuvre une modale avec recherche, catégories et grille d'icônes. Retourne le nom de l'icône, une chaîne comme 'Call02Icon', résolu ailleurs par Icons[name]. Voir Icônes.
PasswordStrengthIndicatorJauge de robustesse d'un mot de passe.

Select et Combobox respectent les mêmes conventions de label, description et erreur qu'Input. Pas de composant <Field> enveloppant.

Input relie son label au champ par id : sans id, cliquer sur le label ne donne pas le focus et un lecteur d'écran n'annonce pas le champ. La prop required n'ajoute qu'un astérisque au label ; elle n'est pas transmise à l'<input>, donc aucune validation native ne s'exécute.

Checkbox et Switch posent aria-disabled et non disabled : ce sont des <button> porteurs d'un rôle ARIA, et l'attribut natif se ferait restaurer par le navigateur au rechargement de la page, au prix d'un écart d'hydratation. Ils restent atteignables au clavier une fois désactivés, et c'est leur gestionnaire de clic qui bloque l'action. Voir États interactifs.

Éditeurs

ComposantRôle
BlockEditorÉditeur de contenu par blocs. Voir BlockEditor.
MarkdownEditorÉditeur markdown, pour les usages qui restent en markdown.

Structure

ComposantRôle
CardCarte : fond, bordure 1 px, rayon 12 px, sans ombre. Voir Gouttière de Card.
ModalModale avec overlay. Les boutons d'action restent dans son pied.
TableTableau. Voir Style par défaut des cellules, Sélection, État de chargement et Hauteur plancher.
TabNavNavigation par onglets.
FilterTabsOnglets de filtre d'une liste.
BreadcrumbFil d'Ariane. Voir Cible de dépôt d'un item.
CalendarGrille mois, semaine et jour. Purement présentationnel. Voir Calendrier.
StatCardTuile de statistique, brique par défaut d'un widget de tableau de bord.
Badge, StatusBadge, TypeBadgeÉtiquettes d'état. Fond pâle, bordure légère, texte de la couleur sémantique.
UserAvatarAvatar, avec repli sur les initiales.
RelativeDateDate relative, « il y a 3 jours ».

Chargement

ComposantRôle
LoadingIndicateur simple.
LoadingStateExport par défaut du module de squelettes.
LoadingSpinner, PageLoadingChargement global.
Skeleton, TableSkeleton, CardSkeleton, FormSkeleton, ButtonSkeleton, ListSkeletonSquelettes par forme de contenu.

Préférer un squelette de la bonne forme à un spinner générique : la page ne saute pas au remplacement.

Table n'utilise aucun de ces composants, il gère son propre chargement. Voir État de chargement de Table.

SEO

ComposantRôle
SeoFieldsZone d'édition SEO complète : champs, score en temps réel, aperçus Google, X, Facebook, LinkedIn, Pinterest et Slack.
SeoScoreBadgePastille de score compacte pour les tables d'admin.

Les deux sont agnostiques de la configuration : siteName, separator et titleTemplate sont passés par l'appelant depuis useLrvoConfig(). Voir SEO.

DropdownMenu regroupe les actions d'une ligne de tableau derrière un bouton à trois points. À utiliser dès que les actions dépassent deux boutons, ou qu'une d'entre elles est destructive : six icônes alignées dans une cellule ne se lisent pas, et une suppression alignée avec les autres se clique par erreur.

Frontière avec SplitButton. SplitButton porte une action principale visible et regroupe derrière son chevron des variantes du même verbe. Un DropdownMenu n'a pas d'action par défaut : c'est une liste, et son déclencheur ne dit rien de ce qu'elle contient. Un en-tête de page prend un SplitButton, une ligne de tableau prend un DropdownMenu.

<DropdownMenu
  items={[
    { label: 'Renommer', icon: PencilEdit01Icon, onClick: () => rename(item) },
    { label: 'Ouvrir', icon: EyeIcon, href: `/admin/items/${item.id}` },
    { separator: true },
    { label: 'Supprimer', icon: Delete02Icon, onClick: () => remove(item), variant: 'danger' },
  ]}
/>
PropRôle
itemsListe des items. Une valeur fausse est ignorée, ce qui permet d'écrire can.delete && { ... } sans filtrer. Une liste vide ne rend rien.
iconIcône du déclencheur, MoreVerticalIcon par défaut.
labelLibellé accessible du déclencheur, « Actions » par défaut.
alignend par défaut, start pour aligner le panneau à gauche.
sizesm par défaut, md pour un déclencheur plus généreux.
disabled, classNameComme Button.

Chaque item accepte label, icon, onClick, href, variant, disabled et description. variant vaut danger, success, warning ou rien. Un item { separator: true } insère un filet.

Un item porteur d'un href rend un <Link>, donc un vrai <a href>, pour la raison exposée dans Notes.

Le panneau est ancré dans un portail, comme celui de SplitButton. Un menu positionné en absolute serait rogné par tout parent qui coupe son débordement : l'overflow-x-auto d'un tableau, ou le corps défilant d'une modale. Ces deux composants sont donc le seul chemin correct pour un menu flottant : ne pas réinstaller un Menu de Headless UI à la main dans un render de colonne.

Les clics ne remontent pas. Le déclencheur et les items coupent la propagation. Une ligne dont le onClick sélectionne ou navigue n'est donc pas déclenchée par l'ouverture du menu.

ContextMenu présente la même liste d'actions, mais au point cliqué et à la place du menu du navigateur. Sa place est une liste de fichiers ou de ressources où le clic droit sur une ligne est le geste attendu.

Frontière avec DropdownMenu. DropdownMenu porte son déclencheur et son état d'ouverture : on le pose dans une cellule et il se débrouille. ContextMenu n'a pas de déclencheur, l'appelant l'ouvre en lui passant des coordonnées.

Il ne remplace pas le bouton à trois points, il s'y ajoute. Une action qui n'existe qu'au clic droit est une action que personne ne trouve, et que le toucher n'atteint pas. Les deux menus se nourrissent donc de la même liste d'items, construite une seule fois par l'appelant.

const [menu, setMenu] = useState(null);
const itemsFor = useCallback(item => [
  { label: 'Renommer', icon: PencilEdit01Icon, onClick: () => rename(item) },
  { separator: true },
  { label: 'Supprimer', icon: Delete02Icon, onClick: () => remove(item), variant: 'danger' },
], [rename, remove]);

<Table
  columns={[/* ... */, { key: 'actions', render: item => <DropdownMenu items={itemsFor(item)} /> }]}
  getRowProps={item => ({
    onContextMenu: (event) => {
      event.preventDefault();
      setMenu({ x: event.clientX, y: event.clientY, item });
    },
  })}
/>

<ContextMenu
  position={menu}
  items={menu ? itemsFor(menu.item) : []}
  onClose={() => setMenu(null)}
/>
PropRôle
position{ x, y } en coordonnées viewport, null quand le menu est fermé. Il n'y a pas de prop open : elle redirait ce que position dit déjà. Les coordonnées viennent de event.clientX et event.clientY.
itemsMême contrat que DropdownMenu, jusqu'aux valeurs fausses ignorées et à { separator: true }. Une liste vide ne rend rien.
onCloseAppelé à chaque fermeture : clic ailleurs, Échap, Tab, défilement, redimensionnement, choix d'un item.

Le panneau s'ouvre là où il tient. À droite du pointeur, à sa gauche s'il déborde, en dessous, au-dessus si la place manque, et il défile quand aucun des deux côtés ne suffit. Un menu tronqué cacherait une action sans le dire.

Le défilement le ferme. Il est posé en coordonnées figées : le laisser flotter au-dessus d'un tableau qui défile le détacherait de la ligne qu'il désigne.

La navigation au clavier passe par le focus du DOM. ↑ et ↓ déplacent le focus d'un item à l'autre en sautant séparateurs et items désactivés, Entrée et Espace l'actionnent nativement, Échap et Tab ferment, et le focus revient d'où il venait.

Le clic droit ne touche pas à la sélection. Le menu vise la ligne cliquée, même quand d'autres cases sont cochées. Les actions d'un lot restent celles de la FloatingActionBar.

Calendrier

Calendar affiche des évènements sur une grille mois, semaine ou jour. Il ne lit rien, ne décide de rien et n'invente aucun évènement : l'appelant lui donne des instants déjà résolus, et récupère l'instant cliqué.

import { Calendar } from '@lrvo/core/shared/components';

const [view, setView] = useState('week');
const [date, setDate] = useState(() => new Date());

<Calendar
  view={view}
  onViewChange={setView}
  date={date}
  onDateChange={setDate}
  events={events}
  onEventClick={(event) => ouvrir(event.id)}
  onSlotClick={({ start }) => creer(start)}
  timeZone="America/Toronto"
/>
PropDéfautRôle
view'month''month', 'week' ou 'day'. Contrôlé.
onViewChange-Sans cette fonction, le sélecteur de vue n'est pas rendu.
date-Instant d'ancrage. La période affichée est celle qui le contient.
onDateChange-Reçoit le nouvel ancrage, à midi, à chaque navigation.
events[]{ id, start, end, title, subtitle, color }. start et end sont des instants, color un hexadécimal à six chiffres.
onEventClick-Reçoit l'évènement cliqué.
onSlotClick-Reçoit { start } pour un créneau vide. Sans cette fonction, les cases ne sont pas cliquables.
dayStartMinute480Première heure affichée en vue semaine et jour, en minutes depuis minuit.
dayEndMinute1140Dernière heure affichée.
stepMinutes30Durée couverte par une ligne de la grille horaire.
weekStartsOn00 pour dimanche, 1 pour lundi.
locale'fr-CA'Locale des noms de jours et du titre de période.
timeZone'America/Toronto'Fuseau de lecture des évènements.
loadingfalseAtténue la grille et pose aria-busy.
toolbartrueMasque la barre de navigation quand elle est fournie ailleurs.

L'arithmétique civile, quel jour suit quel jour, se fait en UTC, où une journée dure toujours 24 heures. Placer un instant sur cette grille est la seule étape qui a besoin d'un fuseau, et elle passe par utcToZonedParts : un jour de passage à l'heure avancée, le rendez-vous de 9 h reste dans la colonne de 9 h. Voir Dates.

La couleur d'un évènement vient de la donnée, pas du design system : elle arrive en hexadécimal libre et sert de pastille, de fond très pâle et de bordure. Un évènement sans couleur retombe sur le gris neutre.

En vue semaine et jour, un évènement occupe la hauteur de sa durée : un rendez-vous de deux heures couvre quatre lignes d'un pas de trente minutes, et un rendez-vous d'un quart d'heure en couvre une demie. Le bloc se resserre quand la place manque : sous 46 pixels le sous-titre disparaît, sous 34 pixels les marges internes tombent, et le texte reste sur une ligne tronquée plutôt que de déborder sur le créneau suivant.

Deux évènements qui se chevauchent se partagent la largeur de la colonne. Un évènement qui passe minuit est tronqué à la fin de sa journée : la grille montre où il commence, la fiche montre jusqu'où il va.

Gouttière de Card

padding est une échelle de gouttières, pas une échelle de tailles. Chaque cran correspond à un usage, et deux crans voisins ne diffèrent que de 4 px : se tromper de cran se paie en points, pas en centimètres.

paddingRenduPour
nonerienLa carte enveloppe, dont le contenu porte déjà sa gouttière : une Table, une liste dont les lignes se paient elles-mêmes.
smp-4La carte dense : un encart dans une colonne étroite.
md (défaut)p-5La carte de contenu ordinaire, celle d'un formulaire.
lgp-6La carte isolée qui occupe la largeur d'une page.

Une valeur inconnue retombe sur md, et non sur l'absence de gouttière.

Une tuile de tableau de bord ne se règle pas avec padding. Sa gouttière est px-5 py-4, qui n'est aucun de ces crans : elle pose padding="none" et l'écrit dans son className, comme le fait StatCard. Son titre non plus ne passe pas par title : une tuile porte un libellé de 11 px en petites capitales, là où title rend un titre de 14 px suivi d'un filet. Voir Étendre l'admin.

L'échelle a porté un cinquième cran, lg: p-8, retiré depuis. Il ne servait que deux cartes de tout l'écosystème et se présentait à tous comme le cran généreux, ce qui en faisait le choix naturel pour une tuile de tableau de bord : la tuile sortait alors deux fois plus épaisse que ses voisines de la même grille.

Style par défaut des cellules de Table

Une colonne sans render (texte brut, type: 'date', type: 'currency') est automatiquement enrobée dans text-xs text-fg-secondary font-code. C'est le style canonique d'une donnée tabulaire : URL, identifiant, montant, date formatée.

Si une colonne fournit render, cet enrobage ne s'applique pas et l'appelant prend la responsabilité de la typographie. La couleur text-fg-secondary reste portée par la cellule elle-même : un render qui retourne du texte sans classe de couleur reste donc lisible dans les deux thèmes, et toute couleur déclarée dans le render l'emporte.

En pratique, pour un simple texte monospace, écrire :

{ key: 'base_url', label: 'URL' }

plutôt que d'envelopper manuellement dans un <span>. Réserver render aux cas qui demandent une composition : badge, lien, icône, mise en gras d'un nom.

column.className s'applique à l'en-tête comme à la cellule. C'est là que se pose une largeur de colonne, w-10 par exemple, que la mise en page automatique du tableau ne devinerait pas.

getRowProps(item) peut renvoyer cellClassName, appliqué à toutes les cellules de la ligne, et à la carte en mobile. C'est le seul endroit d'où imposer un fond de ligne : celui du survol est porté par les cellules et repeindrait un fond posé sur le <tr> par className. Le fond d'une ligne est donc unique et se résout dans cet ordre, cellClassName, puis la sélection, puis le survol. className, lui, reste la classe du <tr> : bordure, opacité, tout ce qui ne se dispute pas la même surface.

Sélection dans Table

selectable ajoute une colonne de cases à cocher en tête de ligne, et une case dans l'en-tête. Sans elle, rien ne change : les tableaux qui ne sélectionnent rien n'ont ni colonne ni décalage.

PropRôle
selectableActive la colonne de sélection.
selectedIdsSet des identifiants cochés. Un tableau est accepté.
onSelectionChange(next)Reçoit le nouveau Set. L'appelant le range où il veut.
getRowIdIdentifiant d'une ligne, item => item.id par défaut.

Le composant tient la mécanique, l'appelant ne garde que l'état :

  • La case d'une ligne bascule cette ligne. Avec Maj, elle étend la sélection depuis la dernière case cochée : étendre est un ajout, une plage qui remplacerait tout ferait perdre le reste.
  • La case d'en-tête coche ou décoche les lignes affichées, et passe en état indéterminé quand une partie seulement l'est. Elle ne touche pas aux autres pages : une case ne peut pas engager ce qu'elle ne montre pas. Tant que le tableau n'affiche aucune ligne, pendant le premier chargement notamment, elle est désactivée.
  • Le clic sur une case ne remonte pas à la ligne. onRowClick garde donc tout son sens à côté de la sélection : cocher sélectionne, cliquer ouvre.
  • Ctrl/Cmd + clic sur la ligne bascule sa case, Maj + clic étend la plage, et onRowClick n'est pas appelé. C'est un raccourci, pas un chemin : la case reste ce qui annonce qu'une sélection existe. L'ancre est la même que celle des cases, donc cocher une case puis étendre depuis une ligne désigne bien la plage attendue. La sélection de texte du navigateur est neutralisée pendant un Maj + clic, sans quoi elle surlignerait la moitié du tableau.
  • Une ligne cochée porte son fond depuis le tableau, survol compris.
  • En mobile, où le tableau devient une liste de cartes, la case se place dans la gouttière gauche de la carte. Le titre de la carte reste la première colonne de données.

La sélection reste à l'appelant. Le composant ne la garde pas : c'est lui qui la vide après une action ou un changement de contexte, et qui décide de ce qu'elle déclenche, en général une FloatingActionBar.

onRowClick(item, event) reçoit l'événement du clic : c'est ce qui permet à un appelant de distinguer un clic simple d'un clic modifié quand le tableau ne sélectionne pas lui-même.

Cible de dépôt d'un item de Breadcrumb

Un fil d'Ariane qui surplombe une liste où l'on déplace des éléments à la souris est une destination naturelle : y lâcher une sélection la remonte d'un ou plusieurs niveaux. Deux clés d'item le permettent.

CléRôle
dropPropsProps étalées sur le <li> de l'item : onDragOver, onDragLeave, onDrop.
highlightedMarque l'item comme cible sous le curseur.

Les gestionnaires se posent sur le <li> et non sur le <button> : le bouton de l'élément courant est désactivé, et un contrôle désactivé ne reçoit aucun événement de pointeur. Le navigateur les remonte à son parent, qui est donc le seul porteur qui fonctionne pour tous les items.

highlighted remplace le fond de survol au lieu de s'y ajouter : le pointeur est toujours sur la cible qu'il vise, et le survol repeindrait le surlignage.

État de chargement de Table

loading ne produit pas le même rendu selon qu'il y a déjà des données ou non.

SituationRendu
loading, data videSquelettes de lignes et de pagination. C'est le premier chargement.
loading, data non videLes lignes restent en place, atténuées à 60 %, aria-busy posé, contrôles de pagination verrouillés.
Pas de loading, data videÉtat vide, emptyMessage et emptyDescription.

Une revalidation ne vide jamais la vue. Trier, changer de page, lancer une recherche ou recharger après une action conserve les lignes affichées jusqu'à l'arrivée des nouvelles.

Ne pas vider data avant un rechargement. Un appelant qui fait setItems([]) puis setLoading(true) retombe sur les squelettes et annule le bénéfice. Poser loading, puis remplacer la liste seulement à la réponse.

column.skeleton pilote l'apparence du squelette de chaque colonne. Sans valeur, une barre de 60 % de largeur.

CléRôle
heightClasse de hauteur, h-4 par défaut.
widthLargeur CSS, 100% par défaut.
classNameClasses supplémentaires, rounded-full pour un squelette de badge par exemple.
secondary{ height, width } d'une seconde barre, pour une cellule à deux lignes.

Hauteur plancher de Table

minHeight fixe une hauteur minimale au tableau, en pixels. Sans elle, la hauteur suit le contenu : une liste de deux lignes donne une carte haute de trois lignes, perdue dans la page, et une liste qui passe de vingt éléments à deux fait sauter toute la mise en page.

<Table minHeight={480} columns={columns} data={nodes} loading={loading} />

Ce que la hauteur change, en plus du gabarit de la carte :

SituationRendu
Premier chargementLe nombre de squelettes se déduit de la hauteur : ils remplissent le tableau au lieu de s'arrêter à cinq lignes.
Liste videLe message et sa description se centrent verticalement dans la hauteur libre.
Liste courteLes lignes restent en haut, à leur hauteur normale. Une ligne ne s'étire jamais pour combler le vide.

Le compte de squelettes se déduit des hauteurs de ligne de la taille demandée, size donc, entre 3 et 15 lignes en desktop et entre 3 et 10 cartes en mobile. Une valeur CSS libre est acceptée, 60vh par exemple : elle s'applique au conteneur, mais le compte de squelettes retombe alors sur sa valeur par défaut, faute de pixels à diviser.

TABLE_MIN_HEIGHT est exporté à côté de Table et vaut la hauteur des listes de l'admin. L'utiliser plutôt qu'un nombre écrit sur place : passer d'une page à l'autre ne doit pas faire sauter la mise en page parce qu'une liste est plus courte que l'autre.

Réserver minHeight aux listes pleine page. Un tableau en modale ou en encart prend la hauteur de son contenu : lui imposer un plancher pousserait les boutons du pied hors de l'écran.

Boutons d'action des formulaires

Le placement dépend du contexte.

Formulaire pleine page : utiliser UnsavedChangesBar. L'appelant fournit initial, current, onCancel, onSave et saving.

  • La barre apparaît dès qu'une modification est détectée et disparaît au retour à l'état initial.
  • Annuler est variant="secondary" et ramène le formulaire à son état initial sans naviguer. onCancel reçoit l'instantané initial en argument.
  • Enregistrer est variant="primary", désactivé pendant saving. Les libellés sont personnalisables par saveLabel et savingLabel.
  • Pas de bouton de fermeture : tant qu'il y a des modifications, l'utilisateur choisit explicitement.
  • Après un enregistrement réussi, rester sur la page et mettre à jour l'instantané initial pour que la barre disparaisse. Pour une page de création, rediriger vers la page d'édition de la ressource créée : l'utilisateur reste avec la ressource, pas dans le listing.
  • Mettre initial à jour avant de rediriger, et rediriger par router.push. Une navigation dure décharge le document, ce qui déclenche le dialogue natif du navigateur sur un formulaire pourtant déjà enregistré. Voir Hooks.
  • Aucun bouton inline en sidebar ou en pied de carte. La barre flottante est l'unique emplacement.

Formulaire en modale : garder les boutons dans le pied de la modale. Une modale est déjà contextualisée visuellement, l'utilisateur ne peut pas la perdre, et son bouton de fermeture sert d'annulation explicite.

Le détail des règles visuelles est dans Design system.

Notes

Toute UI navigationnelle de l'admin doit être un <a href> : un <Link>, ou un <Button href> qui rend un <Link>. useUnsavedChanges intercepte les clics sur les <a href>, mais pas les router.push programmatiques : le routeur de Next.js 16 valide son état avant l'appel à history.pushState, ce qui rend tout correctif côté garde inopérant.

Les composants suffixés .client.js portent 'use client'. Les autres sont neutres et peuvent être rendus côté serveur.

Sur cette page