v0.1.0-alpha
官网Home
docs/guide/components
核心概念 · ComponentsCore concepts · Components

内置组件Components

理解一个 zenit 组件承诺了什么、何时下降抽象层,以及挂载、浮层和测试三份契约。逐个组件的录像、props 与结果类型在组件站。What a zenit component promises, when to drop down a layer, and the three contracts — mounting, overlays and testing. Per-component recordings, props and result types live on the component site.

预计阅读 10 分钟10 min read

这里的「组件」包含什么What “component” means here

zenit 组件不是把颜色和圆角拼起来的 helper。一个公开组件通常同时封装节点结构、持久状态、事件路由、焦点与键盘语义、无障碍属性、主题 token、浮层生命周期,以及可以被 Harness 定位和回读的测试边界。它们都从 ui.widgets 导出。A zenit component isn’t a helper that glues colors and corner radii together. A public component typically packages node structure, persistent state, event routing, focus and keyboard semantics, accessibility properties, theme tokens, overlay lifetime, and a test boundary the harness can locate and read back. They are all exported from ui.widgets.

51组件站收录的 StoryStories on the component site
6使用场景分类(另有 Labs)Use-case categories (plus Labs)
1:1Story 与 E2E test_idStory to E2E test_id
ANATOMY
mount 内部先创建子 Scope;它持有的一切会随父 Scope 一起释放。mount first creates a child Scope; everything it owns is released together with the parent Scope.

先选择正确的抽象层Choose the right layer first

需求Need使用层Layer你仍然负责You still own
标准产品控件Standard product controlsui.widgets.*业务状态、文案、回调与布局位置Business state, copy, callbacks and placement
自定义视觉,保留成熟交互Custom visuals, proven interactionui.hooks · ui.interaction · ui.select_headless绘制、token、a11y label 与组合边界Drawing, tokens, a11y labels and composition boundaries
全新交互模式A new interaction patternui.Node + ui.events命中、键盘、焦点、IME、a11y 和测试契约Hit-testing, keyboard, focus, IME, a11y and the test contract

组件目录Catalog

每个组件在组件站都有独立页面:真实 zenit Storybook.app 的录像、从源码生成的 props 与结果类型,以及录像正在证明的行为。录像由 Harness 按 test_id 定位、驱动虚拟鼠标或输入,并直接录制 Metal drawable。Every component has its own page on the component site: a recording of the real zenit Storybook.app, props and result types generated from source, and what the recording proves. The harness locates each story by test_id, drives a virtual cursor or input, and records the Metal drawable directly.

分类Category数量Count代表组件Examples
操作与选择Actions7Button · Checkbox · Switch · RadioGroup · Slider …
输入与表单Inputs10Input · Textarea · Select · Input · Select · Button · ComboBox …
数据展示Display17Badge · Tag · Chip · Card · Alert …
导航Navigation4Tabs · Accordion · Menu · DropdownMenu
浮层与反馈Overlays5Notifier · Tooltip · Popover · Modal · Sheet
布局与大数据Layout8GlassBox · Divider · HStack / VStack · ui.box · VirtualList …
实验与能力验证Labs13glasslab · glassislands · glassmotion · glasschrome · canvasevents …
Button Story:扫过 variant × size 矩阵,再在 Primary 上展示 hover 与 press。完整说明见 /components/button。The Button story sweeps the variant × size matrix, then hovers and presses Primary. Details at /components/button.

Mount、状态与清理Mount, state & cleanup

多数可视组件采用构造器形式 Config.mount(scope, cx):ui.widgets.Button(props) 返回一个 builder,mount 才真正建树。返回值可能是根 *Node,也可能是包含 wrapper、trigger、body、panel、state 等句柄的结果结构。Most visual components use the builder form Config.mount(scope, cx): ui.widgets.Button(props) returns a builder and mount actually builds the tree. The return value is either the root *Node or a result struct with handles such as wrapper, trigger, body, panel and 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);

少数组件是函数形式,props、scope 与 cx 一次传入:mountScrollArea、mountGrid,以及 Select、ComboBox、TagsInput、NumberStepper、FileUpload、DataTable(它们在 ui.widgets 中是 mountX 函数的别名,写作 ui.widgets.Select(props, scope, cx))。A few components use the function form, taking props, scope and cx in one call: mountScrollArea, mountGrid, plus Select, ComboBox, TagsInput, NumberStepper, FileUpload and DataTable (these are aliases of mountX functions in ui.widgets, called as 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);
组件Component返回Returns
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 }

不要把 config 当成之后还能写入的 live props。组件在 mount 时读取 config 建立节点和状态——在 config 里写 signal.get() 只会读到一次快照。持续变化的数据要通过返回的 state、Signal(例如 Modal 的 .visible(sig))或组件公开的方法进入,也不要跨帧保存临时 config 的指针。组件内部状态挂在 mount 创建的子 Scope 上,父 Scope dispose 时一起释放。Don’t treat config as live props you can write to later. A component reads its config at mount time to build nodes and state — a signal.get() inside config is read once, as a snapshot. Data that keeps changing flows in through the returned state, a Signal (such as Modal’s .visible(sig)) or the component’s public methods; never keep a pointer to a temporary config across frames. A component’s internal state lives on the child Scope that mount creates and is released when the parent Scope is disposed.

浮层为什么必须用组件Why overlays must be components

