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}