Skip to main content

dioxus_bootstrap_css/
overlay.rs

1use dioxus::prelude::*;
2
3/// A single global watcher that bumps a revision on any scroll or resize, so every
4/// visible overlay can re-measure against its trigger.
5///
6/// Overlays are painted `position: fixed` in viewport coordinates, measured once when
7/// they become visible. Without this, the moment the page moves the box stays where it
8/// was and detaches from its trigger — and an overlay opened while its trigger was off
9/// screen never got a second chance to be placed correctly at all.
10///
11/// It is global rather than per-overlay on purpose: one listener pair, installed once,
12/// with no per-id registry to leak or race. Scroll is captured so that scrolling
13/// containers count, not just the window.
14const OVERLAY_WATCH_SCRIPT: &str = r##"
15(function() {
16    const state = window.__dbcssOverlayAnchor = window.__dbcssOverlayAnchor || {
17        revision: 0,
18        installed: false
19    };
20
21    if (state.installed) {
22        return;
23    }
24    state.installed = true;
25
26    let frame = null;
27    const bump = function() {
28        if (frame !== null) {
29            return;
30        }
31        frame = requestAnimationFrame(function() {
32            frame = null;
33            state.revision += 1;
34            window.dispatchEvent(new CustomEvent("dbcss:overlay-anchor"));
35        });
36    };
37
38    window.addEventListener("scroll", bump, { passive: true, capture: true });
39    window.addEventListener("resize", bump, { passive: true });
40})();
41"##;
42
43/// Resolve when the anchor revision moves past `__LAST__`.
44const OVERLAY_EVENT_SCRIPT: &str = r##"
45const last = __LAST__;
46
47return new Promise(function(resolve) {
48    const state = window.__dbcssOverlayAnchor;
49    if (!state) {
50        resolve(last);
51        return;
52    }
53    if (state.revision !== last) {
54        resolve(state.revision);
55        return;
56    }
57
58    const handler = function() {
59        window.removeEventListener("dbcss:overlay-anchor", handler);
60        resolve(window.__dbcssOverlayAnchor.revision);
61    };
62
63    window.addEventListener("dbcss:overlay-anchor", handler);
64});
65"##;
66
67/// Install the global scroll/resize watcher. Idempotent — safe to call per overlay.
68pub fn install_overlay_anchor_watch() {
69    let _ = document::eval(OVERLAY_WATCH_SCRIPT);
70}
71
72/// Await the next scroll/resize revision. `None` means the bridge is gone (the
73/// document went away), which ends the caller's loop rather than spinning.
74pub async fn next_overlay_anchor_revision(last: u64) -> Option<u64> {
75    let script = OVERLAY_EVENT_SCRIPT.replace("__LAST__", &last.to_string());
76    let value = document::eval(&script).await.ok()?;
77    value.as_f64().map(|revision| revision as u64)
78}
79
80/// Overlay placement relative to a trigger element.
81#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
82pub enum OverlayPlacement {
83    /// Choose the first fitting fallback placement.
84    Auto,
85    /// Place overlay above the trigger.
86    #[default]
87    Top,
88    /// Place overlay below the trigger.
89    Bottom,
90    /// Place overlay before the trigger in the inline axis.
91    Start,
92    /// Place overlay after the trigger in the inline axis.
93    End,
94}
95
96impl OverlayPlacement {
97    /// Default fallback order matching Bootstrap's Popper-backed overlays.
98    pub const DEFAULT_FALLBACKS: [OverlayPlacement; 4] = [
99        OverlayPlacement::Top,
100        OverlayPlacement::End,
101        OverlayPlacement::Bottom,
102        OverlayPlacement::Start,
103    ];
104}
105
106/// Rectangle in viewport coordinates.
107#[derive(Clone, Copy, Debug, Default, PartialEq)]
108pub struct OverlayRect {
109    pub x: f64,
110    pub y: f64,
111    pub width: f64,
112    pub height: f64,
113}
114
115impl OverlayRect {
116    pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
117        Self {
118            x,
119            y,
120            width,
121            height,
122        }
123    }
124
125    pub fn right(self) -> f64 {
126        self.x + self.width
127    }
128
129    /// True when this rect overlaps `other` at all. Touching edges do not count:
130    /// a trigger flush against the viewport edge has no room for an overlay.
131    pub fn intersects(self, other: Self) -> bool {
132        self.x < other.right()
133            && other.x < self.right()
134            && self.y < other.bottom()
135            && other.y < self.bottom()
136    }
137
138    pub fn bottom(self) -> f64 {
139        self.y + self.height
140    }
141
142    pub fn center_x(self) -> f64 {
143        self.x + self.width / 2.0
144    }
145
146    pub fn center_y(self) -> f64 {
147        self.y + self.height / 2.0
148    }
149}
150
151/// Overlay offset from the trigger.
152#[derive(Clone, Copy, Debug, Default, PartialEq)]
153pub struct OverlayOffset {
154    /// Cross-axis offset.
155    pub skidding: f64,
156    /// Main-axis distance from the trigger.
157    pub distance: f64,
158}
159
160impl OverlayOffset {
161    pub const ZERO: Self = Self {
162        skidding: 0.0,
163        distance: 0.0,
164    };
165
166    /// Bootstrap tooltip default offset.
167    pub const TOOLTIP: Self = Self {
168        skidding: 0.0,
169        distance: 6.0,
170    };
171
172    /// Bootstrap popover default offset.
173    pub const POPOVER: Self = Self {
174        skidding: 0.0,
175        distance: 8.0,
176    };
177}
178
179/// Bootstrap arrow width (`--bs-popover-arrow-width` / tooltip equivalent, `1rem`).
180const ARROW_SIZE: f64 = 16.0;
181/// Keep the arrow this far from the overlay's rounded corner so it never straddles
182/// the border radius.
183const ARROW_EDGE_INSET: f64 = 8.0;
184
185/// Calculated overlay position.
186#[derive(Clone, Copy, Debug, PartialEq)]
187pub struct OverlayPosition {
188    pub x: f64,
189    pub y: f64,
190    pub placement: OverlayPlacement,
191    /// True when the overlay fits inside the boundary without clamping.
192    pub fits: bool,
193    /// True when the TRIGGER itself intersects the boundary.
194    ///
195    /// A trigger that has scrolled out of view has no on-screen anchor, so
196    /// clamping its overlay into the viewport would park a box somewhere the
197    /// user is looking at unrelated content — which is exactly what a forced-open
198    /// overlay did before this existed. Callers hide the overlay when this is
199    /// false rather than rendering it detached.
200    pub trigger_visible: bool,
201    /// Arrow centre in overlay-local coordinates along the cross axis (x for
202    /// top/bottom placements, y for start/end). Lets the caller keep the arrow
203    /// pointing at the trigger even after the overlay box is clamped to the
204    /// viewport — the job Popper.js does for Bootstrap's own overlays.
205    pub arrow: f64,
206}
207
208impl OverlayPosition {
209    pub fn rect(self, overlay_size: OverlayRect) -> OverlayRect {
210        OverlayRect::new(self.x, self.y, overlay_size.width, overlay_size.height)
211    }
212}
213
214/// Arrow centre (cross-axis, overlay-local) that keeps the arrow over the trigger
215/// centre, clamped so it stays clear of the overlay's rounded corners.
216fn arrow_offset(trigger: OverlayRect, rect: OverlayRect, placement: OverlayPlacement) -> f64 {
217    let (target, cross_size) = match placement {
218        OverlayPlacement::Start | OverlayPlacement::End => {
219            (trigger.center_y() - rect.y, rect.height)
220        }
221        // Top / Bottom / Auto place on the vertical axis, so the arrow slides in x.
222        _ => (trigger.center_x() - rect.x, rect.width),
223    };
224    let lo = ARROW_EDGE_INSET + ARROW_SIZE / 2.0;
225    let hi = (cross_size - ARROW_EDGE_INSET - ARROW_SIZE / 2.0).max(lo);
226    target.clamp(lo, hi)
227}
228
229/// Calculate a viewport-aware overlay position.
230///
231/// `overlay_size.x` and `overlay_size.y` are ignored; only width and height are
232/// used. If no candidate fully fits, the placement with the largest visible
233/// area is selected and clamped inside the padded boundary as far as possible.
234pub fn calculate_overlay_position(
235    trigger: OverlayRect,
236    overlay_size: OverlayRect,
237    boundary: OverlayRect,
238    requested: OverlayPlacement,
239    fallback_placements: &[OverlayPlacement],
240    offset: OverlayOffset,
241    boundary_padding: f64,
242) -> OverlayPosition {
243    let trigger_visible = trigger.intersects(boundary);
244    let candidates = candidate_placements(requested, fallback_placements);
245    let mut best: Option<(OverlayPlacement, OverlayRect, f64)> = None;
246
247    for placement in candidates {
248        let rect = placed_rect(trigger, overlay_size, placement, offset);
249        if fits_boundary(rect, boundary, boundary_padding) {
250            return OverlayPosition {
251                x: rect.x,
252                y: rect.y,
253                placement,
254                fits: true,
255                trigger_visible,
256                arrow: arrow_offset(trigger, rect, placement),
257            };
258        }
259
260        let visible = visible_area(rect, boundary, boundary_padding);
261        if best
262            .map(|(_, _, best_visible)| visible > best_visible)
263            .unwrap_or(true)
264        {
265            best = Some((placement, rect, visible));
266        }
267    }
268
269    let (placement, rect, _) = best.unwrap_or_else(|| {
270        let placement = OverlayPlacement::Top;
271        (
272            placement,
273            placed_rect(trigger, overlay_size, placement, offset),
274            0.0,
275        )
276    });
277    let clamped = clamp_to_boundary(rect, boundary, boundary_padding);
278
279    OverlayPosition {
280        x: clamped.x,
281        y: clamped.y,
282        placement,
283        fits: false,
284        trigger_visible,
285        arrow: arrow_offset(trigger, clamped, placement),
286    }
287}
288
289fn candidate_placements(
290    requested: OverlayPlacement,
291    fallback_placements: &[OverlayPlacement],
292) -> Vec<OverlayPlacement> {
293    let mut candidates = Vec::new();
294
295    if requested == OverlayPlacement::Auto {
296        push_candidates(&mut candidates, fallback_placements);
297        if candidates.is_empty() {
298            push_candidates(&mut candidates, &OverlayPlacement::DEFAULT_FALLBACKS);
299        }
300    } else {
301        candidates.push(requested);
302        push_candidates(&mut candidates, fallback_placements);
303    }
304
305    candidates
306}
307
308fn push_candidates(candidates: &mut Vec<OverlayPlacement>, placements: &[OverlayPlacement]) {
309    for placement in placements {
310        if *placement != OverlayPlacement::Auto && !candidates.contains(placement) {
311            candidates.push(*placement);
312        }
313    }
314}
315
316fn placed_rect(
317    trigger: OverlayRect,
318    overlay_size: OverlayRect,
319    placement: OverlayPlacement,
320    offset: OverlayOffset,
321) -> OverlayRect {
322    match placement {
323        OverlayPlacement::Auto => placed_rect(trigger, overlay_size, OverlayPlacement::Top, offset),
324        OverlayPlacement::Top => OverlayRect::new(
325            trigger.center_x() - overlay_size.width / 2.0 + offset.skidding,
326            trigger.y - overlay_size.height - offset.distance,
327            overlay_size.width,
328            overlay_size.height,
329        ),
330        OverlayPlacement::Bottom => OverlayRect::new(
331            trigger.center_x() - overlay_size.width / 2.0 + offset.skidding,
332            trigger.bottom() + offset.distance,
333            overlay_size.width,
334            overlay_size.height,
335        ),
336        OverlayPlacement::Start => OverlayRect::new(
337            trigger.x - overlay_size.width - offset.distance,
338            trigger.center_y() - overlay_size.height / 2.0 + offset.skidding,
339            overlay_size.width,
340            overlay_size.height,
341        ),
342        OverlayPlacement::End => OverlayRect::new(
343            trigger.right() + offset.distance,
344            trigger.center_y() - overlay_size.height / 2.0 + offset.skidding,
345            overlay_size.width,
346            overlay_size.height,
347        ),
348    }
349}
350
351fn fits_boundary(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> bool {
352    rect.x >= boundary.x + padding
353        && rect.y >= boundary.y + padding
354        && rect.right() <= boundary.right() - padding
355        && rect.bottom() <= boundary.bottom() - padding
356}
357
358fn visible_area(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> f64 {
359    let min_x = boundary.x + padding;
360    let min_y = boundary.y + padding;
361    let max_x = boundary.right() - padding;
362    let max_y = boundary.bottom() - padding;
363
364    let width = (rect.right().min(max_x) - rect.x.max(min_x)).max(0.0);
365    let height = (rect.bottom().min(max_y) - rect.y.max(min_y)).max(0.0);
366    width * height
367}
368
369fn clamp_to_boundary(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> OverlayRect {
370    let min_x = boundary.x + padding;
371    let min_y = boundary.y + padding;
372    let max_x = (boundary.right() - padding - rect.width).max(min_x);
373    let max_y = (boundary.bottom() - padding - rect.height).max(min_y);
374
375    OverlayRect::new(
376        rect.x.clamp(min_x, max_x),
377        rect.y.clamp(min_y, max_y),
378        rect.width,
379        rect.height,
380    )
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    // ── trigger visibility ──────────────────────────────────────────────────
388    //
389    // These lock the arm that fixes a forced-open overlay landing ~2700px from
390    // its trigger: the position is computed once, and if the trigger is below the
391    // fold the box was clamped INTO view rather than suppressed.
392
393    #[test]
394    fn trigger_inside_the_boundary_is_visible() {
395        let position = calculate_overlay_position(
396            OverlayRect::new(100.0, 100.0, 40.0, 20.0),
397            OverlayRect::new(0.0, 0.0, 80.0, 30.0),
398            OverlayRect::new(0.0, 0.0, 300.0, 300.0),
399            OverlayPlacement::Top,
400            &[],
401            OverlayOffset::default(),
402            8.0,
403        );
404        assert!(position.trigger_visible);
405    }
406
407    #[test]
408    fn trigger_below_the_fold_is_not_visible() {
409        // The measured shape of the real defect: trigger far down the document,
410        // viewport only 800px tall.
411        let position = calculate_overlay_position(
412            OverlayRect::new(225.0, 3389.0, 128.0, 38.0),
413            OverlayRect::new(0.0, 0.0, 200.0, 29.0),
414            OverlayRect::new(0.0, 0.0, 1280.0, 800.0),
415            OverlayPlacement::Bottom,
416            &[],
417            OverlayOffset::default(),
418            8.0,
419        );
420        assert!(
421            !position.trigger_visible,
422            "a trigger 3389px down a 800px viewport is off-screen"
423        );
424    }
425
426    #[test]
427    fn trigger_above_the_fold_is_not_visible() {
428        let position = calculate_overlay_position(
429            OverlayRect::new(100.0, -400.0, 40.0, 20.0),
430            OverlayRect::new(0.0, 0.0, 80.0, 30.0),
431            OverlayRect::new(0.0, 0.0, 300.0, 300.0),
432            OverlayPlacement::Top,
433            &[],
434            OverlayOffset::default(),
435            8.0,
436        );
437        assert!(!position.trigger_visible);
438    }
439
440    #[test]
441    fn trigger_scrolled_off_to_the_side_is_not_visible() {
442        let position = calculate_overlay_position(
443            OverlayRect::new(-500.0, 100.0, 40.0, 20.0),
444            OverlayRect::new(0.0, 0.0, 80.0, 30.0),
445            OverlayRect::new(0.0, 0.0, 300.0, 300.0),
446            OverlayPlacement::Top,
447            &[],
448            OverlayOffset::default(),
449            8.0,
450        );
451        assert!(!position.trigger_visible);
452    }
453
454    #[test]
455    fn a_trigger_flush_against_the_edge_does_not_count_as_visible() {
456        // Touching edges only: no room for an overlay, and treating it as visible
457        // would reintroduce the clamped-into-view box by one pixel of slack.
458        let position = calculate_overlay_position(
459            OverlayRect::new(100.0, 300.0, 40.0, 20.0),
460            OverlayRect::new(0.0, 0.0, 80.0, 30.0),
461            OverlayRect::new(0.0, 0.0, 300.0, 300.0),
462            OverlayPlacement::Top,
463            &[],
464            OverlayOffset::default(),
465            8.0,
466        );
467        assert!(!position.trigger_visible);
468    }
469
470    #[test]
471    fn partially_visible_trigger_still_counts() {
472        // Half off the bottom edge: there is still an anchor to point at.
473        let position = calculate_overlay_position(
474            OverlayRect::new(100.0, 290.0, 40.0, 20.0),
475            OverlayRect::new(0.0, 0.0, 80.0, 30.0),
476            OverlayRect::new(0.0, 0.0, 300.0, 300.0),
477            OverlayPlacement::Top,
478            &[],
479            OverlayOffset::default(),
480            8.0,
481        );
482        assert!(position.trigger_visible);
483    }
484
485    fn trigger() -> OverlayRect {
486        OverlayRect::new(100.0, 100.0, 40.0, 20.0)
487    }
488
489    fn overlay() -> OverlayRect {
490        OverlayRect::new(0.0, 0.0, 80.0, 30.0)
491    }
492
493    fn boundary() -> OverlayRect {
494        OverlayRect::new(0.0, 0.0, 300.0, 300.0)
495    }
496
497    #[test]
498    fn requested_top_fits() {
499        let position = calculate_overlay_position(
500            trigger(),
501            overlay(),
502            boundary(),
503            OverlayPlacement::Top,
504            &OverlayPlacement::DEFAULT_FALLBACKS,
505            OverlayOffset::TOOLTIP,
506            0.0,
507        );
508
509        assert_eq!(position.placement, OverlayPlacement::Top);
510        assert!(position.fits);
511        assert_eq!(position.x, 80.0);
512        assert_eq!(position.y, 64.0);
513    }
514
515    #[test]
516    fn offset_skids_on_cross_axis() {
517        let position = calculate_overlay_position(
518            trigger(),
519            overlay(),
520            boundary(),
521            OverlayPlacement::Bottom,
522            &[],
523            OverlayOffset {
524                skidding: 10.0,
525                distance: 12.0,
526            },
527            0.0,
528        );
529
530        assert_eq!(position.placement, OverlayPlacement::Bottom);
531        assert!(position.fits);
532        assert_eq!(position.x, 90.0);
533        assert_eq!(position.y, 132.0);
534    }
535
536    #[test]
537    fn falls_back_when_requested_placement_overflows() {
538        let edge_trigger = OverlayRect::new(100.0, 10.0, 40.0, 20.0);
539        let position = calculate_overlay_position(
540            edge_trigger,
541            overlay(),
542            boundary(),
543            OverlayPlacement::Top,
544            &[OverlayPlacement::Bottom, OverlayPlacement::End],
545            OverlayOffset::TOOLTIP,
546            0.0,
547        );
548
549        assert_eq!(position.placement, OverlayPlacement::Bottom);
550        assert!(position.fits);
551        assert_eq!(position.y, 36.0);
552    }
553
554    #[test]
555    fn auto_uses_first_fitting_fallback() {
556        let edge_trigger = OverlayRect::new(100.0, 10.0, 40.0, 20.0);
557        let position = calculate_overlay_position(
558            edge_trigger,
559            overlay(),
560            boundary(),
561            OverlayPlacement::Auto,
562            &[
563                OverlayPlacement::Top,
564                OverlayPlacement::Bottom,
565                OverlayPlacement::End,
566            ],
567            OverlayOffset::TOOLTIP,
568            0.0,
569        );
570
571        assert_eq!(position.placement, OverlayPlacement::Bottom);
572        assert!(position.fits);
573    }
574
575    #[test]
576    fn start_and_end_place_on_inline_axis() {
577        let start = calculate_overlay_position(
578            trigger(),
579            overlay(),
580            boundary(),
581            OverlayPlacement::Start,
582            &[],
583            OverlayOffset::POPOVER,
584            0.0,
585        );
586        let end = calculate_overlay_position(
587            trigger(),
588            overlay(),
589            boundary(),
590            OverlayPlacement::End,
591            &[],
592            OverlayOffset::POPOVER,
593            0.0,
594        );
595
596        assert_eq!(start.x, 12.0);
597        assert_eq!(start.y, 95.0);
598        assert_eq!(end.x, 148.0);
599        assert_eq!(end.y, 95.0);
600    }
601
602    #[test]
603    fn clamps_best_candidate_to_boundary_padding() {
604        let edge_trigger = OverlayRect::new(0.0, 120.0, 20.0, 20.0);
605        let position = calculate_overlay_position(
606            edge_trigger,
607            overlay(),
608            boundary(),
609            OverlayPlacement::Top,
610            &[],
611            OverlayOffset::ZERO,
612            8.0,
613        );
614
615        assert_eq!(position.placement, OverlayPlacement::Top);
616        assert!(!position.fits);
617        assert_eq!(position.x, 8.0);
618        assert_eq!(position.y, 90.0);
619    }
620
621    #[test]
622    fn arrow_is_centred_when_overlay_fits() {
623        // Centred trigger, overlay fits: the arrow sits at the overlay's centre.
624        let position = calculate_overlay_position(
625            OverlayRect::new(130.0, 20.0, 40.0, 20.0), // center_x = 150
626            OverlayRect::new(0.0, 0.0, 80.0, 30.0),
627            boundary(),
628            OverlayPlacement::Bottom,
629            &[],
630            OverlayOffset::ZERO,
631            0.0,
632        );
633        assert!(position.fits);
634        assert_eq!(position.x, 110.0);
635        // 150 - 110 = 40 = overlay width / 2 (centred).
636        assert_eq!(position.arrow, 40.0);
637    }
638
639    #[test]
640    fn arrow_tracks_trigger_after_horizontal_clamp() {
641        // A trigger near the right edge: the bottom overlay is centred on it,
642        // overflows the boundary, and is clamped left. The arrow must keep pointing
643        // at the trigger centre, not drift to the (now shifted) overlay centre.
644        let position = calculate_overlay_position(
645            OverlayRect::new(280.0, 20.0, 20.0, 20.0), // center_x = 290
646            OverlayRect::new(0.0, 0.0, 120.0, 60.0),
647            boundary(), // 300 wide
648            OverlayPlacement::Bottom,
649            &[],
650            OverlayOffset::ZERO,
651            0.0,
652        );
653        assert_eq!(position.placement, OverlayPlacement::Bottom);
654        assert!(!position.fits);
655        assert_eq!(position.x, 180.0); // clamped so right edge hits 300
656        // trigger.center_x - x = 290 - 180 = 110, clamped to [16, 104] -> 104:
657        // the arrow hugs the trigger side, not the overlay centre (60).
658        assert_eq!(position.arrow, 104.0);
659    }
660
661    #[test]
662    fn clamps_oversized_overlay_to_boundary_start() {
663        let oversized = OverlayRect::new(0.0, 0.0, 400.0, 400.0);
664        let position = calculate_overlay_position(
665            trigger(),
666            oversized,
667            boundary(),
668            OverlayPlacement::Bottom,
669            &[],
670            OverlayOffset::ZERO,
671            8.0,
672        );
673
674        assert!(!position.fits);
675        assert_eq!(position.x, 8.0);
676        assert_eq!(position.y, 8.0);
677    }
678}