Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions crates/rangular-aot/src/glue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,11 @@ impl<H: Host> HostCell<H> {
loop_scope: LoopScope<'_>,
) {
if let Some(path) = rangular_parser::banana_set_target(expr) {
let value = if event_name == "input" {
Value::Str(event_value)
} else {
Value::from(EventPayload::from_dom(event_name, event_value))
let value = match event_name {
"input" => Value::Str(event_value),
// `[(checked)]` → `(change)` writeback; AOT reads `el.checked`.
"change" => Value::Bool(event_value == "true"),
_ => Value::from(EventPayload::from_dom(event_name, event_value)),
};
let _ = self.host.borrow_mut().set(path, value);
return;
Expand Down
23 changes: 22 additions & 1 deletion crates/rangular-aot/src/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ fn lower_one_attr(attr: &Attr, scope: &Scope<'_>, hoist: &mut HoistState) -> Tok
Attr::Static {
name, value: None, ..
} => html_name(name),
Attr::Property { name, expr, .. } if name == "disabled" => {
Attr::Property { name, expr, .. } if name == "disabled" || name == "checked" => {
let ex = hoist.hoist_expr(expr);
let handler = hoist_host_closure(
hoist,
Expand Down Expand Up @@ -468,6 +468,27 @@ fn event_value_tokens(event_name: &str) -> TokenStream {
"click" | "dblclick" | "auxclick" => {
quote! { format!("{},{}", ev.client_x(), ev.client_y()) }
}
"change" => quote! {
{
use wasm_bindgen::JsCast;
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
.map(|el| {
let ty = el.type_();
if ty == "checkbox" || ty == "radio" {
el.checked().to_string()
} else {
el.value()
}
})
.or_else(|| {
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|el| el.value())
})
.unwrap_or_default()
}
},
_ => quote! {
{
use wasm_bindgen::JsCast;
Expand Down
59 changes: 59 additions & 0 deletions crates/rangular-aot/tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ fn two_way_emits_leptos_view() {
);
}

#[test]
fn checked_prop_emits_eval_bool_and_change_banana() {
let out = compile(
r#"<input type="checkbox" [(checked)]="on" />"#,
"checkbox_view",
);
assert!(out.ok(), "{:?}", out.issues);
assert!(
out.code.contains("eval_bool_scoped") && out.code.contains("change"),
"expected bool checked prop + change banana:\n{}",
out.code
);
parse_file(&out.code).unwrap_or_else(|err| panic!("invalid Rust: {err}"));
}

#[test]
fn named_slots_emits_slot_params() {
let html = include_str!("../../../tests/fixtures/components/named-slots/named-slots.html");
Expand Down Expand Up @@ -138,6 +153,50 @@ fn banana_hostcell_sets_via_dom_event() {
assert_eq!(*seed.borrow(), "xyz");
}

#[test]
fn banana_checked_hostcell_sets_bool_via_change() {
use std::cell::RefCell;
use std::rc::Rc;

use rangular_aot::HostCell;
use rangular_expr::{Expr, Host, Value};
use rangular_host::HostError;
use rangular_parser::banana_write_expr;

struct FlagHost {
flag: Rc<RefCell<bool>>,
}

impl Host for FlagHost {
fn get(&self, name: &str) -> Option<Value> {
(name == "flag").then(|| Value::Bool(*self.flag.borrow()))
}

fn set(&mut self, name: &str, value: Value) -> Result<(), HostError> {
if name == "flag" {
if let Some(b) = value.as_bool() {
*self.flag.borrow_mut() = b;
}
}
Ok(())
}

fn call(&mut self, _: &str, _: &[Value]) -> Result<Value, HostError> {
Ok(Value::Unit)
}
}

let flag = Rc::new(RefCell::new(false));
let cell = HostCell::new(FlagHost {
flag: Rc::clone(&flag),
});
let write = banana_write_expr(&Expr::Ident("flag".into()));
cell.emit_dom_event_call("$bananaSet", &write, "change", "true".into());
assert!(*flag.borrow());
cell.emit_dom_event_call("$bananaSet", &write, "change", "false".into());
assert!(!*flag.borrow());
}

#[test]
fn hostcell_dom_event_passes_dollar_event_to_handler() {
use std::cell::RefCell;
Expand Down
8 changes: 4 additions & 4 deletions crates/rangular-parser/src/banana.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ pub const BANANA_SET_CALLEE: &str = "$bananaSet";
/// DOM / Angular-like event for a two-way property.
#[must_use]
pub fn banana_event_name(prop: &str) -> String {
if prop == "value" {
"input".into()
} else {
format!("{prop}Change")
match prop {
"value" => "input".into(),
"checked" => "change".into(),
_ => format!("{prop}Change"),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/rangular-parser/tests/edge_cases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ fn control_flow_blocks_and_errors() {
#[test]
fn banana_helpers_and_attrs() {
assert_eq!(banana_event_name("value"), "input");
assert_eq!(banana_event_name("checked"), "change");
assert_eq!(banana_event_name("seed"), "seedChange");
let write = banana_write_expr(&Expr::Ident("seed".into()));
assert_eq!(banana_set_target(&write), Some("seed"));
Expand Down
6 changes: 3 additions & 3 deletions crates/rangular-runtime/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,12 +267,12 @@ fn render_attrs<H: Host>(attrs: &[Attr], ctx: &mut Ctx<'_, H>) -> Vec<(String, S
} => out.push((name.clone(), String::new())),
Attr::Ref { .. } => {}
Attr::Property { name, .. } if name == "ngTemplateOutlet" => {}
Attr::Property { name, expr, .. } if name == "disabled" => {
let disabled = match eval_expr(expr, ctx) {
Attr::Property { name, expr, .. } if name == "disabled" || name == "checked" => {
let flag = match eval_expr(expr, ctx) {
Value::Bool(b) => b,
other => other.is_truthy(),
};
out.push((format!("prop:{name}"), bool_str(disabled)));
out.push((format!("prop:{name}"), bool_str(flag)));
}
Attr::Property { name, expr, .. } => {
out.push((format!("prop:{name}"), display_value(&eval_expr(expr, ctx))));
Expand Down
79 changes: 74 additions & 5 deletions crates/rangular-runtime/tests/parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,53 +592,122 @@ fn pipes_runtime_snapshot() {
fn two_way_runtime_snapshot() {
struct TwoWayHost {
seed: String,
on: bool,
}

impl Host for TwoWayHost {
fn get(&self, name: &str) -> Option<Value> {
(name == "seed").then(|| Value::Str(self.seed.clone()))
match name {
"seed" => Some(Value::Str(self.seed.clone())),
"on" => Some(Value::Bool(self.on)),
_ => None,
}
}

fn set(&mut self, name: &str, value: Value) -> Result<(), HostError> {
if name == "seed" {
if let Some(s) = value.as_str() {
self.seed = s.to_owned();
match name {
"seed" => {
if let Some(s) = value.as_str() {
self.seed = s.to_owned();
}
}
"on" => {
if let Some(b) = value.as_bool() {
self.on = b;
}
}
_ => {}
}
Ok(())
}

fn call(&mut self, name: &str, _: &[Value]) -> Result<Value, HostError> {
if name == "pushFromHost" {
self.seed = "host-push".into();
self.on = true;
}
Ok(Value::Unit)
}
}

let src =
std::fs::read_to_string(fixture_root().join("components/two-way/two-way.html")).unwrap();
let mut host = TwoWayHost { seed: "abc".into() };
let mut host = TwoWayHost {
seed: "abc".into(),
on: false,
};
let out = interpret(&src, "two-way.html", &mut host);
assert!(out.ok(), "{:?}", out.issues);
let snap = out.snapshot();
assert!(snap.contains(r#"prop:value="abc""#), "{snap}");
assert!(snap.contains(r#"on:input="$bananaSet""#), "{snap}");
assert!(snap.contains(r#"prop:checked="false""#), "{snap}");
assert!(snap.contains(r#"on:change="$bananaSet""#), "{snap}");
assert!(snap.contains(r#"on:click="pushFromHost""#), "{snap}");
assert!(snap.contains("Mirror:"), "{snap}");
assert!(snap.contains("Checked:"), "{snap}");
assert!(snap.contains(">abc<") || snap.contains("abc"), "{snap}");
assert!(compile(&src, "two_way_view").ok());

let ir = binding_ir_snapshot(&binding_ir(&parse(&src, "two-way.html").template));
assert!(ir.contains("prop:value"), "{ir}");
assert!(ir.contains(r#"on:input="$bananaSet""#), "{ir}");
assert!(ir.contains("prop:checked"), "{ir}");
assert!(ir.contains(r#"on:change="$bananaSet""#), "{ir}");

let aot_ir = rangular_aot::structural_ir(&src, "two-way.html").expect("aot ir");
let rt_ir = rangular_runtime::structural_ir(&src, "two-way.html").expect("rt ir");
assert_eq!(aot_ir.1, rt_ir.1);

host.set("seed", Value::Str("xyz".into())).unwrap();
assert_eq!(host.seed, "xyz");
host.set("on", Value::Bool(true)).unwrap();
assert!(host.on);
}

#[test]
fn checkbox_checked_bool_prop_and_banana_change() {
struct CheckHost {
on: bool,
}

impl Host for CheckHost {
fn get(&self, key: &str) -> Option<Value> {
(key == "on").then_some(Value::Bool(self.on))
}

fn set(&mut self, key: &str, value: Value) -> Result<(), HostError> {
if key == "on" {
if let Some(b) = value.as_bool() {
self.on = b;
}
}
Ok(())
}

fn call(&mut self, _: &str, _: &[Value]) -> Result<Value, HostError> {
Ok(Value::Unit)
}
}

let src = r#"<input type="checkbox" [(checked)]="on" />"#;
let mut host = CheckHost { on: false };
let out = interpret(src, "check.html", &mut host);
assert!(out.ok(), "{:?}", out.issues);
let snap = out.snapshot();
assert!(snap.contains(r#"prop:checked="false""#), "{snap}");
assert!(snap.contains(r#"on:change="$bananaSet""#), "{snap}");

host.on = true;
let snap_on = interpret(src, "check.html", &mut host).snapshot();
assert!(snap_on.contains(r#"prop:checked="true""#), "{snap_on}");

let parsed = parse(src, "check.html");
assert!(parsed.ok(), "{:?}", parsed.diagnostics);
let ir = binding_ir_snapshot(&binding_ir(&parsed.template));
assert!(ir.contains("prop:checked"), "{ir}");
assert!(ir.contains(r#"on:change="$bananaSet""#), "{ir}");
assert!(compile(src, "checkbox_view").ok());
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion demo-leptos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Each feature panel is its own folder:
| `io_child` | `[label]` / `[muted]` in, `(muteToggle)` out |
| `io_parent` | AOT heading + live AOT `io_child` (nested tags are not live yet) |
| `pipes` | builtins + demo custom `crab` |
| `two_way` | `[(value)]` banana |
| `two_way` | `[(value)]` + `[(checked)]` banana |
| `field_required` | Host `required` + dirty error |
| `field_validators`| Multi-field Host helpers (`first_error`, length, pattern) |
| `event_payload` | typed `$event` / `EventPayload` |
Expand Down
10 changes: 10 additions & 0 deletions demo-leptos/src/components/two_way/two_way.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,14 @@
Push from Host
</button>
</div>
<div class="two-way__row two-way__row--check">
<label class="two-way__check" for="two-way-on">
<input id="two-way-on" type="checkbox" [(checked)]="on" />
Enabled
</label>
<p class="two-way__mirror">
<span class="two-way__mirror-label">Checked:</span>
<span class="two-way__mirror-value">{{ on }}</span>
</p>
</div>
</section>
27 changes: 21 additions & 6 deletions demo-leptos/src/components/two_way/two_way.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ include!(concat!(env!("OUT_DIR"), "/rangular/two_way_view.rs"));
#[component]
pub fn TwoWayPanel(applied_seed: RwSignal<String>) -> impl IntoView {
let seed = RwSignal::new(String::from("abc"));
let on = RwSignal::new(false);

Effect::new(move |_| {
let value = applied_seed.get();
Expand All @@ -16,30 +17,44 @@ pub fn TwoWayPanel(applied_seed: RwSignal<String>) -> impl IntoView {
seed.set(value);
});

two_way_view(HostCell::new(TwoWayHost { seed }))
two_way_view(HostCell::new(TwoWayHost { seed, on }))
}

struct TwoWayHost {
seed: RwSignal<String>,
on: RwSignal<bool>,
}

impl Host for TwoWayHost {
fn get(&self, name: &str) -> Option<Value> {
(name == "seed").then(|| Value::Str(self.seed.get()))
match name {
"seed" => Some(Value::Str(self.seed.get())),
"on" => Some(Value::Bool(self.on.get())),
_ => None,
}
}

fn set(&mut self, name: &str, value: Value) -> Result<(), HostError> {
if name == "seed"
&& let Some(s) = value.as_str()
{
self.seed.set(s.to_owned());
match name {
"seed" => {
if let Some(s) = value.as_str() {
self.seed.set(s.to_owned());
}
}
"on" => {
if let Some(b) = value.as_bool() {
self.on.set(b);
}
}
_ => {}
}
Ok(())
}

fn call(&mut self, name: &str, _: &[Value]) -> Result<Value, HostError> {
if name == "pushFromHost" {
self.seed.set(String::from("host-push"));
self.on.set(true);
}
Ok(Value::Unit)
}
Expand Down
Loading
Loading