組み込みコンポーネント
zenit のコンポーネントが何を約束するのか、いつ下位レイヤーに降りるべきか、そしてマウント・オーバーレイ・テストという 3 つの契約を説明します。コンポーネントごとの録画、props、結果型はコンポーネントサイトにあります。
ここでいう「コンポーネント」とは
zenit のコンポーネントは、色と角丸を組み合わせるだけの helper ではありません。公開コンポーネントは通常、ノード構造、永続状態、イベントルーティング、フォーカスとキーボードのセマンティクス、アクセシビリティ属性、テーマ token、オーバーレイのライフタイム、そして harness が特定・読み戻しできるテスト境界をまとめて提供します。すべて ui.widgets からエクスポートされます。
まず適切なレイヤーを選ぶ
ui.widgets.*ビジネス状態、文言、コールバック、配置ui.hooks · ui.interaction · ui.select_headless描画、token、a11y ラベル、合成の境界ui.Node + ui.eventsヒットテスト、キーボード、フォーカス、IME、a11y、テスト契約コンポーネントカタログ
各コンポーネントにはコンポーネントサイトに専用ページがあります。実際の zenit Storybook.app の録画、ソースから生成された props と結果型、そして録画が証明している振る舞いです。harness は test_id で各 story を特定し、仮想カーソルや入力を操作して、Metal drawable を直接録画します。
Mount・状態・クリーンアップ
多くの視覚コンポーネントは builder 形式 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 は最上位のオーバーレイだけを閉じ、入れ子のレイヤーは 1 段ずつ戻ります。
- ✓
フォーカス:開いた Modal はフォーカスを trap して自動フォーカスし、閉じると元の位置に戻します。
- ✓
Scope の dispose は barrier のサブツリーを portal から能動的に外すため、ページを切り替えても透明なヒットレイヤーが残りません。
- ✓
退場トランジションの間はオーバーレイを表示したままにし、アニメーションが確定してから停止します。終了状態へ飛ぶことはありません。
ブロックしないアプリ全体の通知には 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 を再利用するので、「デモ」と「テスト」が 2 つの実装に分岐することはありません。