docs/guide/components
コアコンセプト · Components

組み込みコンポーネント

zenit のコンポーネントが何を約束するのか、いつ下位レイヤーに降りるべきか、そしてマウント・オーバーレイ・テストという 3 つの契約を説明します。コンポーネントごとの録画、props、結果型はコンポーネントサイトにあります。

約 10 分で読めます

ここでいう「コンポーネント」とは

zenit のコンポーネントは、色と角丸を組み合わせるだけの helper ではありません。公開コンポーネントは通常、ノード構造、永続状態、イベントルーティング、フォーカスとキーボードのセマンティクス、アクセシビリティ属性、テーマ token、オーバーレイのライフタイム、そして harness が特定・読み戻しできるテスト境界をまとめて提供します。すべて ui.widgets からエクスポートされます。

51コンポーネントサイトの Story
6ユースケース分類(別途 Labs あり)
1:1Story と E2E test_id
ANATOMY
mount はまず子 Scope を作成します。子 Scope が所有するものはすべて親 Scope と一緒に解放されます。

まず適切なレイヤーを選ぶ

要件レイヤー引き続き自分で担うもの
標準的なプロダクト用コントロール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 を直接録画します。

分類数代表例
操作と選択7Button · Checkbox · Switch · RadioGroup · Slider …
入力とフォーム10Input · Textarea · Select · Input · Select · Button · ComboBox …
データ表示17Badge · Tag · Chip · Card · Alert …
ナビゲーション4Tabs · Accordion · Menu · DropdownMenu
オーバーレイとフィードバック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・状態・クリーンアップ

多くの視覚コンポーネントは builder 形式 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 は最上位のオーバーレイだけを閉じ、入れ子のレイヤーは 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 イベントとしてアプリに戻ります。履歴やおやすみモードなどの通知センターのロジックはアプリ側で実装します。

harness が中央の dialog を開き、内部クリックで誤って閉じないことを検証します。Modal
仮想カーソルが trigger にホバーし、吹き出しはレイアウトに触れずに tooltip tier に入ります。Tooltip

Showcase はそのまま回帰テストでもある

e2e/storybook.test.ts は nav.<key> で各 story に切り替え、サブツリーのテキストを収集して期待するデータ値がすべて現れることをアサートしてからスクリーンショットを撮ります。インタラクティブなコンポーネントでは、操作後の状態、ジオメトリ、ピクセルもアサートします。コンポーネントサイトの録画は同じアプリと同じセマンティック id を再利用するので、「デモ」と「テスト」が 2 つの実装に分岐することはありません。

ゲート検出できる問題
テキスト / 状態の読み戻し空のパネル、書き戻さないコールバック、誤ったフィルター / ページ値
ジオメトリのアサーションpadding box、portal の中央配置、Sheet の端揃え、ドラッグ移動量
ピクセルのアサーション空白の GPU レイヤー、blend の劣化、グレースケール化した絵文字、誤った z 順序
録画hover、press、カーソル、IME、スクロール、入場アニメーションのタイミング
zenit · デュアルライセンスオープンソースプロジェクトは GPL-3.0-only のもとで無料で使えます。クローズドソースや商用製品には商用ライセンスが必要です。作者への連絡先:zongyi.xzy#gmail.com(# を @ に置き換え)zenit 5f9add5+wip 2026-09-30