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/// Configuration for the floating position calculation.
109#[derive(Debug, Clone)]
110pub struct FloatingOptions {
111    /// List of [Middleware] strategies to apply.
112    pub middleware: Vec<Middleware>,
113    /// Distance between the trigger and the floating element in pixels.
114    pub offset: f64,
115    /// Distance between the floating element and the scrollable container edges.
116    pub padding: f64,
117    /// The preferred [Placement] strategy.
118    pub placement: Placement,
119}
120
121impl FloatingOptions {
122    /// Returns `true` if the [Middleware::Flip] strategy is enabled.
123    pub fn can_flip(&self) -> bool {
124        self.middleware.contains(&Middleware::Flip)
125    }
126
127    /// Returns `true` if the [Middleware::Shift] strategy is enabled.
128    pub fn can_shift(&self) -> bool {
129        self.middleware.contains(&Middleware::Shift)
130    }
131}
132
133impl Default for FloatingOptions {
134    /// Returns default options: [Middleware::Flip] and [Middleware::Shift] enabled,
135    /// offset: 1.0, padding: 0.0, and [Placement::BottomStart].
136    fn default() -> Self {
137        FloatingOptions {
138            middleware: vec![Middleware::Flip, Middleware::Shift],
139            offset: 1_f64,
140            padding: 0_f64,
141            placement: Placement::BottomStart,
142        }
143    }
144}
145
146impl Floating {
147    /// Asynchronously captures the initial [ScrollState] from a mounted element.
148    ///
149    /// This method is usually called once when the [ScrollableView] is first mounted
150    /// or when its underlying DOM element changes. It performs multiple async
151    /// JS calls to measure the layout.
152    ///
153    /// Returns a default state (zeros) if the element is no longer accessible.
154    pub async fn generate_scroll_state_from_mounted(&self, data: Rc<MountedData>) -> ScrollState {
155        let rect = data.get_client_rect().await;
156        let scroll = data.get_scroll_size().await;
157        let offset = data.get_scroll_offset().await;
158
159        let size = scroll
160            .map(|s| PixelsSize::new(s.width, s.height))
161            .unwrap_or(PixelsSize::new(0_f64, 0_f64));
162        let bounds = rect
163            .map(|r| PixelsSize::new(r.width(), r.height()))
164            .unwrap_or(PixelsSize::new(0_f64, 0_f64));
165        let state = offset
166            .map(|o| PixelsVector2D::new(o.x, o.y))
167            .unwrap_or(PixelsVector2D::new(0_f64, 0_f64));
168
169        ScrollState {
170            size,
171            bounds,
172            state,
173        }
174    }
175
176    /// Synchronously generates a new [ScrollState] from a [ScrollEvent].
177    ///
178    /// This is a high-performance method designed to be called within the `onscroll`
179    /// event handler. It extracts data directly from the event without additional
180    /// JS roundtrips.
181    ///
182    /// # Example
183    /// ```rust
184    /// # use dioxus::prelude::*;
185    /// # use dioxus_floating::use_floating;
186    ///
187    /// # #[component]
188    /// # fn MyComponent() -> Element {
189    ///     let floating = use_floating();
190    ///     let mut scroll_state = use_signal(|| None);
191    /// #    rsx! {
192    ///         div {
193    ///             onscroll: move |evt: ScrollEvent| {
194    ///                 let new_state = floating.generate_scroll_state(evt);
195    ///                  scroll_state.set(Some(new_state));
196    ///             }
197    ///         }
198    /// #    }
199    /// # }
200    /// ```
201    pub fn generate_scroll_state(&self, evt: ScrollEvent) -> ScrollState {
202        ScrollState {
203            size: PixelsSize::new(evt.scroll_width() as f64, evt.scroll_height() as f64),
204            bounds: PixelsSize::new(evt.client_width() as f64, evt.client_height() as f64),
205            state: PixelsVector2D::new(evt.scroll_left(), evt.scroll_top()),
206        }
207    }
208
209    /// Calculates the optimal position for a floating element anchored to a specific point (e.g., a mouse click).
210    ///
211    /// This method treats the input [ClientPoint] as a 1x1 pixel trigger. It is ideal for
212    /// context menus where the anchor position is dynamic and precise.
213    ///
214    /// The returned coordinates (X, Y) are relative to the viewport and are ready
215    /// for use with `position: fixed` and `transform: translate3d`.
216    pub async fn placement_on_point(
217        &self,
218        scroll_state: ScrollState,
219        scrollable_ref: Rc<MountedData>,
220        element_ref: Rc<MountedData>,
221        trigger: ClientPoint,
222        options: FloatingOptions,
223    ) -> (f64, f64) {
224        let scrollable_rect = scrollable_ref
225            .get_client_rect()
226            .await
227            .unwrap_or(PixelsRect::new(
228                PixelsVector2D::new(0_f64, 0_f64).to_point(),
229                scroll_state.bounds,
230            ));
231        let trigger_rect = PixelsRect::new(
232            PixelsVector2D::new(trigger.x, trigger.y).to_point(),
233            PixelsSize::new(1_f64, 1_f64),
234        );
235
236        match element_ref.get_client_rect().await {
237            Ok(element_rect) => {
238                self.calculate_placement(scrollable_rect, element_rect, trigger_rect, options)
239            }
240            Err(_) => (trigger_rect.min_x(), trigger_rect.min_y()),
241        }
242    }
243
244    /// Calculates the optimal position for a floating element anchored to another DOM element (e.g., a button).
245    ///
246    /// This method measures the actual dimensions of the trigger element via `get_client_rect()`.
247    /// It is designed for standard dropdown menus, tooltips, and popovers where
248    /// the floating element needs to align perfectly with its anchor.
249    ///
250    /// The returned coordinates (X, Y) are viewport-relative.
251    pub async fn placement_on_trigger(
252        &self,
253        scroll_state: ScrollState,
254        scrollable_ref: Rc<MountedData>,
255        element_ref: Rc<MountedData>,
256        trigger_ref: Rc<MountedData>,
257        options: FloatingOptions,
258    ) -> (f64, f64) {
259        let scrollable_rect = scrollable_ref
260            .get_client_rect()
261            .await
262            .unwrap_or(PixelsRect::new(
263                PixelsVector2D::new(0_f64, 0_f64).to_point(),
264                scroll_state.bounds,
265            ));
266        let trigger_rect = trigger_ref
267            .get_client_rect()
268            .await
269            .unwrap_or(PixelsRect::new(
270                PixelsVector2D::new(0_f64, 0_f64).to_point(),
271                PixelsSize::new(1_f64, 1_f64),
272            ));
273
274        match element_ref.get_client_rect().await {
275            Ok(element_rect) => {
276                self.calculate_placement(scrollable_rect, element_rect, trigger_rect, options)
277            }
278            Err(_) => (trigger_rect.min_x(), trigger_rect.min_y()),
279        }
280    }
281
282    /// Internal: Computes the initial (ideal) coordinates for the floating element
283    /// without considering viewport boundaries or middleware.
284    fn compute_base_coords(
285        &self,
286        element: PixelsRect,
287        trigger: PixelsRect,
288        options: FloatingOptions,
289    ) -> (f64, f64) {
290        let x: f64;
291        let y: f64;
292
293        // make basic placement element position
294        (x, y) = if options.placement.is_vertical() {
295            let x = match options.placement.get_modifier() {
296                PlacementModifier::Center => {
297                    trigger.min_x() + (trigger.width() / 2_f64) - (element.width() / 2_f64)
298                }
299                PlacementModifier::Start => trigger.min_x(),
300                PlacementModifier::End => trigger.max_x() - element.width(),
301            };
302            let y = if options.placement.is_top() {
303                trigger.min_y() - element.height() - options.offset
304            } else {
305                trigger.max_y() + options.offset
306            };
307            (x, y)
308        } else {
309            let x = if options.placement.is_left() {
310                trigger.min_x() - element.width() - options.offset
311            } else {
312                trigger.max_x() + options.offset
313            };
314            let y = match options.placement.get_modifier() {
315                PlacementModifier::Center => {
316                    trigger.min_y() + (trigger.height() / 2_f64) - (element.height() / 2_f64)
317                }
318                PlacementModifier::Start => trigger.min_y(),
319                PlacementModifier::End => trigger.max_y() - element.height(),
320            };
321            (x, y)
322        };
323
324        (x, y)
325    }
326
327    /// Internal: Adjusts the initial position using the enabled middleware strategies
328    /// (Flip and/or Shift) to ensure the element stays within the scrollable area.
329    fn apply_middleware(
330        &self,
331        initial_pos: (f64, f64),
332        scrollable: PixelsRect,
333        element: PixelsRect,
334        trigger: PixelsRect,
335        options: FloatingOptions,
336    ) -> (f64, f64) {
337        let (mut x, mut y) = initial_pos;
338
339        // flip middleware
340        if options.can_flip() {
341            if options.placement.is_vertical() {
342                if options.placement.is_top() && y < scrollable.min_y() {
343                    y = trigger.max_y() + options.offset;
344                } else if !options.placement.is_top() && y + element.height() > scrollable.max_y() {
345                    y = trigger.min_y() - element.height() - options.offset;
346                }
347            } else {
348                if options.placement.is_left() && x < scrollable.min_x() {
349                    x = trigger.max_x() + options.offset;
350                } else if !options.placement.is_left() && x + element.width() > scrollable.max_x() {
351                    x = trigger.min_x() - element.width() - options.offset;
352                }
353            }
354        }
355        // shift middleware
356        if options.can_shift() {
357            if options.placement.is_vertical() {
358                // Вычисляем границы: насколько далеко мы можем уйти влево или вправо,
359                // чтобы не оторваться от триггера.
360                let min_allowed_x = trigger.min_x() - element.width() + options.padding;
361                let max_allowed_x = trigger.max_x() - options.padding;
362
363                // 1. Пытаемся вписать в экран (scrollable)
364                if x < scrollable.min_x() {
365                    x = scrollable.min_x();
366                }
367                if x + element.width() > scrollable.max_x() {
368                    x = scrollable.max_x() - element.width();
369                }
370
371                // 2. Но не даем уйти дальше границ триггера
372                x = x.clamp(min_allowed_x, max_allowed_x);
373            } else {
374                let min_allowed_y = trigger.min_y() - element.height() + options.padding;
375                let max_allowed_y = trigger.max_y() - options.padding;
376
377                if y < scrollable.min_y() {
378                    y = scrollable.min_y();
379                }
380                if y + element.height() > scrollable.max_y() {
381                    y = scrollable.max_y() - element.height();
382                }
383
384                y = y.clamp(min_allowed_y, max_allowed_y);
385            }
386        }
387
388        (x, y)
389    }
390
391    /// The main entry point for synchronous position calculation.
392    ///
393    /// This method takes pre-measured rectangles and applies the full positioning
394    /// pipeline: base calculation followed by middleware adjustments.
395    ///
396    /// It is useful for manual calculations or when you have already obtained
397    /// the necessary [PixelsRect] data.
398    pub fn calculate_placement(
399        &self,
400        scrollable: PixelsRect,
401        element: PixelsRect,
402        trigger: PixelsRect,
403        options: FloatingOptions,
404    ) -> (f64, f64) {
405        let base_pos = self.compute_base_coords(element, trigger, options.clone());
406        let final_pos =
407            self.apply_middleware(base_pos, scrollable, element, trigger, options.clone());
408
409        tracing::debug!(
410            "Calculated for scrollable: {scrollable:?}, element: {element:?}, trigger: {trigger:?}, option: {options:?}"
411        );
412
413        final_pos
414    }
415}