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
| Composant | Rôle |
|---|---|
Button | Bouton. 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. |
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 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. |
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. suffix ajoute une unité à droite, « $ CA » par exemple. Voir États des champs. |
Textarea | Champ 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. |
Switch | Interrupteur 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. |
Checkbox | Case à 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. |
TagInput | Saisie de plusieurs valeurs sous forme d'étiquettes. |
ColorPicker | Sélecteur de couleur. |
IconPicker | Ouvre 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. |
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. 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. |
Modal | Fenêtre avec un voile noir à 42 %, sans flou, rayon 12 px, ombre. Voir Fenêtre. |
Table | Tableau. Voir Style par défaut des cellules, Sélection, État de chargement, Hauteur plancher et Pagination. |
Pagination | La pagination du Table, seule, pour une liste qui ne passe pas par le tableau. Voir Pagination. |
TabNav | Navigation 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. |
FilterTabs | Sé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. |
Breadcrumb | Fil 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. |
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. Voir Carte de statistique. |
Badge, StatusBadge, TypeBadge | Pastilles d'état. Voir Pastilles. |
UserAvatar | Avatar 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. |
FrazaLogo | Logo Fraza en SVG, dans la couleur du texte. Voir FrazaLogo. |
RelativeDate | Date relative, « il y a 3 jours ». |
Boutons
| Variante | Rendu |
|---|---|
primary (défaut) | Fond primary, l'encre par défaut, texte primary-fg. Survol primary-hover. |
secondary | Fond control, blanc en clair, bordure control-line, texte fg. Survol control-hover. |
ghost | Sans fond, texte fg-secondary. Survol : fond line-soft (control-hover en sombre), texte fg. |
link | Lien d'action, « Voir tout → » : texte en accent, ni hauteur ni gouttière, souligné au survol seulement. |
danger | Fond danger-solid, le rouge franc #dc2626 dans les deux modes, texte danger-fg blanc. Survol : le fond assombri de 15 %. |
dangerSoft | Le danger discret : fond surface, bordure danger-line, texte danger-hover (danger en sombre). Survol danger-soft. |
| Taille | Hauteur | Padding horizontal | Texte | Rayon | Icône |
|---|---|---|---|---|---|
sm | 30 px | 12 px | 12,5 px | 8 px | 14 px |
md (défaut) | 36 px | 14 px | 13 px | 8 px | 16 px |
lg | 42 px | 18 px | 14 px | 8 px | 16 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.
| État | Rendu |
|---|---|
| Repos | Fond field, bordure control-line, rayon rounded-control, hauteur 36 px (h-9), texte de 13 px, gouttière de 12 px. |
| Focus | Bordure accent, halo de 3 px en accent à 18 % (ring-[3px] ring-accent/18), 22 % en sombre. |
| Erreur | Bordure 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 seule | Même boîte grise que désactivé, valeur lisible. Input et Textarea seulement. |
| Taille | Hauteur | Gouttière | Texte |
|---|---|---|---|
sm | 30 px | 10 px | 12,5 px |
md (défaut) | 36 px | 12 px | 13 px |
lg | 42 px | 14 px | 20 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.
| Variante | Rendu |
|---|---|
success, warning, danger | Fond -soft, texte -ink, point de la couleur pleine. |
info, primary | Fond accent-soft, texte et point accent. |
default | Neutre, point fg-subtle. |
cancelled | Neutre, texte fg-muted barré, sans point : « Annulée ». |
purple, pink, orange | Accepté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>FrazaLogo
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" />| Prop | Défaut | Rôle |
|---|---|---|
variant | 'full' | 'full' : icône et mot. 'text' : le mot seul. 'icon' : l'icône seule, carrée. |
className | Taille, 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
| 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. 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
| 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 useFrazaConfig(). 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: ExternalLinkIcon, 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, MoreHorizontalIcon par défaut. |
label | Libellé accessible du déclencheur, « Actions » par défaut. |
align | end par défaut, start pour aligner le panneau à gauche. |
triggerLabel | Libellé visible. Le déclencheur devient un bouton secondaire compact de 34 px, avec un chevron, comme le bouton « Actions » de la charte. |
size | sm 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, className | Comme 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.
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 '@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"
/>| 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.
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" />| Prop | Défaut | Rôle |
|---|---|---|
title | Libellé. | |
value | Chiffre 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. |
href | Ajoute le lien en accent sous le chiffre. | |
linkLabel | 'Voir le détail' | Libellé du lien, suivi d'une flèche. |
change, changeType | Variation sous le chiffre, en 12 px : 'increase' en vert avec ↑, 'decrease' en rouge avec ↓. | |
loading | false | Squelette à 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.
| Prop | Défaut | Rôle |
|---|---|---|
isOpen | true | Ouverture. |
onClose | Appelé par la croix, Échap et le clic sur le voile. | |
title | Titre de la fenêtre. | |
size | 'lg' | xs (300 px, la fenêtre de confirmation), sm, md, lg, xl, full. |
footer | Pied libre. | |
closable | true | false ignore Échap, le voile et retire la croix. |
showClose | selon size | Croix de fermeture dans l'en-tête, aria-label="Fermer". Masquée par défaut en xs, où « Annuler » suffit. |
onSubmit | Enveloppe 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, disabled | false | É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.
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-[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.
| 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,
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 |
|---|---|
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. |
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 |
|---|---|
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, 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} />| Situation | Rendu |
|---|---|
| Premier chargement | La hauteur tient. Le nombre de squelettes s'en déduit : ils remplissent le tableau au lieu de s'arrêter à cinq lignes. |
| Liste vide | La hauteur tient. Le message et sa description s'y centrent verticalement. |
| Lignes affichées | Pas 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.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.