Skip to main content

dioxus_dnd/core/components/
overlay.rs

1//! The pointer-pinned ghost: [`DragOverlay`] (with the drop-settle glide)
2//! and [`SettleSlot`], the wrapper that makes a settling drop read as one
3//! object.
4
5use dioxus::html::MountedData;
6use dioxus::prelude::*;
7
8use std::{cell::Cell, rc::Rc};
9
10use crate::core::hooks::{use_dnd, SettleFlag};
11use crate::core::types::{DragId, DragMode, Point, Rect};
12use crate::core::world::WorldMembership;
13
14use super::merge_style_invariant_last;
15
16/// The functional inline style for a pointer-pinned "ghost": fixed to `pos`
17/// (a viewport-space top-left), out of flow, click-through, above the page.
18/// Kept as a single `fn` so this exact rule has one definition, shared by
19/// every overlay in the crate.
20pub(crate) fn overlay_style(pos: Point) -> String {
21    format!(
22        "position: fixed; left: {}px; top: {}px; pointer-events: none; z-index: 9999;",
23        pos.x, pos.y
24    )
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
28struct SettleGlide {
29    generation: u64,
30    delta: Point,
31}
32
33fn glide_for_generation(
34    glide: Option<SettleGlide>,
35    generation: Option<u64>,
36) -> Option<SettleGlide> {
37    glide.filter(|glide| Some(glide.generation) == generation)
38}
39
40fn overlay_generation_key(generation: Option<u64>) -> String {
41    generation.map_or_else(
42        || "drag".to_string(),
43        |generation| format!("settle-{generation}"),
44    )
45}
46
47fn cleanup_generation(still_armed: bool, owned: Option<u64>, live: Option<u64>) -> Option<u64> {
48    if still_armed {
49        live.or(owned)
50    } else {
51        owned
52    }
53}
54
55/// Renders its children pinned to the pointer while a drag is in flight -
56/// a custom "ghost" that follows the cursor.
57///
58/// Extra attributes (`class`, …) are forwarded to the wrapper div, so the
59/// ghost styles directly - e.g. Tailwind
60/// `class: "rotate-3 scale-105 shadow-xl"`. A forwarded `style` is merged
61/// after the functional positioning rather than replacing it.
62///
63/// With `settle: true`, a successful pointer drop doesn't vanish the ghost:
64/// it glides from the release point until its center meets the receiving
65/// zone's center, then unmounts - the drop-settle animation. During the
66/// glide the drag context is *settling*: `dragging()` is already false
67/// (zones have unlit), but `payload()` stays readable so the ghost keeps
68/// its content. The glide honors `prefers-reduced-motion` via
69/// `data-dnd-motion` (it snaps near-instantly, and cleanup still runs
70/// because `transitionend` still fires). Cancelled drags and keyboard
71/// drops never settle.
72///
73/// Note: the ghost follows the shared context's pointer position, which
74/// pointer drags update on every move. Keyboard drags carry no pointer, so
75/// during one the ghost sits at the viewport origin - check `dnd.mode()`
76/// and skip rendering it if that matters to you.
77#[component]
78pub fn DragOverlay<T: Clone + PartialEq + 'static>(
79    /// Internal marker; never set this.
80    #[props(default)]
81    phantom: std::marker::PhantomData<T>,
82    /// Glide the ghost into the receiving zone on drop instead of
83    /// vanishing. Off by default.
84    #[props(default)]
85    settle: bool,
86    /// Settle transition duration in milliseconds.
87    #[props(default = 200.0)]
88    duration: f64,
89    /// CSS easing function for the settle glide.
90    #[props(default = "ease".to_string())]
91    easing: String,
92    /// Size the ghost to the grabbed element's measured rect. With it, the
93    /// `pointer - grab` anchoring is exact by construction: the ghost
94    /// appears precisely over what was picked up, whatever your ghost rsx
95    /// renders inside. The ghost waits for the pickup measurement (at most
96    /// a frame behind `Draggable`; custom sources must call
97    /// `set_source_rect` or it stays hidden). Off by default (the ghost
98    /// sizes to its content).
99    #[props(default)]
100    match_source: bool,
101    /// Fired when the drop-settle finishes (including the degenerate
102    /// no-glide cases), so completion effects can start as the ghost lands
103    /// instead of racing it. Never fires for cancelled drags, and not when
104    /// the overlay unmounts mid-glide.
105    #[props(default)]
106    on_settled: Option<EventHandler<()>>,
107    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
108    children: Element,
109) -> Element {
110    let _ = phantom;
111    let mut dnd = use_dnd::<T>();
112    // Multi-window: when the provider joined a `DndWorld`, exactly one
113    // joined window presents the ghost each frame (the one under the
114    // global pointer; the receiving one during a settle).
115    let membership = try_use_context::<WorldMembership<T>>().and_then(|m| m.0);
116    let settle_token = move || match membership {
117        Some(joined) => joined.world.settle_token(joined.key),
118        None => dnd.settling().map(|_| 0),
119    };
120    // `use_drop` keeps its initial closure, so carry the latest generation
121    // owned by this component scope in stable, nonreactive storage. An old
122    // scope must never look up and finish a same-window successor's token.
123    let owned_settle_generation = use_hook(|| Rc::new(Cell::new(None::<u64>)));
124    let render_settle_generation = settle_token();
125    owned_settle_generation.set(render_settle_generation);
126    let settle_capability = use_hook(|| DragId::auto().0);
127
128    // Keep settle ownership synchronized with the live prop. Hook
129    // initializers are mount-only, so doing this in a reactive effect is
130    // required for both false -> true and true -> false transitions.
131    let flag = try_use_context::<SettleFlag<T>>();
132    // Arm the initial value synchronously so a drag driven immediately after
133    // `rebuild_in_place` observes the overlay. The effect handles updates.
134    use_hook(move || {
135        if settle {
136            if let Some(flag) = flag {
137                flag.arm(settle_capability);
138            }
139        }
140    });
141    let effect_owned_settle_generation = Rc::clone(&owned_settle_generation);
142    use_effect(use_reactive!(|settle| {
143        if settle {
144            if let Some(f) = flag {
145                f.arm(settle_capability);
146            }
147        } else if flag.is_some_and(|flag| flag.release(settle_capability)) && dnd.alive() {
148            match membership {
149                Some(joined) => {
150                    if let Some(generation) = cleanup_generation(
151                        true,
152                        effect_owned_settle_generation.get(),
153                        joined.world.peek_settle_token(joined.key),
154                    ) {
155                        joined
156                            .world
157                            .finish_settle_generation(joined.key, generation);
158                    }
159                }
160                None => dnd.finish_settle(),
161            }
162        }
163    }));
164    use_drop(move || {
165        // A replacement overlay supersedes this capability before the old
166        // scope drops. Only the still-armed scope may adopt a claim that
167        // arrived before its first settling render.
168        let still_armed = flag.is_some_and(|flag| flag.release(settle_capability));
169        // Unmounting mid-glide: nobody is left to hear transitionend, so
170        // reset now. The aliveness gate covers app shutdown in multi-window
171        // use, where shared state can die before this scope's cleanup.
172        if still_armed && dnd.alive() {
173            match membership {
174                Some(joined) => {
175                    let generation = cleanup_generation(
176                        true,
177                        owned_settle_generation.get(),
178                        joined.world.peek_settle_token(joined.key),
179                    );
180                    if let Some(generation) = generation {
181                        joined
182                            .world
183                            .finish_settle_generation(joined.key, generation);
184                    }
185                }
186                None => dnd.finish_settle(),
187            }
188        }
189    });
190
191    // Tag mounted data with the generation of the keyed DOM node that owns
192    // it. A successor must never measure its predecessor's detached node.
193    let mut node = use_signal(|| None::<(Option<u64>, Rc<MountedData>)>);
194    // The played glide: `Some(delta)` once the ghost has been measured and
195    // the transform released toward the target.
196    let mut glide = use_signal(|| None::<SettleGlide>);
197    // The settle transition is inline; honor prefers-reduced-motion. The
198    // sheet is safe to anchor unconditionally because it only targets
199    // elements carrying `data-dnd-motion`; that marker remains live below.
200    let reduced_motion_css = crate::a11y::use_reduced_motion_css();
201
202    // Every way a settle can complete funnels through here, so `on_settled`
203    // fires exactly once per landed drop - glide or no glide.
204    let mut settled = move |generation: u64| {
205        let finished = match membership {
206            Some(joined) => joined
207                .world
208                .finish_settle_generation(joined.key, generation),
209            None => {
210                let was_settling = dnd.settling().is_some();
211                dnd.finish_settle();
212                was_settling
213            }
214        };
215        if finished {
216            if let Some(h) = &on_settled {
217                h.call(());
218            }
219        }
220    };
221
222    // The ghost's own rect, measured once per settle; retargets reuse it
223    // (the layout rect never moves - the glide is pure transform).
224    let mut from = use_signal(|| None::<Rect>);
225    // The generation whose measurement is in flight. Tagging this state is
226    // load-bearing: an older task must neither block nor clear its successor.
227    let mut measuring = use_signal(|| None::<u64>);
228    let mut measured_generation = use_signal(|| None::<u64>);
229
230    // Measure & play (FLIP, like FlipItem): the settled frame commits at
231    // the release position with the transition armed; this effect then
232    // measures the ghost and releases the transform toward the settle rect.
233    // The effect subscribes to `settling()`, so a `retarget_settle` (the
234    // landed element announcing its real position, see `SettleSlot`) reruns
235    // it and re-aims the transform - CSS transitions continue smoothly from
236    // wherever the ghost currently is, mid-glide included.
237    use_effect(move || {
238        let token = settle_token();
239        match (dnd.settling(), token) {
240            (Some(to), Some(generation)) if settle => {
241                if *measured_generation.peek() != Some(generation) {
242                    measured_generation.set(Some(generation));
243                    from.set(None);
244                    glide.set(None);
245                }
246                if let Some(f) = *from.peek() {
247                    let d = to.center() - f.center();
248                    // A sub-pixel glide would produce no transition (and
249                    // thus no transitionend) - but only when none is
250                    // already running; a retarget of a live glide always
251                    // ends in a transitionend.
252                    let playing = glide_for_generation(*glide.peek(), Some(generation)).is_some();
253                    if d.x.abs() < 1.0 && d.y.abs() < 1.0 && !playing {
254                        settled(generation);
255                    } else {
256                        glide.set(Some(SettleGlide {
257                            generation,
258                            delta: d,
259                        }));
260                    }
261                    return;
262                }
263                if *measuring.peek() == Some(generation) {
264                    // A retarget landed mid-measure; the pending measurement
265                    // reads the latest settle rect when it completes.
266                    return;
267                }
268                // Subscribe here: a generation key remount replaces the old
269                // MountedData asynchronously, and its onmounted write must
270                // wake this measurement effect.
271                let mounted_node = node.read().clone();
272                let Some((node_generation, m)) = mounted_node else {
273                    // The pointer ghost mounts in this render and will wake
274                    // the effect. A keyboard-only custom settle has no ghost
275                    // to animate, so it can finish immediately.
276                    if dnd.mode() == DragMode::Keyboard {
277                        settled(generation);
278                    }
279                    return;
280                };
281                if node_generation != Some(generation) {
282                    // A generation key change has retired this mounted node;
283                    // wait for the successor's onmounted handle.
284                    return;
285                }
286                measuring.set(Some(generation));
287                spawn(async move {
288                    let r = m.get_client_rect().await;
289                    // Clear only this task's tag. A successor may already
290                    // have installed its own measurement generation.
291                    if *measuring.peek() == Some(generation) {
292                        measuring.set(None);
293                    }
294                    if settle_token() != Some(generation) {
295                        return;
296                    }
297                    let Ok(r) = r else {
298                        settled(generation);
299                        return;
300                    };
301                    let f = Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
302                    from.set(Some(f));
303                    // Aim at the *current* settle rect - a retarget may
304                    // have arrived while the measurement was in flight.
305                    let Some(to) = dnd.settling() else {
306                        settled(generation);
307                        return;
308                    };
309                    let d = to.center() - f.center();
310                    if d.x.abs() < 1.0 && d.y.abs() < 1.0 {
311                        settled(generation);
312                    } else {
313                        glide.set(Some(SettleGlide {
314                            generation,
315                            delta: d,
316                        }));
317                    }
318                });
319            }
320            _ => {
321                if glide.peek().is_some() {
322                    glide.set(None);
323                }
324                if from.peek().is_some() {
325                    from.set(None);
326                }
327                if measured_generation.peek().is_some() {
328                    measured_generation.set(None);
329                }
330                if measuring.peek().is_some() {
331                    measuring.set(None);
332                }
333            }
334        }
335    });
336
337    let settle_generation = render_settle_generation;
338    let settling = settle && settle_generation.is_some();
339    if !dnd.dragging() && !settling {
340        return rsx! {};
341    }
342    // A keyboard drag has no meaningful pointer - rendering would pin the
343    // ghost to the viewport corner. Zones already highlight via data-over,
344    // and the LiveRegion narrates; the ghost is pointer furniture.
345    if dnd.mode() == DragMode::Keyboard {
346        return rsx! {};
347    }
348    // A size-matched ghost waits for the pickup measurement (at most a
349    // frame behind `Draggable`): rendering content-sized first would
350    // visibly pop to the matched size when the rect lands. Custom drag
351    // sources must call `set_source_rect`, or the ghost stays hidden.
352    if match_source && dnd.dragging() && dnd.source_rect().is_none() {
353        return rsx! {};
354    }
355    // Multi-window presentation: the world elects one window's overlay per
356    // frame and hands it the anchor in ITS client px, plus the
357    // origin-to-here scale ratio so a size-matched ghost keeps its physical
358    // size across differently-scaled windows. Without a world this is the
359    // classic raw anchor.
360    let (anchor, scale_ratio) = match membership {
361        Some(j) => match j.present_overlay(dnd.pointer(), dnd.grab(), settling) {
362            Some(placed) => placed,
363            None => return rsx! {},
364        },
365        None => (dnd.pointer() - dnd.grab(), 1.0),
366    };
367
368    // Size-matched ghost: the grabbed element's measured rect, border-box
369    // so the ghost's own padding/border stay inside it.
370    let size = match_source
371        .then(|| dnd.source_rect())
372        .flatten()
373        .map(|r| {
374            format!(
375                " width: {}px; height: {}px; box-sizing: border-box;",
376                r.width * scale_ratio,
377                r.height * scale_ratio
378            )
379        })
380        .unwrap_or_default();
381    let played_glide = glide_for_generation(glide(), settle_generation);
382    let functional = if settling {
383        let transform = match played_glide {
384            Some(glide) => format!("translate({}px, {}px)", glide.delta.x, glide.delta.y),
385            None => "none".to_string(),
386        };
387        format!(
388            "{}{size} transform: {transform}; transition: transform {duration}ms {easing};",
389            overlay_style(anchor),
390        )
391    } else {
392        format!("{}{size}", overlay_style(anchor))
393    };
394    let overlay_key = overlay_generation_key(settle_generation);
395    let mut attributes = attributes;
396    super::protect_attributes(
397        &mut attributes,
398        &["data-dnd-motion", "onmounted", "ontransitionend"],
399    );
400    let mut invariant_properties = vec!["position", "left", "top", "pointer-events", "z-index"];
401    if match_source {
402        invariant_properties.extend(["width", "height", "box-sizing"]);
403    }
404    if settling {
405        invariant_properties.extend(["transform", "transition"]);
406    }
407    let style = merge_style_invariant_last(&mut attributes, &functional, &invariant_properties);
408    rsx! {
409        // A one-item keyed list forces an actual DOM-node replacement when
410        // the settle generation changes. A key on a fixed single child is
411        // only an identity hint and may be reused by the renderer.
412        for node_key in [overlay_key] {
413            div {
414                key: "{node_key}",
415                style: style.clone(),
416                "data-dnd-motion": if settle { "true" },
417                onmounted: move |evt: Event<MountedData>| {
418                    // A retired keyed node may report onmounted after its
419                    // successor. Never let it overwrite the current handle.
420                    if settle_token() == settle_generation {
421                        node.set(Some((settle_generation, evt.data())));
422                    }
423                },
424                ontransitionend: move |_| {
425                    // The node key preserves this handler's generation if an old
426                    // transition event was already queued while a successor
427                    // rendered. The live token check covers the inverse ordering.
428                    if let Some(glide) = played_glide {
429                        if settle_token() == Some(glide.generation) {
430                            settled(glide.generation);
431                        }
432                    }
433                },
434                ..attributes.clone(),
435                {children.clone()}
436            }
437        }
438        {reduced_motion_css}
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[derive(Clone, Props)]
447    struct DynamicSettleProps {
448        enabled: Rc<Cell<bool>>,
449        captured: Rc<Cell<Option<SettleFlag<u8>>>>,
450    }
451
452    impl PartialEq for DynamicSettleProps {
453        fn eq(&self, other: &Self) -> bool {
454            Rc::ptr_eq(&self.enabled, &other.enabled) && Rc::ptr_eq(&self.captured, &other.captured)
455        }
456    }
457
458    #[allow(non_snake_case)]
459    fn SettleFlagProbe(props: DynamicSettleProps) -> Element {
460        let mut dnd = use_dnd::<u8>();
461        props.captured.set(try_use_context::<SettleFlag<u8>>());
462        use_hook(move || {
463            dnd.start(
464                1,
465                None,
466                Point::new(10.0, 10.0),
467                Point::default(),
468                crate::core::DropEffect::Move,
469                DragMode::Pointer,
470            );
471        });
472        rsx! {}
473    }
474
475    fn dynamic_settle_app(props: DynamicSettleProps) -> Element {
476        let enabled = props.enabled.get();
477        rsx! {
478            crate::core::DndProvider::<u8> {
479                SettleFlagProbe { enabled: props.enabled, captured: props.captured }
480                DragOverlay::<u8> { settle: enabled, "ghost" }
481            }
482        }
483    }
484
485    fn flush_effects(dom: &mut VirtualDom) {
486        for _ in 0..3 {
487            dom.process_events();
488            dom.render_immediate(&mut dioxus::core::NoOpMutations);
489        }
490    }
491
492    #[test]
493    fn settle_prop_arms_releases_and_renders_motion_css_dynamically() {
494        let enabled = Rc::new(Cell::new(false));
495        let captured = Rc::new(Cell::new(None));
496        let mut dom = VirtualDom::new_with_props(
497            dynamic_settle_app,
498            DynamicSettleProps {
499                enabled: enabled.clone(),
500                captured: captured.clone(),
501            },
502        );
503        dom.rebuild_in_place();
504        flush_effects(&mut dom);
505        assert!(!dom.in_runtime(|| captured.get().unwrap().is_armed()));
506        let html = dioxus_ssr::render(&dom);
507        assert!(html.contains("prefers-reduced-motion"));
508        assert!(!html.contains(r#"data-dnd-motion="true""#));
509
510        enabled.set(true);
511        dom.mark_dirty(ScopeId::APP);
512        flush_effects(&mut dom);
513        assert!(dom.in_runtime(|| captured.get().unwrap().is_armed()));
514        let html = dioxus_ssr::render(&dom);
515        assert!(html.contains("prefers-reduced-motion"));
516        assert!(html.contains(r#"data-dnd-motion="true""#), "{html}");
517
518        enabled.set(false);
519        dom.mark_dirty(ScopeId::APP);
520        flush_effects(&mut dom);
521        assert!(!dom.in_runtime(|| captured.get().unwrap().is_armed()));
522        let html = dioxus_ssr::render(&dom);
523        assert!(html.contains("prefers-reduced-motion"));
524        assert!(!html.contains(r#"data-dnd-motion="true""#));
525    }
526
527    #[test]
528    fn stale_glide_is_not_relabelled_as_its_successor() {
529        let stale = SettleGlide {
530            generation: 7,
531            delta: Point::new(10.0, 20.0),
532        };
533        assert_eq!(glide_for_generation(Some(stale), Some(7)), Some(stale));
534        assert_eq!(glide_for_generation(Some(stale), Some(8)), None);
535        assert_ne!(
536            overlay_generation_key(Some(7)),
537            overlay_generation_key(Some(8))
538        );
539        assert_eq!(cleanup_generation(false, Some(7), Some(8)), Some(7));
540        assert_eq!(cleanup_generation(true, None, Some(8)), Some(8));
541        assert_eq!(cleanup_generation(true, Some(7), Some(8)), Some(8));
542    }
543}
544
545/// Wraps the element a drop just created so the drop-settle reads as ONE
546/// object: while the ghost glides, the wrapper holds the element's space
547/// but keeps it invisible (no "second copy" next to the ghost), re-aims the
548/// glide at its own measured rect (the ghost lands exactly where the
549/// element is, not at the zone's center), and reveals the element the
550/// instant the ghost unmounts.
551///
552/// Set `active: true` only on the just-landed element - typically by
553/// remembering the dropped payload's id in your `on_drop` handler and
554/// comparing. Inert while nothing is settling (keyboard drops, cancelled
555/// drags, overlays without `settle`), so it is always safe to render.
556///
557/// ```text
558/// on_drop: move |o: DropOutcome<Card>| { landed.set(Some(o.payload.id)); /* model */ },
559/// // ...
560/// SettleSlot::<Card> { active: landing() == Some(card.id),
561///     Draggable::<Card> { payload: card.clone(), CardFace { card } }
562/// }
563/// ```
564#[component]
565pub fn SettleSlot<T: Clone + PartialEq + 'static>(
566    /// Internal marker; never set this.
567    #[props(default)]
568    phantom: std::marker::PhantomData<T>,
569    /// True on the element the current settle is delivering.
570    active: bool,
571    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
572    children: Element,
573) -> Element {
574    let _ = phantom;
575    let mut dnd = use_dnd::<T>();
576    let membership = try_use_context::<WorldMembership<T>>().and_then(|m| m.0);
577    let mut node = use_signal(|| None::<Rc<MountedData>>);
578
579    let settle_token = move || match membership {
580        Some(joined) => joined.world.settle_token(joined.key),
581        None => dnd.settling().map(|_| 0),
582    };
583
584    let retarget = move |m: Rc<MountedData>, generation: u64| {
585        spawn(async move {
586            if let Ok(r) = m.get_client_rect().await {
587                if settle_token() == Some(generation) {
588                    dnd.retarget_settle(Rect::new(
589                        r.origin.x,
590                        r.origin.y,
591                        r.size.width,
592                        r.size.height,
593                    ));
594                }
595            }
596        });
597    };
598    // The landed element usually mounts fresh (the drop re-rendered the
599    // model), so onmounted below re-aims. This effect covers the other
600    // order - `active` turning true on an already-mounted element.
601    use_effect(use_reactive!(|active| {
602        if active {
603            if let (Some(m), Some(generation)) = (node.peek().clone(), settle_token()) {
604                retarget(m, generation);
605            }
606        }
607    }));
608
609    // Reading `settling()` here subscribes the reveal: the moment
610    // finish_settle resets the state, the wrapper re-renders visible. Both
611    // states write an explicit value - updating a style string to "" can
612    // leave the old declaration standing.
613    let hidden = active && settle_token().is_some();
614    let mut attributes = attributes;
615    super::protect_attributes(&mut attributes, &["data-settling", "onmounted"]);
616    let style = merge_style_invariant_last(
617        &mut attributes,
618        if hidden {
619            "visibility: hidden;"
620        } else {
621            "visibility: visible;"
622        },
623        &["visibility"],
624    );
625    rsx! {
626        div {
627            style: style,
628            "data-settling": if hidden { "true" },
629            onmounted: move |evt: Event<MountedData>| {
630                let m: Rc<MountedData> = evt.data();
631                node.set(Some(m.clone()));
632                if active {
633                    if let Some(generation) = settle_token() {
634                        retarget(m, generation);
635                    }
636                }
637            },
638            ..attributes,
639            {children}
640        }
641    }
642}