内置组件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.
这里的「组件」包含什么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.
先选择正确的抽象层Choose the right layer first
ui.widgets.*业务状态、文案、回调与布局位置Business state, copy, callbacks and placementui.hooks · ui.interaction · ui.select_headless绘制、token、a11y label 与组合边界Drawing, tokens, a11y labels and composition boundariesui.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.
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.
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)).
// 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 时一起释放。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.
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关闭。Inside clicks stay inside the dialog / panel and never trigger outside-dismiss; clicking the barrier closes it perclose_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.
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.