rustwasm / gloo

A modular toolkit for building fast, reliable Web applications and libraries with Rust and WASM
https://gloo-rs.web.app
Apache License 2.0
1.73k stars 142 forks source link

HtmlElement macro #85

Open dakom opened 5 years ago

dakom commented 5 years ago

Summary

Macro to simplify creating DOM elements

Motivation

web-sys often requires multiple steps and type conversions to accomplish the same results as typical html.

For example: <a href="/foo"><div>Foo</div></a> currently requires something like this:

//create the div element with text "Foo"
let div: HtmlElement = document.create_element("div")?.dyn_into()?;
div.set_text_content(Some("Foo"));

//create the a element and append the div as a child
let anchor: HtmlElement = document.create_element("a")?.dyn_into()?;
anchor.append_child(&div);

//set the href on the a element
let anchor = anchor.unchecked_into::<HtmlHyperlinkElementUtils>();
anchor.set_href("/foo");

Detailed Explanation

From a consumers point of view, the above could be simplified as:

el![ "a", AnchorOptions{href: "/foo"},
    el!["div", None, "Foo"]
]; 

The concept is following the same idea as React's createElement() wherein the arguments are:

  1. element type
  2. element props (optional)
  3. children (optional)

Children must be another element, or raw text, or slices of those.

Macros can be used to allow for shorthand and remove the need for wrapping things in Some(), in other words all of these are legal and mean the same thing:

el!["div", None, None]
el!["div", None]
el!["div"]

Same with these:

el!["div", None, Some("Foo")]
el!["div", None, "Foo"]

And multiple children could be wrapped in brackets:

el!["div", None, [
  el!["div", None, "Hello"],
  el!["div", None, "World"]
]]

Prior Art

The concept of the above is exactly the same as React createElement, only instead of creating proprietary elements it creates real elements

There are a few known projects that use a macro to ultimately create html elements as well - either through a vDom or directly:

Drawbacks, Rationale, and Alternatives

Not sure - I'm opening this issue more for discussion than a direct proposal :) Feel free to shoot it down!

Unresolved Questions

For interop, I think as long as the macro returns native HtmlElements I'd imagine that will flow organically?

Specifically - how do we attach an event listener to nested children? On one foot it seems that this is doable if we break it apart:

let child =  el!["button", None, "Click Me!"];

EventListener::new("click", &child, move |_e| {....});

let parent = el![ "div", None, child]; 
David-OConnor commented 5 years ago

Document can be stored somewhere, or pulled at any time from web_sys. Two approaches for interop are what you described: passing web_sys node/element trees directly, (straightfwd), and interfacing generically through an API, ie for virtual DOMs. The latter approach requires a yet-undeveloped standard interface.

The nature of any specific element-creation API will be opinionated: I think the way around this is for Gloo to provide multiple ones. There are three broad styles I've encountered:

1: Html or JSX-like: Resembles HTML through heavy use of macros. Seems to be the most popular.

2: Pure Rust: eg Sauron. Easy to reason about from looking at the syntax, and for IDEs etc to assist with, but may be verbose.

3: Hybrid: Minimally wrap Rust functions. Your suggestion fits here, as does the one I designed for Seed. I set up macros that are similar to functions, but take an arbitrary number of parameters, and differentiate them based on type. Types can be children, Vecs of children, events, Vecs of events, thinly-wrapped style or attributes HashMaps, text, or lifecycle hooks.

dakom commented 5 years ago

Nice! Wasn't aware of Seed either, added it to "Prior Art" :)

David-OConnor commented 5 years ago

Anyone have thoughts on how to implement a generic API these element-creation APIs could tie into? Maybe with a trait? I have a non-working Element trait I can post for inspiration if anyone would like.

I'm also curious if anyone has thoughts on an API they'd like... no limits. Something new, or a modification to something existing.

Examples, from todomvc:

Yew (HTML-like, powerful use of macros to create arbitrary syntax):

html! {
    <header class="header",>
        <h1>{ "todos" }</h1>
        <input class="new-todo",
             placeholder="What needs to be done?",
             value=&self.state.value,
             oninput=|e| Msg::Update(e.value),
             onkeypress=|e| {
                 if e.key() == "Enter" { Msg::Add } else { Msg::Nope }                       
             }, />
    </header>
}

Sauron (pure Rust):

header(
       [class("header")],
        [
            h1([], [text("todos")]),
           input(
                [
                    class("new-todo"),
                    placeholder("What needs to be done?"),
                    value(self.value.to_string()),
                    oninput(|v: InputEvent| Msg::Update(v.value)),
                    onkeypress(|event: KeyEvent| {
                        if event.key == "Enter" { Msg::Add } else { Msg::Nope }
                    }),
                ],
                [],
            )),
        ],
    )

