Composants

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

@fraza/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 '@fraza/core/shared/components';

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

Le barrel réexporte aussi l'éditeur de blocs et le sélecteur d'icônes, qui pèsent lourd. En build de production, le bundler ne garde que les composants importés : le core déclare ses composants sans effet de bord. Voir Effets de bord et barrels. Dans le core lui-même, un fichier rendu sur une page publique n'utilise pas le barrel et importe chaque composant par son fichier.

Actions

ComposantRôle
ButtonBouton. Onze variantes, dont le lien d'action link, trois tailles de 30, 36 et 42 px, états chargement et désactivé. Rend un <Link> quand href est fourni. Voir Boutons.
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 sans action par défaut. Déclencheur icône de 30 px, ou bouton « Actions » avec triggerLabel. 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. suffix ajoute une unité à droite, « $ CA » par exemple. Voir États des champs.
TextareaChamp multiligne.
Select<select> natif, flèche du navigateur remplacée par un chevron de 14 px. À 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. Piste de 38 × 22 px, accent quand activé, line-strong sinon, bouton blanc de 16 px. Par défaut, une ligne de réglage : libellé à gauche, piste à droite. inline pose la piste puis le libellé à 8 px ; inset={false} retire la gouttière horizontale de la ligne.
CheckboxCase à cocher de 16 px, en accent quand cochée, fond field et bordure fg-muted sinon, avec un état indéterminé pour une sélection partielle. Désactivée, elle passe au gris, cochée ou non. 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 et grille d'icônes, parmi celles de la prop obligatoire icons ({ Nom: Composant }). Retourne le nom de l'icône, une chaîne comme 'Call02Icon', que le rendu résout avec la même liste. Voir Sélecteur d'icône d'un bloc.
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. Titre de 15 px graisse 600 posé au-dessus du contenu, sans filet ; pied séparé par un filet line-soft. Deux familles de variant : default, la carte neutre, et success, info, warning ou danger, qui prennent le fond -soft, la bordure -line et le texte -ink du statut. Voir Gouttière de Card.
ModalFenêtre avec un voile noir à 42 %, sans flou, rayon 12 px, ombre. Voir Fenêtre.
TableTableau. Voir Style par défaut des cellules, Sélection, État de chargement, Hauteur plancher et Pagination.
PaginationLa pagination du Table, seule, pour une liste qui ne passe pas par le tableau. Voir Pagination.
TabNavNavigation par onglets soulignés, 13 px, 20 px entre deux onglets. Onglet actif : trait de 2 px en primary, texte en graisse 600. Un onglet peut porter un compteur, count, en pastille ronde de 11 px sur line-soft.
FilterTabsSélecteur segmenté pour filtrer une liste : piste line-soft (surface-muted en sombre) au rayon 8 px, segments de 28 px au rayon 6 px en 12,5 px graisse 500, segment actif en surface avec une ombre très légère.
BreadcrumbFil d'Ariane sans boîte, 13 px, séparé par des « / ». Éléments en fg-muted, dernier en fg graisse 500. Le survol fonce le texte, sans fond. 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. Voir Carte de statistique.
Badge, StatusBadge, TypeBadgePastilles d'état. Voir Pastilles.
UserAvatarAvatar rond, avec repli sur les initiales en graisse 600 sur fond line (line-strong en sombre). Tailles sm 28 px, topbar 30 px, md 36 px (défaut), lg 64 px, xl 96 px.
FrazaLogoLogo Fraza en SVG, dans la couleur du texte. Voir FrazaLogo.
RelativeDateDate relative, « il y a 3 jours ».

Boutons

VarianteRendu
primary (défaut)Fond primary, l'encre par défaut, texte primary-fg. Survol primary-hover.
secondaryFond control, blanc en clair, bordure control-line, texte fg. Survol control-hover.
ghostSans fond, texte fg-secondary. Survol : fond line-soft (control-hover en sombre), texte fg.
linkLien d'action, « Voir tout → » : texte en accent, ni hauteur ni gouttière, souligné au survol seulement.
dangerFond danger-solid, le rouge franc #dc2626 dans les deux modes, texte danger-fg blanc. Survol : le fond assombri de 15 %.
dangerSoftLe danger discret : fond surface, bordure danger-line, texte danger-hover (danger en sombre). Survol danger-soft.
TailleHauteurPadding horizontalTexteRayonIcône
sm30 px12 px12,5 px8 px14 px
md (défaut)36 px14 px13 px8 px16 px
lg42 px18 px14 px8 px16 px

