Composants
Ce que promet un composant zenit, quand descendre d’une couche, et les trois contrats — montage, overlays et tests. Les enregistrements, props et types de résultat de chaque composant se trouvent sur le site des composants.
Ce que « composant » signifie ici
Un composant zenit n’est pas un helper qui assemble des couleurs et des rayons d’angle. Un composant public regroupe généralement la structure de nœuds, l’état persistant, le routage des événements, la sémantique du focus et du clavier, les propriétés d’accessibilité, les tokens de thème, la durée de vie des overlays et une frontière de test que le harness peut localiser et relire. Tous sont exportés depuis ui.widgets.
Choisissez d’abord la bonne couche
ui.widgets.*État métier, textes, callbacks et placementui.hooks · ui.interaction · ui.select_headlessDessin, tokens, libellés a11y et frontières de compositionui.Node + ui.eventsHit-testing, clavier, focus, IME, a11y et contrat de testCatalogue
Chaque composant a sa propre page sur le site des composants : un enregistrement du vrai zenit Storybook.app, les props et types de résultat générés depuis les sources, et ce que l’enregistrement démontre. Le harness localise chaque story par son test_id, pilote un curseur virtuel ou la saisie, et enregistre directement le drawable Metal.
Mount, état et nettoyage
La plupart des composants visuels utilisent la forme builder Config.mount(scope, cx) : ui.widgets.Button(props) renvoie un builder et c’est mount qui construit réellement l’arbre. La valeur de retour est soit le *Node racine, soit une struct de résultat avec des handles comme wrapper, trigger, body, panel et state.
const EditorActions = struct {
document: *Document,
fn save(self: *EditorActions) void {
self.document.save();
}
};
const bindings = try cx.bindState(EditorActions, .{ .document = document });
const save = try ui.widgets.Button(.{
.label = "Save",
.variant = .primary,
.on_click = cx.on(EditorActions, bindings, EditorActions.save),
}).mount(scope, cx);
const name = try ui.widgets.Input(.{
.label_text = "Project name",
.placeholder = "Untitled",
.required = true,
.width = 320,
}).mount(scope, cx);
try form.appendChild(cx.allocator, name);
try form.appendChild(cx.allocator, save);Quelques composants utilisent la forme fonction et reçoivent props, scope et cx en un seul appel : mountScrollArea, mountGrid, ainsi que Select, ComboBox, TagsInput, NumberStepper, FileUpload et DataTable (ce sont des alias de fonctions mountX dans ui.widgets, appelés sous la forme ui.widgets.Select(props, scope, cx)).
// Function form: props, scope and cx in one call; returns a handle struct.
const area = try ui.widgets.mountScrollArea(.{ .height = 320 }, scope, cx);
try area.content.appendChild(cx.allocator, list);
try root.appendChild(cx.allocator, area.container);Button · Input*NodeModalModalResult{ overlay, dialog, body, portaled }TooltipTooltipResult{ wrapper, trigger, content }SelectSelectMount{ wrapper, trigger, panel, state, is_open }mountScrollAreaScrollAreaResult{ container, content, state }mountGridGridResult{ root, state }Ne traitez pas la config comme des props vivantes que vous pourriez modifier plus tard. Un composant lit sa config au montage pour construire nœuds et état — un signal.get() dans la config n’est lu qu’une fois, comme instantané. Les données qui continuent de changer passent par le state renvoyé, un Signal (comme .visible(sig) de Modal) ou les méthodes publiques du composant ; ne conservez jamais de pointeur vers une config temporaire d’une frame à l’autre. L’état interne d’un composant vit sur le Scope enfant créé par mount et est libéré lors du dispose du Scope parent.
Pourquoi les overlays doivent être des composants
Tooltip, Popover, Menu, Modal, Sheet et le Notifier intégré à l’app enregistrent tous une couche auprès de l’OverlayStack. Les couches avec barrier (Modal / Sheet) sont rattachées au portal de la fenêtre (WindowOverlayPortal sous cx.root) afin d’échapper au clipping overflow de tout ancêtre. Les valeurs z proviennent de tiers sémantiques — overlay 100, dialog 1000, toast 2000 (là où vit le Notifier), tooltip 3000, l’overlay devtools étant fixé à 32000 — et un overlay ouvert depuis un autre passe toujours au-dessus de son hôte. Un positionnement absolu et un grand z-index sur un box ne vous donnent rien de tout cela.
const visible = try scope.createSignal(bool, false);
const modal = try ui.widgets.Modal(.{
.title = "Delete document?",
.width = 420,
}).visible(visible).mount(scope, cx);
try modal.body.appendChild(cx.allocator, confirm_content);
// modal.portaled == true: the barrier already lives under the window-level
// portal. Do not append modal.overlay to the current tree.
// Open it from any handler:
visible.set(true);- ✓
Les clics internes restent dans le dialog / panel et ne déclenchent jamais d’outside-dismiss ; un clic sur la barrier le ferme selon
close_on_overlay. - ✓
Escape ne ferme que l’overlay du dessus ; les couches imbriquées se dépilent une à une.
- ✓
Focus : un Modal ouvert piège le focus et se focalise automatiquement, puis restaure le focus à sa position d’origine à la fermeture.
- ✓
Le dispose du Scope détache activement le sous-arbre de la barrier du portal : changer de page ne laisse jamais de couche de hit invisible.
- ✓
Les transitions de sortie gardent l’overlay visible jusqu’à la fin de l’animation, puis le suspendent — sans sauter directement à l’état final.
Pour les notifications globales non bloquantes, utilisez ui.widgets.Notifier (qui remplace l’ancien ToastManager) : Notifier.init(scope, cx, .{}) enregistre une couche sur le tier toast et, si le portal de la fenêtre existe, y monte son conteneur plein écran (si portaled vaut false, ajoutez vous-même container à votre racine). Ensuite show(.{ .tone = .success, .title = "Saved" }) renvoie un id, update transforme une carte sur place et dismiss la retire. Fermetures, expirations et actions de boutons reviennent à l’app sous forme d’événements Listener ; l’historique, le mode ne pas déranger et le reste de la logique de centre de notifications relèvent de l’app.
Le showcase est aussi une suite de régression
e2e/storybook.test.ts passe à chaque story via nav.<key>, collecte le texte du sous-arbre et vérifie que chaque valeur de données attendue est présente, puis prend une capture ; les composants interactifs vérifient aussi l’état, la géométrie ou les pixels après interaction. Les enregistrements du site des composants réutilisent la même app et les mêmes ids sémantiques : la démo et le test ne divergent jamais en deux implémentations.