dioxus_dnd/core/components.rs
1//! Ready-made components over the shared drag context.
2//!
3//! ```text
4//! rsx! {
5//! DndProvider::<Card> {
6//! Draggable::<Card> { payload: card.clone(), "Drag me" }
7//! DropZone::<Card> {
8//! on_drop: move |outcome: DropOutcome<Card>| { /* ... */ },
9//! "Drop here"
10//! }
11//! }
12//! }
13//! ```
14
15use dioxus::html::MountedData;
16use dioxus::prelude::*;
17
18use std::rc::Rc;
19
20use super::hooks::{use_dnd, use_dnd_provider, use_zone_id, use_zone_registry, SettleFlag};
21use super::registry::{ZoneRecord, ZoneRegistry};
22use super::state::DndContext;
23use super::strings::use_dnd_strings;
24use super::{platform, transition, GestureEffect, GestureEvent, GesturePhase};
25
26/// Context marker a `DropZone` provides so zones nested inside it can
27/// discover their parent - powering hierarchical keyboard traversal with no
28/// configuration.
29#[derive(Clone, Copy, PartialEq)]
30pub struct ParentZone(pub ZoneId);
31
32/// Internal: which hierarchical move an arrow key requested.
33#[derive(Debug, Clone, Copy, PartialEq)]
34enum NavKey {
35 Next,
36 Prev,
37 Descend,
38 Ascend,
39}
40use super::types::{
41 edge_of, effective_effect, Direction, DragMode, DropEffect, DropOutcome, EdgeSet, Point, Rect,
42 ZoneId,
43};
44
45/// Map an arrow key to a hierarchical move, honoring layout direction:
46/// horizontal arrows mirror under RTL (the WAI-ARIA tree convention), so
47/// "into" is always the arrow pointing along reading order. Pure, for
48/// testability.
49fn nav_key(key: &Key, dir: Direction) -> Option<NavKey> {
50 Some(match (key, dir) {
51 (Key::ArrowDown, _) => NavKey::Next,
52 (Key::ArrowUp, _) => NavKey::Prev,
53 (Key::ArrowRight, Direction::Ltr) | (Key::ArrowLeft, Direction::Rtl) => NavKey::Descend,
54 (Key::ArrowLeft, Direction::Ltr) | (Key::ArrowRight, Direction::Rtl) => NavKey::Ascend,
55 _ => return None,
56 })
57}
58
59/// Pull a user-provided `style` out of forwarded attributes and append it to
60/// a functional inline style. Spread attributes land after static ones and
61/// replace them wholesale, so without this a caller passing any `style`
62/// would silently delete functional CSS (`touch-action`, overlay
63/// positioning). The user's declarations come last, so they still win on a
64/// per-property basis.
65pub(crate) fn merge_style(attributes: &mut Vec<Attribute>, functional: &str) -> String {
66 let user = attributes
67 .iter()
68 .position(|a| a.name == "style")
69 .map(|i| attributes.remove(i));
70 match user.map(|a| a.value) {
71 Some(dioxus::core::AttributeValue::Text(s)) => format!("{functional} {s}"),
72 _ => functional.to_string(),
73 }
74}
75
76fn keyboard_drop_points(rect: Option<Rect>) -> (Point, Point) {
77 match rect {
78 Some(r) => {
79 let client = r.center();
80 (client, client - r.origin())
81 }
82 None => (Point::default(), Point::default()),
83 }
84}
85
86/// Provides a `DndContext<T>` to its children.
87#[component]
88pub fn DndProvider<T: Clone + PartialEq + 'static>(
89 /// Internal marker; never set this.
90 #[props(default)]
91 phantom: std::marker::PhantomData<T>,
92 /// Layout direction: `Direction::Rtl` mirrors keyboard navigation and
93 /// spatial zone ordering to follow the visual right-to-left flow.
94 #[props(default)]
95 dir: Direction,
96 children: Element,
97) -> Element {
98 let _ = phantom;
99 use_dnd_provider::<T>();
100 // Synced every render (a compare-and-set no-op when unchanged), so a
101 // live direction switch propagates.
102 use_zone_registry::<T>().set_direction(dir);
103 rsx! {
104 {children}
105 }
106}
107
108fn pointer_client(evt: &PointerEvent) -> Point {
109 let c = evt.client_coordinates();
110 Point::new(c.x, c.y)
111}
112
113/// Deliver the in-flight payload to `target`: acceptance check, settle
114/// routing, outcome construction, the zone's callback. THE drop path - the
115/// `Draggable` pointer gesture and [`crate::test::DragSim`] both end here,
116/// so headless tests exercise exactly what production drops run.
117pub(crate) fn deliver_drop<T: Clone + PartialEq + 'static>(
118 registry: ZoneRegistry<T>,
119 dnd: &mut DndContext<T>,
120 settle_flag: Option<SettleFlag<T>>,
121 target: ZoneId,
122 point: Point,
123 effect: DropEffect,
124) -> bool {
125 let Some(record) = registry.get(target) else {
126 return false;
127 };
128 let Some(p) = dnd.payload() else {
129 return false;
130 };
131 if !record.accepts_payload(&p) {
132 return false;
133 }
134 let origin = (*record.rect.peek())
135 .map(|r| r.origin())
136 .unwrap_or_default();
137 let mode = dnd.mode();
138 let grab = dnd.grab();
139 // A settle-enabled overlay glides the ghost into the target zone:
140 // route the drop through the settling take so the payload stays
141 // readable while it animates. Pointer drops only - a keyboard drag
142 // renders no positioned ghost to glide.
143 let settle_to = match settle_flag {
144 Some(f) if mode == DragMode::Pointer && *f.armed.peek() => *record.rect.peek(),
145 _ => None,
146 };
147 let taken = match settle_to {
148 Some(to) => dnd.take_settling(to),
149 None => dnd.take(),
150 };
151 if let Some((p, from)) = taken {
152 record.on_drop.call(DropOutcome {
153 payload: p,
154 from,
155 to: target,
156 effect,
157 mode,
158 client: point,
159 element: point - origin,
160 grab,
161 // The receiving zone fills this in when it opted in.
162 edge: None,
163 });
164 return true;
165 }
166 false
167}
168
169/// Wraps its children in a focusable pointer/keyboard drag source and pushes
170/// `payload` into the shared context on drag start.
171///
172/// Any extra attributes (`class`, `style`, `id`…) are forwarded to the div.
173///
174/// While this element's payload is in flight the div carries
175/// `data-dragging="true"`, and `data-disabled="true"` when `disabled` -
176/// both are *absent* otherwise, so presence-based selectors (CSS
177/// `[data-dragging]`, Tailwind `data-dragging:opacity-50`) work directly.
178#[component]
179pub fn Draggable<T: Clone + PartialEq + 'static>(
180 /// The value delivered to whichever `DropZone` receives this drag.
181 payload: T,
182 /// The zone this item currently lives in (reported in `DropOutcome::from`).
183 #[props(default)]
184 zone: Option<ZoneId>,
185 /// Drop effect. Defaults to `Move`.
186 #[props(default)]
187 effect: DropEffect,
188 /// Disable dragging without unmounting.
189 #[props(default)]
190 disabled: bool,
191 /// Movement in CSS px before a pointer press becomes a drag.
192 #[props(default = 8.0)]
193 threshold: f64,
194 /// Human label used in screen-reader announcements ("Picked up {label}").
195 #[props(default)]
196 label: Option<String>,
197 /// Fired when a drag begins.
198 #[props(default)]
199 on_drag_start: Option<EventHandler<()>>,
200 /// Fired when the drag ends; `true` if a zone consumed the payload,
201 /// `false` if it was cancelled.
202 #[props(default)]
203 on_drag_end: Option<EventHandler<bool>>,
204 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
205 children: Element,
206) -> Element {
207 let mut dnd = use_dnd::<T>();
208 let registry = use_zone_registry::<T>();
209 let settle_flag = try_use_context::<SettleFlag<T>>();
210 // Everything the keyboard path voices, localizable through context.
211 let strings = use_dnd_strings();
212 // Separate clones for the two closures that need the payload.
213 let kb_payload = payload.clone();
214 let pointer_payload = payload.clone();
215 let kb_label = label.clone();
216 // Comparing against the context payload (rather than a local flag) means
217 // the attribute is also correct when a custom source started the drag.
218 let attr_payload = payload.clone();
219 let mut phase = use_signal(|| GesturePhase::Idle);
220 let mut step = move |event: GestureEvent, threshold: f64| -> GestureEffect {
221 let (next, fx) = transition(*phase.peek(), event, threshold);
222 phase.set(next);
223 fx
224 };
225 let mut node = use_signal(|| None::<Rc<MountedData>>);
226 let mut press_offset = use_signal(Point::default);
227 let mut mods = use_signal(Modifiers::empty);
228 let mut attributes = attributes;
229 let style = merge_style(&mut attributes, "touch-action: none;");
230
231 let mut deliver_to = move |target: ZoneId, point: Point, effect: DropEffect| -> bool {
232 deliver_drop(registry, &mut dnd, settle_flag, target, point, effect)
233 };
234
235 let mut finish_drop = move |point: Point| {
236 let effect = effective_effect(effect, *mods.peek());
237 if let Some(target) = registry.hit_test(point) {
238 if deliver_to(target, point, effect) {
239 if let Some(h) = &on_drag_end {
240 h.call(true);
241 }
242 return;
243 }
244 }
245 spawn(async move {
246 registry.measure_all().await;
247 let target = dnd
248 .payload()
249 .and_then(|p| registry.hit_test_closest(point, &p, 48.0));
250 let dropped = match target {
251 Some(t) => deliver_to(t, point, effect),
252 None => false,
253 };
254 if !dropped {
255 dnd.cancel();
256 }
257 if let Some(h) = &on_drag_end {
258 h.call(dropped);
259 }
260 });
261 };
262
263 rsx! {
264 div {
265 style: style,
266 "data-dragging": if dnd.dragging() && dnd.payload().as_ref() == Some(&attr_payload) { "true" },
267 "data-disabled": if disabled { "true" },
268 onmounted: move |evt: Event<MountedData>| node.set(Some(evt.data())),
269 onpointerdown: move |evt: PointerEvent| {
270 if disabled || !evt.is_primary() {
271 return;
272 }
273 evt.stop_propagation();
274 if let Some(n) = node.peek().clone() {
275 platform::capture_pointer(&n, evt.pointer_id());
276 }
277 let o = evt.element_coordinates();
278 press_offset.set(Point::new(o.x, o.y));
279 let _ = step(
280 GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
281 threshold,
282 );
283 },
284 onpointermove: move |evt: PointerEvent| {
285 let at = pointer_client(&evt);
286 mods.set(evt.modifiers());
287 let event = if matches!(*phase.peek(), GesturePhase::Dragging { .. })
288 && evt.held_buttons().is_empty()
289 {
290 if let Some(n) = node.peek().clone() {
291 platform::release_pointer(&n, evt.pointer_id());
292 }
293 GestureEvent::Up { at, pointer_id: evt.pointer_id() }
294 } else {
295 GestureEvent::Move { at, pointer_id: evt.pointer_id() }
296 };
297 match step(event, threshold) {
298 GestureEffect::Begin { at, .. } => {
299 dnd.start(
300 pointer_payload.clone(),
301 zone,
302 at,
303 *press_offset.peek(),
304 effect,
305 DragMode::Pointer,
306 );
307 registry.refresh_rects();
308 if let Some(h) = &on_drag_start {
309 h.call(());
310 }
311 }
312 GestureEffect::Track { at } => {
313 dnd.update_pointer(at);
314 match registry.hit_test(at) {
315 Some(z) => dnd.enter(z),
316 None => {
317 if let Some(over) = dnd.over() {
318 dnd.leave(over);
319 }
320 }
321 }
322 }
323 GestureEffect::Drop { at: point } => finish_drop(point),
324 _ => {}
325 }
326 },
327 onpointerup: move |evt: PointerEvent| {
328 if let Some(n) = node.peek().clone() {
329 platform::release_pointer(&n, evt.pointer_id());
330 }
331 mods.set(evt.modifiers());
332 let GestureEffect::Drop { at: point } = step(
333 GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
334 threshold,
335 ) else {
336 return;
337 };
338 finish_drop(point);
339 },
340 onpointercancel: move |evt: PointerEvent| {
341 if let Some(n) = node.peek().clone() {
342 platform::release_pointer(&n, evt.pointer_id());
343 }
344 if step(GestureEvent::Cancel, threshold) == GestureEffect::Abort {
345 dnd.cancel();
346 if let Some(h) = &on_drag_end {
347 h.call(false);
348 }
349 }
350 },
351 onlostpointercapture: move |_| {
352 if step(GestureEvent::Cancel, threshold) == GestureEffect::Abort {
353 dnd.cancel();
354 if let Some(h) = &on_drag_end {
355 h.call(false);
356 }
357 }
358 },
359 // --- keyboard interaction ---------------------------------
360 // Space/Enter picks the item up, arrow keys cycle acceptable
361 // zones, Space/Enter drops, Escape cancels. Announcements go
362 // through the context; render `a11y::LiveRegion` to voice them.
363 tabindex: if disabled { -1_i64 } else { 0 },
364 role: "button",
365 aria_roledescription: "draggable",
366 onkeydown: move |evt: KeyboardEvent| {
367 if disabled {
368 return;
369 }
370 let registry = registry;
371 let key = evt.key();
372 let is_activate = matches!(key, Key::Enter)
373 || matches!(&key, Key::Character(c) if c == " ");
374 let kb_drag = dnd.dragging() && dnd.mode() == DragMode::Keyboard;
375
376 if !dnd.dragging() && is_activate {
377 evt.prevent_default();
378 dnd.start(
379 kb_payload.clone(),
380 zone,
381 Point::default(),
382 Point::default(),
383 effect,
384 DragMode::Keyboard,
385 );
386 // Measure zones so arrow-key order can follow visual
387 // (top-to-bottom, left-to-right) layout.
388 registry.refresh_rects();
389 let name = kb_label.clone().unwrap_or_else(|| (strings.item)());
390 dnd.announce((strings.picked_up)(&name));
391 if let Some(h) = &on_drag_start {
392 h.call(());
393 }
394 return;
395 }
396
397 if !kb_drag {
398 return;
399 }
400
401 // Hierarchical navigation (WAI-ARIA tree convention):
402 // Up/Down cycle siblings at the current level; the arrow
403 // along reading order descends into the hovered zone's
404 // children; the opposite one ascends to its parent (both
405 // mirror under RTL). In flat apps (no nesting) they fall
406 // back to next/previous, preserving the simple behavior.
407 let nav = nav_key(&key, registry.direction());
408 if let (Some(nav), Some(p)) = (nav, dnd.payload()) {
409 evt.prevent_default();
410 let over = dnd.over();
411 let next = match nav {
412 NavKey::Next => registry.step_sibling(over, &p, 1),
413 NavKey::Prev => registry.step_sibling(over, &p, -1),
414 NavKey::Descend => over
415 .and_then(|z| registry.first_child(z, &p))
416 .or_else(|| registry.step_sibling(over, &p, 1)),
417 NavKey::Ascend => over
418 .and_then(|z| registry.ascend(z))
419 .or_else(|| registry.step_sibling(over, &p, -1)),
420 };
421 if let Some(next) = next {
422 dnd.enter(next);
423 let record = registry.get(next);
424 let name = record
425 .as_ref()
426 .and_then(|z| z.label.clone())
427 .unwrap_or_else(|| (strings.zone)(next.0));
428 let inside = record
429 .as_ref()
430 .and_then(|z| z.parent)
431 .and_then(|pid| registry.get(pid))
432 .and_then(|pz| pz.label);
433 match inside {
434 Some(parent) => dnd.announce((strings.over_inside)(&name, &parent)),
435 None => dnd.announce((strings.over)(&name)),
436 }
437 } else {
438 dnd.announce((strings.no_targets)());
439 }
440 return;
441 }
442
443 if is_activate {
444 evt.prevent_default();
445 // A custom source can enter() an id from another type's
446 // registry; falling back keeps Enter from dying silently.
447 let target = dnd.over().filter(|z| registry.contains(*z)).or_else(|| {
448 dnd.payload().and_then(|p| registry.step_zone(None, &p, 1))
449 });
450 let Some(target) = target else {
451 dnd.announce((strings.no_target_selected)());
452 return;
453 };
454 if let Some(record) = registry.get(target) {
455 if let Some((p, from)) = dnd.take() {
456 let (client, element) = keyboard_drop_points(*record.rect.peek());
457 record.on_drop.call(DropOutcome {
458 payload: p,
459 from,
460 to: target,
461 effect,
462 mode: DragMode::Keyboard,
463 client,
464 element,
465 grab: Point::default(),
466 edge: None,
467 });
468 let name = record
469 .label
470 .unwrap_or_else(|| (strings.zone)(target.0));
471 dnd.announce((strings.dropped_in)(&name));
472 if let Some(h) = &on_drag_end {
473 h.call(true);
474 }
475 }
476 }
477 return;
478 }
479
480 if matches!(key, Key::Escape) {
481 evt.prevent_default();
482 dnd.cancel();
483 dnd.announce((strings.cancelled)());
484 if let Some(h) = &on_drag_end {
485 h.call(false);
486 }
487 }
488 },
489 ..attributes,
490 {children}
491 }
492 }
493}
494
495/// A region that accepts drags carrying `T`.
496///
497/// Handles the HTML5 boilerplate for you: `preventDefault` on dragover,
498/// enter/leave depth counting (so child elements don't cause hover flicker),
499/// and acceptance filtering.
500///
501/// Styling hooks: while an acceptable drag is in flight anywhere, the div
502/// carries `data-active="true"` (reveal your drop targets); while that drag
503/// hovers *this* zone it also carries `data-over="true"` (highlight it).
504/// Both are absent otherwise, so presence-based selectors (CSS
505/// `[data-over]`, Tailwind `data-over:ring-2`) work directly. Driven by the
506/// shared context, so they light up for pointer, touch and keyboard drags
507/// alike.
508///
509/// Opting into `edge` adds the closest-edge signal for insertion
510/// indicators: while an acceptable *pointer* drag hovers this zone, the div
511/// also carries `data-edge="top" | "right" | "bottom" | "left"` (the zone
512/// edge nearest the pointer, live on every move - see [`edge_of`]), and the
513/// delivered [`DropOutcome::edge`] records it at release. Style it with
514/// value selectors, e.g. Tailwind
515/// `data-[edge=top]:shadow-[0_-2px_0_0_currentColor]`.
516#[component]
517pub fn DropZone<T: Clone + PartialEq + 'static>(
518 /// Stable identity for this zone. Auto-generated if omitted.
519 #[props(default)]
520 id: Option<ZoneId>,
521 /// Human label for screen-reader announcements ("Over {label}").
522 #[props(default)]
523 label: Option<String>,
524 /// Return `false` to reject a payload (zone won't highlight or accept it).
525 #[props(default)]
526 accepts: Option<Callback<T, bool>>,
527 /// Track the zone edge nearest the pointer: `EdgeSet::Vertical` for
528 /// top/bottom (a vertical stack), `EdgeSet::Horizontal` for left/right,
529 /// `EdgeSet::All` for all four. Renders `data-edge` while hovered and
530 /// fills [`DropOutcome::edge`]. Off (absent, `None`) by default.
531 #[props(default)]
532 edge: Option<EdgeSet>,
533 /// Fired on a successful drop.
534 on_drop: EventHandler<DropOutcome<T>>,
535 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
536 children: Element,
537) -> Element {
538 let dnd = use_dnd::<T>();
539 let mut registry = use_zone_registry::<T>();
540 let auto_id = use_zone_id();
541 let zone_id = id.unwrap_or(auto_id);
542 // Nesting is automatic: a DropZone inside another discovers its parent
543 // via context, and provides itself to zones deeper down.
544 let parent = try_use_context::<ParentZone>().map(|p| p.0);
545 use_context_provider(|| ParentZone(zone_id));
546 let mounted = use_signal(|| None::<Rc<MountedData>>);
547 let rect = use_signal(|| None::<super::types::Rect>);
548
549 // Register with the zone registry so keyboard navigation and pointer
550 // hit-testing can find this zone. Callbacks are stable handles, so
551 // registering once per mount is enough.
552 use_hook(|| {
553 registry.register(ZoneRecord {
554 id: zone_id,
555 parent,
556 label: label.clone(),
557 // The zone (not the drag source) owns the edge signal: it knows
558 // its own rect and whether it opted in, so it enriches the
559 // outcome on the way to the app's handler.
560 on_drop: Callback::new(move |mut o: DropOutcome<T>| {
561 if let Some(set) = edge {
562 if o.mode == DragMode::Pointer {
563 if let Some(r) = *rect.peek() {
564 o.edge = Some(edge_of(o.client, r, set));
565 }
566 }
567 }
568 on_drop.call(o)
569 }),
570 accepts,
571 mounted,
572 rect,
573 });
574 });
575 use_drop(move || {
576 registry.unregister(zone_id);
577 });
578 // Keep the registered label in sync if the prop changes across renders.
579 // Registry readers only `peek`, so this render-time write can't loop.
580 registry.sync_label(zone_id, label.clone());
581
582 let acceptable = move || -> bool {
583 match dnd.payload() {
584 Some(p) => accepts.map(|cb| cb.call(p)).unwrap_or(true),
585 None => false,
586 }
587 };
588 // Live closest-edge readout while an acceptable pointer drag hovers.
589 // Guards run cheapest-first, and the pointer signal is only read (so
590 // this zone only re-renders per pointer move) once actually hovered
591 // with the prop set.
592 let live_edge = move || -> Option<&'static str> {
593 let set = edge?;
594 if dnd.over() != Some(zone_id) || dnd.mode() != DragMode::Pointer || !acceptable() {
595 return None;
596 }
597 let r = (*rect.peek())?;
598 Some(edge_of(dnd.pointer(), r, set).as_str())
599 };
600
601 rsx! {
602 div {
603 "data-active": if dnd.dragging() && acceptable() { "true" },
604 "data-over": if dnd.over() == Some(zone_id) && acceptable() { "true" },
605 "data-edge": live_edge(),
606 onmounted: move |evt: Event<MountedData>| {
607 let m: Rc<MountedData> = evt.data();
608 let mut mounted = mounted;
609 let mut rect = rect;
610 mounted.set(Some(m.clone()));
611 // Measure immediately, not just at drag start: a zone that
612 // mounts mid-drag (a virtualized list recycling rows under
613 // the pointer) missed the pickup measurement, and the last
614 // scroll ping ran before this row rendered. Hit-testing
615 // must see the zone as soon as it exists.
616 spawn(async move {
617 if let Ok(r) = m.get_client_rect().await {
618 rect.set(Some(Rect::new(
619 r.origin.x,
620 r.origin.y,
621 r.size.width,
622 r.size.height,
623 )));
624 }
625 });
626 },
627 ..attributes,
628 {children}
629 }
630 }
631}
632
633/// A drop target registered in two payload worlds at once - the bridge
634/// between two coexisting providers (`DndProvider<A>` and `DndProvider<B>`).
635///
636/// Zone ids are process-global while registries are per-type, so one element
637/// can hold the *same* `ZoneId` in both registries, sharing its
638/// `mounted`/`rect` signals. Each world's machinery - hit-testing, `accepts`
639/// filtering, keyboard navigation - then finds the zone independently, and
640/// every drop arrives through its own typed callback: an `A` drag can only
641/// reach `on_drop_a`, a `B` drag only `on_drop_b`. No downcasts, no shared
642/// erased channel.
643///
644/// Reach for this only when two providers genuinely coexist (say, tickets
645/// and teammates as separate features). If one drag world merely carries
646/// several shapes, make the payload an enum and use a plain [`DropZone`].
647/// For more than two worlds, register a shared id yourself with
648/// `use_zone_registry` - this component is that recipe, packaged for the
649/// common pair.
650///
651/// Styling hooks match `DropZone`: `data-active="true"` while an acceptable
652/// drag from *either* world is in flight, `data-over="true"` while one
653/// hovers this zone.
654#[component]
655pub fn BridgeDropZone<A: Clone + PartialEq + 'static, B: Clone + PartialEq + 'static>(
656 /// Stable identity for this zone, valid in both worlds. Auto-generated
657 /// if omitted.
658 #[props(default)]
659 id: Option<ZoneId>,
660 /// Human label for screen-reader announcements, used by both worlds.
661 #[props(default)]
662 label: Option<String>,
663 /// Return `false` to reject a payload from the first world.
664 #[props(default)]
665 accepts_a: Option<Callback<A, bool>>,
666 /// Return `false` to reject a payload from the second world.
667 #[props(default)]
668 accepts_b: Option<Callback<B, bool>>,
669 /// Fired when a drag from the first world drops here.
670 on_drop_a: EventHandler<DropOutcome<A>>,
671 /// Fired when a drag from the second world drops here.
672 on_drop_b: EventHandler<DropOutcome<B>>,
673 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
674 children: Element,
675) -> Element {
676 let dnd_a = use_dnd::<A>();
677 let dnd_b = use_dnd::<B>();
678 let mut reg_a = use_zone_registry::<A>();
679 let mut reg_b = use_zone_registry::<B>();
680 let auto_id = use_zone_id();
681 let zone_id = id.unwrap_or(auto_id);
682 let parent = try_use_context::<ParentZone>().map(|p| p.0);
683 // One unambiguous parent id that resolves in both registries, so nested
684 // zones of either type ascend correctly.
685 use_context_provider(|| ParentZone(zone_id));
686 let mounted = use_signal(|| None::<Rc<MountedData>>);
687 let rect = use_signal(|| None::<super::types::Rect>);
688
689 // Signals are Copy handles, so both records genuinely share one
690 // mounted/rect pair: either world's refresh_rects() re-measures the one
691 // rectangle both registries see.
692 use_hook(|| {
693 reg_a.register(ZoneRecord {
694 id: zone_id,
695 parent,
696 label: label.clone(),
697 on_drop: Callback::new(move |o| on_drop_a.call(o)),
698 accepts: accepts_a,
699 mounted,
700 rect,
701 });
702 reg_b.register(ZoneRecord {
703 id: zone_id,
704 parent,
705 label: label.clone(),
706 on_drop: Callback::new(move |o| on_drop_b.call(o)),
707 accepts: accepts_b,
708 mounted,
709 rect,
710 });
711 });
712 use_drop(move || {
713 reg_a.unregister(zone_id);
714 reg_b.unregister(zone_id);
715 });
716 reg_a.sync_label(zone_id, label.clone());
717 reg_b.sync_label(zone_id, label);
718
719 let acceptable_a = move || -> bool {
720 match dnd_a.payload() {
721 Some(p) => accepts_a.map(|cb| cb.call(p)).unwrap_or(true),
722 None => false,
723 }
724 };
725 let acceptable_b = move || -> bool {
726 match dnd_b.payload() {
727 Some(p) => accepts_b.map(|cb| cb.call(p)).unwrap_or(true),
728 None => false,
729 }
730 };
731
732 rsx! {
733 div {
734 "data-active": if (dnd_a.dragging() && acceptable_a()) || (dnd_b.dragging() && acceptable_b()) { "true" },
735 "data-over": if (dnd_a.over() == Some(zone_id) && acceptable_a())
736 || (dnd_b.over() == Some(zone_id) && acceptable_b()) { "true" },
737 onmounted: move |evt: Event<MountedData>| {
738 let m: Rc<MountedData> = evt.data();
739 let mut mounted = mounted;
740 let mut rect = rect;
741 mounted.set(Some(m.clone()));
742 // Same as DropZone: measure at mount so a bridge appearing
743 // mid-drag is immediately hit-testable in both worlds (the
744 // rect signal is shared, so one measurement serves both).
745 spawn(async move {
746 if let Ok(r) = m.get_client_rect().await {
747 rect.set(Some(Rect::new(
748 r.origin.x,
749 r.origin.y,
750 r.size.width,
751 r.size.height,
752 )));
753 }
754 });
755 },
756 ..attributes,
757 {children}
758 }
759 }
760}
761
762/// The functional inline style for a pointer-pinned "ghost": fixed to `pos`
763/// (a viewport-space top-left), out of flow, click-through, above the page.
764/// Kept as a single `fn` so this exact rule has one definition, shared by
765/// every overlay in the crate.
766pub(crate) fn overlay_style(pos: Point) -> String {
767 format!(
768 "position: fixed; left: {}px; top: {}px; pointer-events: none; z-index: 9999;",
769 pos.x, pos.y
770 )
771}
772
773/// Renders its children pinned to the pointer while a drag is in flight -
774/// a custom "ghost" that follows the cursor.
775///
776/// Extra attributes (`class`, …) are forwarded to the wrapper div, so the
777/// ghost styles directly - e.g. Tailwind
778/// `class: "rotate-3 scale-105 shadow-xl"`. A forwarded `style` is merged
779/// after the functional positioning rather than replacing it.
780///
781/// With `settle: true`, a successful pointer drop doesn't vanish the ghost:
782/// it glides from the release point until its center meets the receiving
783/// zone's center, then unmounts - the drop-settle animation. During the
784/// glide the drag context is *settling*: `dragging()` is already false
785/// (zones have unlit), but `payload()` stays readable so the ghost keeps
786/// its content. The glide honors `prefers-reduced-motion` via
787/// `data-dnd-motion` (it snaps near-instantly, and cleanup still runs
788/// because `transitionend` still fires). Cancelled drags and keyboard
789/// drops never settle.
790///
791/// Note: the ghost follows the shared context's pointer position, which
792/// pointer drags update on every move. Keyboard drags carry no pointer, so
793/// during one the ghost sits at the viewport origin - check `dnd.mode()`
794/// and skip rendering it if that matters to you.
795#[component]
796pub fn DragOverlay<T: Clone + PartialEq + 'static>(
797 /// Internal marker; never set this.
798 #[props(default)]
799 phantom: std::marker::PhantomData<T>,
800 /// Glide the ghost into the receiving zone on drop instead of
801 /// vanishing. Off by default.
802 #[props(default)]
803 settle: bool,
804 /// Settle transition duration in milliseconds.
805 #[props(default = 200.0)]
806 duration: f64,
807 /// CSS easing function for the settle glide.
808 #[props(default = "ease".to_string())]
809 easing: String,
810 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
811 children: Element,
812) -> Element {
813 let _ = phantom;
814 let mut dnd = use_dnd::<T>();
815
816 // Arm settle-aware drops for this provider while mounted. Draggables
817 // check the flag at delivery time, so mount order doesn't matter.
818 let flag = try_use_context::<SettleFlag<T>>();
819 use_hook(move || {
820 if settle {
821 if let Some(mut f) = flag {
822 f.armed.set(true);
823 }
824 }
825 });
826 use_drop(move || {
827 if settle {
828 if let Some(mut f) = flag {
829 f.armed.set(false);
830 }
831 // Unmounting mid-glide: nobody is left to hear transitionend,
832 // so reset now (guarded no-op otherwise).
833 dnd.finish_settle();
834 }
835 });
836
837 let mut node = use_signal(|| None::<Rc<MountedData>>);
838 // The played glide: `Some(delta)` once the ghost has been measured and
839 // the transform released toward the target.
840 let mut glide = use_signal(|| None::<Point>);
841 // The settle transition is inline; honor prefers-reduced-motion. Only
842 // an overlay that settles claims the subtree's stylesheet slot.
843 let reduced_motion_css = crate::a11y::use_reduced_motion_css_if(settle);
844
845 // Measure & play (FLIP, like FlipItem): the settled frame commits at
846 // the release position with the transition armed; this effect then
847 // measures the ghost and releases the transform, so the browser glides
848 // its center onto the zone's center.
849 use_effect(move || {
850 match dnd.settling() {
851 Some(to) if settle => {
852 if glide.peek().is_some() {
853 return;
854 }
855 let Some(m) = node.peek().clone() else {
856 // Never mounted (e.g. keyboard-only ghost skipped) -
857 // nothing to animate.
858 dnd.finish_settle();
859 return;
860 };
861 spawn(async move {
862 let Ok(r) = m.get_client_rect().await else {
863 dnd.finish_settle();
864 return;
865 };
866 let from = Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
867 let d = to.center() - from.center();
868 // A sub-pixel glide would produce no transition (and
869 // thus no transitionend) - finish immediately.
870 if d.x.abs() < 1.0 && d.y.abs() < 1.0 {
871 dnd.finish_settle();
872 } else {
873 glide.set(Some(d));
874 }
875 });
876 }
877 _ => {
878 if glide.peek().is_some() {
879 glide.set(None);
880 }
881 }
882 }
883 });
884
885 let settling = settle && dnd.settling().is_some();
886 if !dnd.dragging() && !settling {
887 return rsx! {};
888 }
889
890 let functional = if settling {
891 let transform = match glide() {
892 Some(d) => format!("translate({}px, {}px)", d.x, d.y),
893 None => "none".to_string(),
894 };
895 format!(
896 "{} transform: {transform}; transition: transform {duration}ms {easing};",
897 overlay_style(dnd.pointer() - dnd.grab()),
898 )
899 } else {
900 overlay_style(dnd.pointer() - dnd.grab())
901 };
902 let mut attributes = attributes;
903 let style = merge_style(&mut attributes, &functional);
904 rsx! {
905 {reduced_motion_css}
906 div {
907 style: style,
908 "data-dnd-motion": if settle { "true" },
909 onmounted: move |evt: Event<MountedData>| node.set(Some(evt.data())),
910 ontransitionend: move |_| {
911 // The only transition this element runs is the settle glide;
912 // finish_settle is a guarded no-op against stray bubbles.
913 if settling && glide.peek().is_some() {
914 dnd.finish_settle();
915 }
916 },
917 ..attributes,
918 {children}
919 }
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 /// Horizontal arrows mirror under RTL: "descend into" is always the
928 /// arrow pointing along reading order. Vertical arrows never mirror.
929 #[test]
930 fn nav_keys_mirror_under_rtl() {
931 for dir in [Direction::Ltr, Direction::Rtl] {
932 assert_eq!(nav_key(&Key::ArrowDown, dir), Some(NavKey::Next));
933 assert_eq!(nav_key(&Key::ArrowUp, dir), Some(NavKey::Prev));
934 assert_eq!(nav_key(&Key::Enter, dir), None);
935 }
936 assert_eq!(
937 nav_key(&Key::ArrowRight, Direction::Ltr),
938 Some(NavKey::Descend)
939 );
940 assert_eq!(
941 nav_key(&Key::ArrowLeft, Direction::Ltr),
942 Some(NavKey::Ascend)
943 );
944 assert_eq!(
945 nav_key(&Key::ArrowRight, Direction::Rtl),
946 Some(NavKey::Ascend)
947 );
948 assert_eq!(
949 nav_key(&Key::ArrowLeft, Direction::Rtl),
950 Some(NavKey::Descend)
951 );
952 }
953
954 #[test]
955 fn keyboard_drop_points_use_zone_center_and_element_offset() {
956 let rect = Rect::new(40.0, 80.0, 200.0, 100.0);
957 let (client, element) = keyboard_drop_points(Some(rect));
958
959 assert_eq!(client, Point::new(140.0, 130.0));
960 assert_eq!(element, Point::new(100.0, 50.0));
961 }
962
963 #[test]
964 fn keyboard_drop_points_fall_back_to_origin_without_rect() {
965 let (client, element) = keyboard_drop_points(None);
966
967 assert_eq!(client, Point::default());
968 assert_eq!(element, Point::default());
969 }
970}