From 9d01f1b7029ab4bc59bbb1c57ccdc02715fe0d43 Mon Sep 17 00:00:00 2001 From: enzotar Date: Thu, 26 Feb 2026 16:51:58 -0800 Subject: [PATCH] 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 --- compiler/ds-codegen/src/js_emitter.rs | 30 +++++++++++++++++++++------ examples/callback-demo.ds | 23 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 examples/callback-demo.ds diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index 69a6eb8..a3cce47 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -467,12 +467,12 @@ impl JsEmitter { self.emit_line(&format!("function DS_{}(props, __children) {{", comp.name)); self.indent += 1; // 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 { - // 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!( - "const {} = (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 + "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 )); } let root_id = self.emit_view_expr(&comp.body, graph); @@ -901,7 +901,18 @@ impl JsEmitter { Expr::ComponentUse { name, props, children } => { let node_var = self.next_node_id(); let props_js: Vec = 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(); if children.is_empty() { self.emit_line(&format!("const {} = DS_{}({{ {} }});", node_var, name, props_js.join(", "))); @@ -1344,7 +1355,14 @@ impl JsEmitter { let stmts: Vec = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect(); 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) + } + } } } diff --git a/examples/callback-demo.ds b/examples/callback-demo.ds new file mode 100644 index 0000000..52171e5 --- /dev/null +++ b/examples/callback-demo.ds @@ -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" } + ] + ] +]