diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index 21a2897..f0099de 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -334,10 +334,50 @@ impl JsEmitter { node_var, dom_event, handler_js )); } + "bind" => { + // Two-way binding: signal <-> input + // Use raw signal name (not emit_expr which adds .value) + let signal_name = match val { + Expr::Ident(name) => name.clone(), + _ => self.emit_expr(val), + }; + // Signal -> input + self.emit_line(&format!( + "DS.effect(() => {{ {}.value = {}.value; }});", + node_var, signal_name + )); + // Input -> signal + self.emit_line(&format!( + "{}.addEventListener('input', (e) => {{ {}.value = e.target.value; DS.flush(); }});", + node_var, signal_name + )); + } "class" => { let js = self.emit_expr(val); self.emit_line(&format!("{}.className += ' ' + {};", node_var, js)); } + "placeholder" => { + let js = self.emit_expr(val); + self.emit_line(&format!("{}.placeholder = {};", node_var, js)); + } + "value" => { + let js = self.emit_expr(val); + self.emit_line(&format!( + "DS.effect(() => {{ {}.value = {}.value; }});", + node_var, js + )); + } + "style" => { + let js = self.emit_expr(val); + self.emit_line(&format!("{}.style.cssText = {};", node_var, js)); + } + "disabled" => { + let js = self.emit_expr(val); + self.emit_line(&format!( + "DS.effect(() => {{ {}.disabled = {}.value; }});", + node_var, js + )); + } _ => { let js = self.emit_expr(val); self.emit_line(&format!("{}.setAttribute('{}', {});", node_var, key, js)); @@ -943,8 +983,25 @@ const DS = (() => { return params; } + // ── Async Resources ── + function resource(fetcher) { + const state = new Signal({ status: 'loading', data: null, error: null }); + const eff = effect(() => { + state.value = { status: 'loading', data: state._value.data, error: null }; + Promise.resolve(fetcher()) + .then(data => { state.value = { status: 'ok', data, error: null }; }) + .catch(err => { state.value = { status: 'error', data: null, error: err.message || String(err) }; }); + }); + return state; + } + + function fetchJSON(url) { + return resource(() => fetch(url).then(r => r.json())); + } + return { signal, derived, effect, batch, flush, onEvent, emit, keyedList, route: _route, navigate, matchRoute, + resource, fetchJSON, Signal, Derived, Effect }; })(); "#; diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index 031ce73..c4f67a4 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -719,6 +719,14 @@ impl Parser { None => Ok(Expr::Ident(fallback)), } } + // Element with props only: `input { bind: name }` + else if is_ui_element(&name) && matches!(self.peek(), TokenKind::LBrace) { + let fallback = name.clone(); + match self.parse_element(name)? { + Some(el) => Ok(el), + None => Ok(Expr::Ident(fallback)), + } + } else { Ok(Expr::Ident(name)) } diff --git a/examples/form.ds b/examples/form.ds new file mode 100644 index 0000000..90d1a2b --- /dev/null +++ b/examples/form.ds @@ -0,0 +1,28 @@ +-- DreamStack Form Example +-- Two-way binding, validation, and reactive form state + +let name = "" +let email = "" +let message = "" +let submitted = false +let char_count = 0 + +view main = column [ + text "Contact Form" + + text "Name" + input { bind: name, placeholder: "Your name" } + + text "Email" + input { bind: email, placeholder: "you@example.com" } + + text "Message" + input { bind: message, placeholder: "Type your message..." } + + text char_count + + button "Submit" { click: submitted = true } + + when submitted -> + text "Thanks for your message!" +]