diff --git a/Cargo.lock b/Cargo.lock index f3793382..7fda8c7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3158,6 +3158,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "radix-leptos-portal" +version = "0.0.2" +dependencies = [ + "cfg-if", + "leptos", + "leptos-node-ref", + "leptos_dom", + "radix-leptos-primitive", + "send_wrapper", + "web-sys", +] + [[package]] name = "radix-leptos-primitive" version = "0.0.2" @@ -3183,12 +3196,14 @@ dependencies = [ "console_error_panic_hook", "console_log", "leptos", + "leptos-node-ref", "leptos_router", "log", "radix-leptos-accessible-icon", "radix-leptos-arrow", "radix-leptos-aspect-ratio", "radix-leptos-label", + "radix-leptos-portal", "radix-leptos-separator", "radix-leptos-visually-hidden", "tailwind_fuse", diff --git a/Cargo.toml b/Cargo.toml index eec5b2be..9bff9b3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "packages/primitives/leptos/focus-guards", "packages/primitives/leptos/id", "packages/primitives/leptos/label", + "packages/primitives/leptos/portal", "packages/primitives/leptos/primitive", "packages/primitives/leptos/separator", "packages/primitives/leptos/use-controllable-state", @@ -68,6 +69,7 @@ radix-leptos-icons = { path = "./packages/icons/leptos", version = "0.0.2" } radix-leptos-id = { path = "./packages/primitives/leptos/id", version = "0.0.2" } radix-leptos-label = { path = "./packages/primitives/leptos/label", version = "0.0.2" } # radix-leptos-popper = { path = "./packages/primitives/leptos/popper", version = "0.0.2" } +radix-leptos-portal = { path = "./packages/primitives/leptos/portal", version = "0.0.2" } # radix-leptos-presence = { path = "./packages/primitives/leptos/presence", version = "0.0.2" } radix-leptos-primitive = { path = "./packages/primitives/leptos/primitive", version = "0.0.2" } # radix-leptos-roving-focus = { path = "./packages/primitives/leptos/roving-focus", version = "0.0.2" } diff --git a/book/src/primitives/utilities/portal.md b/book/src/primitives/utilities/portal.md index a704db70..08e74e2b 100644 --- a/book/src/primitives/utilities/portal.md +++ b/book/src/primitives/utilities/portal.md @@ -15,14 +15,7 @@ Install the component from your command line. {{#tab name="Leptos" }} ```shell -# CSR -cargo add radix-leptos-portal --features csr - -# Hydrate -cargo add radix-leptos-portal --features hydrate - -# SSR -cargo add radix-leptos-portal --features ssr +cargo add radix-leptos-portal ``` - [View on crates.io](https://crates.io/crates/radix-leptos-portal) @@ -89,11 +82,11 @@ Anything you put inside this component will be rendered in a separate `
` el {{#tabs global="framework" }} {{#tab name="Leptos" }} -| Prop | Type | Default | -| --------------- | ----------------------------- | ------- | -| `as_child` | `MaybeProp` | `false` | -| `container` | `MaybeProp` | - | -| `container_ref` | `NodeRef` | - | +| Prop | Type | Default | +| --------------- | ------------------------------------------ | ------- | +| `as_child` | `MaybeProp` | `false` | +| `container` | `MaybeProp>` | - | +| `container_ref` | `NodeRef` | - | {{#endtab }} {{#tab name="Yew" }} diff --git a/packages/primitives/leptos/portal/Cargo.toml b/packages/primitives/leptos/portal/Cargo.toml index 50274b38..53b43fef 100644 --- a/packages/primitives/leptos/portal/Cargo.toml +++ b/packages/primitives/leptos/portal/Cargo.toml @@ -13,13 +13,15 @@ version.workspace = true cfg-if = "1.0.0" leptos.workspace = true leptos_dom.workspace = true -tracing = "0.1" +leptos-node-ref.workspace = true +radix-leptos-primitive.workspace = true +send_wrapper.workspace = true web-sys.workspace = true -[features] -csr = ["leptos_dom/csr"] -hydrate = ["leptos_dom/hydrate"] -ssr = ["leptos_dom/ssr"] +# [features] +# csr = ["leptos_dom/csr"] +# hydrate = ["leptos_dom/hydrate"] +# ssr = ["leptos_dom/ssr"] -[package.metadata.cargo-all-features] -skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]] +# [package.metadata.cargo-all-features] +# skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]] diff --git a/packages/primitives/leptos/portal/src/portal.rs b/packages/primitives/leptos/portal/src/portal.rs index 05c1dc41..c15962ce 100644 --- a/packages/primitives/leptos/portal/src/portal.rs +++ b/packages/primitives/leptos/portal/src/portal.rs @@ -1,122 +1,109 @@ -use leptos::{html::AnyElement, *}; +use leptos::{html, prelude::*}; +use leptos_node_ref::AnyNodeRef; use leptos_portal::LeptosPortal; +use radix_leptos_primitive::Primitive; +use send_wrapper::SendWrapper; #[component] pub fn Portal( - #[prop(into, optional)] container: MaybeProp, - #[prop(optional)] container_ref: NodeRef, + #[prop(into, optional)] container: MaybeProp>, + #[prop(optional)] container_ref: AnyNodeRef, #[prop(into, optional)] as_child: MaybeProp, - #[prop(optional)] node_ref: NodeRef, - #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, + #[prop(optional)] node_ref: AnyNodeRef, children: ChildrenFn, ) -> impl IntoView { - let attrs = StoredValue::new(attrs); let children = StoredValue::new(children); + // TODO: pass attrs to primitive view! { - - - {children.with_value(|children| children())} - - + // + + + {children.with_value(|children| children())} + + + // } } /// Based on [`leptos::Portal`]. mod leptos_portal { - use cfg_if::cfg_if; - use leptos::{component, html::AnyElement, ChildrenFn, MaybeProp, NodeRef}; - use leptos_dom::IntoView; + use std::sync::Arc; + + use leptos::prelude::{ + Effect, Get, IntoView, MaybeProp, Owner, RwSignal, Set, Signal, TypedChildrenFn, component, + mount_to, untrack, + }; + use leptos_dom::helpers::document; + use leptos_node_ref::AnyNodeRef; + use send_wrapper::SendWrapper; /// Renders components somewhere else in the DOM. /// /// Useful for inserting modals and tooltips outside of a cropping layout. - /// If no mount point is given, the portal is inserted in `document.body`; - #[cfg_attr( - any(debug_assertions, feature = "ssr"), - tracing::instrument(level = "trace", skip_all) - )] + /// If no mount point is given, the portal is inserted in `document.body`. #[component] - pub fn LeptosPortal( + pub fn LeptosPortal( /// Target element where the children will be appended #[prop(into, optional)] - mount: MaybeProp, - #[prop(optional)] mount_ref: NodeRef, + mount: MaybeProp>, + #[prop(optional)] mount_ref: AnyNodeRef, /// The children to teleport into the `mount` element - children: ChildrenFn, - ) -> impl IntoView { - cfg_if! { if #[cfg(all(target_arch = "wasm32", any(feature = "hydrate", feature = "csr")))] { - use leptos::{on_cleanup, Effect, RwSignal, Signal, SignalGet, SignalSet, StoredValue}; - use leptos_dom::{document, Mountable}; + children: TypedChildrenFn, + ) -> impl IntoView + where + V: IntoView + 'static, + { + if cfg!(target_arch = "wasm32") + && Owner::current_shared_context() + .map(|sc| sc.is_browser()) + .unwrap_or(true) + { use web_sys::wasm_bindgen::JsCast; - let children = StoredValue::new(children); - let mount = Signal::derive(move || { mount_ref .get() - .map(|mount| { - let element: &web_sys::HtmlElement = &mount; - element.clone().unchecked_into::() - }) + .map(|mount| SendWrapper::new(mount.unchecked_into::())) .or_else(|| mount.get()) - .unwrap_or_else(|| document().body().expect("body to exist").into()) + .unwrap_or_else(|| { + SendWrapper::new(document().body().expect("body to exist").into()) + }) }); + let children = children.into_inner(); - let current_mount: RwSignal> = RwSignal::new(None); - let current_nodes: RwSignal>> = RwSignal::new(None); - - let remove_nodes = move |current_mount: &web_sys::Element | { - if let Some(current_nodes) = current_nodes.get() { - for current_node in current_nodes { - current_mount.remove_child(¤t_node).expect("child to be removed"); - } - } - }; + let current_mount: RwSignal>> = + RwSignal::new(None); Effect::new(move |_| { let mount = mount.get(); - if current_mount.get().as_ref() != Some(&mount) { - if let Some(current_mount) = current_mount.get() { - remove_nodes(¤t_mount); - } + + if current_mount.get().as_deref() != Some(&*mount) { current_mount.set(Some(mount)); } }); Effect::new(move |_| { if let Some(current_mount) = current_mount.get() { - remove_nodes(¤t_mount); + let handle = + SendWrapper::new(mount_to((*current_mount).clone().unchecked_into(), { + let children = Arc::clone(&children); + move || untrack(|| children()) + })); - let node = children.with_value(|children| children().into_view().get_mountable_node()); - if let Some(fragment) = node.dyn_ref::() { - let mut nodes: Vec = vec![]; - for index in 0..fragment.children().length() { - nodes.push(fragment.children().item(index).expect("child to exist").into()); + Owner::on_cleanup({ + move || { + let handle = handle.take(); + drop(handle); } - - current_mount.append_child(&node).expect("child to be appended"); - current_nodes.set(Some(nodes)); - } else { - current_nodes.set(Some(vec![current_mount.append_child(&node).expect("child to be appended")])); - } - } - }); - - on_cleanup(move || { - if let Some(current_mount) = current_mount.get() { - remove_nodes(¤t_mount); + }) } }); - } else { - let _ = mount; - let _ = mount_ref; - let _ = children; - }} + } } } diff --git a/packages/primitives/leptos/primitive/src/primitive.rs b/packages/primitives/leptos/primitive/src/primitive.rs index 6e6b8dce..e56d99ef 100644 --- a/packages/primitives/leptos/primitive/src/primitive.rs +++ b/packages/primitives/leptos/primitive/src/primitive.rs @@ -79,10 +79,10 @@ where original.run(event.clone()); } - if !check_default_prevented || !event.clone().into().default_prevented() { - if let Some(our) = &our_handler { - our.run(event); - } + if (!check_default_prevented || !event.clone().into().default_prevented()) + && let Some(our) = &our_handler + { + our.run(event); } } } diff --git a/packages/primitives/leptos/use-controllable-state/src/use_controllable_state.rs b/packages/primitives/leptos/use-controllable-state/src/use_controllable_state.rs index 1ac0c0b0..21e2f973 100644 --- a/packages/primitives/leptos/use-controllable-state/src/use_controllable_state.rs +++ b/packages/primitives/leptos/use-controllable-state/src/use_controllable_state.rs @@ -27,10 +27,10 @@ pub fn use_controllable_state( let set_value = Callback::new(move |next_value| { if is_controlled.get() { - if next_value != prop.get() { - if let Some(on_change) = on_change { - on_change.run(next_value); - } + if next_value != prop.get() + && let Some(on_change) = on_change + { + on_change.run(next_value); } } else { set_uncontrolled_prop.set(next_value); @@ -57,11 +57,11 @@ fn use_uncontrolled_state( Effect::new(move |_| { let value = value.get(); - if prev_value.get() != value { - if let Some(on_change) = on_change { - on_change.run(value.clone()); - prev_value.set(value); - } + if prev_value.get() != value + && let Some(on_change) = on_change + { + on_change.run(value.clone()); + prev_value.set(value); } }); diff --git a/packages/primitives/leptos/use-escape-keydown/src/use_escape_keydown.rs b/packages/primitives/leptos/use-escape-keydown/src/use_escape_keydown.rs index a0296f4b..0472dee1 100644 --- a/packages/primitives/leptos/use-escape-keydown/src/use_escape_keydown.rs +++ b/packages/primitives/leptos/use-escape-keydown/src/use_escape_keydown.rs @@ -17,10 +17,10 @@ pub fn use_escape_keydown( type HandleKeyDown = dyn Fn(KeyboardEvent); let handle_key_down: Arc>> = Arc::new(SendWrapper::new( Closure::new(move |event: KeyboardEvent| { - if event.key() == "Escape" { - if let Some(on_escape_key_down) = on_escape_key_down { - on_escape_key_down.run(event); - } + if event.key() == "Escape" + && let Some(on_escape_key_down) = on_escape_key_down + { + on_escape_key_down.run(event); } }), )); diff --git a/packages/primitives/yew/checkbox/src/checkbox.rs b/packages/primitives/yew/checkbox/src/checkbox.rs index bdcd0302..a5a50bdb 100644 --- a/packages/primitives/yew/checkbox/src/checkbox.rs +++ b/packages/primitives/yew/checkbox/src/checkbox.rs @@ -391,25 +391,25 @@ fn BubbleInput(props: &BubbleInputProps) -> Html { use_effect_with( (node_ref.clone(), prev_checked, props.checked, props.bubbles), |(node_ref, prev_checked, checked, bubbles)| { - if let Some(input) = node_ref.cast::() { - if **prev_checked != *checked { - let init = web_sys::EventInit::new(); - init.set_bubbles(*bubbles); - - let event = web_sys::Event::new_with_event_init_dict("click", &init) - .expect("Click event should be instantiated."); - - input.set_indeterminate(is_indeterminiate(*checked)); - input.set_checked(match checked { - CheckedState::False => false, - CheckedState::True => true, - CheckedState::Indeterminate => false, - }); - - input - .dispatch_event(&event) - .expect("Click event should be dispatched."); - } + if let Some(input) = node_ref.cast::() + && **prev_checked != *checked + { + let init = web_sys::EventInit::new(); + init.set_bubbles(*bubbles); + + let event = web_sys::Event::new_with_event_init_dict("click", &init) + .expect("Click event should be instantiated."); + + input.set_indeterminate(is_indeterminiate(*checked)); + input.set_checked(match checked { + CheckedState::False => false, + CheckedState::True => true, + CheckedState::Indeterminate => false, + }); + + input + .dispatch_event(&event) + .expect("Click event should be dispatched."); } }, ); diff --git a/packages/primitives/yew/focus-scope/src/focus_scope.rs b/packages/primitives/yew/focus-scope/src/focus_scope.rs index 662742e3..ae43de49 100644 --- a/packages/primitives/yew/focus-scope/src/focus_scope.rs +++ b/packages/primitives/yew/focus-scope/src/focus_scope.rs @@ -399,44 +399,40 @@ pub fn FocusScope(props: &FocusScopeProps) -> Html { .active_element() .map(|element| element.unchecked_into::()); - if is_tab_key { - if let Some(focused_element) = focused_element { - // Yew messes up `current_target`, see https://yew.rs/docs/concepts/html/events#event-delegation. - // - // let container = event - // .current_target() - // .expect("Event should have current target.") - // .unchecked_into::(); - let container = container_ref - .cast::() - .expect("Container should exist."); - let (first, last) = get_tabbable_edges(&container); - let has_tabbable_elements_inside = first.is_some() && last.is_some(); - - if !has_tabbable_elements_inside { - if focused_element == container { - event.prevent_default(); + if is_tab_key && let Some(focused_element) = focused_element { + // Yew messes up `current_target`, see https://yew.rs/docs/concepts/html/events#event-delegation. + // + // let container = event + // .current_target() + // .expect("Event should have current target.") + // .unchecked_into::(); + let container = container_ref + .cast::() + .expect("Container should exist."); + let (first, last) = get_tabbable_edges(&container); + let has_tabbable_elements_inside = first.is_some() && last.is_some(); + + if !has_tabbable_elements_inside { + if focused_element == container { + event.prevent_default(); + } + } else { + #[allow(clippy::collapsible_else_if)] + if !event.shift_key() + && &focused_element == last.as_ref().expect("Last option checked above.") + { + event.prevent_default(); + + if r#loop { + focus(first, Some(FocusOptions { select: true })); } - } else { - #[allow(clippy::collapsible_else_if)] - if !event.shift_key() - && &focused_element - == last.as_ref().expect("Last option checked above.") - { - event.prevent_default(); - - if r#loop { - focus(first, Some(FocusOptions { select: true })); - } - } else if event.shift_key() - && &focused_element - == first.as_ref().expect("First option checked above.") - { - event.prevent_default(); + } else if event.shift_key() + && &focused_element == first.as_ref().expect("First option checked above.") + { + event.prevent_default(); - if r#loop { - focus(last, Some(FocusOptions { select: true })); - } + if r#loop { + focus(last, Some(FocusOptions { select: true })); } } } @@ -521,11 +517,11 @@ fn get_tabbable_candidates(container: &web_sys::HtmlElement) -> Vec() { - if input_element.disabled() || input_element.type_() == "hidden" { - // NodeFilter.FILTER_SKIP - return 3; - } + if let Some(input_element) = node.dyn_ref::() + && (input_element.disabled() || input_element.type_() == "hidden") + { + // NodeFilter.FILTER_SKIP + return 3; } if html_element.tab_index() >= 0 { @@ -715,10 +711,10 @@ impl FocusScopeStack { fn add(&mut self, focus_scope: FocusScopeAPI) { // Pause the currently active focus scope (at the top of the stack). - if let Some(active_focus_scope) = self.stack.first_mut() { - if focus_scope != *active_focus_scope { - active_focus_scope.pause(); - } + if let Some(active_focus_scope) = self.stack.first_mut() + && focus_scope != *active_focus_scope + { + active_focus_scope.pause(); } // Remove in case it already exists (because we'll re-add it at the top of the stack). diff --git a/packages/primitives/yew/primitive/src/primitive.rs b/packages/primitives/yew/primitive/src/primitive.rs index adab54ef..cab75427 100644 --- a/packages/primitives/yew/primitive/src/primitive.rs +++ b/packages/primitives/yew/primitive/src/primitive.rs @@ -12,10 +12,10 @@ pub fn compose_callbacks + 'static>( original_event_handler.emit(event.clone()); } - if !check_for_default_prevented || !event.clone().into().default_prevented() { - if let Some(our_event_handler) = &our_event_handler { - our_event_handler.emit(event); - } + if (!check_for_default_prevented || !event.clone().into().default_prevented()) + && let Some(our_event_handler) = &our_event_handler + { + our_event_handler.emit(event); } }) } diff --git a/packages/primitives/yew/select/src/select.rs b/packages/primitives/yew/select/src/select.rs index fd8d4360..5d88b425 100644 --- a/packages/primitives/yew/select/src/select.rs +++ b/packages/primitives/yew/select/src/select.rs @@ -1469,208 +1469,178 @@ where dir, on_placed, )| { - if let Some(trigger) = trigger_ref.cast::() { - if let Some(value_node) = value_node_ref.cast::() { - if let Some(content_wrapper) = - content_wrapper_ref.cast::() - { - if let Some(content) = content_ref.cast::() { - if let Some(viewport) = viewport_ref.cast::() { - if let Some(selected_item) = selected_item { - if let Some(selected_item_text) = selected_item_text { - let window = window().expect("Window should exist."); - let window_inner_width = window - .inner_width() - .expect("Window should have inner width.") - .as_f64() - .expect("Inner width should be a number."); - let window_inner_height = window - .inner_height() - .expect("Window should have inner height.") - .as_f64() - .expect("Inner height should be a number."); - - let trigger_rect = trigger.get_bounding_client_rect(); - - // Horizontal positioning - let content_rect = content.get_bounding_client_rect(); - let value_node_rect = value_node.get_bounding_client_rect(); - let item_text_rect = - selected_item_text.get_bounding_client_rect(); - - if *dir != Direction::Rtl { - let item_text_offset = - item_text_rect.left() - content_rect.left(); - let left = value_node_rect.left() - item_text_offset; - let left_delta = trigger_rect.left() - left; - let min_content_width = - trigger_rect.width() + left_delta; - let content_width = - min_content_width.max(content_rect.width()); - let right_edge = window_inner_width - CONTENT_MARGIN; - let clamped_left = clamp( - left, - [ - CONTENT_MARGIN, - // Prevents the content from going off the starting edge of the - // viewport. It may still go off the ending edge, but this can be - // controlled by the user since they may want to manage overflow in a - // specific way. - // https://github.com/radix-ui/primitives/issues/2049 - CONTENT_MARGIN.max(right_edge - content_width), - ], - ); - - content_wrapper - .style() - .set_property( - "min-width", - &format!("{min_content_width}px"), - ) - .expect("Min width should be set."); - content_wrapper - .style() - .set_property("left", &format!("{clamped_left}px")) - .expect("Left should be set."); - } else { - let item_text_offset = - content_rect.right() - item_text_rect.right(); - let right = window_inner_width - - value_node_rect.right() - - item_text_offset; - let right_delta = - window_inner_width - trigger_rect.right() - right; - let min_content_width = - trigger_rect.width() + right_delta; - let content_width = - min_content_width.max(content_rect.width()); - let left_edge = window_inner_width - CONTENT_MARGIN; - let clamped_right = clamp( - right, - [ - CONTENT_MARGIN, - CONTENT_MARGIN.max(left_edge - content_width), - ], - ); - - content_wrapper - .style() - .set_property( - "min-width", - &format!("{min_content_width}px"), - ) - .expect("Min width should be set."); - content_wrapper - .style() - .set_property("left", &format!("{clamped_right}px")) - .expect("Left should be set."); - } - - // Vertical positioning - let items = get_items.emit(()); - let available_height = - window_inner_height - CONTENT_MARGIN * 2.0; - let items_height = viewport.scroll_height() as f64; - - let content_styles = window - .get_computed_style(&content) - .expect("Element is valid.") - .expect("Element should have computed style."); - let content_border_top_width = content_styles - .get_property_value("border-top-width") - .expect("Compyted style should have border top width.") - .trim_end_matches("px") - .parse::() - .expect("Border top width should be a number."); - let content_padding_top = content_styles - .get_property_value("padding-top") - .expect("Compyted style should have padding top.") - .trim_end_matches("px") - .parse::() - .expect("Padding top should be a number."); - let content_border_bottom_width = content_styles - .get_property_value("border-bottom-width") - .expect( - "Compyted style should have border bottom width.", - ) - .trim_end_matches("px") - .parse::() - .expect("Border bottom width should be a number."); - let content_padding_bottom = content_styles - .get_property_value("padding-bottom") - .expect("Compyted style should have padding bottom.") - .trim_end_matches("px") - .parse::() - .expect("Padding bottom should be a number."); - let full_content_height = content_border_top_width - + content_padding_top - + items_height - + content_padding_bottom - + content_border_bottom_width; - let min_content_height = - (selected_item.offset_height() as f64 * 5.0) - .min(full_content_height); - - let viewport_styles = window - .get_computed_style(&viewport) - .expect("Element is valid.") - .expect("Element should have computed style."); - let viewport_padding_top = viewport_styles - .get_property_value("padding-top") - .expect("Compyted style should have padding top.") - .trim_end_matches("px") - .parse::() - .expect("Padding top should be a number."); - let viewport_padding_bottom = viewport_styles - .get_property_value("padding-bottom") - .expect("Compyted style should have padding bottom.") - .trim_end_matches("px") - .parse::() - .expect("Padding bottom should be a number."); - - let top_edge_to_trigger_middle = trigger_rect.top() - + trigger_rect.height() / 2.0 - - CONTENT_MARGIN; - let trigger_middle_to_bottom_edge = - available_height - top_edge_to_trigger_middle; - - let selected_item_half_height = - selected_item.offset_height() as f64 / 2.0; - let item_offset_middle = selected_item.offset_top() as f64 - + selected_item_half_height; - let content_top_to_item_middle = content_border_top_width - - content_padding_top - + item_offset_middle; - let item_middle_to_content_bottom = - full_content_height - content_top_to_item_middle; - - let will_align_without_top_overflow = - content_top_to_item_middle - <= top_edge_to_trigger_middle; - - if will_align_without_top_overflow { - let is_last_item = !items.is_empty() - && items - .last() - .expect("Last item should exist.") - .r#ref - .cast::() - .is_some_and(|element| { - &element == selected_item - }); - - content_wrapper - .style() - .set_property("bottom", "0px") - .expect("Bottom should be set."); - - let viewport_offset_bottom = content.client_height() - as f64 - - viewport.offset_top() as f64 - - viewport.offset_height() as f64; - let clamped_trigger_middle_to_bottom_edge = - trigger_middle_to_bottom_edge.max( - selected_item_half_height + if let Some(trigger) = trigger_ref.cast::() + && let Some(value_node) = value_node_ref.cast::() + && let Some(content_wrapper) = content_wrapper_ref.cast::() + && let Some(content) = content_ref.cast::() + && let Some(viewport) = viewport_ref.cast::() + && let Some(selected_item) = selected_item + && let Some(selected_item_text) = selected_item_text + { + let window = window().expect("Window should exist."); + let window_inner_width = window + .inner_width() + .expect("Window should have inner width.") + .as_f64() + .expect("Inner width should be a number."); + let window_inner_height = window + .inner_height() + .expect("Window should have inner height.") + .as_f64() + .expect("Inner height should be a number."); + + let trigger_rect = trigger.get_bounding_client_rect(); + + // Horizontal positioning + let content_rect = content.get_bounding_client_rect(); + let value_node_rect = value_node.get_bounding_client_rect(); + let item_text_rect = selected_item_text.get_bounding_client_rect(); + + if *dir != Direction::Rtl { + let item_text_offset = item_text_rect.left() - content_rect.left(); + let left = value_node_rect.left() - item_text_offset; + let left_delta = trigger_rect.left() - left; + let min_content_width = trigger_rect.width() + left_delta; + let content_width = min_content_width.max(content_rect.width()); + let right_edge = window_inner_width - CONTENT_MARGIN; + let clamped_left = clamp( + left, + [ + CONTENT_MARGIN, + // Prevents the content from going off the starting edge of the + // viewport. It may still go off the ending edge, but this can be + // controlled by the user since they may want to manage overflow in a + // specific way. + // https://github.com/radix-ui/primitives/issues/2049 + CONTENT_MARGIN.max(right_edge - content_width), + ], + ); + + content_wrapper + .style() + .set_property("min-width", &format!("{min_content_width}px")) + .expect("Min width should be set."); + content_wrapper + .style() + .set_property("left", &format!("{clamped_left}px")) + .expect("Left should be set."); + } else { + let item_text_offset = content_rect.right() - item_text_rect.right(); + let right = window_inner_width - value_node_rect.right() - item_text_offset; + let right_delta = window_inner_width - trigger_rect.right() - right; + let min_content_width = trigger_rect.width() + right_delta; + let content_width = min_content_width.max(content_rect.width()); + let left_edge = window_inner_width - CONTENT_MARGIN; + let clamped_right = clamp( + right, + [ + CONTENT_MARGIN, + CONTENT_MARGIN.max(left_edge - content_width), + ], + ); + + content_wrapper + .style() + .set_property("min-width", &format!("{min_content_width}px")) + .expect("Min width should be set."); + content_wrapper + .style() + .set_property("left", &format!("{clamped_right}px")) + .expect("Left should be set."); + } + + // Vertical positioning + let items = get_items.emit(()); + let available_height = window_inner_height - CONTENT_MARGIN * 2.0; + let items_height = viewport.scroll_height() as f64; + + let content_styles = window + .get_computed_style(&content) + .expect("Element is valid.") + .expect("Element should have computed style."); + let content_border_top_width = content_styles + .get_property_value("border-top-width") + .expect("Compyted style should have border top width.") + .trim_end_matches("px") + .parse::() + .expect("Border top width should be a number."); + let content_padding_top = content_styles + .get_property_value("padding-top") + .expect("Compyted style should have padding top.") + .trim_end_matches("px") + .parse::() + .expect("Padding top should be a number."); + let content_border_bottom_width = content_styles + .get_property_value("border-bottom-width") + .expect("Compyted style should have border bottom width.") + .trim_end_matches("px") + .parse::() + .expect("Border bottom width should be a number."); + let content_padding_bottom = content_styles + .get_property_value("padding-bottom") + .expect("Compyted style should have padding bottom.") + .trim_end_matches("px") + .parse::() + .expect("Padding bottom should be a number."); + let full_content_height = content_border_top_width + + content_padding_top + + items_height + + content_padding_bottom + + content_border_bottom_width; + let min_content_height = + (selected_item.offset_height() as f64 * 5.0).min(full_content_height); + + let viewport_styles = window + .get_computed_style(&viewport) + .expect("Element is valid.") + .expect("Element should have computed style."); + let viewport_padding_top = viewport_styles + .get_property_value("padding-top") + .expect("Compyted style should have padding top.") + .trim_end_matches("px") + .parse::() + .expect("Padding top should be a number."); + let viewport_padding_bottom = viewport_styles + .get_property_value("padding-bottom") + .expect("Compyted style should have padding bottom.") + .trim_end_matches("px") + .parse::() + .expect("Padding bottom should be a number."); + + let top_edge_to_trigger_middle = + trigger_rect.top() + trigger_rect.height() / 2.0 - CONTENT_MARGIN; + let trigger_middle_to_bottom_edge = available_height - top_edge_to_trigger_middle; + + let selected_item_half_height = selected_item.offset_height() as f64 / 2.0; + let item_offset_middle = + selected_item.offset_top() as f64 + selected_item_half_height; + let content_top_to_item_middle = + content_border_top_width - content_padding_top + item_offset_middle; + let item_middle_to_content_bottom = + full_content_height - content_top_to_item_middle; + + let will_align_without_top_overflow = + content_top_to_item_middle <= top_edge_to_trigger_middle; + + if will_align_without_top_overflow { + let is_last_item = !items.is_empty() + && items + .last() + .expect("Last item should exist.") + .r#ref + .cast::() + .is_some_and(|element| &element == selected_item); + + content_wrapper + .style() + .set_property("bottom", "0px") + .expect("Bottom should be set."); + + let viewport_offset_bottom = content.client_height() as f64 + - viewport.offset_top() as f64 + - viewport.offset_height() as f64; + let clamped_trigger_middle_to_bottom_edge = trigger_middle_to_bottom_edge.max( + selected_item_half_height // Viewport might have padding bottom, // include it to avoid a scrollable viewport. + match is_last_item { @@ -1679,33 +1649,29 @@ where } + viewport_offset_bottom + content_border_bottom_width, - ); - let height = content_top_to_item_middle - + clamped_trigger_middle_to_bottom_edge; - - content_wrapper - .style() - .set_property("height", &format!("{height}px")) - .expect("Height should be set."); - } else { - let is_first_item = !items.is_empty() - && items - .first() - .expect("First item should exist.") - .r#ref - .cast::() - .is_some_and(|element| { - &element == selected_item - }); - - content_wrapper - .style() - .set_property("top", "0px") - .expect("Top should be set."); - - let clamped_top_edge_to_trigger_middle = - top_edge_to_trigger_middle.max( - content_border_top_width + ); + let height = content_top_to_item_middle + clamped_trigger_middle_to_bottom_edge; + + content_wrapper + .style() + .set_property("height", &format!("{height}px")) + .expect("Height should be set."); + } else { + let is_first_item = !items.is_empty() + && items + .first() + .expect("First item should exist.") + .r#ref + .cast::() + .is_some_and(|element| &element == selected_item); + + content_wrapper + .style() + .set_property("top", "0px") + .expect("Top should be set."); + + let clamped_top_edge_to_trigger_middle = top_edge_to_trigger_middle.max( + content_border_top_width + viewport.offset_top() as f64 // Viewport might have padding top, // include it to avoid a scrollable viewport. @@ -1714,53 +1680,35 @@ where false => 0.0, } + selected_item_half_height, - ); - let height = clamped_top_edge_to_trigger_middle - + item_middle_to_content_bottom; - - content_wrapper - .style() - .set_property("height", &format!("{height}px")) - .expect("Height should be set."); - viewport.set_scroll_top( - (content_top_to_item_middle - - top_edge_to_trigger_middle - + viewport.offset_top() as f64) - as i32, - ); - } - - content_wrapper - .style() - .set_property( - "margin", - &format!("{CONTENT_MARGIN}px 0px"), - ) - .expect("Margin should be set."); - content_wrapper - .style() - .set_property( - "min-height", - &format!("{min_content_height}px"), - ) - .expect("Min height should be set."); - content_wrapper - .style() - .set_property( - "max-height", - &format!("{available_height}px"), - ) - .expect("Min height should be set."); - - on_placed.emit(()); - - // TODO: request animation frame - } - } - } - } - } + ); + let height = clamped_top_edge_to_trigger_middle + item_middle_to_content_bottom; + + content_wrapper + .style() + .set_property("height", &format!("{height}px")) + .expect("Height should be set."); + viewport.set_scroll_top( + (content_top_to_item_middle - top_edge_to_trigger_middle + + viewport.offset_top() as f64) as i32, + ); } + + content_wrapper + .style() + .set_property("margin", &format!("{CONTENT_MARGIN}px 0px")) + .expect("Margin should be set."); + content_wrapper + .style() + .set_property("min-height", &format!("{min_content_height}px")) + .expect("Min height should be set."); + content_wrapper + .style() + .set_property("max-height", &format!("{available_height}px")) + .expect("Min height should be set."); + + on_placed.emit(()); + + // TODO: request animation frame } }, ); diff --git a/packages/primitives/yew/switch/src/switch.rs b/packages/primitives/yew/switch/src/switch.rs index c4e53651..eaec16c3 100644 --- a/packages/primitives/yew/switch/src/switch.rs +++ b/packages/primitives/yew/switch/src/switch.rs @@ -280,20 +280,20 @@ fn BubbleInput(props: &BubbleInputProps) -> Html { use_effect_with( (node_ref.clone(), prev_checked, props.checked, props.bubbles), |(node_ref, prev_checked, checked, bubbles)| { - if let Some(input) = node_ref.cast::() { - if **prev_checked != *checked { - let init = web_sys::EventInit::new(); - init.set_bubbles(*bubbles); + if let Some(input) = node_ref.cast::() + && **prev_checked != *checked + { + let init = web_sys::EventInit::new(); + init.set_bubbles(*bubbles); - let event = web_sys::Event::new_with_event_init_dict("click", &init) - .expect("Click event should be instantiated."); + let event = web_sys::Event::new_with_event_init_dict("click", &init) + .expect("Click event should be instantiated."); - input.set_checked(*checked); + input.set_checked(*checked); - input - .dispatch_event(&event) - .expect("Click event should be dispatched."); - } + input + .dispatch_event(&event) + .expect("Click event should be dispatched."); } }, ); diff --git a/packages/primitives/yew/tooltip/src/tooltip.rs b/packages/primitives/yew/tooltip/src/tooltip.rs index 33ba37df..b7ffc3fe 100644 --- a/packages/primitives/yew/tooltip/src/tooltip.rs +++ b/packages/primitives/yew/tooltip/src/tooltip.rs @@ -1294,10 +1294,10 @@ fn TooltipContentImpl(props: &TooltipContentImplProps) -> Html { move |event: Event| { let target = event.target_dyn_into::(); - if let Some(target) = target { - if target.contains(Some(&trigger)) { - on_close.emit(()); - } + if let Some(target) = target + && target.contains(Some(&trigger)) + { + on_close.emit(()); } } }); diff --git a/packages/primitives/yew/use-controllable-state/src/use_controllable_state.rs b/packages/primitives/yew/use-controllable-state/src/use_controllable_state.rs index 617d1c64..3a9e02d8 100644 --- a/packages/primitives/yew/use-controllable-state/src/use_controllable_state.rs +++ b/packages/primitives/yew/use-controllable-state/src/use_controllable_state.rs @@ -31,10 +31,10 @@ where let set_value = Callback::from(move |next_value| { if is_controlled { - if next_value != prop { - if let Some(on_change) = &on_change { - on_change.emit(next_value); - } + if next_value != prop + && let Some(on_change) = &on_change + { + on_change.emit(next_value); } } else { uncontrolled_prop.set(next_value); @@ -64,11 +64,11 @@ where use_effect_with((value.clone(), prev_value), |(value, prev_value)| { let value = (**value).clone(); - if **prev_value != value { - if let Some(on_change) = on_change { - on_change.emit(value.clone()); - prev_value.set(value); - } + if **prev_value != value + && let Some(on_change) = on_change + { + on_change.emit(value.clone()); + prev_value.set(value); } }); diff --git a/stories/leptos/Cargo.toml b/stories/leptos/Cargo.toml index 3ddc0b5a..4776a4d2 100644 --- a/stories/leptos/Cargo.toml +++ b/stories/leptos/Cargo.toml @@ -14,6 +14,7 @@ console_log.workspace = true console_error_panic_hook.workspace = true leptos = { workspace = true, features = ["csr"] } leptos_router.workspace = true +leptos-node-ref.workspace = true log.workspace = true radix-leptos-accessible-icon.workspace = true radix-leptos-arrow.workspace = true @@ -25,9 +26,7 @@ radix-leptos-aspect-ratio.workspace = true radix-leptos-label.workspace = true # radix-leptos-menu.workspace = true # radix-leptos-popper.workspace = true -# radix-leptos-portal = { workspace = true, features = [ -# "csr", -# ] } +radix-leptos-portal.workspace = true # radix-leptos-presence.workspace = true # radix-leptos-progress.workspace = true radix-leptos-separator.workspace = true diff --git a/stories/leptos/src/app.rs b/stories/leptos/src/app.rs index b4320c4d..d4c5cf78 100644 --- a/stories/leptos/src/app.rs +++ b/stories/leptos/src/app.rs @@ -4,7 +4,9 @@ use leptos_router::{ path, }; -use crate::primitives::{accessible_icon, arrow, aspect_ratio, label, separator, visually_hidden}; +use crate::primitives::{ + accessible_icon, arrow, aspect_ratio, label, portal, separator, visually_hidden, +}; #[component] fn NavLink(href: H, children: Children) -> impl IntoView @@ -130,15 +132,15 @@ pub fn App() -> impl IntoView { //
  • Chromatic
  • // // - //
  • - // Portal +
  • + Portal - //
      - //
    • Base
    • - //
    • Custom Container
    • - //
    • Chromatic
    • - //
    - //
  • +
      +
    • Base
    • +
    • Custom Container
    • +
    • Chromatic
    • +
    + //
  • // Presence @@ -252,9 +254,9 @@ pub fn App() -> impl IntoView { // // - // - // - // + + + // // diff --git a/stories/leptos/src/primitives.rs b/stories/leptos/src/primitives.rs index 43a1e6cd..dedc2aae 100644 --- a/stories/leptos/src/primitives.rs +++ b/stories/leptos/src/primitives.rs @@ -9,7 +9,7 @@ pub mod label; // pub mod menu; // pub mod playground; // pub mod popper; -// pub mod portal; +pub mod portal; // pub mod presence; // pub mod progress; pub mod separator; diff --git a/stories/leptos/src/primitives/portal.rs b/stories/leptos/src/primitives/portal.rs index a7d4f2c0..f92d0891 100644 --- a/stories/leptos/src/primitives/portal.rs +++ b/stories/leptos/src/primitives/portal.rs @@ -1,4 +1,5 @@ -use leptos::{html::AnyElement, *}; +use leptos::prelude::*; +use leptos_node_ref::AnyNodeRef; use radix_leptos_portal::Portal; #[component] @@ -30,7 +31,7 @@ pub fn Base() -> impl IntoView { #[component] pub fn CustomContainer() -> impl IntoView { - let portal_container_ref: NodeRef = NodeRef::new(); + let portal_container_ref = AnyNodeRef::new(); view! {
    impl IntoView {
    - {view! { -
    -

    Container B

    -
    - }.into_any().node_ref(portal_container_ref)} +
    +

    Container B

    +
    } } #[component] pub fn Chromatic() -> impl IntoView { - let portal_container_ref: NodeRef = NodeRef::new(); + let portal_container_ref = AnyNodeRef::new(); view! {
    @@ -115,15 +115,14 @@ pub fn Chromatic() -> impl IntoView {
    - {view! { -
    -

    Container C

    -
    - }.into_any().node_ref(portal_container_ref)} +
    +

    Container C

    +

    zIndex and order

    See squares in top-left