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:
parent
33ed843abb
commit
a35d44bd59
3 changed files with 93 additions and 0 deletions
|
|
@ -334,10 +334,50 @@ impl JsEmitter {
|
||||||
node_var, dom_event, handler_js
|
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" => {
|
"class" => {
|
||||||
let js = self.emit_expr(val);
|
let js = self.emit_expr(val);
|
||||||
self.emit_line(&format!("{}.className += ' ' + {};", node_var, js));
|
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);
|
let js = self.emit_expr(val);
|
||||||
self.emit_line(&format!("{}.setAttribute('{}', {});", node_var, key, js));
|
self.emit_line(&format!("{}.setAttribute('{}', {});", node_var, key, js));
|
||||||
|
|
@ -943,8 +983,25 @@ const DS = (() => {
|
||||||
return params;
|
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,
|
return { signal, derived, effect, batch, flush, onEvent, emit,
|
||||||
keyedList, route: _route, navigate, matchRoute,
|
keyedList, route: _route, navigate, matchRoute,
|
||||||
|
resource, fetchJSON,
|
||||||
Signal, Derived, Effect };
|
Signal, Derived, Effect };
|
||||||
})();
|
})();
|
||||||
"#;
|
"#;
|
||||||
|
|
|
||||||
|
|
@ -719,6 +719,14 @@ impl Parser {
|
||||||
None => Ok(Expr::Ident(fallback)),
|
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 {
|
else {
|
||||||
Ok(Expr::Ident(name))
|
Ok(Expr::Ident(name))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
examples/form.ds
Normal file
28
examples/form.ds
Normal 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!"
|
||||||
|
]
|
||||||
Loading…
Add table
Reference in a new issue