Skip to main content

dioxus_floating/
floating.rs

1use std::rc::Rc;
2
3use dioxus::html::geometry::{ClientPoint, PixelsRect, PixelsSize, PixelsVector2D};
4use dioxus::logger::tracing;
5use dioxus::prelude::*;
6
7/// The core engine for calculating floating positions.
8///
9/// `Floating` provides methods to compute the coordinates of elements
10/// based on their size, the trigger position, and the boundaries of
11/// the scrollable container.
12#[derive(Debug, Clone, Copy, Default)]
13pub struct Floating;
14
15/// Represents the geometric state of a scrollable container.
16#[derive(Debug, Clone, Copy)]
17pub struct ScrollState {
18    /// Total size of the scrollable content (scrollHeight/scrollWidth).
19    pub size: PixelsSize,
20    /// Visible dimensions of the container (clientHeight/clientWidth).
21    pub bounds: PixelsSize,
22    /// Current scroll position (scrollTop/scrollLeft).
23    pub state: PixelsVector2D,
24}
25
26/// Defines the preferred side and alignment of the floating element relative to its trigger.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum Placement {
29    TopStart,
30    TopCenter,
31    TopEnd,
32    BottomStart,
33    BottomCenter,
34    BottomEnd,
35    LeftStart,
36    LeftCenter,
37    LeftEnd,
38    RightStart,
39    RightCenter,
40    RightEnd,
41}
42
43impl Placement {
44    /// Returns `true` if the placement is on the Top or Bottom side.
45    pub fn is_vertical(&self) -> bool {
46        matches!(
47            self,
48            Placement::TopStart
49                | Placement::TopCenter
50                | Placement::TopEnd
51                | Placement::BottomStart
52                | Placement::BottomCenter
53                | Placement::BottomEnd
54        )
55    }
56
57    /// Returns `true` if the side is Top.
58    pub fn is_top(&self) -> bool {
59        matches!(
60            self,
61            Placement::TopEnd | Placement::TopCenter | Placement::TopStart
62        )
63    }
64
65    /// Returns `true` if the side is Left.
66    pub fn is_left(&self) -> bool {
67        matches!(
68            self,
69            Placement::LeftCenter | Placement::LeftEnd | Placement::LeftStart
70        )
71    }
72
73    /// Returns the [PlacementModifier] (Start, Center, or End) for the current placement.
74    pub fn get_modifier(&self) -> PlacementModifier {
75        match *self {
76            Placement::BottomCenter => PlacementModifier::Center,
77            Placement::LeftCenter => PlacementModifier::Center,
78            Placement::RightCenter => PlacementModifier::Center,
79            Placement::TopCenter => PlacementModifier::Center,
80            Placement::BottomEnd => PlacementModifier::End,
81            Placement::LeftEnd => PlacementModifier::End,
82            Placement::RightEnd => PlacementModifier::End,
83            Placement::TopEnd => PlacementModifier::End,
84            Placement::BottomStart => PlacementModifier::Start,
85            Placement::LeftStart => PlacementModifier::Start,
86            Placement::RightStart => PlacementModifier::Start,
87            Placement::TopStart => PlacementModifier::Start,
88        }
89    }
90}
91
92/// Modifiers that define alignment on the transverse axis.
93pub enum PlacementModifier {
94    Center,
95    Start,
96    End,
97}
98
99/// Strategic logic used to adjust the floating position when it overflows the viewport.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub enum Middleware {
102    /// Flips the element to the opposite side if there isn't enough space (e.g., Top -> Bottom).
103    Flip,
104    /// Shifts the element along the transverse axis to keep it within the viewport.
105    Shift,
106}
107
108/// Offset options for the floating element.
109#[derive(Debug, Clone, PartialEq)]
110pub struct OffsetOptions {
111    /// Offset along the main axis. (Vertical, X)
112    pub main_axis: f64,
113    /// Offset along the cross axis. (Horizontal, Y)
114    pub cross_axis: f64,
115}
116
117impl Default for OffsetOptions {
118    fn default() -> Self {
119        Self {
120            main_axis: 1_f64,
121            cross_axis: 1_f64,
122        }
123    }
124}
125
126impl OffsetOptions {
127    /// Creates a new [OffsetOptions] with the specified offsets.
128    pub fn new(main_axis: f64, cross_axis: f64) -> Self {
129        Self {
130            main_axis,
131            cross_axis,
132        }
133    }
134
135    /// Creates a new [OffsetOptions] with the same offset for both axes.
136    pub fn rect(offset: f64) -> Self {
137        Self {
138            main_axis: offset,
139            cross_axis: offset,
140        }
141    }
142
143    /// Creates a new [OffsetOptions] with zero offset for both axes.
144    pub fn zero() -> Self {
145        Self::rect(0_f64)
146    }
147}
148
149/// Configuration for the floating position calculation.
150#[derive(Debug, Clone)]
151pub struct FloatingOptions {
152    /// List of [Middleware] strategies to apply.
153    pub middleware: Vec<Middleware>,
154    /// Distance between the trigger and the floating element in pixels.
155    pub offset: OffsetOptions,
156    /// Distance between the floating element and the scrollable container edges.
157    pub padding: f64,
158    /// The preferred [Placement] strategy.
159    pub placement: Placement,
160}
161
162impl FloatingOptions {
163    /// Returns `true` if the [Middleware::Flip] strategy is enabled.
164    pub fn can_flip(&self) -> bool {
165        self.middleware.contains(&Middleware::Flip)
166    }
167
168    /// Returns `true` if the [Middleware::Shift] strategy is enabled.
169    pub fn can_shift(&self) -> bool {
170        self.middleware.contains(&Middleware::Shift)
171    }
172}
173
174impl Default for FloatingOptions {
175    /// Returns default options: [Middleware::Flip] and [Middleware::Shift] enabled,
176    /// offset: 1.0, padding: 0.0, and [Placement::BottomStart].
177    fn default() -> Self {
178        FloatingOptions {
179            middleware: vec![Middleware::Flip, Middleware::Shift],
180            offset: OffsetOptions::default(),
181            padding: 0_f64,
182            placement: Placement::BottomStart,
183        }
184    }
185}
186
187impl Floating {
188    /// Asynchronously captures the initial [ScrollState] from a mounted element.
189    ///
190    /// This method is usually called once when the [ScrollableView] is first mounted
191    /// or when its underlying DOM element changes. It performs multiple async
192    /// JS calls to measure the layout.
193    ///
194    /// Returns a default state (zeros) if the element is no longer accessible.
195    pub async fn generate_scroll_state_from_mounted(&self, data: Rc<MountedData>) -> ScrollState {
196        let rect = data.get_client_rect().await;
197        let scroll = data.get_scroll_size().await;
198        let offset = data.get_scroll_offset().await;
199
200        let size = scroll
201            .map(|s| PixelsSize::new(s.width, s.height))
202            .unwrap_or(PixelsSize::new(0_f64, 0_f64));
203        let bounds = rect
204            .map(|r| PixelsSize::new(r.width(), r.height()))
205            .unwrap_or(PixelsSize::new(0_f64, 0_f64));
206        let state = offset
207            .map(|o| PixelsVector2D::new(o.x, o.y))
208            .unwrap_or(PixelsVector2D::new(0_f64, 0_f64));
209
210        ScrollState {
211            size,
212            bounds,
213            state,
214        }
215    }
216
217    /// Synchronously generates a new [ScrollState] from a [ScrollEvent].
218    ///
219    /// This is a high-performance method designed to be called within the `onscroll`
220    /// event handler. It extracts data directly from the event without additional
221    /// JS roundtrips.
222    ///
223    /// # Example
224    /// ```rust
225    /// # use dioxus::prelude::*;
226    /// # use dioxus_floating::use_floating;
227    ///
228    /// # #[component]
229    /// # fn MyComponent() -> Element {
230    ///     let floating = use_floating();
231    ///     let mut scroll_state = use_signal(|| None);
232    /// #    rsx! {
233    ///         div {
234    ///             onscroll: move |evt: ScrollEvent| {
235    ///                 let new_state = floating.generate_scroll_state(evt);
236    ///                  scroll_state.set(Some(new_state));
237    ///             }
238    ///         }
239    /// #    }
240    /// # }
241    /// ```
242    pub fn generate_scroll_state(&self, evt: ScrollEvent) -> ScrollState {
243        ScrollState {
244            size: PixelsSize::new(evt.scroll_width() as f64, evt.scroll_height() as f64),
245            bounds: PixelsSize::new(evt.client_width() as f64, evt.client_height() as f64),
246            state: PixelsVector2D::new(evt.scroll_left(), evt.scroll_top()),
247        }
248    }
249
250    /// Calculates the optimal position for a floating element anchored to a specific point (e.g., a mouse click).
251    ///
252    /// This method treats the input [ClientPoint] as a 1x1 pixel trigger. It is ideal for
253    /// context menus where the anchor position is dynamic and precise.
254    ///
255    /// The returned coordinates (X, Y) are relative to the viewport and are ready
256    /// for use with `position: fixed` and `transform: translate3d`.
257    pub async fn placement_on_point(
258        &self,
259        scroll_state: ScrollState,
260        scrollable_ref: Rc<MountedData>,
261        element_ref: Rc<MountedData>,
262        trigger: ClientPoint,
263        options: FloatingOptions,
264    ) -> (f64, f64) {
265        let scrollable_rect = scrollable_ref
266            .get_client_rect()
267            .await
268            .unwrap_or(PixelsRect::new(
269                PixelsVector2D::new(0_f64, 0_f64).to_point(),
270                scroll_state.bounds,
271            ));
272        let trigger_rect = PixelsRect::new(
273            PixelsVector2D::new(trigger.x, trigger.y).to_point(),
274            PixelsSize::new(1_f64, 1_f64),
275        );
276
277        match element_ref.get_client_rect().await {
278            Ok(element_rect) => {
279                self.calculate_placement(scrollable_rect, element_rect, trigger_rect, options)
280            }
281            Err(_) => (trigger_rect.min_x(), trigger_rect.min_y()),
282        }
283    }
284
285    /// Calculates the optimal position for a floating element anchored to another DOM element (e.g., a button).
286    ///
287    /// This method measures the actual dimensions of the trigger element via `get_client_rect()`.
288    /// It is designed for standard dropdown menus, tooltips, and popovers where
289    /// the floating element needs to align perfectly with its anchor.
290    ///
291    /// The returned coordinates (X, Y) are viewport-relative.
292    pub async fn placement_on_trigger(
293        &self,
294        scroll_state: ScrollState,
295        scrollable_ref: Rc<MountedData>,
296        element_ref: Rc<MountedData>,
297        trigger_ref: Rc<MountedData>,
298        options: FloatingOptions,
299    ) -> (f64, f64) {
300        let scrollable_rect = scrollable_ref
301            .get_client_rect()
302            .await
303            .unwrap_or(PixelsRect::new(
304                PixelsVector2D::new(0_f64, 0_f64).to_point(),
305                scroll_state.bounds,
306            ));
307        let trigger_rect = trigger_ref
308            .get_client_rect()
309            .await
310            .unwrap_or(PixelsRect::new(
311                PixelsVector2D::new(0_f64, 0_f64).to_point(),
312                PixelsSize::new(1_f64, 1_f64),
313            ));
314
315        match element_ref.get_client_rect().await {
316            Ok(element_rect) => {
317                self.calculate_placement(scrollable_rect, element_rect, trigger_rect, options)
318            }
319            Err(_) => (trigger_rect.min_x(), trigger_rect.min_y()),
320        }
321    }
322
323    /// Internal: Computes the initial (ideal) coordinates for the floating element
324    /// without considering viewport boundaries or middleware.
325    fn compute_base_coords(
326        &self,
327        element: PixelsRect,
328        trigger: PixelsRect,
329        options: FloatingOptions,
330    ) -> (f64, f64) {
331        let x: f64;
332        let y: f64;
333
334        // make basic placement element position
335        (x, y) = if options.placement.is_vertical() {
336            let x = match options.placement.get_modifier() {
337                PlacementModifier::Center => {
338                    trigger.min_x() + (trigger.width() / 2_f64) - (element.width() / 2_f64)
339                }
340                PlacementModifier::Start => trigger.min_x(),
341                PlacementModifier::End => trigger.max_x() - element.width(),
342            };
343            let y = if options.placement.is_top() {
344                trigger.min_y() - element.height() - options.offset.cross_axis
345            } else {
346                trigger.max_y() + options.offset.cross_axis
347            };
348            (x + options.offset.main_axis, y)
349        } else {
350            let x = if options.placement.is_left() {
351                trigger.min_x() - element.width() - options.offset.main_axis
352            } else {
353                trigger.max_x() + options.offset.main_axis
354            };
355            let y = match options.placement.get_modifier() {
356                PlacementModifier::Center => {
357                    trigger.min_y() + (trigger.height() / 2_f64) - (element.height() / 2_f64)
358                }
359                PlacementModifier::Start => trigger.min_y(),
360                PlacementModifier::End => trigger.max_y() - element.height(),
361            };
362            (x, y + options.offset.cross_axis)
363        };
364
365        (x, y)
366    }
367
368    /// Internal: Adjusts the initial position using the enabled middleware strategies
369    /// (Flip and/or Shift) to ensure the element stays within the scrollable area.
370    fn apply_middleware(
371        &self,
372        initial_pos: (f64, f64),
373        scrollable: PixelsRect,
374        element: PixelsRect,
375        trigger: PixelsRect,
376        options: FloatingOptions,
377    ) -> (f64, f64) {
378        let (mut x, mut y) = initial_pos;
379
380        // flip middleware
381        if options.can_flip() {
382            if options.placement.is_vertical() {
383                if options.placement.is_top() && y < scrollable.min_y() {
384                    y = trigger.max_y() + options.offset.cross_axis;
385                } else if !options.placement.is_top() && y + element.height() > scrollable.max_y() {
386                    y = trigger.min_y() - element.height() - options.offset.cross_axis;
387                }
388            } else if options.placement.is_left() && x < scrollable.min_x() {
389                x = trigger.max_x() + options.offset.main_axis;
390            } else if !options.placement.is_left() && x + element.width() > scrollable.max_x() {
391                x = trigger.min_x() - element.width() - options.offset.main_axis;
392            }
393        }
394        // shift middleware
395        if options.can_shift() {
396            if options.placement.is_vertical() {
397                // Вычисляем границы: насколько далеко мы можем уйти влево или вправо,
398                // чтобы не оторваться от триггера.
399                let min_allowed_x = trigger.min_x() - element.width() + options.padding;
400                let max_allowed_x = trigger.max_x() - options.padding;
401
402                // 1. Пытаемся вписать в экран (scrollable)
403                if x < scrollable.min_x() {
404                    x = scrollable.min_x();
405                }
406                if x + element.width() > scrollable.max_x() {
407                    x = scrollable.max_x() - element.width();
408                }
409
410                // 2. Но не даем уйти дальше границ триггера
411                x = x.clamp(min_allowed_x, max_allowed_x);
412            } else {
413                let min_allowed_y = trigger.min_y() - element.height() + options.padding;
414                let max_allowed_y = trigger.max_y() - options.padding;
415
416                if y < scrollable.min_y() {
417                    y = scrollable.min_y();
418                }
419                if y + element.height() > scrollable.max_y() {
420                    y = scrollable.max_y() - element.height();
421                }
422
423                y = y.clamp(min_allowed_y, max_allowed_y);
424            }
425        }
426
427        (x, y)
428    }
429
430    /// The main entry point for synchronous position calculation.
431    ///
432    /// This method takes pre-measured rectangles and applies the full positioning
433    /// pipeline: base calculation followed by middleware adjustments.
434    ///
435    /// It is useful for manual calculations or when you have already obtained
436    /// the necessary [PixelsRect] data.
437    pub fn calculate_placement(
438        &self,
439        scrollable: PixelsRect,
440        element: PixelsRect,
441        trigger: PixelsRect,
442        options: FloatingOptions,
443    ) -> (f64, f64) {
444        let base_pos = self.compute_base_coords(element, trigger, options.clone());
445        let final_pos =
446            self.apply_middleware(base_pos, scrollable, element, trigger, options.clone());
447
448        tracing::debug!(
449            "Calculated for scrollable: {scrollable:?}, element: {element:?}, trigger: {trigger:?}, option: {options:?}"
450        );
451
452        final_pos
453    }
454}