Skip to main content

orbital_base_components/overlay/positioning/
mod.rs

1mod resolve_external_anchor;
2mod resolve_offset;
3mod styles;
4mod types;
5mod use_anchor_position;
6
7use leptos::tachys::view::any_view::IntoAny;
8use leptos::{html, prelude::*, tachys::html::node_ref::node_ref};
9use orbital_motion::{
10    resolve_presence_motion, MotionElementCallback, OrbitalPresence, PresenceMotion,
11};
12use orbital_style::inject_style;
13use orbital_theme::ThemeInjection;
14use send_wrapper::SendWrapper;
15use std::cell::RefCell;
16use std::rc::Rc;
17
18use crate::overlay::panel_surface::OverlayArrowInjection;
19use crate::overlay::OverlayDismiss;
20use crate::Handler;
21
22pub use resolve_external_anchor::resolve_external_anchor;
23pub use styles::positioning_panel_styles;
24pub use types::{
25    AnchorArrow, AnchorPosition, AnchorWidth, AnchoredPanel, RepositionInjection,
26    UseAnchorPositionOptions,
27};
28pub use use_anchor_position::use_anchor_position;
29
30/// Dynamic placement from the anchor-position hook, exposed to overlay surfaces.
31#[derive(Clone, Copy)]
32pub struct OverlayPlacementInjection {
33    pub placement_label: Signal<String>,
34}
35
36/// Positions an anchored panel relative to trigger content.
37#[component]
38pub fn AnchoredPositioner<T, FT>(
39    #[prop(optional, into)] on_css_transition_after_leave: Option<Handler>,
40    #[prop(default = None)] mount: Option<NodeRef<html::Div>>,
41    #[prop(optional)] panel_ref: Option<NodeRef<html::Div>>,
42    #[prop(default = false)] dismiss_on_anchor_hidden: bool,
43    panel: AnchoredPanel<FT>,
44    children: TypedChildren<T>,
45) -> impl IntoView
46where
47    T: AddAnyAttr + IntoView + Send + 'static,
48    FT: AddAnyAttr + IntoView + Send + 'static,
49{
50    inject_style("orbital-positioning-panel", positioning_panel_styles());
51
52    let theme = expect_context::<ThemeInjection>();
53    let theme_id = StoredValue::new(theme.id());
54
55    let AnchoredPanel {
56        show: panel_show,
57        width: panel_width,
58        placement: panel_placement,
59        children: panel_children,
60        auto_height,
61        arrow,
62        motion: panel_motion,
63    } = panel;
64
65    let arrow_injection = arrow.as_ref().map(|anchor| OverlayArrowInjection {
66        node_ref: anchor.node_ref,
67    });
68    // Portal content mounts under a fresh owner; re-provide dismiss from AnchoredOverlay.
69    let overlay_dismiss = use_context::<OverlayDismiss>();
70
71    let AnchorPosition {
72        target_ref,
73        content_ref,
74        panel_ref,
75        placement,
76        sync_position,
77        ensure_listener,
78        remove_listener,
79    } = use_anchor_position(
80        panel_width,
81        Signal::from(panel_placement),
82        auto_height,
83        arrow,
84        None,
85        panel_ref,
86        UseAnchorPositionOptions {
87            mount_ref: mount.clone(),
88            dismiss_on_anchor_hidden,
89            on_dismiss: overlay_dismiss.map(|d| d.close),
90        },
91    );
92
93    let placement_label = Signal::derive(move || placement.get().as_str().to_string());
94    provide_context(OverlayPlacementInjection { placement_label });
95
96    let reposition_injection = RepositionInjection::new({
97        let sync_position = sync_position.clone();
98        move || sync_position()
99    });
100
101    let sync_on_enter = {
102        let sync_position = sync_position.clone();
103        MotionElementCallback::new(move |_: web_sys::HtmlElement| sync_position())
104    };
105    let _on_before_enter = sync_on_enter;
106    let _on_after_enter = sync_on_enter;
107
108    #[cfg(not(feature = "ssr"))]
109    {
110        let sync_position = sync_position.clone();
111        Effect::new(move |_| {
112            if target_ref.try_get().flatten().is_none() || content_ref.try_get().flatten().is_none()
113            {
114                return;
115            }
116            if panel_show.get() {
117                sync_position();
118                remove_listener();
119                ensure_listener();
120            } else {
121                remove_listener();
122            }
123        });
124    }
125    #[cfg(feature = "ssr")]
126    let _ = (&ensure_listener, &remove_listener);
127
128    let on_after_leave = MotionElementCallback::new(move |_: web_sys::HtmlElement| {
129        if let Some(on_css_transition_after_leave) = &on_css_transition_after_leave {
130            on_css_transition_after_leave.run(());
131        }
132    });
133
134    let motion = resolve_presence_motion(panel_motion, PresenceMotion::fade());
135
136    let render_panel_children = panel_children.into_inner();
137    let panel_builder = SendWrapper::new(Rc::new(RefCell::new(Some(render_panel_children))));
138    let on_before_enter = sync_on_enter;
139    let on_after_enter = sync_on_enter;
140
141    let trigger_view = children.into_inner()()
142        .into_inner()
143        .add_any_attr(node_ref(target_ref));
144
145    // Panel slot children are `FnOnce`; build them only when the portal first mounts and
146    // reuse the cached view so parent re-renders do not dispose live panel signals.
147    let portal_panel = {
148        let panel_builder = panel_builder.clone();
149        let reposition_injection = reposition_injection.clone();
150        let on_before_enter = on_before_enter;
151        let on_after_enter = on_after_enter;
152        let on_after_leave = on_after_leave;
153        let overlay_dismiss = overlay_dismiss;
154        move || {
155            if let Some(dismiss) = overlay_dismiss {
156                provide_context(dismiss);
157            }
158            if let Some(inj) = arrow_injection {
159                provide_context(inj);
160            }
161            provide_context(reposition_injection.clone());
162
163            let Some(build_children) = panel_builder.borrow_mut().take() else {
164                return ().into_any();
165            };
166            let inner = build_children().into_inner();
167
168            let panel_body = view! {
169                <div class="orbital-positioning-content">
170                    {inner}
171                </div>
172            }
173            .add_any_attr(node_ref(content_ref));
174
175            let presence = view! {
176                <OrbitalPresence
177                    show=panel_show
178                    motion=motion
179                    on_before_enter=on_before_enter
180                    on_after_enter=on_after_enter
181                    on_after_leave=on_after_leave
182                >
183                    {panel_body}
184                </OrbitalPresence>
185            };
186
187            let wrapped = view! {
188                <div
189                    class="orbital-theme-provider orbital-positioning-panel"
190                    node_ref=panel_ref
191                    data-orbital-placement=move || placement.get().as_str()
192                    data-orbital-theme-id=move || theme_id.get_value()
193                >
194                    {presence}
195                </div>
196            };
197
198            wrapped.into_any()
199        }
200    };
201
202    if let Some(mount_el) = mount.as_ref().and_then(|r| r.try_get().flatten()) {
203        view! {
204            {trigger_view}
205            <super::portal::Portal immediate=panel_show mount=mount_el.into()>
206                {portal_panel}
207            </super::portal::Portal>
208        }
209    } else if mount.is_some() {
210        view! {
211            {trigger_view}
212            <super::portal::Portal immediate=panel_show mount_ref=mount>
213                {portal_panel}
214            </super::portal::Portal>
215        }
216    } else {
217        view! {
218            {trigger_view}
219            <super::portal::Portal immediate=panel_show>
220                {portal_panel}
221            </super::portal::Portal>
222        }
223    }
224}