diff --git a/Cargo.lock b/Cargo.lock index 67535d0..fe03852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -715,6 +715,14 @@ dependencies = [ "web-sys", ] +[[package]] +name = "leptos-maybe-callback" +version = "0.0.3" +dependencies = [ + "leptos", + "log", +] + [[package]] name = "leptos-node-ref" version = "0.0.3" @@ -867,9 +875,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" [[package]] name = "manyhow" diff --git a/Cargo.toml b/Cargo.toml index 702fd62..077a969 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,4 @@ version = "0.0.3" [workspace.dependencies] leptos = "0.7.0" +log = "0.4.25" diff --git a/packages/leptos-maybe-callback/Cargo.toml b/packages/leptos-maybe-callback/Cargo.toml new file mode 100644 index 0000000..d131575 --- /dev/null +++ b/packages/leptos-maybe-callback/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "leptos-maybe-callback" +description = "Optional callbacks for Leptos." + +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +leptos.workspace = true + +[dev-dependencies] +log.workspace = true diff --git a/packages/leptos-maybe-callback/README.md b/packages/leptos-maybe-callback/README.md new file mode 100644 index 0000000..92c7e07 --- /dev/null +++ b/packages/leptos-maybe-callback/README.md @@ -0,0 +1,104 @@ +# Leptos Maybe Callback + +Optional callbacks for [Leptos](https://leptos.dev/). + +## Documentation + +Documentation for the crate is available on [Docs.rs](https://docs.rs/): + +- [`leptos-node-ref`](https://docs.rs/leptos-maybe-callback/latest/leptos_maybe_callback/) + +## Example + +### Component with Optional Callback Prop + +Define a component that accepts an optional callback using `#[prop(into, optional)]`. This allows passing a closure, a +`Callback`, or omitting the prop. + +```rust +use leptos::{ev::MouseEvent, prelude::*}; +use leptos_maybe_callback::MaybeCallback; + +/// A button component with an optional `onclick` callback. +#[component] +pub fn Button( + #[prop(into, optional)] + onclick: MaybeCallback, +) -> impl IntoView { + view! { + + } +} +``` + +### Using the Component with a Closure + +Use the `Button` component and provide a closure for the `onclick` prop. + +```rust +use leptos::prelude::*; +use leptos_maybe_callback::MaybeCallback; + +/// Parent component using `Button` with a closure. +#[component] +pub fn ButtonWithClosure() -> impl IntoView { + view! { +
+
+ } +} +``` + +### Using the Component with a `Callback` + +Alternatively, pass a `Callback` as the `onclick` prop. + +```rust +use leptos::{ev::MouseEvent, prelude::*}; +use leptos_maybe_callback::MaybeCallback; + +/// Parent component using `Button` with a `Callback`. +#[component] +pub fn ButtonWithCallback() -> impl IntoView { + let on_click = Callback::new(|event: MouseEvent| { + log::info!("Clicked with event: {:?}", event); + }); + + view! { +
+
+ } +} +``` + +### Omitting the Callback + +If no callback is needed, omit the `onclick` prop or pass `None`. + +```rust +use leptos::{ev::MouseEvent, prelude::*}; +use leptos_maybe_callback::MaybeCallback; + +/// Parent component using `Button` without a callback. +#[component] +pub fn ButtonWithoutCallback() -> impl IntoView { + view! { +
+
+ } +} +``` + +## Rust For Web + +The Leptos Maybe Callback project is part of [Rust For Web](https://github.com/RustForWeb). + +[Rust For Web](https://github.com/RustForWeb) creates and ports web UI libraries for Rust. All projects are free and open source. diff --git a/packages/leptos-maybe-callback/src/lib.rs b/packages/leptos-maybe-callback/src/lib.rs new file mode 100644 index 0000000..2b5f3d1 --- /dev/null +++ b/packages/leptos-maybe-callback/src/lib.rs @@ -0,0 +1,131 @@ +//! Optional callbacks for [Leptos](https://leptos.dev/). +//! +//! # Example +//! +//! ## Component with Optional Callback Prop +//! +//! Define a component that accepts an optional callback using `#[prop(into, optional)]`. This allows passing a closure, a +//! `Callback`, or omitting the prop. +//! +//! ``` +//! use leptos::{ev::MouseEvent, prelude::*}; +//! use leptos_maybe_callback::MaybeCallback; +//! +//! /// A button component with an optional `onclick` callback. +//! #[component] +//! pub fn Button( +//! #[prop(into, optional)] +//! onclick: MaybeCallback, +//! ) -> impl IntoView { +//! view! { +//! +//! } +//! } +//! ``` +//! +//! ## Using the Component with a Closure +//! +//! Use the `Button` component and provide a closure for the `onclick` prop. +//! +//! ``` +//! # use leptos::ev::MouseEvent; +//! use leptos::prelude::*; +//! use leptos_maybe_callback::MaybeCallback; +//! +//! # #[component] +//! # pub fn Button( +//! # #[prop(into, optional)] +//! # onclick: MaybeCallback, +//! # ) -> impl IntoView { +//! # view! { +//! # +//! # } +//! # } +//! # +//! /// Parent component using `Button` with a closure. +//! #[component] +//! pub fn ButtonWithClosure() -> impl IntoView { +//! view! { +//!
+//!
+//! } +//! } +//! ``` +//! +//! ## Using the Component with a `Callback` +//! +//! Alternatively, pass a `Callback` as the `onclick` prop. +//! +//! ```rust +//! use leptos::{ev::MouseEvent, prelude::*}; +//! use leptos_maybe_callback::MaybeCallback; +//! +//! # #[component] +//! # pub fn Button( +//! # #[prop(into, optional)] +//! # onclick: MaybeCallback, +//! # ) -> impl IntoView { +//! # view! { +//! # +//! # } +//! # } +//! # +//! /// Parent component using `Button` with a `Callback`. +//! #[component] +//! pub fn ButtonWithCallback() -> impl IntoView { +//! let on_click = Callback::new(|event: MouseEvent| { +//! log::info!("Clicked with event: {:?}", event); +//! }); +//! +//! view! { +//!
+//!
+//! } +//! } +//! ``` +//! +//! ## Omitting the Callback +//! +//! If no callback is needed, omit the `onclick` prop or pass `None`. +//! +//! ```rust +//! use leptos::{ev::MouseEvent, prelude::*}; +//! use leptos_maybe_callback::MaybeCallback; +//! +//! # #[component] +//! # pub fn Button( +//! # #[prop(into, optional)] +//! # onclick: MaybeCallback, +//! # ) -> impl IntoView { +//! # view! { +//! # +//! # } +//! # } +//! # +//! /// Parent component using `Button` without a callback. +//! #[component] +//! pub fn ButtonWithoutCallback() -> impl IntoView { +//! view! { +//!
+//!
+//! } +//! } +//! ``` + +mod maybe_callback; + +pub use maybe_callback::*; diff --git a/packages/leptos-maybe-callback/src/maybe_callback.rs b/packages/leptos-maybe-callback/src/maybe_callback.rs new file mode 100644 index 0000000..04b9079 --- /dev/null +++ b/packages/leptos-maybe-callback/src/maybe_callback.rs @@ -0,0 +1,138 @@ +use std::ops::Deref; + +use leptos::prelude::{Callable, Callback}; + +/// A wrapper around an optional callback that provides convenient conversion and method call semantics. +#[derive(Debug, Clone)] +pub struct MaybeCallback(pub Option>); + +impl MaybeCallback { + /// Creates a new [`MaybeCallback`] from a callback. + pub fn new(callback: impl Into>) -> Self { + Self(Some(callback.into())) + } + + /// Returns a reference to the contained callback, if any. + pub fn as_ref(&self) -> Option<&Callback> { + self.0.as_ref() + } + + /// Runs the stored callback if available. + pub fn run(&self, event: T) { + if let Some(ref cb) = self.0 { + cb.run(event); + } + } + + /// Converts this `MaybeCallback` into a `MaybeCallback` by applying `f`. + pub fn map(self, f: impl FnOnce(Callback) -> Callback) -> MaybeCallback { + MaybeCallback(self.0.map(f)) + } + + /// Returns `true` if the callback is `Some`. + pub fn is_some(&self) -> bool { + self.0.is_some() + } + + /// Returns `true` if the callback is `None`. + pub fn is_none(&self) -> bool { + self.0.is_none() + } + + /// Converts `MaybeCallback` into a `Callback` that conditionally runs the inner callback. + pub fn as_callback(&self) -> Callback { + // Clone the inner `Option>` to own it within the closure. + let callback = self.0; + Callback::new(move |event: T| { + if let Some(ref cb) = callback { + cb.run(event); + } + }) + } + + /// Consumes `MaybeCallback` and returns a `FnMut(T)` closure that runs the callback if present. + pub fn into_handler(self) -> impl FnMut(T) { + move |event: T| { + self.run(event); + } + } + + /// Borrows `MaybeCallback` and returns a `FnMut(T)` closure that runs the callback if present. + /// This method clones the inner callback to avoid consuming `self`. + pub fn as_handler(&self) -> impl FnMut(T) + '_ { + let callback = self.0; + move |event: T| { + if let Some(ref cb) = callback { + cb.run(event); + } + } + } +} + +impl Default for MaybeCallback { + fn default() -> Self { + Self(None) + } +} + +impl Deref for MaybeCallback { + type Target = Option>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From> for Option> { + fn from(maybe: MaybeCallback) -> Self { + maybe.0 + } +} + +impl From> for MaybeCallback { + fn from(callback: Callback) -> Self { + Self(Some(callback)) + } +} + +impl From>> for MaybeCallback { + fn from(option: Option>) -> Self { + Self(option) + } +} + +impl From>>> for MaybeCallback { + fn from(opt: Option>>) -> Self { + Self(opt.flatten()) + } +} + +impl From for MaybeCallback +where + T: 'static, + F: Fn(T) + Send + Sync + 'static, +{ + fn from(f: F) -> Self { + Self(Some(Callback::new(f))) + } +} + +impl From> for MaybeCallback +where + T: 'static, + F: Fn(T) + Send + Sync + 'static, +{ + fn from(opt: Option) -> Self { + Self(opt.map(Callback::new)) + } +} + +impl From>> for MaybeCallback +where + T: 'static, + F: Fn(T) + Send + Sync + 'static, +{ + fn from(opt: Option>) -> Self { + Self(opt.flatten().map(Callback::new)) + } +} diff --git a/packages/leptos-maybe-callback/tests/maybe_callback.rs b/packages/leptos-maybe-callback/tests/maybe_callback.rs new file mode 100644 index 0000000..cb4cabd --- /dev/null +++ b/packages/leptos-maybe-callback/tests/maybe_callback.rs @@ -0,0 +1,216 @@ +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::Arc; + +use leptos::prelude::{Callable, Callback}; +use leptos_maybe_callback::*; + +/// Tests the default value of `MaybeCallback`, expecting it to be `None`. +#[test] +fn test_default() { + let maybe_callback: MaybeCallback<()> = Default::default(); + assert!( + maybe_callback.is_none(), + "Expected MaybeCallback to be None by default." + ); +} + +/// Tests creating a `MaybeCallback` from a `Some(Callback)`, expecting it to contain `Some`. +#[test] +fn test_from_some_callback() { + let was_called = Arc::new(AtomicBool::new(false)); + let was_called_clone = Arc::clone(&was_called); + + let cb = Callback::new(move |_: bool| { + was_called_clone.store(true, Ordering::SeqCst); + }); + + let maybe = MaybeCallback::from(Some(cb)); + assert!(maybe.is_some(), "Expected MaybeCallback to be Some."); + + // Execute the callback to ensure it works + maybe.run(true); + assert!( + was_called.load(Ordering::SeqCst), + "Callback was not called." + ); +} + +/// Tests creating a `MaybeCallback` from `None`, expecting it to be `None`. +#[test] +fn test_from_none_callback() { + let maybe: MaybeCallback<()> = MaybeCallback::from(None::>); + assert!( + maybe.is_none(), + "Expected MaybeCallback to be None when initialized with None." + ); +} + +/// Tests the `run` method when `MaybeCallback` contains `Some(Callback)`. +#[test] +fn test_run_some() { + let counter = Arc::new(AtomicI32::new(0)); + let counter_clone = Arc::clone(&counter); + + let cb = Callback::new(move |val: i32| { + counter_clone.fetch_add(val, Ordering::SeqCst); + }); + + let maybe = MaybeCallback::from(Some(cb)); + maybe.run(5); + assert_eq!( + counter.load(Ordering::SeqCst), + 5, + "Counter should have been incremented by 5." + ); +} + +/// Tests the `run` method when `MaybeCallback` is `None`, ensuring no action is taken. +#[test] +fn test_run_none() { + let counter = Arc::new(AtomicI32::new(0)); + let maybe: MaybeCallback = MaybeCallback::from(None::>); + maybe.run(5); + // Should remain unchanged + assert_eq!( + counter.load(Ordering::SeqCst), + 0, + "Counter should remain unchanged when MaybeCallback is None." + ); +} + +/// Tests the `map` method on a `MaybeCallback` containing `Some(Callback)`. +#[test] +fn test_map_some() { + let was_called = Arc::new(AtomicBool::new(false)); + let was_called_clone = Arc::clone(&was_called); + + let original_cb = Callback::new(move |val: i32| { + assert_eq!(val, 42, "Callback received incorrect value."); + was_called_clone.store(true, Ordering::SeqCst); + }); + + let maybe = MaybeCallback::from(Some(original_cb)); + + // Map i32 -> &str + let new_maybe = maybe.map(|cb| { + Callback::new(move |_: &str| { + cb.run(42); // calls original callback + }) + }); + + assert!(new_maybe.is_some(), "Mapped MaybeCallback should be Some."); + + // Execute the mapped callback + new_maybe.run("Hello"); + assert!( + was_called.load(Ordering::SeqCst), + "Mapped callback was not called." + ); +} + +/// Tests the `map` method on a `MaybeCallback` that is `None`, ensuring it remains `None`. +#[test] +fn test_map_none() { + let maybe: MaybeCallback = MaybeCallback::from(None::>); + let new_maybe = maybe.map(|_cb| { + // This closure should never be called + Callback::new(|val: &str| println!("val: {}", val)) + }); + assert!( + new_maybe.is_none(), + "Mapped MaybeCallback should remain None when original is None." + ); +} + +/// Tests the `as_handler` method by generating multiple handlers and ensuring they work independently. +#[test] +fn test_as_handler_multiple() { + let counter1 = Arc::new(AtomicI32::new(0)); + let counter1_clone = Arc::clone(&counter1); + + let counter2 = Arc::new(AtomicI32::new(0)); + let counter2_clone = Arc::clone(&counter2); + + let cb = Callback::new(move |val: i32| { + counter1_clone.fetch_add(val, Ordering::SeqCst); + }); + + let maybe = MaybeCallback::from(Some(cb)); + let mut handler1 = maybe.as_handler(); + + // Create another handler with a different callback + let cb2 = Callback::new(move |val: i32| { + counter2_clone.fetch_add(val, Ordering::SeqCst); + }); + let maybe2 = MaybeCallback::from(Some(cb2)); + let mut handler2 = maybe2.as_handler(); + + handler1(10); + handler2(20); + + assert_eq!( + counter1.load(Ordering::SeqCst), + 10, + "First handler should have incremented counter1 by 10." + ); + assert_eq!( + counter2.load(Ordering::SeqCst), + 20, + "Second handler should have incremented counter2 by 20." + ); +} + +/// Tests the `as_callback` method to ensure it returns a `Callback` that conditionally executes. +#[test] +fn test_as_callback_some() { + let was_called = Arc::new(AtomicBool::new(false)); + let was_called_clone = Arc::clone(&was_called); + + let cb = Callback::new(move |_| { + was_called_clone.store(true, Ordering::SeqCst); + }); + + let maybe = MaybeCallback::from(Some(cb)); + let callback = maybe.as_callback(); + callback.run(()); + + assert!( + was_called.load(Ordering::SeqCst), + "Callback should have been executed." + ); +} + +/// Tests the `as_callback` method when `MaybeCallback` is `None`, ensuring it does nothing. +#[test] +fn test_as_callback_none() { + let maybe: MaybeCallback<()> = MaybeCallback::from(None::>); + let callback = maybe.as_callback(); + // Should not panic or do anything + callback.run(()); +} + +/// Tests the `into_handler` method by consuming the `MaybeCallback` and ensuring it cannot be used afterward. +#[test] +fn test_into_handler() { + let counter = Arc::new(AtomicI32::new(0)); + let counter_clone = Arc::clone(&counter); + + let cb = Callback::new(move |val: i32| { + counter_clone.fetch_add(val, Ordering::SeqCst); + }); + + let maybe = MaybeCallback::from(Some(cb)); + let mut handler = maybe.into_handler(); + handler(15); + + assert_eq!( + counter.load(Ordering::SeqCst), + 15, + "Counter should have been incremented by 15." + ); + + // Since `maybe` is consumed, attempting to use it should result in a compile-time error. + // Uncommenting the following lines should cause a compilation error. + // + // maybe.run(10); // Error: use of moved value: `maybe` +} diff --git a/packages/leptos-node-ref/src/lib.rs b/packages/leptos-node-ref/src/lib.rs index 7e1af45..b4f610d 100644 --- a/packages/leptos-node-ref/src/lib.rs +++ b/packages/leptos-node-ref/src/lib.rs @@ -1,5 +1,5 @@ //! Node reference extras for [Leptos](https://leptos.dev/). -//! + mod any_node_ref; pub use any_node_ref::*; diff --git a/packages/leptos-struct-component/src/lib.rs b/packages/leptos-struct-component/src/lib.rs index 261445f..7c01d76 100644 --- a/packages/leptos-struct-component/src/lib.rs +++ b/packages/leptos-struct-component/src/lib.rs @@ -1,4 +1,5 @@ //! Define [Leptos](https://leptos.dev/) components using structs. + // mod attributes; pub use leptos_struct_component_macro::*; diff --git a/packages/leptos-style/src/lib.rs b/packages/leptos-style/src/lib.rs index 2ebe4d6..9da68b2 100644 --- a/packages/leptos-style/src/lib.rs +++ b/packages/leptos-style/src/lib.rs @@ -1,4 +1,5 @@ //! Style for [Yew](https://yew.rs/) components. + mod style; pub use crate::style::*;