diff --git a/Cargo.lock b/Cargo.lock index 1ba583d..b633a5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,9 +95,9 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "camino" @@ -655,18 +655,19 @@ checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2" [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" dependencies = [ + "once_cell", "wasm-bindgen", ] [[package]] name = "leptos" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5046c590aea121f6ad5e71fcb75453a933425d39527b9a3b1b295235afc8df" +checksum = "5c4e32cac886183e0faf8dd30944072302359a8508380827f649ef9bbdd179ad" dependencies = [ "any_spawner", "cfg-if", @@ -697,9 +698,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "leptos-maybe-callback" +version = "0.0.4" +dependencies = [ + "leptos", +] + [[package]] name = "leptos-node-ref" -version = "0.0.3" +version = "0.0.4" dependencies = [ "leptos", "send_wrapper", @@ -707,7 +715,7 @@ dependencies = [ [[package]] name = "leptos-struct-component" -version = "0.0.3" +version = "0.0.4" dependencies = [ "leptos", "leptos-node-ref", @@ -716,7 +724,7 @@ dependencies = [ [[package]] name = "leptos-struct-component-macro" -version = "0.0.3" +version = "0.0.4" dependencies = [ "proc-macro2", "quote", @@ -725,17 +733,25 @@ dependencies = [ [[package]] name = "leptos-style" -version = "0.0.3" +version = "0.0.4" dependencies = [ "indexmap", "leptos", ] +[[package]] +name = "leptos-typed-fallback-show" +version = "0.0.4" +dependencies = [ + "leptos", + "leptos-node-ref", +] + [[package]] name = "leptos_config" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2d64c43e2554108c26da3127f8384d92ca76c6f0b7288d1c09c8cc68152064" +checksum = "1961b08a7dc3f4a559223be87c44b56bb565bf0b438909a0c3c31d88bfad0982" dependencies = [ "config", "regex", @@ -746,9 +762,9 @@ dependencies = [ [[package]] name = "leptos_dom" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c15aca81dc2edd040b51c46734f65c6f36e6ba8a31347c1354c94b958044ae0" +checksum = "652ba5e3a5c4e703e5bd4b24b62de9dbeedca84e5f31aff045bf329014418496" dependencies = [ "js-sys", "or_poisoned", @@ -761,9 +777,9 @@ dependencies = [ [[package]] name = "leptos_hot_reload" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0445f3a62696d2d66bef288911af34405718880b4b8dd6c5cfb7751fd8ffcc6b" +checksum = "77bc67823a8eb1c961ee08dd7b3a814964c63928a64954e3521733b56dd7e66f" dependencies = [ "anyhow", "camino", @@ -779,9 +795,9 @@ dependencies = [ [[package]] name = "leptos_macro" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92f690c955274f1722ee6c66463ace79301d53a8c2bf7f6e4e61b978ca239e20" +checksum = "0d4b2418deac01fe9a3862d410a6a6beff235e4b124d9e874986bb22617aeea6" dependencies = [ "attribute-derive", "cfg-if", @@ -801,9 +817,9 @@ dependencies = [ [[package]] name = "leptos_server" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93450589df3b3e398c7f5ea64d8f1c8369b1ba9b90e1f70f6cb996b8d443ca3e" +checksum = "5cc1e6a182eebf8b1739dff377e63688eb7ab08c133f657e939969daa356a275" dependencies = [ "any_spawner", "base64", @@ -1326,9 +1342,9 @@ dependencies = [ [[package]] name = "server_fn" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "033cb8014aa86a7ce0c6ee58d23dce1a078b2e320dc6c53bb439663993199b1f" +checksum = "1abb4001047ecff14681ec411bbd9a8a95a7b405e543ed043fece8f0c5a2f4fe" dependencies = [ "bytes", "const_format", @@ -1356,9 +1372,9 @@ dependencies = [ [[package]] name = "server_fn_macro" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0249e8a55ca464a1e69f02a95d562f2c65e92e301093a02ebf15d21f68f2a99e" +checksum = "7883cf3cb2522ce663df033e2fd3093a4497e9b8ed30f354200ef7058c9c792c" dependencies = [ "const_format", "convert_case", @@ -1370,9 +1386,9 @@ dependencies = [ [[package]] name = "server_fn_macro_default" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c54a6d43cd0f3d2bdf0c85b6119f378b6b89d528159af9cde77f229faeecbc" +checksum = "a734ef90a83ee9517468b38a017f602a869b382c18e8e8b4118f3f1b88f2856c" dependencies = [ "server_fn_macro", "syn", @@ -1667,9 +1683,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" dependencies = [ "cfg-if", "once_cell", @@ -1678,13 +1694,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn", @@ -1693,21 +1708,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1715,9 +1731,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", @@ -1728,9 +1744,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" [[package]] name = "wasm-streams" @@ -1747,9 +1763,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 702fd62..be00a61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,10 @@ authors = ["Rust For Web "] edition = "2021" license = "MIT" repository = "https://github.com/RustForWeb/leptos-utils" -version = "0.0.3" +version = "0.0.4" [workspace.dependencies] -leptos = "0.7.0" +leptos = "0.7.2" +leptos-struct-component-macro = { path = "./packages/leptos-struct-component-macro" } +leptos-node-ref = { path = "./packages/leptos-node-ref" } + diff --git a/packages/leptos-maybe-callback/Cargo.toml b/packages/leptos-maybe-callback/Cargo.toml new file mode 100644 index 0000000..3785f27 --- /dev/null +++ b/packages/leptos-maybe-callback/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "leptos-maybe-callback" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +leptos.workspace = true \ No newline at end of file diff --git a/packages/leptos-maybe-callback/README.md b/packages/leptos-maybe-callback/README.md new file mode 100644 index 0000000..6d31add --- /dev/null +++ b/packages/leptos-maybe-callback/README.md @@ -0,0 +1,144 @@ +# Leptos Maybe Callback + +Lightweight, type-safe optional callbacks for [Leptos](https://leptos.dev/). Provides a zero-cost abstraction for +conditionally handling event callbacks in your Leptos components. + +## Features + +- **Optional Callbacks**: Represent callbacks that may or may not exist. +- **Zero-Cost Abstraction**: Minimal overhead using Rust's `Option` and enums. +- **Seamless Integration**: Works effortlessly with Leptos signals and components. +- **Flexible Conversions**: Convert from various callback-like types, including nested `Option`s and `Fn` closures. +- **Thread-Safe & Wasm-Ready**: Implements `Send + Sync` where applicable. +- **Convenient Handler Generation**: Utilize `as_handler` and `into_handler` methods for generating event handlers. + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +leptos-maybe-callback = "0.4.0" +``` + +## Usage Examples + +### 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::prelude::*; +use leptos_maybe_callback::MaybeCallback; + +/// A button component with an optional `onclick` callback. +#[component] +#[allow(non_snake_case)] +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] +#[allow(non_snake_case)] +pub fn ButtonWithClosure() -> impl IntoView { + view! { +
+
+ } +} +``` + +### Using the Component with a `Callback` + +Alternatively, pass a `Callback` as the `onclick` prop. + +```rust +use leptos::prelude::*; +use leptos_maybe_callback::MaybeCallback; + +/// Parent component using `Button` with a `Callback`. +#[component] +#[allow(non_snake_case)] +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::prelude::*; +use leptos_maybe_callback::MaybeCallback; + +/// Parent component using `Button` without a callback. +#[component] +#[allow(non_snake_case)] +pub fn ButtonWithoutCallback() -> impl IntoView { + view! { +
+
+ } +} +``` + +## Documentation + +Comprehensive (WIP + TBD) documentation is available on [docs.rs](https://docs.rs/leptos-maybe-callback). + +## Testing + +Run the test suite with: + +```bash +cargo test +``` + +## Contributing + +Contributions are welcome! Please submit pull requests or open issues +on [GitHub](https://github.com/RustForWeb/leptos-maybe-callback). + +## Credits + +The initial idea and implementation of `MaybeCallback` were contributed +by [@israelbarbara](https://github.com/israelbarbara) on Discord ( +26/12/2024). [@geoffreygarrett](https://github.com/geoffreygarrett) facilitated its integration into +the [RustForWeb](https://github.com/RustForWeb) project. + +## Rust For Web + +Part of the [Rust For Web](https://github.com/RustForWeb) initiative to create and port 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..e012f69 --- /dev/null +++ b/packages/leptos-maybe-callback/src/lib.rs @@ -0,0 +1,5 @@ +//! [`MaybeCallback`] extras for [Leptos](https://leptos.dev/). +//! +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..2dd236a --- /dev/null +++ b/packages/leptos-maybe-callback/src/maybe_callback.rs @@ -0,0 +1,189 @@ +use std::ops::Deref; +use leptos::prelude::{Callback, Callable}; + +/// A wrapper around an optional callback that provides convenient conversion +/// and method call semantics. This type implements `From` for various callback-like +/// types including `Fn` traits and nested `Option`s. +#[derive(Debug, Clone)] +pub struct MaybeCallback(pub Option>); + +impl Deref for MaybeCallback { + type Target = Option>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +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.clone(); + 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.clone(); + move |event: T| { + if let Some(ref cb) = callback { + cb.run(event); + } + } + } +} + +// Implement `From` for various callback-like types. + +// From `MaybeCallback` to `Option>` +impl From> for Option> { + fn from(maybe: MaybeCallback) -> Self { + maybe.0 + } +} + +// From `Callback` to `MaybeCallback` +impl From> for MaybeCallback { + fn from(callback: Callback) -> Self { + Self(Some(callback)) + } +} + +// From `Option>` to `MaybeCallback` +impl From>> for MaybeCallback { + fn from(option: Option>) -> Self { + Self(option) + } +} + +// From `Option>>` to `MaybeCallback` +impl From>>> for MaybeCallback { + fn from(opt: Option>>) -> Self { + Self(opt.flatten()) + } +} + +// From a closure `F` to `MaybeCallback` +impl From for MaybeCallback +where + T: 'static, + F: Fn(T) + Send + Sync + 'static, +{ + fn from(f: F) -> Self { + Self(Some(Callback::new(f))) + } +} + +// From `Option` to `MaybeCallback` +impl From> for MaybeCallback +where + T: 'static, + F: Fn(T) + Send + Sync + 'static, +{ + fn from(opt: Option) -> Self { + Self(opt.map(Callback::new)) + } +} + +// From `Option>` to `MaybeCallback` +impl From>> for MaybeCallback +where + T: 'static, + F: Fn(T) + Send + Sync + 'static, +{ + fn from(opt: Option>) -> Self { + Self(opt.flatten().map(Callback::new)) + } +} + +impl Default for MaybeCallback { + fn default() -> Self { + Self(None) + } +} + +/// Returns a `FnMut(T)` that runs the callback if present. +#[deprecated( + since = "0.5.0", + note = "Use `MaybeCallback::into_handler` method instead." +)] +pub fn generate_handler(callback: impl Into>) -> impl FnMut(T) +where + T: 'static, +{ + let maybe_callback = callback.into(); + move |event: T| { + maybe_callback.run(event); + } +} + +/// Builds a handler from a [`MaybeCallback`]. +#[deprecated( + since = "0.5.0", + note = "Use `MaybeCallback::into_handler` method instead." +)] +pub struct Handler; + +#[deprecated( + since = "0.5.0", + note = "Use `MaybeCallback::into_handler` method instead." +)] +impl Handler { + /// Returns a `FnMut(T)` that runs the callback if present. + pub fn from(callback: MaybeCallback) -> impl FnMut(T) + where + T: 'static, + { + move |event: T| { + callback.run(event); + } + } +} 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..2cf3567 --- /dev/null +++ b/packages/leptos-maybe-callback/tests/maybe_callback.rs @@ -0,0 +1,246 @@ +use leptos::prelude::{Callback, Callable}; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::Arc; +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` +} + +/// Tests the deprecated `generate_handler` function to ensure it still works as expected. +#[test] +#[allow(deprecated)] +fn test_generate_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 mut handler = generate_handler(cb); + handler(10); + handler(5); + assert_eq!( + counter.load(Ordering::SeqCst), + 15, + "Counter should have been incremented by 15 using generate_handler." + ); +} + +/// Tests the deprecated `Handler::from` method to ensure it still works as expected. +#[test] +#[allow(deprecated)] +fn test_handler_from() { + 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 mut handler_fn = Handler::from(MaybeCallback::from(Some(cb))); + handler_fn(7); + handler_fn(3); + assert_eq!( + counter.load(Ordering::SeqCst), + 10, + "Counter should have been incremented by 10 using Handler::from." + ); +} diff --git a/packages/leptos-node-ref/src/any_node_ref.rs b/packages/leptos-node-ref/src/any_node_ref.rs index 4472afe..5df6865 100644 --- a/packages/leptos-node-ref/src/any_node_ref.rs +++ b/packages/leptos-node-ref/src/any_node_ref.rs @@ -1,9 +1,15 @@ +use std::marker::PhantomData; use leptos::{ + attr::{Attribute, NextAttribute}, + html::ElementType, prelude::{ guards::{Derefable, ReadGuard}, - DefinedAt, ReadUntracked, RwSignal, Set, Track, + DefinedAt, Get, NodeRef, ReadUntracked, RwSignal, Set, Track, + }, + tachys::{ + html::node_ref::NodeRefContainer, + renderer::types::Element, }, - tachys::{html::node_ref::NodeRefContainer, renderer::types::Element}, }; use send_wrapper::SendWrapper; @@ -12,7 +18,7 @@ use send_wrapper::SendWrapper; pub struct AnyNodeRef(RwSignal>>); impl AnyNodeRef { - /// Creates a new node reference. + /// Creates a new `AnyNodeRef`. #[track_caller] pub fn new() -> Self { Self(RwSignal::new(None)) @@ -55,151 +61,157 @@ impl Track for AnyNodeRef { } } -macro_rules! impl_html_any_node_ref { - ($($element:ident),*,) => { - $(impl NodeRefContainer for AnyNodeRef { - fn load(self, el: &Element) { - // safe to construct SendWrapper here, because it will only run in the browser - // so it will always be accessed or dropped from the main thread - self.0.set(Some(SendWrapper::new(el.clone()))); - } - })* - }; -} - -macro_rules! impl_math_any_node_ref { - ($($element:ident),*,) => { - $(impl NodeRefContainer for AnyNodeRef { - fn load(self, el: &Element) { - // safe to construct SendWrapper here, because it will only run in the browser - // so it will always be accessed or dropped from the main thread - self.0.set(Some(SendWrapper::new(el.clone()))); - } - })* - }; -} - -macro_rules! impl_svg_any_node_ref { - ($($element:ident),*,) => { - $(impl NodeRefContainer for AnyNodeRef { - fn load(self, el: &Element) { - // safe to construct SendWrapper here, because it will only run in the browser - // so it will always be accessed or dropped from the main thread - self.0.set(Some(SendWrapper::new(el.clone()))); - } - })* - }; -} - -impl_html_any_node_ref!( - A, Abbr, Address, Area, Article, Aside, Audio, B, Base, Bdi, Bdo, Blockquote, Body, Br, Button, - Canvas, Caption, Cite, Code, Col, Colgroup, Data, Datalist, Dd, Del, Details, Dfn, Dialog, Div, - Dl, Dt, Em, Embed, Fieldset, Figcaption, Figure, Footer, Form, H1, H2, H3, H4, H5, H6, Head, - Header, Hgroup, Hr, Html, I, Iframe, Img, Input, Ins, Kbd, Label, Legend, Li, Link, Main, Map, - Mark, Menu, Meta, Meter, Nav, Noscript, Object, Ol, Optgroup, Option_, Output, P, Picture, - Portal, Pre, Progress, Q, Rp, Rt, Ruby, S, Samp, Script, Search, Section, Select, Slot, Small, - Source, Span, Strong, Style, Sub, Summary, Sup, Table, Tbody, Td, Template, Textarea, Tfoot, - Th, Thead, Time, Title, Tr, Track, U, Ul, Var, Video, Wbr, -); - -impl_math_any_node_ref!( - Math, - Mi, - Mn, - Mo, - Ms, - Mspace, - Mtext, - Menclose, - Merror, - Mfenced, - Mfrac, - Mpadded, - Mphantom, - Mroot, - Mrow, - Msqrt, - Mstyle, - Mmultiscripts, - Mover, - Mprescripts, - Msub, - Msubsup, - Msup, - Munder, - Munderover, - Mtable, - Mtd, - Mtr, - Maction, - Annotation, - Semantics, -); - -impl_svg_any_node_ref!( - A, - Animate, - AnimateMotion, - AnimateTransform, - Circle, - ClipPath, - Defs, - Desc, - Discard, - Ellipse, - FeBlend, - FeColorMatrix, - FeComponentTransfer, - FeComposite, - FeConvolveMatrix, - FeDiffuseLighting, - FeDisplacementMap, - FeDistantLight, - FeDropShadow, - FeFlood, - FeFuncA, - FeFuncB, - FeFuncG, - FeFuncR, - FeGaussianBlur, - FeImage, - FeMerge, - FeMergeNode, - FeMorphology, - FeOffset, - FePointLight, - FeSpecularLighting, - FeSpotLight, - FeTile, - FeTurbulence, - Filter, - ForeignObject, - G, - Hatch, - Hatchpath, - Image, - Line, - LinearGradient, - Marker, - Mask, - Metadata, - Mpath, - Path, - Pattern, - Polygon, - Polyline, - RadialGradient, - Rect, - Script, - Set, - Stop, - Style, - Svg, - Switch, - Symbol, - Text, - TextPath, - Title, - Tspan, - View, -); +/// Allows converting any node reference into our type-erased `AnyNodeRef`. +pub trait IntoAnyNodeRef { + /// Converts `self` into an `AnyNodeRef`. + fn into_any(self) -> AnyNodeRef; +} + +impl NodeRefContainer for AnyNodeRef { + fn load(self, el: &Element) { + self.0.set(Some(SendWrapper::new(el.clone()))); + } +} + +impl IntoAnyNodeRef for NodeRef +where + E: ElementType, + E::Output: AsRef, + NodeRef: Get>, +{ + fn into_any(self) -> AnyNodeRef { + let any_ref = AnyNodeRef::new(); + if let Some(element) = self.get() { + NodeRefContainer::::load(any_ref, element.as_ref()); + } + any_ref + } +} + +impl IntoAnyNodeRef for AnyNodeRef { + fn into_any(self) -> AnyNodeRef { + self + } +} + +impl From> for AnyNodeRef +where + NodeRef: IntoAnyNodeRef, +{ + fn from(value: NodeRef) -> Self { + value.into_any() + } +} + +/// Attribute wrapper for node refs that allows conditional rendering across elements. +/// +/// Useful when distributing node refs across multiple rendering branches. +#[derive(Debug)] +pub struct AnyNodeRefAttr { + container: C, + ty: PhantomData, +} + +impl Clone for AnyNodeRefAttr +where + C: Clone, +{ + fn clone(&self) -> Self { + Self { + container: self.container.clone(), + ty: PhantomData, + } + } +} + +impl Attribute for AnyNodeRefAttr +where + E: ElementType + 'static, + C: NodeRefContainer + Clone + 'static, + Element: PartialEq, +{ + const MIN_LENGTH: usize = 0; + type State = Element; + type AsyncOutput = Self; + type Cloneable = Self; + type CloneableOwned = Self; + + #[inline(always)] + fn html_len(&self) -> usize { + 0 + } + + fn to_html( + self, + _buf: &mut String, + _class: &mut String, + _style: &mut String, + _inner_html: &mut String, + ) { + } + + fn hydrate(self, el: &Element) -> Self::State { + self.container.load(el); + el.clone() + } + + fn build(self, el: &Element) -> Self::State { + self.container.load(el); + el.clone() + } + + fn rebuild(self, state: &mut Self::State) { + self.container.load(state); + } + + fn into_cloneable(self) -> Self::Cloneable { + self + } + + fn into_cloneable_owned(self) -> Self::CloneableOwned { + self + } + + fn dry_resolve(&mut self) {} + + async fn resolve(self) -> Self::AsyncOutput { + self + } +} + +impl NextAttribute for AnyNodeRefAttr +where + E: ElementType + 'static, + C: NodeRefContainer + Clone + 'static, + Element: PartialEq, +{ + type Output = (Self, NewAttr); + + fn add_any_attr( + self, + new_attr: NewAttr, + ) -> Self::Output { + (self, new_attr) + } +} + +/// Constructs an attribute to attach an `AnyNodeRef` to an element. +/// +/// Enables adding node refs in conditional/dynamic rendering branches. +pub fn any_node_ref(container: C) -> AnyNodeRefAttr +where + E: ElementType, + C: NodeRefContainer, +{ + AnyNodeRefAttr { + container, + ty: PhantomData, + } +} + +pub mod prelude { + pub use super::*; + pub use AnyNodeRef; + pub use IntoAnyNodeRef; + pub use any_node_ref; +} diff --git a/packages/leptos-node-ref/tests/any_node_ref.rs b/packages/leptos-node-ref/tests/any_node_ref.rs new file mode 100644 index 0000000..a310d42 --- /dev/null +++ b/packages/leptos-node-ref/tests/any_node_ref.rs @@ -0,0 +1,81 @@ +use leptos::{html, prelude::*}; +use leptos_node_ref::{any_node_ref, prelude::*}; + +#[test] +fn test_any_node_ref_creation() { + let node_ref = AnyNodeRef::new(); + assert!(node_ref.get().is_none(), "New AnyNodeRef should be empty"); +} + +#[test] +fn test_to_any_node_ref() { + let div_ref: NodeRef = NodeRef::new(); + let any_ref = div_ref.into_any(); + assert!(any_ref.get().is_none(), "Converted AnyNodeRef should be initially empty"); +} + +#[test] +fn test_clone_and_copy() { + let node_ref = AnyNodeRef::new(); + let cloned_ref = node_ref; + let _copied_ref = cloned_ref; // Should be copyable + assert!(cloned_ref.get().is_none(), "Cloned AnyNodeRef should be empty"); +} + +#[test] +fn test_default() { + let node_ref = AnyNodeRef::default(); + assert!(node_ref.get().is_none(), "Default AnyNodeRef should be empty"); +} + +#[test] +fn test_into_any_node_ref_trait() { + let div_ref: NodeRef = NodeRef::new(); + let _any_ref: AnyNodeRef = div_ref.into_any(); + + let input_ref: NodeRef = NodeRef::new(); + let _any_input_ref: AnyNodeRef = input_ref.into_any(); +} + +#[test] +fn test_from_node_ref() { + let div_ref: NodeRef = NodeRef::new(); + let _any_ref: AnyNodeRef = div_ref.into(); +} + +#[test] +fn test_any_node_ref_attr() { + let node_ref = AnyNodeRef::new(); + let _attr = any_node_ref::(node_ref); +} + +#[test] +fn test_defined_at() { + let node_ref = AnyNodeRef::new(); + assert!(node_ref.defined_at().is_some()); +} + +#[test] +fn test_track_and_untracked() { + let node_ref = AnyNodeRef::new(); + // Just testing that these don't panic + node_ref.track(); + let _untracked = node_ref.try_read_untracked(); +} + +#[test] +fn test_into_any_identity() { + let node_ref = AnyNodeRef::new(); + let same_ref = node_ref.into_any(); + + // Instead of checking pointer equality, we should verify: + // 1. Both refs are initially empty + assert!(node_ref.get().is_none()); + assert!(same_ref.get().is_none()); + + // 2. When we set one, both should reflect the change + // (This would require a mock Element to test properly) + + // 3. They should have the same defined_at location + assert_eq!(node_ref.defined_at(), same_ref.defined_at()); +} \ No newline at end of file diff --git a/packages/leptos-struct-component/Cargo.toml b/packages/leptos-struct-component/Cargo.toml index 3207286..a44ab7f 100644 --- a/packages/leptos-struct-component/Cargo.toml +++ b/packages/leptos-struct-component/Cargo.toml @@ -10,7 +10,7 @@ version.workspace = true [dependencies] leptos.workspace = true -leptos-struct-component-macro = { path = "../leptos-struct-component-macro", version = "0.0.3" } +leptos-struct-component-macro.workspace = true [dev-dependencies] -leptos-node-ref = { path = "../leptos-node-ref" } +leptos-node-ref.workspace = true diff --git a/packages/leptos-struct-component/tests/struct_component.rs b/packages/leptos-struct-component/tests/struct_component.rs index ec5eca7..b71beff 100644 --- a/packages/leptos-struct-component/tests/struct_component.rs +++ b/packages/leptos-struct-component/tests/struct_component.rs @@ -122,11 +122,10 @@ pub fn Image( pub fn App() -> impl IntoView { view! { - + + // attributes={[ + // ("src", "https://picsum.photos/id/10/200/300") + // ]} } } diff --git a/packages/leptos-style/tests/component.rs b/packages/leptos-style/tests/component.rs index 6ae1953..3cc2f2f 100644 --- a/packages/leptos-style/tests/component.rs +++ b/packages/leptos-style/tests/component.rs @@ -9,13 +9,7 @@ fn Button( children: Children, ) -> impl IntoView { view! { - } @@ -26,11 +20,7 @@ fn App() -> impl IntoView { view! { diff --git a/packages/leptos-typed-fallback-show/Cargo.toml b/packages/leptos-typed-fallback-show/Cargo.toml new file mode 100644 index 0000000..062a1c4 --- /dev/null +++ b/packages/leptos-typed-fallback-show/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "leptos-typed-fallback-show" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +leptos-node-ref.workspace = true +leptos.workspace = true \ No newline at end of file diff --git a/packages/leptos-typed-fallback-show/src/lib.rs b/packages/leptos-typed-fallback-show/src/lib.rs new file mode 100644 index 0000000..4773521 --- /dev/null +++ b/packages/leptos-typed-fallback-show/src/lib.rs @@ -0,0 +1,5 @@ +//! [`TypedFallbackShow`] extras for [Leptos](https://leptos.dev/). +//! +mod typed_fallback_show; + +pub use typed_fallback_show::*; diff --git a/packages/leptos-typed-fallback-show/src/typed_fallback_show.rs b/packages/leptos-typed-fallback-show/src/typed_fallback_show.rs new file mode 100644 index 0000000..24f9224 --- /dev/null +++ b/packages/leptos-typed-fallback-show/src/typed_fallback_show.rs @@ -0,0 +1,28 @@ +use leptos::{ + either::Either, + prelude::*, +}; + +/// We need our own show instead of leptos' Show because attribute spreading does not work +/// across AnyView as of 0.7.2, which is required here. +#[component] +#[allow(non_snake_case)] +pub fn TypedFallbackShow( + children: TypedChildrenFn, + when: W, + fallback: F, +) -> impl IntoView +where + W: Fn() -> bool + Send + Sync + 'static, + F: Fn() -> IV + Send + Sync + 'static, + IV: IntoView + 'static, + C: IntoView + 'static, +{ + let memoized_when = ArcMemo::new(move |_| when()); + let children = children.into_inner(); + + move || match memoized_when.get() { + true => Either::Left(children()), + false => Either::Right(fallback().into_view()), + } +} \ No newline at end of file