Skip to main content

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}