Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/app/docs/cli/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Write an owned `build.zig`/`build.zig.zon` into the app (once); the verbs then d
native eject component <name> [dir]
```

Write an owned copy of a library composite into `src/components/` (once, never overwriting — ejecting again errors with the file to delete first). Ejectable today: `stepper`, `timeline`, `timeline-item` — the library views that are compositions of primitives; engine controls are not on the menu (theme them through [tokens](/docs/theming) instead). All three eject as Native markup templates, so they work in TypeScript apps without an app-side Zig file; use them through `<import>` and `<use>`. Each file opens with a header comment walking through the call-site migration, and each builds a widget tree identical to its library form at the moment of ejection. Unknown names get a did-you-mean plus the full ejectable list. See [Building Components](/docs/building-components#use-eject-or-build).
Write an owned copy of a library composite into `src/components/` (once, never overwriting — ejecting again errors with the file to delete first). Ejectable today: `stepper`, `timeline`, `timeline-item` — the library views that are compositions of primitives; engine controls are not on the menu (theme them through [tokens](/docs/theming) instead). TypeScript apps receive Native markup templates through `<import>` and `<use>`; Zig-core apps receive a compatible Zig view when one exists, while components without a Zig form are rejected with a clear message. Each file opens with a header comment walking through the call-site migration, and each builds a widget tree identical to its library form at the moment of ejection. Unknown names get a did-you-mean plus the full ejectable list. See [Building Components](/docs/building-components#use-eject-or-build).

### `native doctor`

Expand Down
2 changes: 1 addition & 1 deletion docs/src/app/docs/native-ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Paths resolve relative to the importing file, subdirectories and transitive impo

In a default TypeScript app, `src/app.native` may import any component file under `src/`. The generated build discovers and embeds every other `.native` file for the runtime and compiled engines, including mobile builds. `native dev` re-resolves the imported closure from disk, so editing a component file refreshes the view without restarting. Secondary-window roots keep their narrower `src/windows/` import root.

A template can also take markup children. The body marks the insertion point with a single `<slot/>`, and the use site's children build in the consumer's scope — they see the model paths and loop variables where the `<use>` is written — then land at the slot's position:
A template can also take markup children. The body marks the insertion point with a single `<slot/>`, and the use site's children build in the consumer's scope — they see the model paths and loop variables where the `<use>` is written — then land at the slot's position. A `<use>` may also carry `on-press="tag"` or `on-press="tag:{binding}"` to forward a typed press to the expanded root:

```html
<template name="section" args="title">
Expand Down
2 changes: 1 addition & 1 deletion docs/src/lib/component-vocab.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@
},
{
"name": "use",
"doc": "Expands a template in place: template names an earlier definition, other attributes must match its args exactly (defaulted args may be omitted). Children are slot content: built in the consumer's scope and inserted at the template's <slot/>."
"doc": "Expands a template in place: template names an earlier definition, value attributes must match its args exactly (defaulted args may be omitted), and on-press may forward a typed message to the expanded root. Children are slot content: built in the consumer's scope and inserted at the template's <slot/>."
},
{
"name": "import",
Expand Down
2 changes: 1 addition & 1 deletion skill-data/native-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,7 @@ When the same subtree repeats with different data (board columns, dashboard sect

Rules and semantics:

- A template takes `name` (kebab-case), optional `args` (space-separated names, each optionally `name=default`), and exactly one element child. `<use template="name">` is allowed anywhere an element is (including as a `for` child or the view root); its other attributes must match the template's `args` exactly — missing args without a default and extra args are errors.
- A template takes `name` (kebab-case), optional `args` (space-separated names, each optionally `name=default`), and exactly one element child. `<use template="name">` is allowed anywhere an element is (including as a `for` child or the view root); its value attributes must match the template's `args` exactly — missing args without a default and extra args are errors. A use may additionally carry `on-press`, which forwards one typed message (including a use-site payload binding) to the expanded root.
- Arg defaults are LITERALS only (`args="title trend=flat count=0"`): a default evaluates in no scope, so `{binding}` defaults are errors. A use site may omit any defaulted arg. `args="name="` declares an EMPTY-STRING default; defaults are unquoted — quotes in a default would be literal characters, so a quoted default (`name='x'`) is a teaching error.
- The template body is built IN PLACE of the `<use>`: structural widget ids hash through the parent chain at the expansion site, exactly as if you had written the body inline. Two uses at different sites get different ids; the same site is stable across rebuilds. Rewriting copy-pasted markup as a template does not change any widget id.
- Args bind like `for` variables: an arg whose value is a `{binding}` naming an iterable (model slice/array field, pub decl, or model fn — the same set `for each` accepts) is iterable inside the template (`<for each="cards" ...>`); any other arg (literal or scalar binding) is a value usable in bindings, interpolation, and equality (`{title}`, `label="{title}"`). Args are evaluated at the use site; inside the body only the args, the model, and the body's own loop variables are in scope. Value args are scalars — `{arg.field}` is an error.
Expand Down
14 changes: 13 additions & 1 deletion src/primitives/canvas/ui_markup.zig
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,9 @@ pub const MessageExpression = struct {
payload: []const u8 = "",
};

/// Parse an `on-*` attribute value: `msg` or `msg:{path}`.
/// Parse an `on-*` attribute value: `msg` or `msg:{path}`. Event attributes
/// on `<use>` are limited to `on-press`; the use forwards that message to
/// the template root after resolving it in the consumer's scope.
pub fn parseMessageExpression(value: []const u8) ?MessageExpression {
if (std.mem.indexOfScalar(u8, value, ':')) |colon| {
const tag = value[0..colon];
Expand Down Expand Up @@ -1995,6 +1997,7 @@ pub const use_undefined_template_message = "use references an undefined template
pub const use_earlier_template_message = "use may only reference templates defined earlier in the file";
pub const use_missing_arg_message = "use is missing an argument the template declares in args (only args declared with a default, like trend=flat, may be omitted)";
pub const use_extra_arg_message = "use passes an argument the template does not declare in args";
pub const use_forwarded_event_message = "use only forwards on-press to the template root (the message is resolved at the use site)";
pub const use_children_without_slot_message = "this template has no <slot/> - use-site children need an insertion point; add <slot/> to the template body or remove the children";
pub const slot_outside_template_message = "slot is only allowed inside a template body - it marks where use-site children are inserted";
pub const slot_in_use_children_message = "a slot cannot sit inside use-site children - slot forwarding is not supported; each template body declares its own slot";
Expand Down Expand Up @@ -2174,6 +2177,15 @@ fn validateUse(document: MarkupDocument, node: MarkupNode, template_limit: usize
}
for (node.attrs) |attribute| {
if (std.mem.eql(u8, attribute.name, "template")) continue;
if (std.mem.eql(u8, attribute.name, "on-press")) {
if (parseMessageExpression(attribute.value) == null) {
return attrError(node, attribute, "invalid message expression: on-* takes a Msg tag (\"add\") or tag with one binding payload (\"toggle:{item.id}\")");
}
continue;
}
if (std.mem.startsWith(u8, attribute.name, "on-")) {
return attrError(node, attribute, use_forwarded_event_message);
}
if (!templateDeclaresArg(template_node, attribute.name)) {
return attrError(node, attribute, use_extra_arg_message);
}
Expand Down
33 changes: 30 additions & 3 deletions src/primitives/canvas/ui_markup_compiled.zig
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,22 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
// ------------------------------------------------------ building

fn buildElement(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype) Ui.Node {
return buildElementWithForwardedPress(node, entries, ui, model, scope, null);
}

fn buildElementWithForwardedPress(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype, forwarded_press: ?MsgT) Ui.Node {
var built = buildElementInner(node, entries, ui, model, scope, forwarded_press);
if (forwarded_press) |msg| {
// A press declared on <use> belongs to the expanded root.
// The root-specific builder may also use it to render its
// press affordance; this final stamp covers generic roots.
built.on_press = msg;
built.widget.semantics.focusable = true;
}
return built;
}

fn buildElementInner(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype, forwarded_press: ?MsgT) Ui.Node {
if (comptime std.mem.eql(u8, node.name, "markdown")) {
return buildMarkdown(node, entries, ui, model, scope);
}
Expand All @@ -279,7 +295,7 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
return buildTimeline(node, entries, ui, model, scope);
}
if (comptime std.mem.eql(u8, node.name, "timeline-item")) {
return buildTimelineItem(node, entries, ui, model, scope);
return buildTimelineItem(node, entries, ui, model, scope, forwarded_press);
}
if (comptime std.mem.eql(u8, node.name, "chart")) {
return buildChart(node, entries, ui, model, scope);
Expand Down Expand Up @@ -1197,7 +1213,7 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
}

/// Comptime mirror of the interpreter's `buildTimelineItem`.
fn buildTimelineItem(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype) Ui.Node {
fn buildTimelineItem(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype, forwarded_press: ?MsgT) Ui.Node {
comptime {
if (node.children.len != 0) fail(node.children[0], markup.timeline_item_children_message);
if (node.attr("title") == null) fail(node, markup.timeline_item_title_message);
Expand Down Expand Up @@ -1277,6 +1293,7 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
if (comptime (node.attr("global-key") != null)) {
options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings");
}
if (forwarded_press) |msg| options.on_press = msg;
return ui.timelineItem(options);
}

Expand Down Expand Up @@ -1649,14 +1666,24 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
.parent = buildArgScope(specs, entries, node, ui, model, scope),
.item = scope,
};
return buildElement(comptime template_node.children[0], body_entries, ui, model, body_scope);
const forwarded_press: ?MsgT = if (comptime (node.attr("on-press") != null)) blk: {
const press_attr = comptime node.attrEntry("on-press").?;
var scratch: Ui.ElementOptions = .{};
applyMessageAttr(node, press_attr, entries, ui, model, scope, &scratch);
break :blk scratch.on_press;
} else null;
return buildElementWithForwardedPress(comptime template_node.children[0], body_entries, ui, model, body_scope, forwarded_press);
}

fn useArgSpecs(comptime node: markup.MarkupNode, comptime template_node: markup.MarkupNode, comptime site_entries: []const ScopeEntry) []const ArgSpec {
comptime {
@setEvalBranchQuota(10_000);
for (node.attrs) |attribute| {
if (std.mem.eql(u8, attribute.name, "template")) continue;
if (std.mem.eql(u8, attribute.name, "on-press")) continue;
if (std.mem.startsWith(u8, attribute.name, "on-")) {
fail(node, markup.use_forwarded_event_message);
}
if (!markup.templateDeclaresArg(template_node, attribute.name)) {
fail(node, markup.use_extra_arg_message);
}
Expand Down
18 changes: 18 additions & 0 deletions src/primitives/canvas/ui_markup_compiled_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ const EjectedStepperDefaultMarkup =
;
const EjectedStepperDefaultCompiled = canvas.CompiledMarkupView(fixture.TemplateModel, fixture.TemplateMsg, EjectedStepperDefaultMarkup);

const ForwardedModel = struct { id: u32 = 7 };
const ForwardedMsg = union(enum) { open: u32 };
const ForwardedUi = canvas.Ui(ForwardedMsg);
const ForwardedCompiled = canvas.CompiledMarkupView(ForwardedModel, ForwardedMsg,
\\<template name="item" args="title">
\\ <timeline-item title="{title}" />
\\</template>
\\<use template="item" title="Build" on-press="open:{id}" />
);
test "compiled segmented-control and vector icon button match the interpreter" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
Expand Down Expand Up @@ -133,6 +142,15 @@ test "compiled ejected stepper defaults preserve unkeyed identity" {
try testing.expectEqualDeep(library.root, compiled.root);
}

test "compiled template use forwards a typed root press" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var ui = ForwardedUi.init(arena);
const tree = try ui.finalize(ForwardedCompiled.build(&ui, &ForwardedModel{}));
try testing.expectEqual(ForwardedMsg{ .open = 7 }, tree.msgForPointer(tree.root.id, .up).?);
try testing.expectEqual(@as(usize, 1), tree.root.children.len);
}
const zero_card_padding_markup =
\\<card padding="0">
\\ <text>Flush</text>
Expand Down
9 changes: 9 additions & 0 deletions src/primitives/canvas/ui_markup_contract.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,15 @@ const Checker = struct {
const template_node = self.document.templates[template_index];
if (template_node.children.len != 1 or template_node.children[0].kind != .element) return;

// A use-site press is forwarded to the expanded template root. It
// is a message expression, not a template argument, and its payload
// resolves in the consumer's scope.
for (node.attrs) |attribute| {
if (std.mem.eql(u8, attribute.name, "on-press")) {
try self.checkMessageAttr(node, attribute);
}
}

// Evaluate every arg's kind against the pristine use-site scope
// before any entry is pushed, so args cannot see each other.
const saved_len = self.len;
Expand Down
14 changes: 14 additions & 0 deletions src/primitives/canvas/ui_markup_contract_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,20 @@ test "segmented-control bindings and messages check through the model contract"
try testing.expectEqual(null, try contract.checkDocument(arena, document, &model_contract, &usage));
}

test "the contract checker validates a forwarded template-root press" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const document = try parseFixture(arena,
\\<template name="item" args="title">
\\ <timeline-item title="{title}" />
\\</template>
\\<row>
\\ <use template="item" title="Build" on-press="remove:{profile.age}" />
\\</row>
);
try testing.expectEqual(null, try contract.checkDocument(arena, document, &model_contract, null));
}
test "the contract checker validates stepper slot content in the consumer scope" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
Expand Down
1 change: 1 addition & 0 deletions src/primitives/canvas/ui_markup_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,7 @@ test "slot placement rules validate with teaching messages" {
.{ .source = "<row>\n <slot/>\n</row>", .message = markup.slot_outside_template_message },
.{ .source = "<template name=\"t\"><column><slot gap=\"2\"/></column></template>\n<row />", .message = markup.slot_attrs_message },
.{ .source = "<template name=\"t\"><column><slot><text>x</text></slot></column></template>\n<row />", .message = markup.slot_children_message },
.{ .source = "<template name=\"t\"><text>x</text></template>\n<use template=\"t\" on-toggle=\"toggle\" />", .message = markup.use_forwarded_event_message },
.{ .source = "<template name=\"a\"><column><slot/></column></template>\n<template name=\"b\"><column><use template=\"a\"><slot/></use></column></template>\n<row />", .message = markup.slot_in_use_children_message },
};
for (cases) |case| {
Expand Down
Loading
Loading