feat: component event callbacks + function prop forwarding

- ComponentUse wraps Assign/MethodCall/Block props as arrow functions
- Component prop wrapper preserves function props (typeof check)
- Event handler fallthrough calls function-type identifiers
- New: examples/callback-demo.ds with Button onClick callbacks
- All existing examples build successfully
This commit is contained in:
enzotar 2026-02-26 16:51:58 -08:00
parent cbd6dfc7a6
commit 9d01f1b702
2 changed files with 47 additions and 6 deletions

View file

@ -467,12 +467,12 @@ impl JsEmitter {
self.emit_line(&format!("function DS_{}(props, __children) {{", comp.name)); self.emit_line(&format!("function DS_{}(props, __children) {{", comp.name));
self.indent += 1; self.indent += 1;
// Destructure props into local signal-compatible variables // Destructure props into local signal-compatible variables
// Props may be raw values or signals — wrap in a signal-like accessor // Props may be raw values, signals, or callback functions
for p in &comp.props { for p in &comp.props {
// Create a signal-like wrapper: if prop is already a signal, use it; otherwise wrap // Create a signal-like wrapper: if prop is a function, keep as-is; if already a signal, use it; otherwise wrap
self.emit_line(&format!( self.emit_line(&format!(
"const {} = (props.{} !== undefined && props.{} !== null) ? (typeof props.{} === 'object' && 'value' in props.{} ? props.{} : {{ get value() {{ return props.{}; }} }}) : {{ get value() {{ return \"\"; }} }};", "const {} = (typeof props.{} === 'function') ? props.{} : (props.{} !== undefined && props.{} !== null) ? (typeof props.{} === 'object' && 'value' in props.{} ? props.{} : {{ get value() {{ return props.{}; }} }}) : {{ get value() {{ return \"\"; }} }};",
p.name, p.name, p.name, p.name, p.name, p.name, p.name p.name, p.name, p.name, p.name, p.name, p.name, p.name, p.name, p.name
)); ));
} }
let root_id = self.emit_view_expr(&comp.body, graph); let root_id = self.emit_view_expr(&comp.body, graph);
@ -901,7 +901,18 @@ impl JsEmitter {
Expr::ComponentUse { name, props, children } => { Expr::ComponentUse { name, props, children } => {
let node_var = self.next_node_id(); let node_var = self.next_node_id();
let props_js: Vec<String> = props.iter() let props_js: Vec<String> = props.iter()
.map(|(k, v)| format!("{}: {}", k, self.emit_expr(v))) .map(|(k, v)| {
// Detect callback props (assignments, method calls, blocks)
let is_callback = matches!(v,
Expr::Assign(_, _, _) | Expr::MethodCall(_, _, _) | Expr::Block(_)
);
if is_callback {
let handler_js = self.emit_event_handler_expr(v);
format!("{}: () => {{ {}; DS.flush() }}", k, handler_js)
} else {
format!("{}: {}", k, self.emit_expr(v))
}
})
.collect(); .collect();
if children.is_empty() { if children.is_empty() {
self.emit_line(&format!("const {} = DS_{}({{ {} }});", node_var, name, props_js.join(", "))); self.emit_line(&format!("const {} = DS_{}({{ {} }});", node_var, name, props_js.join(", ")));
@ -1344,7 +1355,14 @@ impl JsEmitter {
let stmts: Vec<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect(); let stmts: Vec<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect();
stmts.join("; ") stmts.join("; ")
} }
_ => self.emit_expr(expr), _ => {
// For plain identifiers that might be callback props, call them
if let Expr::Ident(name) = expr {
format!("if (typeof {} === 'function') {}()", name, name)
} else {
self.emit_expr(expr)
}
}
} }
} }

23
examples/callback-demo.ds Normal file
View file

@ -0,0 +1,23 @@
-- DreamStack Callback Demo
-- Tests component event callbacks
import { Card } from "../registry/components/card"
import { Button } from "../registry/components/button"
let count = 0
let message = "Click a button"
view main = column [
text "🔗 Component Callbacks" { variant: "title" }
text "Components can trigger parent actions" { variant: "subtitle" }
Card { title: "Using Button Component", subtitle: "callback props" } [
text "Count: {count}" { variant: "title" }
text message { variant: "subtitle" }
row [
Button { label: "+1", onClick: count += 1, variant: "primary" }
Button { label: "-1", onClick: count -= 1, variant: "secondary" }
Button { label: "Reset", onClick: count = 0, variant: "ghost" }
]
]
]