docs/guide/components
核心概念 · Components

内置组件

理解一个 zenit 组件承诺了什么、何时下降抽象层,以及挂载、浮层和测试三份契约。逐个组件的录像、props 与结果类型在组件站。

预计阅读 10 分钟

这里的「组件」包含什么

zenit 组件不是把颜色和圆角拼起来的 helper。一个公开组件通常同时封装节点结构、持久状态、事件路由、焦点与键盘语义、无障碍属性、主题 token、浮层生命周期,以及可以被 Harness 定位和回读的测试边界。它们都从 ui.widgets 导出。

51组件站收录的 Story
6使用场景分类(另有 Labs)
1:1Story 与 E2E test_id
ANATOMY
mount 内部先创建子 Scope;它持有的一切会随父 Scope 一起释放。

先选择正确的抽象层

需求使用层你仍然负责
标准产品控件ui.widgets.*业务状态、文案、回调与布局位置
自定义视觉,保留成熟交互ui.hooks · ui.interaction · ui.select_headless绘制、token、a11y label 与组合边界
全新交互模式ui.Node + ui.events命中、键盘、焦点、IME、a11y 和测试契约

组件目录

每个组件在组件站都有独立页面:真实 zenit Storybook.app 的录像、从源码生成的 props 与结果类型,以及录像正在证明的行为。录像由 Harness 按 test_id 定位、驱动虚拟鼠标或输入,并直接录制 Metal drawable。

分类数量代表组件
操作与选择7Button · Checkbox · Switch · RadioGroup · Slider …
输入与表单10Input · Textarea · Select · Input · Select · Button · ComboBox …
数据展示17Badge · Tag · Chip · Card · Alert …
浮层与反馈5Notifier · Tooltip · Popover · Modal · Sheet
布局与大数据8GlassBox · Divider · HStack / VStack · ui.box · VirtualList …
实验与能力验证13glasslab · glassislands · glassmotion · glasschrome · canvasevents …
Button Story:扫过 variant × size 矩阵,再在 Primary 上展示 hover 与 press。完整说明见 /components/button。

Mount、状态与清理

多数可视组件采用构造器形式 Config.mount(scope, cx):ui.widgets.Button(props) 返回一个 builder,mount 才真正建树。返回值可能是根 *Node,也可能是包含 wrapper、trigger、body、panel、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))。

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);
组件返回
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 时一起释放。

浮层为什么必须用组件

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 得不到这些行为。

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 从栈顶向下找第一个可关闭的层。
  • ✓

    内部点击留在 dialog / panel 内,不触发 outside-dismiss;点击 barrier 按 close_on_overlay 关闭。

  • ✓

    Escape 只关闭最上层浮层,嵌套层级逐级退栈。

  • ✓

    焦点:Modal 打开时 trap 焦点并自动聚焦,关闭后还原到之前的位置。

  • ✓

    Scope dispose 会主动从 portal 摘除 barrier 子树,切换页面后不会留下透明命中层。

  • ✓

    退场动画期间浮层保持可见,动画提交后才挂起,而不是直接跳到终点。

不阻塞的全局提醒用 ui.widgets.Notifier(取代了旧的 ToastManager):Notifier.init(scope, cx, .{}) 在 toast tier 注册一层,并在窗口 portal 存在时把整窗容器挂进去(portaled 为 false 时自己把 container 挂到根上);之后 show(.{ .tone = .success, .title = "Saved" }) 返回 id,update 原地改写、dismiss 收起。关闭、到期和按钮操作通过 Listener 事件回到应用;历史与勿扰等通知中心逻辑由应用自己实现。

Harness 打开居中 dialog,并验证内部点击不会误关。Modal
虚拟鼠标悬停 trigger,气泡进入 tooltip tier,不改变布局树。Tooltip

Showcase 同时是一组回归测试

e2e/storybook.test.ts 按 nav.<key> 切换每个 Story,先收集子树文本并断言预期数据值全部出现,再截图;交互组件还会断言交互后的状态、几何或像素。组件站的录像复用同一个应用和同一组语义 id,所以「演示」和「测试」不会分叉成两套实现。

门禁能抓住的问题
文本 / 状态回读空面板、回调未写回、过滤 / 分页值错误
几何断言padding box、portal 居中、Sheet 贴边、拖拽位移错误
像素断言GPU 层空白、blend 退化、emoji 灰度化、z 顺序覆盖错误
录像hover、press、光标、IME、滚动与入场动画的时间行为
zenit · 双授权开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。可联系作者:zongyi.xzy#gmail.com(# 换成 @)zenit 5f9add5+wip 2026-09-30