diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0571f1..8b9c83b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Added macro `raw_svg!` (#589). - Added `browser::dom::Namespace` to `prelude`. - Adapted to Rust 1.51.0. +- Added an experimental component API, feature-flagged behind `experimental-component-api`, and the `experimental-component` example to demonstrate its use. ## v0.8.0 - [BREAKING] Rename `linear_gradient!` to `linearGradient!` for consistency with the other svg macros (same with `radial_gradient!` and `mesh_gradient!`) (#377). diff --git a/Cargo.lock b/Cargo.lock index c9aef7ef..76551dc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,6 +520,13 @@ dependencies = [ "serde", ] +[[package]] +name = "experimental_component" +version = "0.1.0" +dependencies = [ + "seed", +] + [[package]] name = "failure" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 6636dd55..194d7eb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,6 +129,7 @@ members = [ "examples/custom_elements", "examples/drop_zone", "examples/el_key", + "examples/experimental_component", "examples/graphql", "examples/i18n", "examples/markdown", @@ -163,3 +164,4 @@ exclude = [ default = ["panic-hook"] panic-hook = ["console_error_panic_hook"] markdown = ["pulldown-cmark"] +experimental-component-api = [] diff --git a/examples/experimental_component/Cargo.toml b/examples/experimental_component/Cargo.toml new file mode 100644 index 00000000..5386105b --- /dev/null +++ b/examples/experimental_component/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "experimental_component" +version = "0.1.0" +authors = ["glennsl"] +edition = "2018" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +seed = { path = "../../", features = ["experimental-component-api"] } diff --git a/examples/experimental_component/Makefile.toml b/examples/experimental_component/Makefile.toml new file mode 100644 index 00000000..e188fabe --- /dev/null +++ b/examples/experimental_component/Makefile.toml @@ -0,0 +1,27 @@ +extend = "../../Makefile.toml" + +# ---- BUILD ---- + +[tasks.build] +alias = "default_build" + +[tasks.build_release] +alias = "default_build_release" + +# ---- START ---- + +[tasks.start] +alias = "default_start" + +[tasks.start_release] +alias = "default_start_release" + +# ---- TEST ---- + +[tasks.test_firefox] +alias = "default_test_firefox" + +# ---- LINT ---- + +[tasks.clippy] +alias = "default_clippy" diff --git a/examples/experimental_component/README.md b/examples/experimental_component/README.md new file mode 100644 index 00000000..acff2a3e --- /dev/null +++ b/examples/experimental_component/README.md @@ -0,0 +1,11 @@ +## Experimental component API example + +Demonstrates how to use the experimental component API. + +--- + +```bash +cargo make start +``` + +Open [127.0.0.1:8000](http://127.0.0.1:8000) in your browser. diff --git a/examples/experimental_component/index.html b/examples/experimental_component/index.html new file mode 100644 index 00000000..0dfc2eb9 --- /dev/null +++ b/examples/experimental_component/index.html @@ -0,0 +1,18 @@ + + + + + + + Experimental component API example + + + +
+ + + + diff --git a/examples/experimental_component/src/button.rs b/examples/experimental_component/src/button.rs new file mode 100644 index 00000000..a5bde1b0 --- /dev/null +++ b/examples/experimental_component/src/button.rs @@ -0,0 +1,97 @@ +#![allow(dead_code)] + +use seed::{prelude::*, *}; +use std::borrow::Cow; +use std::rc::Rc; + +pub struct Button { + pub label: S, +} + +impl>> Button { + pub fn into_component(self) -> ButtonComponent { + ButtonComponent { + label: self.label.into(), + outlined: false, + disabled: false, + on_clicks: Vec::new(), + } + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct ButtonComponent { + label: Cow<'static, str>, + outlined: bool, + disabled: bool, + on_clicks: Vec Ms>>, +} + +impl ButtonComponent { + pub const fn outlined(mut self, outlined: bool) -> Self { + self.outlined = outlined; + self + } + + pub const fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_click(mut self, on_click: impl FnOnce() -> Ms + Clone + 'static) -> Self { + self.on_clicks.push(Rc::new(move || on_click.clone()())); + self + } +} + +impl Component for ButtonComponent { + fn view(&self) -> Node { + let attrs = { + let mut attrs = attrs! {}; + + if self.disabled { + attrs.add(At::from("aria-disabled"), true); + attrs.add(At::TabIndex, -1); + attrs.add(At::Disabled, AtValue::None); + } + + attrs + }; + + let css = { + let color = "teal"; + + let mut css = style! { + St::TextDecoration => "none", + }; + + if self.outlined { + css.merge(style! { + St::Color => color, + St::BackgroundColor => "transparent", + St::Border => format!("{} {} {}", px(2), "solid", color), + }); + } else { + css.merge(style! { St::Color => "white", St::BackgroundColor => color }); + }; + + if self.disabled { + css.merge(style! {St::Opacity => 0.5}); + } else { + css.merge(style! {St::Cursor => "pointer"}); + } + + css + }; + + let mut button = button![css, attrs, self.label]; + + if !self.disabled { + for on_click in self.on_clicks.iter().cloned() { + button.add_event_handler(ev(Ev::Click, move |_| on_click())); + } + } + + button + } +} diff --git a/examples/experimental_component/src/lib.rs b/examples/experimental_component/src/lib.rs new file mode 100644 index 00000000..6b412543 --- /dev/null +++ b/examples/experimental_component/src/lib.rs @@ -0,0 +1,77 @@ +use seed::{prelude::*, *}; + +mod button; +use button::Button; + +// ------ ------ +// Init +// ------ ------ + +fn init(_: Url, _: &mut impl Orders) -> Model { + Model::default() +} + +// ------ ------ +// Model +// ------ ------ + +type Model = i32; + +// ------ ------ +// Update +// ------ ------ + +enum Msg { + Increment(i32), + Decrement(i32), +} + +#[allow(clippy::needless_pass_by_value)] +fn update(msg: Msg, model: &mut Model, _: &mut impl Orders) { + match msg { + Msg::Increment(d) => *model += d, + Msg::Decrement(d) => *model -= d, + } +} + +// ------ ------ +// View +// ------ ------ + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn view(model: &Model) -> Node { + div![ + style! { + St::Display => "flex" + St::AlignItems => "center", + }, + comp![Button { label: "-100" }, + disabled => true, + on_click => || Msg::Decrement(100), + ], + comp![Button { label: "-10" }, on_click => || Msg::Decrement(10)], + comp![Button { label: "-1" }, + outlined => true, + on_click => || Msg::Decrement(1), + ], + div![style! { St::Margin => "0 1em" }, model], + comp![Button { label: "+1" }, + outlined => true, + on_click => || Msg::Increment(1), + ], + comp![Button { label: "+10" }, on_click => || Msg::Increment(10)], + comp![Button { label: "+100" }, + disabled => true, + on_click => || Msg::Increment(100), + ] + ] +} + +// ------ ------ +// Start +// ------ ------ + +#[wasm_bindgen(start)] +pub fn start() { + App::start("app", init, update, view); +} diff --git a/src/lib.rs b/src/lib.rs index e914dd55..a9365bf7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -198,8 +198,8 @@ pub mod prelude { // https://github.com/rust-lang-nursery/reference/blob/master/src/macros-by-example.md shortcuts::*, virtual_dom::{ - el_key, el_ref::el_ref, AsAtValue, At, AtValue, CSSValue, El, ElRef, Ev, EventHandler, - IntoNodes, Node, St, Tag, ToClasses, UpdateEl, UpdateElForIterator, View, + el_key, el_ref::el_ref, AsAtValue, At, AtValue, CSSValue, Component, El, ElRef, Ev, + EventHandler, IntoNodes, Node, St, Tag, ToClasses, UpdateEl, UpdateElForIterator, View, }, }; pub use indexmap::IndexMap; // for attrs and style to work. diff --git a/src/shortcuts.rs b/src/shortcuts.rs index 68555c48..bb218f2c 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -553,3 +553,17 @@ macro_rules! key_value_pairs { } }; } + +#[cfg(feature = "experimental-component-api")] +/// Instantiates and renders a `Component` +/// +/// NOTE: This is an experimental API that requires the `experimental-component-api` feature. +#[macro_export] +macro_rules! comp { + ($init:expr, $($opt_field:ident => $opt_val:expr),* $(,)?) => { + $crate::virtual_dom::component::instantiate( + $init.into_component() + $( .$opt_field($opt_val) )* + ) + }; +} diff --git a/src/virtual_dom.rs b/src/virtual_dom.rs index 3dbb8c37..74f34cb6 100644 --- a/src/virtual_dom.rs +++ b/src/virtual_dom.rs @@ -1,4 +1,5 @@ pub mod attrs; +pub mod component; pub mod el_ref; pub mod event_handler_manager; pub mod mailbox; @@ -11,6 +12,7 @@ pub mod values; pub mod view; pub use attrs::Attrs; +pub use component::Component; pub use el_ref::{el_ref, ElRef, SharedNodeWs}; pub use event_handler_manager::{EventHandler, EventHandlerManager, Listener}; pub use mailbox::Mailbox; diff --git a/src/virtual_dom/component.rs b/src/virtual_dom/component.rs new file mode 100644 index 00000000..8451cd10 --- /dev/null +++ b/src/virtual_dom/component.rs @@ -0,0 +1,17 @@ +use super::Node; + +pub trait Component { + fn view(&self) -> Node; +} + +#[allow(clippy::needless_pass_by_value)] +pub fn instantiate>(component: C) -> Node { + // TODO: This is where we'd create a boundary node and a state container + // that can then either be passed to `render` to be populated, or capture + // hook calls indirectly like React does. + // + // The boundary node will own the state container and remember the component + // configuration, so that it can do a local re-render when triggered by a + // hook. + component.view() +}