From c87328f5cfb1884fc72635e84d7703b88a3bb744 Mon Sep 17 00:00:00 2001 From: Marc-Stefan Cassola Date: Thu, 19 Oct 2023 21:15:36 +0100 Subject: [PATCH] feat: add directives with `use:` (#1821) --- examples/directives/.cargo/config.toml | 2 + examples/directives/Cargo.toml | 17 +++++ examples/directives/Makefile.toml | 5 ++ examples/directives/README.md | 7 ++ examples/directives/index.html | 7 ++ examples/directives/rust-toolchain.toml | 3 + examples/directives/src/lib.rs | 51 +++++++++++++ examples/directives/src/main.rs | 8 +++ examples/directives/tests/web.rs | 58 +++++++++++++++ leptos_dom/src/directive.rs | 83 ++++++++++++++++++++++ leptos_dom/src/html.rs | 30 ++++++-- leptos_dom/src/lib.rs | 73 +++++++++++++++++-- leptos_macro/src/view/client_builder.rs | 4 +- leptos_macro/src/view/component_builder.rs | 19 ++++- leptos_macro/src/view/mod.rs | 17 ++++- 15 files changed, 370 insertions(+), 14 deletions(-) create mode 100644 examples/directives/.cargo/config.toml create mode 100644 examples/directives/Cargo.toml create mode 100644 examples/directives/Makefile.toml create mode 100644 examples/directives/README.md create mode 100644 examples/directives/index.html create mode 100644 examples/directives/rust-toolchain.toml create mode 100644 examples/directives/src/lib.rs create mode 100644 examples/directives/src/main.rs create mode 100644 examples/directives/tests/web.rs create mode 100644 leptos_dom/src/directive.rs diff --git a/examples/directives/.cargo/config.toml b/examples/directives/.cargo/config.toml new file mode 100644 index 0000000000..84671750fa --- /dev/null +++ b/examples/directives/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--cfg=web_sys_unstable_apis"] diff --git a/examples/directives/Cargo.toml b/examples/directives/Cargo.toml new file mode 100644 index 0000000000..8be0418ef8 --- /dev/null +++ b/examples/directives/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "directives" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = { path = "../../leptos", features = ["csr", "nightly"] } +log = "0.4" +console_log = "1" +console_error_panic_hook = "0.1.7" +web-sys = { version = "0.3", features = ["Clipboard", "Navigator"] } + +[dev-dependencies] +wasm-bindgen-test = "0.3.0" +wasm-bindgen = "0.2" +web-sys = "0.3" +gloo-timers = { version = "0.3", features = ["futures"] } diff --git a/examples/directives/Makefile.toml b/examples/directives/Makefile.toml new file mode 100644 index 0000000000..7c753497be --- /dev/null +++ b/examples/directives/Makefile.toml @@ -0,0 +1,5 @@ +extend = [ + { path = "../cargo-make/main.toml" }, + { path = "../cargo-make/wasm-test.toml" }, + { path = "../cargo-make/trunk_server.toml" }, +] diff --git a/examples/directives/README.md b/examples/directives/README.md new file mode 100644 index 0000000000..9ad496184f --- /dev/null +++ b/examples/directives/README.md @@ -0,0 +1,7 @@ +# Leptos Directives Example + +This example showcases a basic leptos app that shows how to write and use directives. + +## Getting Started + +See the [Examples README](../README.md) for setup and run instructions. diff --git a/examples/directives/index.html b/examples/directives/index.html new file mode 100644 index 0000000000..5c37472ec1 --- /dev/null +++ b/examples/directives/index.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/examples/directives/rust-toolchain.toml b/examples/directives/rust-toolchain.toml new file mode 100644 index 0000000000..e9743fb495 --- /dev/null +++ b/examples/directives/rust-toolchain.toml @@ -0,0 +1,3 @@ + +[toolchain] +channel = "nightly" diff --git a/examples/directives/src/lib.rs b/examples/directives/src/lib.rs new file mode 100644 index 0000000000..dac012c790 --- /dev/null +++ b/examples/directives/src/lib.rs @@ -0,0 +1,51 @@ +use leptos::{ev::click, html::AnyElement, *}; + +pub fn highlight(el: HtmlElement) { + let mut highlighted = false; + + let _ = el.clone().on(click, move |_| { + highlighted = !highlighted; + + if highlighted { + let _ = el.clone().style("background-color", "yellow"); + } else { + let _ = el.clone().style("background-color", "transparent"); + } + }); +} + +pub fn copy_to_clipboard(el: HtmlElement, content: &str) { + let content = content.to_string(); + + let _ = el.clone().on(click, move |evt| { + evt.prevent_default(); + evt.stop_propagation(); + + let _ = window() + .navigator() + .clipboard() + .expect("navigator.clipboard to be available") + .write_text(&content); + + let _ = el.clone().inner_html(format!("Copied \"{}\"", &content)); + }); +} + +#[component] +pub fn SomeComponent() -> impl IntoView { + view! { +

Some paragraphs

+

that can be clicked

+

in order to highlight them

+ } +} + +#[component] +pub fn App() -> impl IntoView { + let data = "Hello World!"; + + view! { + "Copy \"" {data} "\" to clipboard" + + } +} diff --git a/examples/directives/src/main.rs b/examples/directives/src/main.rs new file mode 100644 index 0000000000..aeb773e370 --- /dev/null +++ b/examples/directives/src/main.rs @@ -0,0 +1,8 @@ +use directives::App; +use leptos::*; + +fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + mount_to_body(|| view! { }) +} diff --git a/examples/directives/tests/web.rs b/examples/directives/tests/web.rs new file mode 100644 index 0000000000..9b392e46e2 --- /dev/null +++ b/examples/directives/tests/web.rs @@ -0,0 +1,58 @@ +use gloo_timers::future::sleep; +use std::time::Duration; +use wasm_bindgen::JsCast; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); +use directives::App; +use leptos::*; +use web_sys::HtmlElement; + +#[wasm_bindgen_test] +async fn test_directives() { + mount_to_body(|| view! { }); + sleep(Duration::ZERO).await; + + let document = leptos::document(); + let paragraphs = document.query_selector_all("p").unwrap(); + + assert_eq!(paragraphs.length(), 3); + + for i in 0..paragraphs.length() { + println!("i: {}", i); + let p = paragraphs + .item(i) + .unwrap() + .dyn_into::() + .unwrap(); + assert_eq!( + p.style().get_property_value("background-color").unwrap(), + "" + ); + + p.click(); + + assert_eq!( + p.style().get_property_value("background-color").unwrap(), + "yellow" + ); + + p.click(); + + assert_eq!( + p.style().get_property_value("background-color").unwrap(), + "transparent" + ); + } + + let a = document + .query_selector("a") + .unwrap() + .unwrap() + .dyn_into::() + .unwrap(); + assert_eq!(a.inner_html(), "Copy \"Hello World!\" to clipboard"); + + a.click(); + assert_eq!(a.inner_html(), "Copied \"Hello World!\""); +} diff --git a/leptos_dom/src/directive.rs b/leptos_dom/src/directive.rs new file mode 100644 index 0000000000..ed343b61ab --- /dev/null +++ b/leptos_dom/src/directive.rs @@ -0,0 +1,83 @@ +use crate::{html::AnyElement, HtmlElement}; +use std::rc::Rc; + +/// Trait for a directive handler function. +/// This is used so it's possible to use functions with one or two +/// parameters as directive handlers. +/// +/// You can use directives like the following. +/// +/// ``` +/// # use leptos::{*, html::AnyElement}; +/// +/// // This doesn't take an attribute value +/// fn my_directive(el: HtmlElement) { +/// // do sth +/// } +/// +/// // This requires an attribute value +/// fn another_directive(el: HtmlElement, params: i32) { +/// // do sth +/// } +/// +/// #[component] +/// pub fn MyComponent() -> impl IntoView { +/// view! { +/// // no attribute value +///
+/// +/// // with an attribute value +///
+/// } +/// } +/// ``` +/// +/// A directive is just syntactic sugar for +/// +/// ```ignore +/// let node_ref = create_node_ref(); +/// +/// create_effect(move |_| { +/// if let Some(el) = node_ref.get() { +/// directive_func(el, possibly_some_param); +/// } +/// }); +/// ``` +/// +/// A directive can be a function with one or two parameters. +/// The first is the element the directive is added to and the optional +/// second is the parameter that is provided in the attribute. +pub trait Directive { + /// Calls the handler function + fn run(&self, el: HtmlElement, param: P); +} + +impl Directive<(HtmlElement,), ()> for F +where + F: Fn(HtmlElement), +{ + fn run(&self, el: HtmlElement, _: ()) { + self(el) + } +} + +impl Directive<(HtmlElement, P), P> for F +where + F: Fn(HtmlElement, P), +{ + fn run(&self, el: HtmlElement, param: P) { + self(el, param); + } +} + +impl Directive for Rc> { + fn run(&self, el: HtmlElement, param: P) { + (**self).run(el, param) + } +} + +impl Directive for Box> { + fn run(&self, el: HtmlElement, param: P) { + (**self).run(el, param); + } +} diff --git a/leptos_dom/src/html.rs b/leptos_dom/src/html.rs index 0067c8a219..3fbc952aa8 100644 --- a/leptos_dom/src/html.rs +++ b/leptos_dom/src/html.rs @@ -62,15 +62,16 @@ cfg_if! { } use crate::{ + create_node_ref, ev::EventDescriptor, hydration::HydrationCtx, macro_helpers::{ Attribute, IntoAttribute, IntoClass, IntoProperty, IntoStyle, }, - Element, Fragment, IntoView, NodeRef, Text, View, + Directive, Element, Fragment, IntoView, NodeRef, Text, View, }; -use leptos_reactive::Oco; -use std::fmt; +use leptos_reactive::{create_effect, Oco}; +use std::{fmt, rc::Rc}; /// Trait which allows creating an element tag. pub trait ElementDescriptor: ElementDescriptorBounds { @@ -508,7 +509,6 @@ impl HtmlElement { use once_cell::unsync::OnceCell; use std::{ cell::RefCell, - rc::Rc, task::{Poll, Waker}, }; @@ -1146,6 +1146,28 @@ impl HtmlElement { } } +impl HtmlElement { + /// Bind the directive to the element. + #[inline(always)] + pub fn directive( + self, + handler: impl Directive + 'static, + param: P, + ) -> Self { + let node_ref = create_node_ref::(); + + let handler = Rc::new(handler); + + let _ = create_effect(move |_| { + if let Some(el) = node_ref.get() { + Rc::clone(&handler).run(el.into_any(), param.clone()); + } + }); + + self.node_ref(node_ref) + } +} + impl IntoView for HtmlElement { #[cfg_attr(any(debug_assertions, feature = "ssr"), instrument(level = "trace", name = "", skip_all, fields(tag = %self.element.name())))] #[cfg_attr(all(target_arch = "wasm32", feature = "web"), inline(always))] diff --git a/leptos_dom/src/lib.rs b/leptos_dom/src/lib.rs index e0aa7a2ec6..56055f3dfe 100644 --- a/leptos_dom/src/lib.rs +++ b/leptos_dom/src/lib.rs @@ -10,6 +10,7 @@ pub extern crate tracing; mod components; +mod directive; mod events; pub mod helpers; pub mod html; @@ -25,8 +26,10 @@ pub mod ssr; pub mod ssr_in_order; pub mod svg; mod transparent; + use cfg_if::cfg_if; pub use components::*; +pub use directive::*; #[cfg(all(target_arch = "wasm32", feature = "web"))] pub use events::add_event_helper; #[cfg(all(target_arch = "wasm32", feature = "web"))] @@ -46,9 +49,9 @@ pub use node_ref::*; use once_cell::unsync::Lazy as LazyCell; #[cfg(not(all(target_arch = "wasm32", feature = "web")))] use smallvec::SmallVec; -use std::{borrow::Cow, fmt}; #[cfg(all(target_arch = "wasm32", feature = "web"))] -use std::{cell::RefCell, rc::Rc}; +use std::cell::RefCell; +use std::{borrow::Cow, fmt, rc::Rc}; pub use transparent::*; #[cfg(all(target_arch = "wasm32", feature = "web"))] use wasm_bindgen::JsCast; @@ -135,7 +138,7 @@ where } } -impl IntoView for std::rc::Rc N> +impl IntoView for Rc N> where N: IntoView + 'static, { @@ -709,8 +712,7 @@ impl View { /// Adds an event listener, analogous to [`HtmlElement::on`]. /// - /// This method will attach an event listener to **all** child - /// [`HtmlElement`] children. + /// This method will attach an event listener to **all** children #[inline(always)] pub fn on( self, @@ -755,7 +757,7 @@ impl View { let event_handler = Rc::new(RefCell::new(event_handler)); c.children.iter().cloned().for_each(|c| { - let event_handler = event_handler.clone(); + let event_handler = Rc::clone(&event_handler); _ = c.on(event.clone(), Box::new(move |e| event_handler.borrow_mut()(e))); }); @@ -775,6 +777,65 @@ impl View { self } + + /// Adds a directive analogous to [`HtmlElement::directive`]. + /// + /// This method will attach directive to **all** child + /// [`HtmlElement`] children. + #[inline(always)] + pub fn directive( + self, + handler: impl Directive + 'static, + param: P, + ) -> Self + where + T: ?Sized + 'static, + P: Clone + 'static, + { + cfg_if::cfg_if! { + if #[cfg(debug_assertions)] { + trace!("calling directive()"); + let span = ::tracing::Span::current(); + let handler = move |e, p| { + let _guard = span.enter(); + handler.run(e, p); + }; + } + } + + self.directive_impl(Box::new(handler), param) + } + + fn directive_impl( + self, + handler: Box>, + param: P, + ) -> Self + where + T: ?Sized + 'static, + P: Clone + 'static, + { + cfg_if! { if #[cfg(all(target_arch = "wasm32", feature = "web"))] { + match &self { + Self::Element(el) => { + let _ = el.clone().into_html_element().directive(handler, param); + } + Self::Component(c) => { + let handler = Rc::from(handler); + + for child in c.children.iter().cloned() { + let _ = child.directive(Rc::clone(&handler), param.clone()); + } + } + _ => {} + } + } else { + let _ = handler; + let _ = param; + }} + + self + } } #[cfg_attr(debug_assertions, instrument)] diff --git a/leptos_macro/src/view/client_builder.rs b/leptos_macro/src/view/client_builder.rs index b4fa314579..631eb1fb7b 100644 --- a/leptos_macro/src/view/client_builder.rs +++ b/leptos_macro/src/view/client_builder.rs @@ -6,7 +6,7 @@ use super::{ is_self_closing, is_svg_element, parse_event_name, slot_helper::{get_slot, slot_to_tokens}, }; -use crate::attribute_value; +use crate::{attribute_value, view::directive_call_from_attribute_node}; use leptos_hot_reload::parsing::{is_component_node, value_to_string}; use proc_macro2::{Ident, Span, TokenStream, TokenTree}; use quote::{quote, quote_spanned}; @@ -383,6 +383,8 @@ pub(crate) fn attribute_to_tokens( quote! { .#node_ref(#value) } + } else if let Some(name) = name.strip_prefix("use:") { + directive_call_from_attribute_node(node, name) } else if let Some(name) = name.strip_prefix("on:") { let handler = attribute_value(node); diff --git a/leptos_macro/src/view/component_builder.rs b/leptos_macro/src/view/component_builder.rs index cc6b535c01..41924e8577 100644 --- a/leptos_macro/src/view/component_builder.rs +++ b/leptos_macro/src/view/component_builder.rs @@ -4,6 +4,7 @@ use super::{ client_builder::{fragment_to_tokens, TagType}, event_from_attribute_node, }; +use crate::view::directive_call_from_attribute_node; use proc_macro2::{Ident, TokenStream, TokenTree}; use quote::{format_ident, quote}; use rstml::node::{NodeAttribute, NodeElement}; @@ -34,6 +35,7 @@ pub(crate) fn component_to_tokens( && !attr.key.to_string().starts_with("clone:") && !attr.key.to_string().starts_with("on:") && !attr.key.to_string().starts_with("attr:") + && !attr.key.to_string().starts_with("use:") }) .map(|attr| { let name = &attr.key; @@ -82,6 +84,19 @@ pub(crate) fn component_to_tokens( }) .collect::>(); + let directives = attrs + .clone() + .filter_map(|attr| { + attr.key + .to_string() + .strip_prefix("use:") + .map(|ident| directive_call_from_attribute_node(attr, ident)) + }) + .collect::>(); + + let events_and_directives = + events.into_iter().chain(directives).collect::>(); + let dyn_attrs = attrs .filter(|attr| attr.key.to_string().starts_with("attr:")) .filter_map(|attr| { @@ -192,12 +207,12 @@ pub(crate) fn component_to_tokens( /* #[cfg(debug_assertions)] IdeTagHelper::add_component_completion(&mut component, node); */ - if events.is_empty() { + if events_and_directives.is_empty() { component } else { quote! { #component.into_view() - #(#events)* + #(#events_and_directives)* } } } diff --git a/leptos_macro/src/view/mod.rs b/leptos_macro/src/view/mod.rs index 4ef2dd8131..f7cbf329dc 100644 --- a/leptos_macro/src/view/mod.rs +++ b/leptos_macro/src/view/mod.rs @@ -1,7 +1,7 @@ use crate::{attribute_value, Mode}; use convert_case::{Case::Snake, Casing}; use proc_macro2::{Ident, Span, TokenStream, TokenTree}; -use quote::{quote, quote_spanned}; +use quote::{format_ident, quote, quote_spanned}; use rstml::node::{KeyedAttribute, Node, NodeElement, NodeName}; use syn::{spanned::Spanned, Expr, Expr::Tuple, ExprLit, ExprPath, Lit}; @@ -531,3 +531,18 @@ pub(crate) fn event_from_attribute_node( }; (event_type, handler) } + +pub(crate) fn directive_call_from_attribute_node( + attr: &KeyedAttribute, + directive_name: &str, +) -> TokenStream { + let handler = format_ident!("{directive_name}", span = attr.key.span()); + + let param = if let Some(value) = attr.value() { + quote! { #value.into() } + } else { + quote! { () } + }; + + quote! { .directive(#handler, #param) } +}