Six variantes, pas une de plus : il n'existe pas de bouton de succès ni d'avertissement. Une variante inconnue retombe sur primary.

Le rayon est rounded-control à toutes les tailles : il suit l'option radius du thème. Un bouton icône seule est carré, de la même hauteur, et prend un aria-label. Le focus clavier pose un anneau de 2 px en accent, décalé de 2 px.

Désactivé, l'apparence dépend de la famille. Un bouton plein prend un fond line-soft et un texte fg-subtle. Un bouton bordé garde son fond, sa bordure pâlit en line et son texte passe en fg-subtle. Un bouton fantôme n'a ni fond ni bordure, et son texte pâlit encore.

En chargement, le bouton refuse le clic et l'envoi du formulaire, mais garde les couleurs de sa variante : seul un vrai disabled grise. L'indicateur prend la place de l'icône. Sans icône, il se pose au centre du libellé rendu transparent, toujours lu par les lecteurs d'écran : la largeur du bouton ne change pas.

<Button variant="link" href="/admin/billing/invoices">Voir tout →</Button>

États des champs

Input, Select, Textarea, TagInput, Combobox, MarkdownEditor et les champs de recherche des menus suivent une seule recette.

ÉtatRendu
ReposFond field, bordure control-line, rayon rounded-control, hauteur 36 px (h-9), texte de 13 px, gouttière de 12 px.
FocusBordure accent, halo de 3 px en accent à 18 % (ring-[3px] ring-accent/18), 22 % en sombre.
ErreurBordure danger, halo danger à 12 %, focus compris, message en danger sous le champ.
DésactivéFond canvas, bordure line, texte fg-subtle. Le label et l'aide grisent avec lui.
Lecture seuleMême boîte grise que désactivé, valeur lisible. Input et Textarea seulement.
TailleHauteurGouttièreTexte
sm30 px10 px12,5 px
md (défaut)36 px12 px13 px
lg42 px14 px20 px graisse 600, pour un titre éditable

Le label est en 12,5 px graisse 500, couleur fg, le texte d'aide en 12 px fg-muted.

Unité d'un champ

suffix pose une unité à droite du champ, dans une zone canvas séparée par un filet line, en 13 px fg-secondary. La boîte, la bordure et le focus passent sur l'enveloppe : champ et unité s'allument ensemble.

<Input id="price" label="Prix" type="number" value={price} onChange={setPrice} suffix="$ CA" />

Pastilles

Badge rend une pastille ronde, sans bordure : fond -soft du statut, texte en encre -ink, 12 px graisse 500, en IBM Plex Sans, et un point de 6 px de la couleur du statut. L'encre est la teinte de la charte, « Payée » en #166534 sur #f0fdf4 : plus foncée que le point en clair, elle tient le contraste AA. Les variantes info et primary prennent l'accent : fond accent-soft, texte et point accent. La variante default est neutre, « Brouillon » : fond surface-muted (line en sombre), texte fg-secondary.

VarianteRendu
success, warning, dangerFond -soft, texte -ink, point de la couleur pleine.
info, primaryFond accent-soft, texte et point accent.
defaultNeutre, point fg-subtle.
cancelledNeutre, texte fg-muted barré, sans point : « Annulée ».
purple, pink, orangeAcceptées pour les appels existants, rendues en neutre : un type n'est pas un état.