Seed (hybrid - arguments inside macros are normal Rust, but optional and in arbitrary order, using Rust's type system):

header![ class!["header"],
    h1!["todos"],
    input![
        attrs! {
            At::Class => "new-todo";
            At::PlaceHolder => "What needs to be done?";
            At::Value => model.entry_text;
         },
         raw_ev(Ev::KeyDown, Msg::New),
         input_ev(Ev::Input, Msg::Update),
    ]        
],
Pauan commented 5 years ago

Sorry for the delay on this. I tend to agree with @David-OConnor that some sort of unified interface seems more appropriate right now.

The reason I say that is because there's so many different syntaxes for an HTML macro, with different trade-offs. For example, the above would look like this with dominator:

html!("header", {
    .class("header")
    .children(&mut [
        html!("h1", {
            .text("todos")
        }),

        html!("input", {
            .class("new-todo")
            .attribute("placeholder", "What needs to be done?")
            .property_signal("value", this.value.signal_cloned())
            .event(clone!(this => move |e: InputEvent| this.on_input(e.value())))
            .event(clone!(this => move |e: KeyPressEvent| {
                if e.key() == "Enter" { this.add(); } else { this.nope(); }
            }))
        }),
    ])
})

There's many good reasons for why I chose this style for dominator, but I don't think it generalizes to other DOM frameworks (and vice versa).

Even if we ignore vdom and just focus on creating raw HTML, there's still a lot of different syntax choices.

But if we had a unified interface which all of the syntaxes expanded to, then people could pick and choose which syntax they like. That's why I think we should be focusing on the interface first.

axelf4 commented 5 years ago

Anyone have thoughts on how to implement a generic API these element-creation APIs could tie into?

This is pretty much just dumle or typed-html. For instance, dumle aims to make VDOM instantiation zero-cost, so with a trait that converts the types of dumle into the set of types of some other library, or renders DOM nodes, one could use dumle's Yew inspired html! macro and it would turn into the correct thing.

JeanMertz commented 5 years ago

One thing I've come to dislike about macros is the lack of rustfmt support. Usually this isn't such a big deal, but I've been using typed-html for a while, and when doing UI work, a lot of HTML gets moved around, inside divs, outside of divs, etc, and the formatting just ends up all over the place.

What would help here is there's some kind of way to define the html outside of the rust files, into template files which an editor can format using any popular formatting tools such as prettier, and then load in those files at compile time.

This still isn't great though, as you want to be able to mix the HTML with Rust-specific branching logic (keeping it to a minimum of course, but there'll always be some logic involved).

I guess one solution could be to have some kind of compile-time transformation of Handlebars-like templating in your external html files, that get translated to regular Rust code, and inserted into your code.

But, that's getting a bit off-topic. The main point I wanted to make is that I started out really liking typed-html, and I still do like it, but I dislike the lack of formatting within macros.

David-OConnor commented 5 years ago

Separate template files would be a nice addition as well. Some web devs (eg Vue users) prefer it over integration with code.

capveg-netdebug commented 11 months ago

What's the status of this discussion? Is there an implementation of any of these ideas? I was super excited to find gloo just now as I was hoping that someone had implemented a html!() like macro ... but it seems like it's not here yet? I implemented my own ... which is fairly fugly but had dramatically reduced my typing. I assumed it was so crap that it should be thrown away once I found a better implementation, but a quick look through gloo didn't show one. Thoughts?

FYI:

/**
 * Modestly helpful HTML macro that will create an element and set all of the attributes
 *
 * /// let div = html!("div", {
 * ///  "name" => "root_div",
 * ///  "id" => "container",
 * ///   /* .... more attributes*/
 * /// });
 * /// assert_eq!(div.get_attribute("name"), Some("root_div");
 * ///
 * 
 * Alternatively, can also append children at the end
 * /// let list = html!("ui", {"id = "mylist"},
 * ///      html!("li", {"inner_html" => "item1"}),
 * ///      html!("li", {"inner_html" => "item2"}),
 * ///      html!("li", {"inner_html" => "item3"}),
 * /// );
 * /// assert_eq!(list.children().lenght(), 3);
 * 
 * A smarter person than I would have been able to simplify this into a single case...
 */