Tooltip、Popover、Menu、Modal、Sheet 以及应用内提醒 Notifier 都通过 OverlayStack 注册一层浮层。带 barrier 的层(Modal / Sheet)会被挂到窗口级 portal(cx.root 下的 WindowOverlayPortal),从而逃出祖先的 overflow 裁剪。层的 z 值按语义 tier 自动分配:overlay 100、dialog 1000、toast 2000(Notifier 所在层)、tooltip 3000,devtools overlay 固定 32000;从浮层里再打开的浮层总是压过宿主层。给一个 box 设 absolute 和高 z-index 得不到这些行为。Tooltip, Popover, Menu, Modal, Sheet and the in-app Notifier all register a layer with the OverlayStack. Layers with a barrier (Modal / Sheet) are re-parented to the window-level portal (WindowOverlayPortal under cx.root) so they escape any ancestor’s overflow clipping. Z values come from semantic tiers — overlay 100, dialog 1000, toast 2000 (where the Notifier lives), tooltip 3000, with the devtools overlay fixed at 32000 — and an overlay opened from inside another always stacks above its host. Absolute positioning and a large z-index on a box give you none of this.

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
mount 的位置不决定浮层在哪里绘制:barrier 进入 portal,z 由 tier 决定,Escape 从栈顶向下找第一个可关闭的层。Where you call mount doesn’t decide where the overlay draws: the barrier goes to the portal, the tier decides z, and Escape walks down from the top of the stack to the first dismissible layer.
  • ✓

    内部点击留在 dialog / panel 内,不触发 outside-dismiss;点击 barrier 按 close_on_overlay 关闭。Inside clicks stay inside the dialog / panel and never trigger outside-dismiss; clicking the barrier closes it per close_on_overlay.

  • ✓

    Escape 只关闭最上层浮层,嵌套层级逐级退栈。Escape closes only the topmost overlay; nested layers unwind one at a time.

  • ✓

    焦点:Modal 打开时 trap 焦点并自动聚焦,关闭后还原到之前的位置。Focus: an open Modal traps and auto-focuses, then restores focus to where it was on close.

  • ✓

    Scope dispose 会主动从 portal 摘除 barrier 子树,切换页面后不会留下透明命中层。Scope disposal actively detaches the barrier subtree from the portal, so switching pages never leaves an invisible hit layer behind.

  • ✓

    退场动画期间浮层保持可见,动画提交后才挂起,而不是直接跳到终点。Exit transitions keep the overlay visible until the animation commits, then suspend it — no jump to the end state.

不阻塞的全局提醒用 ui.widgets.Notifier(取代了旧的 ToastManager):Notifier.init(scope, cx, .{}) 在 toast tier 注册一层,并在窗口 portal 存在时把整窗容器挂进去(portaled 为 false 时自己把 container 挂到根上);之后 show(.{ .tone = .success, .title = "Saved" }) 返回 id,update 原地改写、dismiss 收起。关闭、到期和按钮操作通过 Listener 事件回到应用;历史与勿扰等通知中心逻辑由应用自己实现。For non-blocking app-wide notifications use ui.widgets.Notifier (it replaces the old ToastManager): Notifier.init(scope, cx, .{}) registers a toast-tier layer and, when the window portal exists, mounts its full-window container there (if portaled is false, append container to your root yourself). Then show(.{ .tone = .success, .title = "Saved" }) returns an id, update transforms a card in place and dismiss retires it. Dismissals, expiry and button actions come back to the app as Listener events; history, do-not-disturb and other notification-center logic belong to the app.

Harness 打开居中 dialog,并验证内部点击不会误关。ModalThe harness opens a centered dialog and verifies inside clicks don’t dismiss it. Modal
虚拟鼠标悬停 trigger,气泡进入 tooltip tier,不改变布局树。TooltipA virtual cursor hovers the trigger; the bubble enters the tooltip tier without touching layout. Tooltip

Showcase 同时是一组回归测试The showcase is also a regression suite

e2e/storybook.test.ts 按 nav.<key> 切换每个 Story,先收集子树文本并断言预期数据值全部出现,再截图;交互组件还会断言交互后的状态、几何或像素。组件站的录像复用同一个应用和同一组语义 id,所以「演示」和「测试」不会分叉成两套实现。e2e/storybook.test.ts switches to each story via nav.<key>, collects the subtree’s text and asserts every expected data value is present, then takes a screenshot; interactive components also assert state, geometry or pixels after interacting. The component site’s recordings reuse the same app and the same semantic ids, so the demo and the test never fork into two implementations.

门禁Gate能抓住的问题What it catches
文本 / 状态回读Text / state read-back空面板、回调未写回、过滤 / 分页值错误Empty panels, callbacks that never write back, wrong filter / page values
几何断言Geometry assertionspadding box、portal 居中、Sheet 贴边、拖拽位移错误Padding boxes, portal centering, Sheet edge alignment, drag deltas
像素断言Pixel assertionsGPU 层空白、blend 退化、emoji 灰度化、z 顺序覆盖错误Blank GPU layers, blend regressions, grayscale emoji, wrong z order
录像Recordingshover、press、光标、IME、滚动与入场动画的时间行为Timing of hover, press, cursor, IME, scrolling and enter animations
zenit · 双授权Dual-licensed开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。Free for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.可联系作者:Contact the author: zongyi.xzy#gmail.com(# 换成 @) (replace # with @)zenit 5f9add5+wip 2026-09-30