diff --git a/Cargo.toml b/Cargo.toml index 9bcec600..e2874730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,9 +22,13 @@ console_log = "1.0.0" console_error_panic_hook = "0.1.7" dioxus = "0.5.6" dioxus-router = "0.5.6" -leptos = "0.6.13" -leptos_dom = "0.6.13" -leptos_router = "0.6.13" +leptos = "0.7.2" +leptos_dom = "0.7.2" +leptos_router = "0.7.2" +leptos-node-ref = { version = "0.0.3" } +leptos-maybe-callback = { version = "0.0.3" } +leptos-typed-fallback-show = { version = "0.0.3" } +leptos-use = "0.15.0" log = "0.4.22" serde = "1.0.198" serde_json = "1.0.116" @@ -38,3 +42,4 @@ yew-style = "0.1.4" [patch.crates-io] yew = { git = "https://github.com/RustForWeb/yew.git", branch = "feature/use-composed-ref" } yew-router = { git = "https://github.com/RustForWeb/yew.git", branch = "feature/use-composed-ref" } +leptos-node-ref = { git = "https://github.com/geoffreygarrett/leptos-utils", branch = "feature/any-node-ref" } \ No newline at end of file diff --git a/packages/primitives/leptos/arrow/Cargo.toml b/packages/primitives/leptos/arrow/Cargo.toml index 095e6ca2..75524f9c 100644 --- a/packages/primitives/leptos/arrow/Cargo.toml +++ b/packages/primitives/leptos/arrow/Cargo.toml @@ -10,4 +10,6 @@ version.workspace = true [dependencies] leptos.workspace = true -radix-leptos-primitive = { path = "../primitive", version = "0.0.2" } +leptos-node-ref.workspace = true +leptos-typed-fallback-show.workspace = true +radix-leptos-primitive = { path = "../primitive", version = "0.0.2" } \ No newline at end of file diff --git a/packages/primitives/leptos/arrow/src/arrow.rs b/packages/primitives/leptos/arrow/src/arrow.rs index 2fccbab6..a453288b 100644 --- a/packages/primitives/leptos/arrow/src/arrow.rs +++ b/packages/primitives/leptos/arrow/src/arrow.rs @@ -1,42 +1,70 @@ -use leptos::{html::AnyElement, *}; -use radix_leptos_primitive::Primitive; +use leptos::{prelude::*, svg}; +use leptos::attr::{Attr, AttributeKey, AttributeValue}; +use radix_leptos_primitive::{Primitive}; +use leptos_node_ref::AnyNodeRef; +use leptos_typed_fallback_show::TypedFallbackShow; + +/* ------------------------------------------------------------------------------------------------- + * Arrow + * -----------------------------------------------------------------------------------------------*/ + +const NAME: &'static str = "Arrow"; #[component] +#[allow(non_snake_case)] pub fn Arrow( - #[prop(into, optional)] width: MaybeProp, - #[prop(into, optional)] height: MaybeProp, - #[prop(into, optional)] as_child: MaybeProp, - #[prop(optional)] node_ref: NodeRef, - #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, #[prop(optional)] children: Option, + #[prop(into, optional, default=10.0.into())] width: MaybeProp, + #[prop(into, optional, default=5.0.into())] height: MaybeProp, + #[prop(into, optional)] as_child: MaybeProp, + #[prop(into, optional)] node_ref: AnyNodeRef, ) -> impl IntoView { - let width = move || width.get().unwrap_or(10.0); - let height = move || height.get().unwrap_or(5.0); let children = StoredValue::new(children); - let mut attrs = attrs.clone(); - attrs.extend([ - ("width", width.into_attribute()), - ("height", height.into_attribute()), - ("viewBox", "0 0 30 10".into_attribute()), - ("preserveAspectRatio", "none".into_attribute()), - ]); + #[cfg(debug_assertions)] + Effect::new(move |_| { + leptos::logging::log!("[{NAME}] width: {:?}", width.get()); + leptos::logging::log!("[{NAME}] height: {:?}", height.get()); + leptos::logging::log!("[{NAME}] node_ref: {:?}", node_ref.get()); + leptos::logging::log!("[{NAME}] as_child: {:?}", as_child.get()); + }); view! { - + fallback=move || { + view! { + + } } > - {children.with_value(|children| children.as_ref().map(|children| children()))} - + {children + .with_value(|maybe_children| { + { maybe_children.as_ref().map(|child_fn| child_fn()) } + }) + .attr("viewBox", "0 0 30 10") + .attr("preserveAspectRatio", "none")} + - } + }; +} + +/* ------------------------------------------------------------------------------------------------- + * Primitive re-exports + * -----------------------------------------------------------------------------------------------*/ + +pub mod primitive { + pub use super::*; + pub use Arrow as Root; } diff --git a/packages/primitives/leptos/aspect-ratio/Cargo.toml b/packages/primitives/leptos/aspect-ratio/Cargo.toml index e878a456..9483a6a3 100644 --- a/packages/primitives/leptos/aspect-ratio/Cargo.toml +++ b/packages/primitives/leptos/aspect-ratio/Cargo.toml @@ -12,3 +12,4 @@ version.workspace = true [dependencies] leptos.workspace = true radix-leptos-primitive = { path = "../primitive", version = "0.0.2" } +leptos-node-ref = { workspace = true } diff --git a/packages/primitives/leptos/aspect-ratio/src/aspect_ratio.rs b/packages/primitives/leptos/aspect-ratio/src/aspect_ratio.rs index 1cdfd320..2f1342da 100644 --- a/packages/primitives/leptos/aspect-ratio/src/aspect_ratio.rs +++ b/packages/primitives/leptos/aspect-ratio/src/aspect_ratio.rs @@ -1,41 +1,79 @@ -use leptos::{html::AnyElement, *}; +use leptos::{prelude::*, attr::{Attribute, AttributeValue}, html}; use radix_leptos_primitive::Primitive; +use leptos_node_ref::AnyNodeRef; + +const DEFAULT_RATIO: f64 = 1.0; + +/* ------------------------------------------------------------------------------------------------- + * AspectRatio + * -----------------------------------------------------------------------------------------------*/ + +const NAME: &'static str = "AspectRatio"; #[component] +#[allow(non_snake_case)] pub fn AspectRatio( - #[prop(into, optional)] ratio: MaybeProp, - #[prop(into, optional)] as_child: MaybeProp, - #[prop(optional)] node_ref: NodeRef, - #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, - children: ChildrenFn, + /// Children passed to the AspectRatio component + children: TypedChildrenFn, + + /// Change the default rendered element for the one passed as a child + #[prop(into, optional, default = false.into())] + as_child: MaybeProp, + + /// The desired ratio when rendering the content (e.g., 16/9). Defaults to 1.0 if not specified. + #[prop(into, optional, default = DEFAULT_RATIO.into())] + ratio: MaybeProp, + + /// Reference to the underlying DOM node + #[prop(into, optional)] + node_ref: AnyNodeRef, ) -> impl IntoView { - let ratio = Signal::derive(move || ratio.get().unwrap_or(1.0)); + // calculates the percent-based padding for the aspect ratio + let padding_bottom = Signal::derive(move || { + 100.0 + / ratio + .get() + .unwrap_or(DEFAULT_RATIO) + .clamp(f64::EPSILON, f64::MAX) + }); - let mut attrs = attrs.clone(); - // TODO: merge existing style - attrs.extend([( - "style", - // Ensures children expand in ratio - "position: absolute; top: 0px; right: 0px; bottom: 0px; left: 0px;".into_attribute(), - )]); + #[cfg(debug_assertions)] + Effect::new(move |_| { + leptos::logging::log!("[{NAME}] ratio: {:?}", ratio.get()); + leptos::logging::log!("[{NAME}] as_child: {:?}", as_child.get()); + leptos::logging::log!("[{NAME}] node_ref: {:?}", node_ref.get()); + }); view! { + // ensures inner element is contained
- {children()} - + children=children + style:position="absolute" + style:top="0" + style:right="0" + style:bottom="0" + style:left="0" + />
} } + +/* ------------------------------------------------------------------------------------------------- + * Primitive re-exports + * -----------------------------------------------------------------------------------------------*/ + +pub mod primitive { + pub use super::*; + pub use AspectRatio as Root; +} diff --git a/packages/primitives/leptos/avatar/Cargo.toml b/packages/primitives/leptos/avatar/Cargo.toml index b02eefc0..54ad3e48 100644 --- a/packages/primitives/leptos/avatar/Cargo.toml +++ b/packages/primitives/leptos/avatar/Cargo.toml @@ -11,5 +11,9 @@ version.workspace = true [dependencies] leptos.workspace = true +leptos-node-ref.workspace = true +leptos-maybe-callback.workspace = true +leptos-use.workspace = true radix-leptos-primitive = { path = "../primitive", version = "0.0.2" } +radix-leptos-context = { path = "../context", version = "0.0.2" } web-sys.workspace = true diff --git a/packages/primitives/leptos/avatar/src/avatar.rs b/packages/primitives/leptos/avatar/src/avatar.rs index 573df686..1922c258 100644 --- a/packages/primitives/leptos/avatar/src/avatar.rs +++ b/packages/primitives/leptos/avatar/src/avatar.rs @@ -1,9 +1,18 @@ -use leptos::{html::AnyElement, *}; -use radix_leptos_primitive::Primitive; -use web_sys::{ - wasm_bindgen::{closure::Closure, JsCast}, - HtmlImageElement, -}; +use leptos::prelude::*; +use leptos::context::Provider; +use leptos::{html}; +use leptos::html::Img; +use leptos::wasm_bindgen::closure::Closure; +use leptos::wasm_bindgen::JsCast; +use leptos_node_ref::prelude::*; +use leptos_use::{use_timeout_fn, UseTimeoutFnReturn}; +use leptos_maybe_callback::MaybeCallback; +use radix_leptos_context::create_context; +use radix_leptos_primitive::{Primitive, VoidPrimitive}; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ImageLoadingStatus { @@ -19,182 +28,231 @@ struct AvatarContextValue { on_image_loading_status_change: Callback, } +/* ------------------------------------------------------------------------------------------------- + * Avatar (Root) + * -----------------------------------------------------------------------------------------------*/ + +const AVATAR_NAME: &'static str = "Avatar"; + +create_context!( + context_type: AvatarContextValue, + provider: AvatarProvider, + hook: use_avatar_context, + root: AVATAR_NAME +); + #[component] +#[allow(non_snake_case)] pub fn Avatar( + /// If `true`, renders only its children without a `` wrapper. #[prop(into, optional)] as_child: MaybeProp, - #[prop(optional)] node_ref: NodeRef, - #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, - children: ChildrenFn, + /// A reference to the underlying `` element, if needed. + #[prop(into, optional)] node_ref: AnyNodeRef, + /// The children of the Avatar component. + children: TypedChildrenFn, ) -> impl IntoView { - let (image_loading_status, set_image_loading_status) = create_signal(ImageLoadingStatus::Idle); + let children = StoredValue::new(children.into_inner()); + + // Initialize the image loading status signal using `RwSignal` + let image_loading_status = RwSignal::new(ImageLoadingStatus::Idle); + // Define the context value with the current loading status and a callback to update it let context_value = AvatarContextValue { - image_loading_status, - on_image_loading_status_change: Callback::new(move |image_loading_status| { - set_image_loading_status.set(image_loading_status) + image_loading_status: image_loading_status.read_only(), + on_image_loading_status_change: Callback::new(move |status| { + image_loading_status.set(status); }), }; view! { - - - {children()} + + + {children.with_value(|children| children())} - + } } +/* ------------------------------------------------------------------------------------------------- + * AvatarImage + * -----------------------------------------------------------------------------------------------*/ + +const IMAGE_NAME: &'static str = "AvatarImage"; + #[component] +#[allow(non_snake_case)] pub fn AvatarImage( #[prop(into, optional)] src: MaybeProp, - #[prop(into, optional)] on_loading_status_change: Option>, + #[prop(into, optional)] referrer_policy: MaybeProp, + #[prop(into, optional)] on_loading_status_change: MaybeCallback, #[prop(into, optional)] as_child: MaybeProp, - #[prop(optional)] node_ref: NodeRef, - #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, - #[prop(optional)] children: Option, + #[prop(optional)] node_ref: NodeRef, ) -> impl IntoView { - let children = StoredValue::new(children); - - let context = expect_context::(); - let image_loading_status = use_image_loading_status(src.clone()); - let handle_loading_status_change = move |status: ImageLoadingStatus| { - if let Some(on_loading_status_change) = on_loading_status_change { - on_loading_status_change.call(status); - } - context.on_image_loading_status_change.call(status); - }; + let context = use_avatar_context(IMAGE_NAME); + let loading_status = use_image_loading_status(src.clone(), referrer_policy.clone()); + // Update context and callback when loading status changes Effect::new(move |_| { - let image_loading_status = image_loading_status.get(); - if image_loading_status != ImageLoadingStatus::Idle { - handle_loading_status_change(image_loading_status); - } + let status = loading_status.get(); + context.on_image_loading_status_change.run(status); + on_loading_status_change.run(status); }); - let mut attrs = attrs.clone(); - attrs.extend([("src", src.into_attribute())]); - let attrs = StoredValue::new(attrs); - view! { - - + - {children.with_value(|children| children.as_ref().map(|children| children()))} - + {()} + } } +/* ------------------------------------------------------------------------------------------------- + * AvatarFallback + * -----------------------------------------------------------------------------------------------*/ + +const FALLBACK_NAME: &'static str = "AvatarFallback"; + #[component] pub fn AvatarFallback( - #[prop(into, optional)] delay_ms: MaybeProp, + /// Children (for example, initials or an icon). + children: TypedChildrenFn, + /// Delay (in ms) before showing the fallback ``. If no delay, fallback appears immediately. + #[prop(into, optional)] delay_ms: MaybeProp, + /// If `true`, renders only its children without a `` wrapper. #[prop(into, optional)] as_child: MaybeProp, - #[prop(optional)] node_ref: NodeRef, - #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, - #[prop(optional)] children: Option, + /// A reference to the `` element for the fallback. + #[prop(into, optional)] node_ref: AnyNodeRef, ) -> impl IntoView { - let attrs = StoredValue::new(attrs); - let children = StoredValue::new(children); + let children = StoredValue::new(children.into_inner()); + let context = use_avatar_context(FALLBACK_NAME); - let context = expect_context::(); - let (can_render, set_can_render) = create_signal(delay_ms.get().is_none()); + // use_timeout_fn from leptos_use to handle the delay before showing fallback + let UseTimeoutFnReturn { start, stop, is_pending, .. } = use_timeout_fn( + move |_| {}, + delay_ms.get().unwrap_or_default(), + ); - let handler: Closure = Closure::new(move || { - set_can_render.set(true); - }); + // If no delay is set, fallback can render immediately + let can_render = RwSignal::new(delay_ms.get().is_none()); - let timer_id = StoredValue::new(None::); - Effect::new(move |_| { - if let Some(timer_id) = timer_id.get_value() { - window().clear_timeout_with_handle(timer_id); - } + // Re-initialize the timer whenever `delay_ms` changes + Effect::new(move || { + stop(); + can_render.set(delay_ms.get().is_none()); + + #[cfg(debug_assertions)] + leptos::logging::log!( + "[{FALLBACK_NAME}] delay_ms changed: {:?}", + delay_ms.get() + ); - if let Some(delay_ms) = delay_ms.get() { - timer_id.set_value(Some( - window() - .set_timeout_with_callback_and_timeout_and_arguments_0( - handler.as_ref().unchecked_ref(), - delay_ms, - ) - .expect("Timeout should be set."), - )); + if let Some(ms) = delay_ms.get() { + #[cfg(debug_assertions)] + leptos::logging::log!("[{FALLBACK_NAME}] Starting timeout for {} ms", ms); + start(ms as i32); } }); - on_cleanup(move || { - if let Some(timer_id) = timer_id.get_value() { - window().clear_timeout_with_handle(timer_id); + // Watch if the timer has completed + Effect::new(move || { + if !is_pending.get() && delay_ms.get().is_some() { + #[cfg(debug_assertions)] + leptos::logging::log!("[{FALLBACK_NAME}] Timer completed, can_render=true"); + can_render.set(true); } }); + // Render fallback only if `can_render` is true and the image is not loaded view! { - - - {children.with_value(|children| children.as_ref().map(|children| children()))} + + + {children.with_value(|children| children())} } } -fn use_image_loading_status(src: MaybeProp) -> ReadSignal { - let (loading_status, set_loading_status) = create_signal(ImageLoadingStatus::Idle); - let is_mounted = StoredValue::new(true); +/* -----------------------------------------------------------------------------------------------*/ - let update_status_loaded: Closure = Closure::new(move || { - if is_mounted.get_value() { - set_loading_status.set(ImageLoadingStatus::Loaded); - } - }); - let update_status_error: Closure = Closure::new(move || { - if is_mounted.get_value() { - set_loading_status.set(ImageLoadingStatus::Error); - } - }); +fn use_image_loading_status( + src: MaybeProp, + referrer_policy: MaybeProp, +) -> ReadSignal { + let loading_status = RwSignal::new(ImageLoadingStatus::Idle); Effect::new(move |_| { - if let Some(src) = src.get() { - let image = document() - .create_element("img") - .map(|element| element.unchecked_into::()) - .expect("Image element should be created."); - - set_loading_status.set(ImageLoadingStatus::Loading); - - image - .add_event_listener_with_callback( - "load", - update_status_loaded.as_ref().unchecked_ref(), - ) - .expect("Load event listener should be added."); - image - .add_event_listener_with_callback( - "error", - update_status_error.as_ref().unchecked_ref(), - ) - .expect("Error event listener should be added."); - image.set_src(&src); + if let Some(src_val) = src.get() { + #[cfg(debug_assertions)] + leptos::logging::log!("[{IMAGE_NAME}] Starting load for: {}", src_val); + + loading_status.set(ImageLoadingStatus::Loading); + + let image = web_sys::HtmlImageElement::new().unwrap(); + + // Clone image for closures + let image_clone = image.clone(); + let onload = Closure::wrap(Box::new(move || { + if image_clone.natural_width() > 0 { + #[cfg(debug_assertions)] + leptos::logging::log!("[{IMAGE_NAME}] Load successful"); + loading_status.set(ImageLoadingStatus::Loaded); + } else { + #[cfg(debug_assertions)] + leptos::logging::log!("[{IMAGE_NAME}] Load failed - invalid image"); + loading_status.set(ImageLoadingStatus::Error); + } + }) as Box); + + let onerror = Closure::wrap(Box::new(move || { + #[cfg(debug_assertions)] + leptos::logging::log!("[{IMAGE_NAME}] Load failed"); + loading_status.set(ImageLoadingStatus::Error); + }) as Box); + + image.set_onload(Some(onload.as_ref().unchecked_ref())); + image.set_onerror(Some(onerror.as_ref().unchecked_ref())); + + if let Some(policy) = referrer_policy.get() { + image.set_referrer_policy(&policy); + } + + image.set_src(&src_val); + + onload.forget(); + onerror.forget(); } else { - set_loading_status.set(ImageLoadingStatus::Error); + #[cfg(debug_assertions)] + leptos::logging::log!("[{IMAGE_NAME}] No src provided"); + loading_status.set(ImageLoadingStatus::Error); } }); - on_cleanup(move || { - is_mounted.set_value(false); - }); + loading_status.read_only() +} + +/* ------------------------------------------------------------------------------------------------- + * Primitive re-exports + * -----------------------------------------------------------------------------------------------*/ - loading_status +pub mod primitive { + // Re-export core items so consumers can use avatar::primitive::* as AvatarPrimitive + pub use super::*; + pub use Avatar as Root; + pub use AvatarImage as Image; + pub use AvatarFallback as Fallback; } diff --git a/packages/primitives/leptos/avatar/tests/avatar.rs b/packages/primitives/leptos/avatar/tests/avatar.rs new file mode 100644 index 00000000..8156fdfb --- /dev/null +++ b/packages/primitives/leptos/avatar/tests/avatar.rs @@ -0,0 +1,293 @@ +// TODO: Implement +// #![cfg(target_arch = "wasm32")] +// +// use std::cell::RefCell; +// use std::rc::Rc; +// +// use leptos::*; +// use wasm_bindgen::prelude::*; +// use wasm_bindgen_test::*; +// use web_sys::Element; +// +// // Import your Avatar module: +// // (Adjust the path if your file is located elsewhere.) +// use your_crate::avatar::primitive as Avatar; +// +// wasm_bindgen_test_configure!(run_in_browser); +// +// // For test parity, define constants +// const ROOT_TEST_ID: &str = "avatar-root"; +// const FALLBACK_TEXT: &str = "AB"; +// const IMAGE_ALT_TEXT: &str = "Fake Avatar"; +// const DELAY_MS: i32 = 300; +// +// /////////////////////////////////////////////////////////////////////////////// +// // Utility: find DOM elements +// /////////////////////////////////////////////////////////////////////////////// +// +// fn query_element_by_test_id(doc: &web_sys::Document, test_id: &str) -> Option { +// // data-testid=... +// doc.query_selector(&format!(r#"[data-testid="{}"]"#, test_id)) +// .ok() +// .flatten() +// } +// +// fn query_element_by_text(doc: &web_sys::Document, text: &str) -> Option { +// // Very naive text check +// doc.query_selector(&format!(":contains('{}')", text)).ok().flatten() +// } +// +// fn query_img_by_alt(doc: &web_sys::Document, alt: &str) -> Option { +// // :contains(...) won't work for alt. We do a simpler approach: +// let imgs = doc.get_elements_by_tag_name("img"); +// for i in 0..imgs.length() { +// let el = imgs.item(i).unwrap(); +// if el.get_attribute("alt").unwrap_or_default() == alt { +// return Some(el); +// } +// } +// None +// } +// +// /////////////////////////////////////////////////////////////////////////////// +// // Test: Avatar with fallback and no image +// /////////////////////////////////////////////////////////////////////////////// +// +// #[wasm_bindgen_test] +// fn avatar_with_fallback_no_image() { +// // Mount the component +// let container = leptos::document().create_element("div").unwrap(); +// leptos::document().body().unwrap().append_child(&container).unwrap(); +// +// let view = view! { +// +// {FALLBACK_TEXT} +// +// }; +// +// // Render into the container +// leptos::mount_to(container, view); +// +// // We can do a basic presence check +// let doc = leptos::document(); +// let root_elem = query_element_by_test_id(&doc, ROOT_TEST_ID) +// .expect("Should find the avatar root by test ID"); +// assert_eq!(root_elem.tag_name(), "SPAN"); // Usually , because it's the Root +// +// // Check that fallback text is present +// let fallback_el = query_element_by_text(&doc, FALLBACK_TEXT) +// .expect("Fallback text should be in the DOM"); +// assert_eq!(fallback_el.text_content().unwrap(), FALLBACK_TEXT); +// } +// +// /////////////////////////////////////////////////////////////////////////////// +// // Test: Avatar with fallback and a "working" image +// /////////////////////////////////////////////////////////////////////////////// +// +// // We can simulate a 'working' image by calling the onload callback ourselves. +// // Alternatively, you can define a custom element in JS to mock load events. +// #[wasm_bindgen_test(async)] +// fn avatar_with_fallback_and_working_image() -> impl std::future::Future<()> { +// use leptos::spawn_local; +// use gloo_timers::callback::Timeout; +// +// // We'll store references so we can check DOM after a delay +// let container = leptos::document().create_element("div").unwrap(); +// leptos::document().body().unwrap().append_child(&container).unwrap(); +// +// let view = view! { +// +// {FALLBACK_TEXT} +// +// +// }; +// leptos::mount_to(container.clone(), view); +// +// let doc = leptos::document(); +// let fallback_el = query_element_by_text(&doc, FALLBACK_TEXT) +// .expect("Fallback should be in the DOM initially"); +// assert_eq!(fallback_el.text_content().unwrap(), FALLBACK_TEXT); +// +// // The should not be present yet (the load hasn't "fired") +// { +// let imgs = doc.get_elements_by_tag_name("img"); +// assert_eq!(imgs.length(), 0, "No image in DOM initially"); +// } +// +// // We'll artificially "simulate" the load event after DELAY_MS +// // Because uses `use_event_listener` on 'load' event, +// // we can dispatch it ourselves. +// +// let promise = js_sys::Promise::new(&mut |resolve, _reject| { +// let doc_cloned = doc.clone(); +// Timeout::new(DELAY_MS as u32, move || { +// // dispatch a 'load' event to the +// let imgs = doc_cloned.get_elements_by_tag_name("img"); +// for i in 0..imgs.length() { +// let evt = web_sys::Event::new("load").unwrap(); +// imgs.item(i).unwrap().dispatch_event(&evt).unwrap(); +// } +// resolve.call0(&JsValue::NULL).unwrap(); +// }) +// .forget(); +// }); +// +// // Then we check if the fallback disappears and the image is present +// async move { +// wasm_bindgen_futures::JsFuture::from(promise).await.unwrap(); +// +// // Now check if an is in the DOM +// let imgs = doc.get_elements_by_tag_name("img"); +// assert_eq!(imgs.length(), 1, "Image should appear after load"); +// let image_el = imgs.item(0).unwrap(); +// assert_eq!(image_el.get_attribute("alt").unwrap_or_default(), IMAGE_ALT_TEXT); +// } +// } +// +// /////////////////////////////////////////////////////////////////////////////// +// // Test: Avatar with fallback and delayed render +// /////////////////////////////////////////////////////////////////////////////// +// +// #[wasm_bindgen_test(async)] +// fn avatar_with_fallback_delayed_render() -> impl std::future::Future<()> { +// use gloo_timers::callback::Timeout; +// +// let container = leptos::document().create_element("div").unwrap(); +// leptos::document().body().unwrap().append_child(&container).unwrap(); +// +// // The fallback is delayed by DELAY_MS +// let view = view! { +// +// {FALLBACK_TEXT} +// +// }; +// leptos::mount_to(container.clone(), view); +// +// let doc = leptos::document(); +// +// // Immediately, the fallback should NOT be present +// assert!( +// query_element_by_text(&doc, FALLBACK_TEXT).is_none(), +// "Fallback should not be rendered yet" +// ); +// +// // After ~DELAY_MS, we expect the fallback to appear +// let promise = js_sys::Promise::new(&mut |resolve, _reject| { +// Timeout::new(DELAY_MS as u32, move || { +// resolve.call0(&JsValue::NULL).unwrap(); +// }) +// .forget(); +// }); +// +// async move { +// wasm_bindgen_futures::JsFuture::from(promise).await.unwrap(); +// let fallback_el = query_element_by_text(&doc, FALLBACK_TEXT) +// .expect("Fallback should appear after the delay"); +// assert_eq!(fallback_el.text_content().unwrap(), FALLBACK_TEXT); +// } +// } +// +// /////////////////////////////////////////////////////////////////////////////// +// // Test: Avatar with an image that only loads when referrerPolicy="no-referrer" +// /////////////////////////////////////////////////////////////////////////////// +// +// #[wasm_bindgen_test(async)] +// fn avatar_image_referrer_policy_no_referrer() -> impl std::future::Future<()> { +// use gloo_timers::callback::Timeout; +// +// let container = leptos::document().create_element("div").unwrap(); +// leptos::document().body().unwrap().append_child(&container).unwrap(); +// +// let view = view! { +// +// {FALLBACK_TEXT} +// +// +// }; +// leptos::mount_to(container.clone(), view); +// +// let doc = leptos::document(); +// let fallback_el = query_element_by_text(&doc, FALLBACK_TEXT) +// .expect("Fallback should be in the DOM initially"); +// assert_eq!(fallback_el.text_content().unwrap(), FALLBACK_TEXT); +// +// // No images initially +// let imgs = doc.get_elements_by_tag_name("img"); +// assert_eq!(imgs.length(), 0, "Image is not loaded yet"); +// +// // We'll artificially "simulate" that only if referrerPolicy=="no-referrer" triggers 'load' +// // Otherwise 'error' +// let promise = js_sys::Promise::new(&mut |resolve, _reject| { +// Timeout::new(DELAY_MS as u32, move || { +// let imgs = doc.get_elements_by_tag_name("img"); +// for i in 0..imgs.length() { +// let image = imgs.item(i).unwrap(); +// let policy = image.get_attribute("referrerPolicy").unwrap_or_default(); +// let evt_type = if policy == "no-referrer" { "load" } else { "error" }; +// let evt = web_sys::Event::new(evt_type).unwrap(); +// image.dispatch_event(&evt).unwrap(); +// } +// resolve.call0(&JsValue::NULL).unwrap(); +// }) +// .forget(); +// }); +// +// async move { +// wasm_bindgen_futures::JsFuture::from(promise).await.unwrap(); +// +// // After the "mock" load event, we expect an +// let imgs = doc.get_elements_by_tag_name("img"); +// assert_eq!(imgs.length(), 1, "We should have 1 in the DOM now"); +// let image_el = imgs.item(0).unwrap(); +// assert_eq!(image_el.get_attribute("alt").unwrap_or_default(), IMAGE_ALT_TEXT); +// } +// } +// +// /////////////////////////////////////////////////////////////////////////////// +// // Test: Avatar with an image that breaks unless referrerPolicy="no-referrer" +// /////////////////////////////////////////////////////////////////////////////// +// +// #[wasm_bindgen_test(async)] +// fn avatar_image_referrer_policy_origin_breaks() -> impl std::future::Future<()> { +// use gloo_timers::callback::Timeout; +// +// let container = leptos::document().create_element("div").unwrap(); +// leptos::document().body().unwrap().append_child(&container).unwrap(); +// +// let view = view! { +// +// {FALLBACK_TEXT} +// +// +// }; +// leptos::mount_to(container.clone(), view); +// +// let doc = leptos::document(); +// let fallback_el = query_element_by_text(&doc, FALLBACK_TEXT) +// .expect("Fallback should be visible initially"); +// assert_eq!(fallback_el.text_content().unwrap(), FALLBACK_TEXT); +// +// let promise = js_sys::Promise::new(&mut |resolve, _reject| { +// Timeout::new(DELAY_MS as u32, move || { +// // We'll simulate "error" if policy != "no-referrer" +// let imgs = doc.get_elements_by_tag_name("img"); +// for i in 0..imgs.length() { +// let image = imgs.item(i).unwrap(); +// let policy = image.get_attribute("referrerPolicy").unwrap_or_default(); +// let evt_type = if policy == "no-referrer" { "load" } else { "error" }; +// let evt = web_sys::Event::new(evt_type).unwrap(); +// image.dispatch_event(&evt).unwrap(); +// } +// resolve.call0(&JsValue::NULL).unwrap(); +// }) +// .forget(); +// }); +// +// async move { +// wasm_bindgen_futures::JsFuture::from(promise).await.unwrap(); +// +// // Because we triggered an "error" event, the should never appear +// let imgs = doc.get_elements_by_tag_name("img"); +// assert_eq!(imgs.length(), 0, "No should be in the DOM after an error."); +// } +// } diff --git a/packages/primitives/leptos/compose-refs/Cargo.toml b/packages/primitives/leptos/compose-refs/Cargo.toml index e980a089..6771c149 100644 --- a/packages/primitives/leptos/compose-refs/Cargo.toml +++ b/packages/primitives/leptos/compose-refs/Cargo.toml @@ -10,3 +10,4 @@ version.workspace = true [dependencies] leptos.workspace = true +leptos-node-ref.workspace = true \ No newline at end of file diff --git a/packages/primitives/leptos/compose-refs/src/compose_refs.rs b/packages/primitives/leptos/compose-refs/src/compose_refs.rs index 2529cdf0..dfbf0ba5 100644 --- a/packages/primitives/leptos/compose-refs/src/compose_refs.rs +++ b/packages/primitives/leptos/compose-refs/src/compose_refs.rs @@ -1,21 +1,254 @@ -use leptos::{html::ElementDescriptor, Effect, NodeRef}; +use leptos::{ + html::{self, ElementType}, + prelude::*, + tachys::html::node_ref::NodeRefContainer, + wasm_bindgen::JsCast, + web_sys::Element, +}; +use leptos_node_ref::prelude::*; +use std::{rc::Rc, iter::IntoIterator}; -fn compose_refs(refs: Vec>) -> NodeRef { - let composed_ref = NodeRef::new(); +/// A trait for composable node references that can be combined, +/// while maintaining static dispatch (tuples) and dynamic dispatch (iterables). +pub trait ComposeRefs { + /// Applies the composition to a given DOM node. + fn compose_with(&self, node: &Element); +} + +// ------------------------------------- +// 1. Static Implementations +// ------------------------------------- + +impl ComposeRefs for AnyNodeRef { + #[inline(always)] + fn compose_with(&self, node: &Element) { + >::load(*self, node); + } +} + +impl ComposeRefs for NodeRef +where + T: ElementType, + T::Output: JsCast, +{ + #[inline(always)] + fn compose_with(&self, node: &Element) { + as NodeRefContainer>::load(*self, node); + } +} + +// NOTE: See macro ahead, replaces these. These are +// left for illustration for now. +// impl ComposeRefs for (A, B) +// where +// A: ComposeRefs, +// B: ComposeRefs, +// { +// #[inline(always)] +// fn compose_with(&self, node: &Element) { +// self.0.compose_with(node); +// self.1.compose_with(node); +// } +// } + +// impl ComposeRefs for (A, B, C) +// where +// A: ComposeRefs, +// B: ComposeRefs, +// C: ComposeRefs, +// { +// #[inline(always)] +// fn compose_with(&self, node: &Element) { +// self.0.compose_with(node); +// self.1.compose_with(node); +// self.2.compose_with(node); +// } +// } + +macro_rules! impl_compose_refs_tuple { + ($($idx:tt $type:ident),+) => { + impl<$($type),+> ComposeRefs for ($($type),+) + where + $($type: ComposeRefs),+ + { + #[inline(always)] + fn compose_with(&self, node: &Element) { + $( + self.$idx.compose_with(node); + )+ + } + } + } +} + +impl_compose_refs_tuple!(0 A, 1 B); +impl_compose_refs_tuple!(0 A, 1 B, 2 C); +impl_compose_refs_tuple!(0 A, 1 B, 2 C, 3 D); +impl_compose_refs_tuple!(0 A, 1 B, 2 C, 3 D, 4 E); +impl_compose_refs_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F); + +// ------------------------------------- +// 2. Dynamic Implementations +// ------------------------------------- + +/// Implementation for arrays of any size +impl ComposeRefs for [T; N] { + fn compose_with(&self, node: &Element) { + for item in self.iter() { + item.compose_with(node); + } + } +} + +/// Implementation for slice references +impl ComposeRefs for &[T] { + fn compose_with(&self, node: &Element) { + for item in (*self).iter() { + item.compose_with(node); + } + } +} + +/// Implementation for Vec +impl ComposeRefs for Vec { + fn compose_with(&self, node: &Element) { + for item in self.iter() { + item.compose_with(node); + } + } +} + +// ------------------------------------- +// 3. compose_refs + Hook +// ------------------------------------- +/// Combines multiple node references into a single reference that, when set, +/// updates all input references to point to the same DOM node. +/// +/// - **Static**: Tuples (`(ref1, ref2, ...)`)—no heap allocation. +/// - **Dynamic**: Any iterable (`Vec`, slice, array) of references. +/// +/// # Examples +/// ```rust +/// use leptos::{html::Div, html::Button}; +/// use leptos::prelude::NodeRef; +/// use leptos_node_ref::prelude::*; +/// use radix_leptos_compose_refs::compose_refs; +/// +/// // 1) Static composition (tuples): +/// let div_ref = NodeRef::
::new(); +/// let btn_ref = NodeRef::