Tailles : sm 20 px de haut (la pastille d'une ligne de tableau, défaut), md 22 px, lg 26 px. color reçoit une couleur libre, celle d'un rôle choisie par l'utilisateur : elle ne peint que le point, la pastille reste neutre et lisible.

dot vaut true par défaut. Passer dot={false} pour une étiquette qui n'est pas un état : un type, une catégorie. TypeBadge le fait déjà, StatusBadge garde le point.

<Badge variant="success">Payée</Badge>
<Badge variant="default" dot={false}>Service</Badge>

Le logo Fraza en SVG inline, dessiné en currentColor : sa couleur vient du texte du parent. Il est noir ou blanc selon le mode, jamais à l'accent. Composant pur, sans 'use client' : il se rend dans un Server Component comme dans un composant client.

import { FrazaLogo } from '@fraza/core/shared/components';

<FrazaLogo variant="full" className="h-[14px] w-auto text-fg-subtle" />
PropDéfautRôle
variant'full''full' : icône et mot. 'text' : le mot seul. 'icon' : l'icône seule, carrée.
classNameTaille, donnée par la hauteur (h-[26px] w-auto), et couleur de texte.
title'Fraza'Nom accessible. title={null} masque le logo aux lecteurs d'écran, quand il est décoratif ou posé à côté d'un « Fraza » visible.

L'admin l'utilise en tête de la carte des pages d'authentification, à 26 px, quand le projet n'a pas de FRAZA_LOGO_URL. Dans le core, un fichier rendu sur une page publique l'importe par son fichier, shared/components/FrazaLogo.js, plutôt que par le barrel.

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. Les squelettes pulsent (animate-pulse), sans dégradé, dans des conteneurs surface, et un squelette de bouton a la hauteur du bouton qu'il remplace. Une barre est en surface-muted, et en line en sombre, où surface-muted se confondrait avec la carte. Les largeurs des barres sont stables d'un rendu à l'autre, serveur compris.

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 useFrazaConfig(). 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: ExternalLinkIcon, 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, MoreHorizontalIcon par défaut.
labelLibellé accessible du déclencheur, « Actions » par défaut.
alignend par défaut, start pour aligner le panneau à gauche.
triggerLabelLibellé visible. Le déclencheur devient un bouton secondaire compact de 34 px, avec un chevron, comme le bouton « Actions » de la charte.
sizesm par défaut. sm et md donnent tous deux le bouton fantôme de 30 × 30 px : un tableau n'a qu'une taille d'icône d'action. md reste accepté pour les appels existants.
disabled, classNameComme Button.

Chaque item accepte label, icon, onClick, href, variant, disabled et description. variant vaut danger 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.

Recette du panneau, commune à DropdownMenu, ContextMenu, SplitButton et au menu utilisateur de l'admin : fond surface, bordure line (line-strong en sombre), coins de 12 px (rounded-card), 5 px de marge intérieure, ombre 0 8px 24px à 12 %, plus dense en sombre. Les items font 32 px, coins de 6 px (rounded-item), gouttière de 10 px, libellé de 13 px graisse 400 et icône de 16 px. Le libellé passe en graisse 500 quand il chapeaute une description en 12 px fg-muted. Au focus, l'item prend le fond surface-muted. L'item danger a son libellé en danger-hover (danger en sombre) et le fond danger-soft au focus. Le séparateur est un filet line-soft. L'action destructrice se place à la fin, après un séparateur. Le menu utilisateur de l'admin finit par « Se déconnecter », dans la couleur des autres items : la déconnexion n'est pas une action destructrice.

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 '@fraza/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.

Carte de statistique

StatCard suit la carte de la charte : un libellé discret en 12,5 px fg-muted, le chiffre en 24 px graisse 700, interlettrage -0,03 em, et un lien optionnel en accent, « Voir le détail → ». Gouttière de 18 × 16 px. Pas de tuile d'icône colorée.

<StatCard title="En retard" value="3" tone="danger" href="/admin/billing/invoices?status=overdue" />
PropDéfautRôle
titleLibellé.
valueChiffre affiché.
tone'default'Couleur du chiffre : 'default' (encre), 'danger', 'warning', 'success'. La charte ne colore le chiffre que pour un état qui demande l'attention, « En retard » en rouge par exemple.
hrefAjoute le lien en accent sous le chiffre.
linkLabel'Voir le détail'Libellé du lien, suivi d'une flèche.
change, changeTypeVariation sous le chiffre, en 12 px : 'increase' en vert avec ↑, 'decrease' en rouge avec ↓.
loadingfalseSquelette à la place du chiffre.

icon, color et bgColor restent acceptés pour ne casser aucun appelant, mais ne sont plus rendus.

Fenêtre

Modal suit la recette « Menus et fenêtres » de la charte. Voile noir à 42 %, sans flou. Fenêtre surface, bordure line (line-strong en sombre), coins de 12 px, marge intérieure de 20 px, ombre 0 16px 48px à 20 %, hauteur limitée à 90 % de l'écran. Le titre est en 16 px graisse 700, le texte en 13 px fg-secondary. Les boutons s'alignent à droite : Annuler en secondaire, puis l'action.

Au repos, ni l'en-tête ni le pied ne portent de filet. Quand le corps défile, un filet line-soft (line en sombre) apparaît sous le titre dès que le contenu a défilé, et au-dessus des boutons tant qu'il reste du contenu en dessous. Un corps coupé net se lit donc comme un contenu qui continue.

Dans le pied, les boutons de taille md passent en 34 px sur 12 px de gouttière : la version compacte de la charte pour les fenêtres. Un pied fourni par l'appelant suit la même règle.

PropDéfautRôle
isOpentrueOuverture.
onCloseAppelé par la croix, Échap et le clic sur le voile.
titleTitre de la fenêtre.
size'lg'xs (300 px, la fenêtre de confirmation), sm, md, lg, xl, full.
footerPied libre.
closabletruefalse ignore Échap, le voile et retire la croix.
showCloseselon sizeCroix de fermeture dans l'en-tête, aria-label="Fermer". Masquée par défaut en xs, où « Annuler » suffit.
onSubmitEnveloppe le contenu dans un <form> et pose le pied standard.
submitLabel, cancelLabel'Enregistrer', 'Annuler'Libellés du pied standard.
submitVariant'primary'Variante du bouton d'envoi, 'danger' pour une suppression.
loading, disabledfalseÉtat du bouton d'envoi.

Pagination

La barre de pagination du Table apparaît au-delà de vingt éléments. Elle suit la charte : flèches précédente et suivante en boutons bordés de 30 px, numéros de page en cases de 30 px aux coins de 8 px, en 12,5 px graisse 500. La page courante est en fond primary, les autres en fg-secondary. Sous sm, les numéros se replient et les flèches restent.

Pagination est la même, seule, pour une liste qui ne passe pas par le tableau, une grille de médias par exemple. Rien ne s'affiche s'il n'y a qu'une page.

import { Pagination } from '@fraza/core/shared/components';

<Pagination currentPage={page} totalPages={pages} onPageChange={setPage} disabled={loading} />

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-[18px] 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é discret de 12,5 px, 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-[13px] text-fg-secondary, en IBM Plex Sans. C'est le style canonique d'une cellule. La chasse fixe (font-code) ne sert qu'aux données qu'on compare ou qu'on recopie, numéro de facture, code, date technique : elle se pose dans un render. Un montant et une date relative restent en sans.

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, é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.

L'en-tête prend en clair le fond surface-hover, et aucun en sombre. Quand le tableau est le premier enfant de sa carte, les cellules d'angle de l'en-tête suivent les coins de la carte. Sous un en-tête de carte, l'en-tête du tableau reste droit.

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, accent-soft, 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.

L'état vide suit la charte : icône de 17 px dans un cercle gris de 36 px, titre court en 13 px graisse 600, phrase d'explication en 12 px fg-muted. L'icône vaut InboxIcon par défaut, emptyIcon la remplace.

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, tant qu'il n'a pas de lignes à montrer : pendant le premier chargement et quand la liste est vide. Dès que des lignes s'affichent, la hauteur suit le contenu et la carte se termine sous la dernière ligne. Une liste courte ne traîne pas une grande zone vide sous elle.

<Table minHeight={TABLE_MIN_HEIGHT} columns={columns} data={nodes} loading={loading} />
SituationRendu
Premier chargementLa hauteur tient. Le nombre de squelettes s'en déduit : ils remplissent le tableau au lieu de s'arrêter à cinq lignes.
Liste videLa hauteur tient. Le message et sa description s'y centrent verticalement.
Lignes affichéesPas de plancher : la carte suit le nombre de lignes. Un rechargement garde les lignes en place, donc la hauteur ne retombe pas au plancher.

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 320 px, la hauteur des listes de l'admin. L'utiliser plutôt qu'un nombre écrit sur place : un chargement ou une liste vide gardent ainsi la même allure d'une page à 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