feat: two-way binding, form props, and async resources

Phase 8 features:
- bind prop: two-way signal <-> input sync
- placeholder, value, style, disabled props
- DS.resource() for reactive async data fetching
- DS.fetchJSON() convenience wrapper
- Props-only element parsing: input { bind: x }
- examples/form.ds: contact form with validation

Fixed: double .value.value bug in bind codegen.
This commit is contained in:
enzotar 2026-02-25 08:08:37 -08:00
parent 33ed843abb
commit a35d44bd59
3 changed files with 93 additions and 0 deletions

View file

@ -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 };
})();
"#;

View file

@ -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))
}

28
examples/form.ds Normal file
View file

@ -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!"
]