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 — y 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.