Skip to main content

dioxus_floating/
lib.rs

1use std::rc::Rc;
2
3use dioxus::{
4    document::{Eval, eval},
5    prelude::*,
6};
7use dioxus::{html::geometry::ClientPoint, logger::tracing};
8
9mod floating;
10mod scrollable_view;
11
12pub use floating::{Floating, FloatingOptions, Middleware, OffsetOptions, Placement, ScrollState};
13pub use scrollable_view::{ScrollableContext, ScrollableView};
14
15/// Returns the global [Floating] engine instance.
16///
17/// This hook initializes the positioning engine (with default settings)
18/// and ensures it persists across component re-renders.
19pub fn use_floating() -> Floating {
20    use_hook(Floating::default)
21}
22
23/// Accesses the nearest [ScrollableContext] provided by a [ScrollableView].
24///
25/// # Panics
26/// This hook will panic if used outside of a [ScrollableView] component.
27/// Use `try_use_context::<ScrollableContext>()` if you need a non-panicking version.
28pub fn use_scroll_context() -> ScrollableContext {
29    use_context::<ScrollableContext>()
30}
31
32/// A shorthand hook to access the current [ScrollState] from the context.
33///
34/// Returns a [Signal] containing the dimensions and scroll offsets of
35/// the nearest [ScrollableView].
36pub fn use_scroll_state() -> Signal<Option<ScrollState>> {
37    let ctx = use_scroll_context();
38
39    ctx.scroll_state
40}
41
42/// A shorthand hook to access the [MountedData] of the parent [ScrollableView].
43///
44/// Useful when you need to programmatically control the scroll container
45/// (e.g., calling `scroll_to`) from a child component.
46pub fn use_scrollable_ref() -> Signal<Option<Rc<MountedData>>> {
47    let ctx = use_scroll_context();
48
49    ctx.scrollable_ref
50}
51
52/// Registers a document-level click listener and calls the handler when the click
53/// happens outside of the element with the provided id.
54///
55/// This is useful for dropdowns, popovers, and context menus that should close
56/// when focus moves away from their root element.
57pub fn use_click_outside(
58    target_element_id: ReadSignal<String>,
59    on_click_outside: EventHandler<()>,
60) {
61    let mut eval_handle = use_signal(|| Option::<Eval>::None);
62
63    use_effect(move || {
64        let element_id = target_element_id();
65
66        spawn(async move {
67            let mut eval = eval(&format!(
68                r#"
69                const elementId = "{}";
70                const controller = new AbortController();
71
72                window.addEventListener("click", (event) => {{
73                    const element = document.getElementById(elementId);
74                    if (element && !element.contains(event.target)) {{
75                        dioxus.send("outside");
76                    }}
77                }}, {{ capture: true, signal: controller.signal }});
78
79                await dioxus.recv();
80                controller.abort();
81                "#,
82                element_id
83            ));
84
85            if let Some(old_eval) = eval_handle() {
86                let _ = old_eval.send("drop");
87            }
88            eval_handle.set(Some(eval.clone()));
89
90            while let Ok(message) = eval.recv::<String>().await {
91                if message == "outside" {
92                    on_click_outside.call(());
93                }
94            }
95        });
96    });
97
98    use_drop(move || {
99        if let Some(eval) = eval_handle() {
100            let _ = eval.send("drop");
101        }
102    });
103}
104
105/// The result of a floating position calculation.
106///
107/// This structure is returned by positioning hooks and contains raw coordinates
108/// and a readiness flag. It is designed to be used with `use_memo` to generate
109/// custom CSS styles.
110#[derive(Debug, Clone, Copy, Default)]
111pub struct FloatingResult {
112    // Calculated X coordinate (viewport-relative pixels).
113    pub x: f64,
114    // Calculated Y coordinate (viewport-relative pixels).
115    pub y: f64,
116    // Use this to toggle visibility (e.g., opacity) to prevent flickering.
117    pub is_ready: bool,
118}
119
120/// Reactive hook for positioning a floating element relative to a trigger element (anchor).
121///
122/// This hook automatically finds the nearest [ScrollableView] context to handle
123/// scrolling and overflow boundary detection.
124///
125/// # Behavior
126/// - It recalculates the position whenever the trigger, the element itself,
127///   or the parent's scroll state changes.
128/// - It uses a 1ms delay to ensure the browser has performed a Layout pass
129///   before measuring dimensions.
130///
131/// # Warning
132/// This hook must be used within a [ScrollableView] component. If no context
133/// is found, it will log a warning and return default (zero) coordinates.
134///
135/// # Example
136///
137/// ```rust
138/// use dioxus::prelude::*;
139/// use dioxus::html::geometry::PixelsVector2D;
140/// use dioxus_floating::{use_placement, FloatingOptions};
141///
142/// fn MyElement() -> Element {
143///     let mut element_ref = use_signal(|| None);
144///     let mut trigger_ref = use_signal(|| None);
145///     let mut is_opened = use_signal(|| false);
146///
147///     let placement = use_placement(element_ref, trigger_ref, FloatingOptions::default());
148///
149///     rsx! {
150///         // The trigger element
151///         button {
152///             onmounted: move |e| trigger_ref.set(Some(e.data.clone())),
153///             onclick: move |_| is_opened.toggle(),
154///             "Toggle Dropdown"
155///         }
156///     
157///         // The floating element
158///         if is_opened() {
159///             div {
160///                 onmounted: move |e| element_ref.set(Some(e.data.clone())),
161///                 // Use is_ready to prevent the element from "jumping" into position
162///                 class: if placement().is_ready { "opacity-100" } else { "opacity-0" },
163///                 style: "position: fixed; transform: translate3d({placement().x}px, {placement().y}px, 0);",
164///                 "I am a dropdown content"
165///             }
166///         }
167///     }
168/// }
169/// ```
170///
171/// # Example: Custom Style Generation
172///
173/// ```rust
174/// use dioxus::prelude::*;
175/// use dioxus_floating::{use_placement, FloatingOptions};
176///
177/// #[component]
178/// fn MyComponent() -> Element {
179///     let el = use_signal(|| None);
180///     let tr = use_signal(|| None);
181///     let pos = use_placement(el, tr, FloatingOptions::default());
182///
183///     let style = use_memo(move || {
184///         pos.with(|p| format!(
185///             "position: fixed; transform: translate3d({}px, {}px, 0); opacity: {};",
186///             p.x, p.y, if p.is_ready { 1 } else { 0 }
187///         ))
188///     });
189///     rsx!{}
190/// }
191/// ```
192pub fn use_placement<E, T>(
193    // Signal containing the reference to the floating element.
194    element_ref: E,
195    // Signal containing the reference to the trigger (anchor) element.
196    trigger_ref: T,
197    // Positioning options including [Placement], [Middleware], and offsets.
198    options: FloatingOptions,
199) -> ReadSignal<FloatingResult>
200where
201    E: Into<ReadSignal<Option<Rc<MountedData>>>>,
202    T: Into<ReadSignal<Option<Rc<MountedData>>>>,
203{
204    let element_ref = element_ref.into();
205    let trigger_ref = trigger_ref.into();
206
207    let floating = use_floating();
208    let mut result = use_signal(FloatingResult::default);
209
210    // context without panic
211    let context = match try_use_context::<ScrollableContext>() {
212        Some(ctx) => ctx,
213        None => {
214            tracing::warn!(
215                "use_placement hook used outside of ScrollableView. \
216                Ensure your component is wrapped in a ScrollableView or provide a ScrollableContext."
217            );
218            return result.into();
219        }
220    };
221
222    use_effect(move || {
223        let zip = (context.scroll_state)()
224            .zip((context.scrollable_ref)())
225            .zip(element_ref())
226            .zip(trigger_ref());
227
228        if let Some((((scroll_state, scrollable), element), trigger)) = zip {
229            let options = options.clone();
230            spawn(async move {
231                // wait render virtual dom elements
232                gloo_timers::future::TimeoutFuture::new(1).await;
233
234                let pos = floating
235                    .placement_on_trigger(scroll_state, scrollable, element, trigger, options)
236                    .await;
237
238                result.set(FloatingResult {
239                    x: pos.0,
240                    y: pos.1,
241                    is_ready: true,
242                });
243
244                tracing::debug!(
245                    "Floating placement updated: x={}, y={}, ready=true",
246                    pos.0,
247                    pos.1
248                );
249            });
250        } else {
251            // drop ready flag
252            if result.peek().is_ready {
253                result.set(FloatingResult::default());
254                tracing::debug!("Floating placement reset: ready=false");
255            }
256        }
257    });
258
259    result.into()
260}
261
262/// Reactive hook for positioning a floating element relative to a specific point (e.g., mouse click).
263///
264/// This is specifically designed for context menus or custom popups that appear at
265/// a given [ClientPoint]. It automatically subscribes to the nearest [ScrollableView]
266/// to handle positioning within a scrollable area.
267///
268/// # Note on Usage:
269/// Unlike `use_placement`, this hook expects a point in viewport coordinates.
270/// If you are using this for a context menu, ensure you capture the coordinates
271/// from the `MouseEvent`.
272///
273/// # Example
274///
275/// ```rust
276/// use dioxus::prelude::*;
277/// use dioxus_floating::{use_placement_on_point, FloatingOptions};
278///
279/// #[component]
280/// fn MyComponent() -> Element {
281///     let mut click_point = use_signal(|| None);
282///     let mut element_ref = use_signal(|| None);
283///
284///     let placement = use_placement_on_point(
285///         element_ref,
286///         click_point,
287///         FloatingOptions::default(),
288///     );
289///
290///     rsx! {
291///         div {
292///             oncontextmenu: move |e| {
293///                 e.prevent_default();
294///                 click_point.set(Some(e.client_coordinates()));
295///             },
296///             "Right click here to open menu"
297///         }
298///     
299///         // Render the element as soon as we have a target point
300///         if click_point().is_some() {
301///             div {
302///                 onmounted: move |e| element_ref.set(Some(e.data.clone())),
303///                 // Keep it invisible until positioning is calculated
304///                 class: if placement().is_ready { "opacity-100" } else { "opacity-0" },
305///                 style: "position: fixed; transform: translate3d({placement().x}px, {placement().y}px, 0);",
306///                 "Context Menu Content"
307///             }
308///         }
309///     }
310/// }
311/// ```
312///
313/// # Example: Custom Style Generation
314///
315/// ```rust
316/// use dioxus::prelude::*;
317/// use dioxus_floating::{use_placement_on_point, FloatingOptions};
318///
319/// #[component]
320/// fn MyComponent() -> Element {
321///     let el = use_signal(|| None);
322///     let mut click = use_signal(|| None);
323///     let pos = use_placement_on_point(el, click, FloatingOptions::default());
324///     let style = use_memo(move || {
325///         pos.with(|p| format!(
326///             "position: fixed; transform: translate3d({}px, {}px, 0); opacity: {};",
327///             p.x, p.y, if p.is_ready { 1 } else { 0 }
328///         ))
329///     });
330///     rsx! {
331///         button {
332///             onclick: move |evt: MouseEvent| { click.set(Some(evt.client_coordinates())) }
333///         }
334///     }
335/// }
336/// ```
337pub fn use_placement_on_point<E, T>(
338    element_ref: E,
339    trigger_point: T,
340    options: FloatingOptions,
341) -> ReadSignal<FloatingResult>
342where
343    E: Into<ReadSignal<Option<Rc<MountedData>>>>,
344    T: Into<ReadSignal<Option<ClientPoint>>>,
345{
346    let element_ref = element_ref.into();
347    let trigger_point = trigger_point.into();
348    let floating = use_floating();
349    let mut result = use_signal(FloatingResult::default);
350    // context without panic
351    let context = match try_use_context::<ScrollableContext>() {
352        Some(ctx) => ctx,
353        None => {
354            tracing::warn!(
355                "use_placement hook used outside of ScrollableView. \
356                Ensure your component is wrapped in a ScrollableView or provide a ScrollableContext."
357            );
358            return result.into();
359        }
360    };
361
362    use_effect(move || {
363        let zip = (context.scroll_state)()
364            .zip((context.scrollable_ref)())
365            .zip(element_ref())
366            .zip(trigger_point());
367
368        if let Some((((scroll_state, scrollable), element), trigger)) = zip {
369            let options = options.clone();
370            spawn(async move {
371                // wait render virtual dom elements
372                gloo_timers::future::TimeoutFuture::new(1).await;
373
374                let pos = floating
375                    .placement_on_point(scroll_state, scrollable, element, trigger, options)
376                    .await;
377
378                result.set(FloatingResult {
379                    x: pos.0,
380                    y: pos.1,
381                    is_ready: true,
382                });
383
384                tracing::debug!(
385                    "Floating placement updated: x={}, y={}, ready=true",
386                    pos.0,
387                    pos.1
388                );
389            });
390        } else {
391            // drop ready flag
392            if result.peek().is_ready {
393                result.set(FloatingResult::default());
394                tracing::debug!("Floating placement reset: ready=false");
395            }
396        }
397    });
398
399    result.into()
400}