内置组件
理解一个 zenit 组件承诺了什么、何时下降抽象层,以及挂载、浮层和测试三份契约。逐个组件的录像、props 与结果类型在组件站。
这里的「组件」包含什么
zenit 组件不是把颜色和圆角拼起来的 helper。一个公开组件通常同时封装节点结构、持久状态、事件路由、焦点与键盘语义、无障碍属性、主题 token、浮层生命周期,以及可以被 Harness 定位和回读的测试边界。它们都从 ui.widgets 导出。
先选择正确的抽象层
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。
Mount、状态与清理
多数可视组件采用构造器形式 Config.mount(scope, cx):ui.widgets.Button(props) 返回一个 builder,mount 才真正建树。返回值可能是根 *Node,也可能是包含 wrapper、trigger、body、panel、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);少数组件是函数形式,props、scope 与 cx 一次传入:mountScrollArea、mountGrid,以及 Select、ComboBox、TagsInput、NumberStepper、FileUpload、DataTable(它们在 ui.widgets 中是 mountX 函数的别名,写作 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 }不要把 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 得不到这些行为。
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);- ✓
内部点击留在 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 事件回到应用;历史与勿扰等通知中心逻辑由应用自己实现。
Showcase 同时是一组回归测试
e2e/storybook.test.ts 按 nav.<key> 切换每个 Story,先收集子树文本并断言预期数据值全部出现,再截图;交互组件还会断言交互后的状态、几何或像素。组件站的录像复用同一个应用和同一组语义 id,所以「演示」和「测试」不会分叉成两套实现。