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}