docs/guide/components
Conceptos clave · Components

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.

10 min de lectura

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.

51Stories en el sitio de componentes
6Categorías de uso (más Labs)
1:1Story a test_id de E2E
ANATOMY
mount crea primero un Scope hijo; todo lo que posee se libera junto con el Scope padre.

Elige primero la capa correcta

NecesidadCapaSigues a cargo de
Controles de producto estándarui.widgets.*Estado de negocio, textos, callbacks y ubicación
Visual propio, interacción probadaui.hooks · ui.interaction · ui.select_headlessDibujo, tokens, etiquetas a11y y límites de composición
Un nuevo patrón de interacciónui.Node + ui.eventsHit-testing, teclado, foco, IME, a11y y el contrato de pruebas

Catá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.

CategoríaCantidadEjemplos
Acciones7Button · Checkbox · Switch · RadioGroup · Slider …
Visualización17Badge · Tag · Chip · Card · Alert …
Navegación4Tabs · Accordion · Menu · DropdownMenu
Superposiciones5Notifier · Tooltip · Popover · Modal · Sheet
La story de Button recorre la matriz variant × size y luego hace hover y press sobre Primary. Detalles en /components/button.

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.

mount_form.zig
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)).

scroll_area.zig
// 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);
ComponenteDevuelve
Button · Input*Node
ModalModalResult{ 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.

modal.zig
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);
OVERLAY PORTAL
Dónde llamas a mount no decide dónde se dibuja el overlay: el barrier va al portal, el tier decide la z y Escape recorre la pila desde arriba hasta la primera capa que se pueda cerrar.
  • ✓

    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 harness abre un dialog centrado y verifica que los clics internos no lo cierran. Modal
Un cursor virtual pasa sobre el trigger; la burbuja entra en el tier tooltip sin tocar el layout. Tooltip

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.

ControlQué detecta
Relectura de texto / estadoPaneles vacíos, callbacks que nunca escriben de vuelta, valores de filtro / página erróneos
Aserciones de geometríaPadding boxes, centrado en el portal, alineación de Sheet al borde, desplazamientos de arrastre
Aserciones de píxelesCapas GPU en blanco, regresiones de blend, emoji en escala de grises, orden z incorrecto
GrabacionesTiempos de hover, press, cursor, IME, scroll y animaciones de entrada
zenit · Doble licenciaGratis para proyectos de código abierto bajo GPL-3.0-only; los productos cerrados o comerciales necesitan una licencia comercial.Contacta con el autor: zongyi.xzy#gmail.com (cambia # por @)zenit 5f9add5+wip 2026-09-30