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
| Composant | Rôle |
|---|---|
Button | Bouton. Sept variantes, trois tailles, états chargement et désactivé. Rend un <Link> quand href est fourni. |
SplitButton | Bouton 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é. |
DropdownMenu | Menu déroulant à déclencheur discret, sans action par défaut. Sa place est la fin d'une ligne de tableau. Voir Menu déroulant. |
FloatingActionBar | Barre flottante des actions d'une sélection : médias, posts, utilisateurs, fichiers. |
UnsavedChangesBar | Barre flottante des formulaires pleine page. Encapsule useUnsavedChanges, l'animation de rappel et le rendu. Voir Hooks. |
Saisie
| Composant | Rôle |
|---|---|
Input | Champ texte, avec label, description et erreur. |
Textarea | Champ 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. |
Switch | Interrupteur booléen. |
Checkbox | Case à 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. |
TagInput | Saisie de plusieurs valeurs sous forme d'étiquettes. |
ColorPicker | Sélecteur de couleur. |
IconPicker | Ouvre 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. |
PasswordStrengthIndicator | Jauge 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
| Composant | Rôle |
|---|---|
BlockEditor | Éditeur de contenu par blocs. Voir BlockEditor. |
MarkdownEditor | Éditeur markdown, pour les usages qui restent en markdown. |
Structure
| Composant | Rôle |
|---|---|
Card | Carte : fond, bordure 1 px, rayon 12 px, sans ombre. Voir Gouttière de Card. |
Modal | Modale avec overlay. Les boutons d'action restent dans son pied. |
Table | Tableau. Voir Style par défaut des cellules, Sélection, État de chargement et Hauteur plancher. |
TabNav | Navigation par onglets. |
FilterTabs | Onglets de filtre d'une liste. |
Breadcrumb | Fil d'Ariane. Voir Cible de dépôt d'un item. |
Calendar | Grille mois, semaine et jour. Purement présentationnel. Voir Calendrier. |
StatCard | Tuile 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. |
UserAvatar | Avatar, avec repli sur les initiales. |
RelativeDate | Date relative, « il y a 3 jours ». |
Chargement
| Composant | Rôle |
|---|---|
Loading | Indicateur simple. |
LoadingState | Export par défaut du module de squelettes. |
LoadingSpinner, PageLoading | Chargement global. |
Skeleton, TableSkeleton, CardSkeleton, FormSkeleton, ButtonSkeleton, ListSkeleton | Squelettes 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
| Composant | Rôle |
|---|---|
SeoFields | Zone d'édition SEO complète : champs, score en temps réel, aperçus Google, X, Facebook, LinkedIn, Pinterest et Slack. |
SeoScoreBadge | Pastille 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.
Menu déroulant
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' },
]}
/>| Prop | Rôle |
|---|---|
items | Liste des items. Une valeur fausse est ignorée, ce qui permet d'écrire can.delete && { ... } sans filtrer. Une liste vide ne rend rien. |
icon | Icône du déclencheur, MoreVerticalIcon par défaut. |
label | Libellé accessible du déclencheur, « Actions » par défaut. |
align | end par défaut, start pour aligner le panneau à gauche. |
size | sm par défaut, md pour un déclencheur plus généreux. |
disabled, className | Comme 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.
Menu contextuel
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)}
/>| Prop | Rô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. |
items | Même contrat que DropdownMenu, jusqu'aux valeurs fausses ignorées et à { separator: true }. Une liste vide ne rend rien. |
onClose | Appelé à 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"
/>| Prop | Défaut | Rô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. |
dayStartMinute | 480 | Première heure affichée en vue semaine et jour, en minutes depuis minuit. |
dayEndMinute | 1140 | Dernière heure affichée. |
stepMinutes | 30 | Durée couverte par une ligne de la grille horaire. |
weekStartsOn | 0 | 0 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. |
loading | false | Atténue la grille et pose aria-busy. |
toolbar | true | Masque 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.
padding | Rendu | Pour |
|---|---|---|
none | rien | La carte enveloppe, dont le contenu porte déjà sa gouttière : une Table, une liste dont les lignes se paient elles-mêmes. |
sm | p-4 | La carte dense : un encart dans une colonne étroite. |
md (défaut) | p-5 | La carte de contenu ordinaire, celle d'un formulaire. |
lg | p-6 | La 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.
| Prop | Rôle |
|---|---|
selectable | Active la colonne de sélection. |
selectedIds | Set des identifiants cochés. Un tableau est accepté. |
onSelectionChange(next) | Reçoit le nouveau Set. L'appelant le range où il veut. |
getRowId | Identifiant 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.
onRowClickgarde 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, etonRowClickn'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 unMaj+ 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 |
|---|---|
dropProps | Props étalées sur le <li> de l'item : onDragOver, onDragLeave, onDrop. |
highlighted | Marque 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.
| Situation | Rendu |
|---|---|
loading, data vide | Squelettes de lignes et de pagination. C'est le premier chargement. |
loading, data non vide | Les 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 |
|---|---|
height | Classe de hauteur, h-4 par défaut. |
width | Largeur CSS, 100% par défaut. |
className | Classes 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 :
| Situation | Rendu |
|---|---|
| Premier chargement | Le nombre de squelettes se déduit de la hauteur : ils remplissent le tableau au lieu de s'arrêter à cinq lignes. |
| Liste vide | Le message et sa description se centrent verticalement dans la hauteur libre. |
| Liste courte | Les 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.onCancelreçoit l'instantané initial en argument. - Enregistrer est
variant="primary", désactivé pendantsaving. Les libellés sont personnalisables parsaveLabeletsavingLabel. - 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é
initialpour 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 parrouter.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.