내장 컴포넌트
zenit 컴포넌트가 무엇을 약속하는지, 언제 하위 레이어로 내려가야 하는지, 그리고 마운트·오버레이·테스트라는 세 가지 계약을 설명합니다. 컴포넌트별 녹화, props, 결과 타입은 컴포넌트 사이트에 있습니다.
여기서 말하는 ‘컴포넌트’란
zenit 컴포넌트는 색상과 모서리 반경을 이어 붙이는 helper가 아닙니다. 공개 컴포넌트는 보통 노드 구조, 영속 상태, 이벤트 라우팅, 포커스와 키보드 시맨틱, 접근성 속성, 테마 token, 오버레이 수명, 그리고 harness가 찾아내고 다시 읽을 수 있는 테스트 경계를 함께 캡슐화합니다. 모두 ui.widgets에서 export됩니다.
먼저 올바른 레이어 선택하기
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는 최상위 오버레이만 닫고, 중첩된 레이어는 한 단계씩 해제됩니다.
- ✓
포커스: 열린 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를 재사용하므로 ‘데모’와 ‘테스트’가 두 구현으로 갈라지지 않습니다.