#[macro_export]
macro_rules! html {
    // html!("div", {"key"=>"value", ...})
    ($e:expr) => {
        {
        (|| -> Result<web_sys::Element, wasm_bindgen::JsValue> {
        let d = web_sys::window().expect("window").document().expect("document");
        let element = d.create_element($e)?;
        Ok(element)
    })()
    }
    };
    ($e:expr, {$( $k:expr => $v:expr ),* $(,)?}) => {
        {
        (|| -> Result<web_sys::Element, wasm_bindgen::JsValue> {
        let d = web_sys::window().expect("window").document().expect("document");
        let element = d.create_element($e)?;
        $(
            element.set_attribute($k,$v)?;
        )*
        Ok(element)
    })()
    }
    };
    // html!("div", {"key"=>"value", ...}, child1, child2, ...)
    ($e:expr, {$( $k:expr => $v:expr ),* $(,)?}, $($c:expr),*) => {
        {
        (|| -> Result<web_sys::Element, wasm_bindgen::JsValue> {
        let d = web_sys::window().expect("window").document().expect("document");
        let element = d.create_element($e)?;
        $(
            element.set_attribute($k,$v)?;
        )*
        $(
            element.append_child($c)?;
        )*
        Ok(element)
    })()
    }
    };
}
ranile commented 11 months ago

I don't believe gloo is the right place for such a macro (open to be convinced otherwise though). It feels a lot like a web framework/library job to do it. There are crates that can build html trees. I believe any of them can be modified to convert to HtmlElement instead of their own virtual DOM.

Yew's html! macro can generate HTML for you. It should be fairly simple to build wrapper around yew's VNode that ignores everything but VText and VTag and converts those two to web_sys::HtmlElement

capveg-netdebug commented 11 months ago

I have to say, that answer really confuses me. From the motivation in the main README.md :

In particular I’d love to see a modular effort towards implementing a virtual DOM library with JSX like syntax. There have been several efforts on this front but all have seemed relatively monolithic and “batteries included”. I hope this will change in 2019.

— Ryan Levick in [Rust WebAssembly 2019](https://blog.ryanlevick.com/posts/rust-wasm-2019/)

Part of the reason why I got excited about gloo is it is less opinionated than Yew/Leptos/etc. about how to run your application. All of those other projects make fairly heavy assumptions about what you're doing and how you should do them. Maybe they're good assumptions, maybe they're not, but I do think there's room for a light-weight no-hassle JSX macro that just automates away the document.create_element(...), .set_attribute(), .set_inner_html(), .append_child() dance that's inherent to writing front-ends in Rust now.

But I guess implicit in your answer is two things:

1) This doesn't exist in gloo now 2) To get there would require at least some amount of code changes/code copying from another project that already does this "and more", if someone (like me) didn't want the "and more".

Does that sound correct? Thanks for the reply!

Thoughts?

ranile commented 11 months ago

More so than that, there's no agreeable public API: https://github.com/yewstack/yew/discussions/2941

Gloo, as you mentioned, is not opinionated. I'm not sure about providing a specific DSL (even with if it's HTML) where there are multiple ways to do the same thing, each with their own pros and cons. There have been cases of merging code from external crates to gloo, so perhaps this could be one such case? See https://github.com/yewstack/yew/issues/1841 and https://github.com/rustwasm/gloo/pull/180

To be clear, I'm not entirely against it. I just would like to avoid adding an API and then changing it entirely, forcing users to completely rewrite their code.

capveg-netdebug commented 11 months ago

I hear you about lots of options - but right now, AFAICT, there's none that only write to the DOM via web_sys, so my inclination is to write it, get comments/see if people like it, and iterate.

How about this for next steps: I'll let more people comment and I'll try to put up a pull request and we'll see which one happens first :-)

ranile commented 11 months ago

If you want to work on it, I suggest looking at the syn-rsx crate. It implements a lot of the parsing for you

Pauan commented 11 months ago

@capveg-netdebug AFAICT, there's none that only write to the DOM via web_sys, so my inclination is to write it, get comments/see if people like it, and iterate.

dominator compiles down to raw web-sys calls, it doesn't use a vdom, so it's currently the closest framework to what you want.

dominator is an FRP framework, so it does a lot more than just generate DOM nodes, but you can use it to just generate static DOM nodes if you want to.

If you just need to generate static DOM nodes, then it's pretty easy to create your own html! macro, or you can use the typed-html crate, which can output either a String or VDom.

But as @hamza1311 said, you will have to make design choices and tradeoffs. There are many different ways to create an html! macro, there isn't a single obviously-correct design.

Also, personally, I don't think it's particularly useful to have an html! macro that just generates static DOM nodes. Generating static DOM is easy, it's the dynamic changing DOM that is hard. And that's where frameworks like Yew / MoonZoon / dominator are useful, because they handle dynamic DOM.