Componentes
Qué promete un componente de zenit, cuándo bajar de capa y los tres contratos: montaje, overlays y pruebas. Las grabaciones, props y tipos de resultado de cada componente están en el sitio de componentes.
Qué significa aquí “componente”
Un componente de zenit no es un helper que junta colores y radios de esquina. Un componente público suele empaquetar estructura de nodos, estado persistente, enrutamiento de eventos, semántica de foco y teclado, propiedades de accesibilidad, tokens de tema, ciclo de vida de overlays y un límite de prueba que el harness puede localizar y releer. Todos se exportan desde ui.widgets.
Elige primero la capa correcta
ui.widgets.*Estado de negocio, textos, callbacks y ubicaciónui.hooks · ui.interaction · ui.select_headlessDibujo, tokens, etiquetas a11y y límites de composiciónui.Node + ui.eventsHit-testing, teclado, foco, IME, a11y y el contrato de pruebasCatálogo
Cada componente tiene su propia página en el sitio de componentes: una grabación del zenit Storybook.app real, props y tipos de resultado generados desde el código fuente, y lo que la grabación demuestra. El harness localiza cada story por test_id, maneja un cursor virtual o la entrada y graba directamente el drawable de Metal.
Mount, estado y limpieza
La mayoría de los componentes visuales usan la forma builder Config.mount(scope, cx): ui.widgets.Button(props) devuelve un builder y mount es quien construye el árbol. El valor de retorno es el *Node raíz o un struct de resultado con handles como wrapper, trigger, body, panel y 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);Unos pocos componentes usan la forma de función y reciben props, scope y cx en una sola llamada: mountScrollArea, mountGrid, además de Select, ComboBox, TagsInput, NumberStepper, FileUpload y DataTable (son alias de funciones mountX en ui.widgets y se llaman como 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 }No trates la config como props vivas en las que puedas escribir después. Un componente lee su config al montarse para construir nodos y estado: un signal.get() dentro de la config se lee una sola vez, como instantánea. Los datos que siguen cambiando entran a través del state devuelto, un Signal (como .visible(sig) de Modal) o los métodos públicos del componente; nunca guardes un puntero a una config temporal entre frames. El estado interno de un componente vive en el Scope hijo que crea mount y se libera cuando se hace dispose del Scope padre.
Por qué los overlays deben ser componentes
Tooltip, Popover, Menu, Modal, Sheet y el Notifier integrado en la app registran una capa en el OverlayStack. Las capas con barrier (Modal / Sheet) se reubican en el portal a nivel de ventana (WindowOverlayPortal bajo cx.root) para escapar del recorte por overflow de cualquier ancestro. Los valores z salen de tiers semánticos: overlay 100, dialog 1000, toast 2000 (donde vive el Notifier), tooltip 3000, con el overlay de devtools fijo en 32000. Un overlay abierto desde otro siempre queda por encima de su anfitrión. Posicionamiento absoluto y un z-index alto en un box no te dan nada de esto.
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);- ✓
Los clics internos se quedan dentro del dialog / panel y nunca disparan outside-dismiss; hacer clic en el barrier lo cierra según
close_on_overlay. - ✓
Escape cierra solo el overlay superior; las capas anidadas se deshacen de una en una.
- ✓
Foco: un Modal abierto atrapa el foco y lo enfoca automáticamente; al cerrarse, lo devuelve a donde estaba.
- ✓
El dispose del Scope separa activamente el subárbol del barrier del portal, así que cambiar de página nunca deja una capa de impacto invisible.
- ✓
Las transiciones de salida mantienen el overlay visible hasta que la animación termina y luego lo suspenden, sin saltar al estado final.
Para notificaciones globales no bloqueantes usa ui.widgets.Notifier (reemplaza al antiguo ToastManager): Notifier.init(scope, cx, .{}) registra una capa en el tier toast y, si existe el portal de la ventana, monta ahí su contenedor de ventana completa (si portaled es false, añade tú mismo container a tu raíz). Luego show(.{ .tone = .success, .title = "Saved" }) devuelve un id, update transforma una tarjeta en su sitio y dismiss la retira. Los cierres, vencimientos y acciones de botones vuelven a la app como eventos Listener; el historial, el modo no molestar y demás lógica del centro de notificaciones son cosa de la app.
El showcase también es una suite de regresión
e2e/storybook.test.ts cambia a cada story mediante nav.<key>, recoge el texto del subárbol y comprueba que aparece cada valor de datos esperado, y luego hace una captura; los componentes interactivos también comprueban estado, geometría o píxeles tras interactuar. Las grabaciones del sitio de componentes reutilizan la misma app y los mismos ids semánticos, así que la demo y la prueba nunca se bifurcan en dos implementaciones.