Skip to main content

dioxus_dnd/core/components/
drop_zone.rs

1//! Drop targets: [`DropZone`], the two-world [`BridgeDropZone`], and the
2//! N-world [`crate::bridge_drop_zone!`] macro, plus the [`ParentZone`]
3//! context marker nested zones discover their parent through.
4
5use dioxus::html::MountedData;
6use dioxus::prelude::*;
7
8use std::rc::Rc;
9
10use crate::core::hooks::{
11    use_bridge_world, use_dnd, use_zone_id, use_zone_registry, BridgeGeometry,
12};
13use crate::core::registry::ZoneRecord;
14use crate::core::types::{edge_of, DragMode, DropOutcome, EdgeSet, Rect, ZoneId};
15use crate::core::world::use_joined_window;
16
17/// Context marker a `DropZone` provides so zones nested inside it can
18/// discover their parent - powering hierarchical keyboard traversal with no
19/// configuration.
20#[derive(Clone, Copy, PartialEq)]
21pub struct ParentZone(pub ZoneId);
22
23/// A region that accepts drags carrying `T`.
24///
25/// Handles the HTML5 boilerplate for you: `preventDefault` on dragover,
26/// enter/leave depth counting (so child elements don't cause hover flicker),
27/// and acceptance filtering.
28///
29/// Styling hooks: while an acceptable drag is in flight anywhere, the div
30/// carries `data-active="true"` (reveal your drop targets); while that drag
31/// hovers *this* zone it also carries `data-over="true"` (highlight it).
32/// Both are absent otherwise, so presence-based selectors (CSS
33/// `[data-over]`, Tailwind `data-over:ring-2`) work directly. Driven by the
34/// shared context, so they light up for pointer, touch and keyboard drags
35/// alike.
36///
37/// Opting into `edge` adds the closest-edge signal for insertion
38/// indicators: while an acceptable *pointer* drag hovers this zone, the div
39/// also carries `data-edge="top" | "right" | "bottom" | "left"` (the zone
40/// edge nearest the pointer, live on every move - see [`edge_of`]), and the
41/// delivered [`DropOutcome::edge`] records it at release. Style it with
42/// value selectors, e.g. Tailwind
43/// `data-[edge=top]:shadow-[0_-2px_0_0_currentColor]`.
44#[component]
45pub fn DropZone<T: Clone + PartialEq + 'static>(
46    /// Stable identity for this zone. Auto-generated if omitted.
47    #[props(default)]
48    id: Option<ZoneId>,
49    /// Human label for screen-reader announcements ("Over {label}").
50    #[props(default)]
51    label: Option<String>,
52    /// Return `false` to reject a payload (zone won't highlight or accept it).
53    #[props(default)]
54    accepts: Option<Callback<T, bool>>,
55    /// Track the zone edge nearest the pointer: `EdgeSet::Vertical` for
56    /// top/bottom (a vertical stack), `EdgeSet::Horizontal` for left/right,
57    /// `EdgeSet::All` for all four. Renders `data-edge` while hovered and
58    /// fills [`DropOutcome::edge`]. Off (absent, `None`) by default.
59    #[props(default)]
60    edge: Option<EdgeSet>,
61    /// Fired on a successful drop.
62    on_drop: EventHandler<DropOutcome<T>>,
63    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
64    children: Element,
65) -> Element {
66    let dnd = use_dnd::<T>();
67    let joined = use_joined_window::<T>();
68    let mut registry = use_zone_registry::<T>();
69    let auto_id = use_zone_id();
70    let zone_id = id.unwrap_or(auto_id);
71    // Nesting is automatic: a DropZone inside another discovers its parent
72    // via context, and provides itself to zones deeper down.
73    let parent = try_use_context::<ParentZone>().map(|p| p.0);
74    use_context_provider(|| ParentZone(zone_id));
75    // Register with the zone registry so keyboard navigation and pointer
76    // hit-testing can find this zone. Callbacks are stable handles, so
77    // registering once per mount is enough.
78    let registration = use_hook(|| {
79        registry.register(ZoneRecord {
80            id: zone_id,
81            parent,
82            label: label.clone(),
83            // The zone (not the drag source) owns the edge signal: it knows
84            // its own rect and whether it opted in, so it enriches the
85            // outcome on the way to the app's handler.
86            on_drop: Callback::new(move |mut o: DropOutcome<T>| {
87                if let Some(set) = edge {
88                    if o.mode == DragMode::Pointer {
89                        if let Some(r) = registry.cached_rect(zone_id) {
90                            o.edge = Some(edge_of(o.client, r, set));
91                        }
92                    }
93                }
94                on_drop.call(o)
95            }),
96            accepts,
97            mounted: None,
98            rect: None,
99        })
100    });
101    use_drop(move || {
102        registry.unregister(zone_id);
103    });
104    // Keep the registered label in sync if the prop changes across renders.
105    // Registry readers only `peek`, so this render-time write can't loop.
106    registry.sync_label(zone_id, label.clone());
107
108    let acceptable = move || -> bool {
109        match dnd.payload() {
110            Some(p) => accepts.map(|cb| cb.call(p)).unwrap_or(true),
111            None => false,
112        }
113    };
114    let is_over = move || match joined {
115        Some(joined) => joined.is_over(zone_id),
116        None => dnd.over() == Some(zone_id),
117    };
118    // Live closest-edge readout while an acceptable pointer drag hovers.
119    // Guards run cheapest-first, and the pointer signal is only read (so
120    // this zone only re-renders per pointer move) once actually hovered
121    // with the prop set.
122    let live_edge = move || -> Option<&'static str> {
123        let set = edge?;
124        if !is_over() || dnd.mode() != DragMode::Pointer || !acceptable() {
125            return None;
126        }
127        let r = registry.cached_rect(zone_id)?;
128        let pointer = joined
129            .and_then(|joined| joined.local_pointer())
130            .unwrap_or_else(|| dnd.pointer());
131        Some(edge_of(pointer, r, set).as_str())
132    };
133
134    rsx! {
135        div {
136            "data-active": if dnd.dragging() && acceptable() { "true" },
137            "data-over": if is_over() && acceptable() { "true" },
138            "data-edge": live_edge(),
139            onmounted: move |evt: Event<MountedData>| {
140                let m: Rc<MountedData> = evt.data();
141                let mut registry = registry;
142                registry.set_mounted(registration, m.clone());
143                // Measure immediately, not just at drag start: a zone that
144                // mounts mid-drag (a virtualized list recycling rows under
145                // the pointer) missed the pickup measurement, and the last
146                // scroll ping ran before this row rendered. Hit-testing
147                // must see the zone as soon as it exists.
148                spawn(async move {
149                    if let Ok(r) = m.get_client_rect().await {
150                        registry.set_rect_if_present(registration, Rect::new(
151                            r.origin.x,
152                            r.origin.y,
153                            r.size.width,
154                            r.size.height,
155                        ));
156                    }
157                });
158            },
159            ..attributes,
160            {children}
161        }
162    }
163}
164
165/// A drop target registered in two payload worlds at once - the bridge
166/// between two coexisting providers (`DndProvider<A>` and `DndProvider<B>`).
167///
168/// Zone ids are process-global while registries are per-type, so one element
169/// can hold the *same* `ZoneId` in both registries. The element fans its
170/// mounted handle and each measurement into both provider-owned geometry
171/// records. Each world's machinery - hit-testing, `accepts` filtering,
172/// keyboard navigation - then finds the zone independently, and every drop
173/// arrives through its own typed callback: an `A` drag can only reach
174/// `on_drop_a`, a `B` drag only `on_drop_b`. No downcasts, no shared erased
175/// channel.
176///
177/// Reach for this only when two providers genuinely coexist (say, tickets
178/// and teammates as separate features). If one drag world merely carries
179/// several shapes, make the payload an enum and use a plain [`DropZone`].
180/// For more than two worlds, generate a component for your exact type list
181/// with [`crate::bridge_drop_zone!`] - or go lower-level and call
182/// [`use_bridge_world`] once per world yourself.
183///
184/// Styling hooks match `DropZone`: `data-active="true"` while an acceptable
185/// drag from *either* world is in flight, `data-over="true"` while one
186/// hovers this zone.
187#[component]
188pub fn BridgeDropZone<A: Clone + PartialEq + 'static, B: Clone + PartialEq + 'static>(
189    /// Stable identity for this zone, valid in both worlds. Auto-generated
190    /// if omitted.
191    #[props(default)]
192    id: Option<ZoneId>,
193    /// Human label for screen-reader announcements, used by both worlds.
194    #[props(default)]
195    label: Option<String>,
196    /// Return `false` to reject a payload from the first world.
197    #[props(default)]
198    accepts_a: Option<Callback<A, bool>>,
199    /// Return `false` to reject a payload from the second world.
200    #[props(default)]
201    accepts_b: Option<Callback<B, bool>>,
202    /// Fired when a drag from the first world drops here.
203    on_drop_a: EventHandler<DropOutcome<A>>,
204    /// Fired when a drag from the second world drops here.
205    on_drop_b: EventHandler<DropOutcome<B>>,
206    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
207    children: Element,
208) -> Element {
209    let auto_id = use_zone_id();
210    let zone_id = id.unwrap_or(auto_id);
211    let parent = try_use_context::<ParentZone>().map(|p| p.0);
212    // One unambiguous parent id that resolves in both registries, so nested
213    // zones of either type ascend correctly.
214    use_context_provider(|| ParentZone(zone_id));
215    let geometry = use_hook(BridgeGeometry::default);
216    // One `use_bridge_world` per world: same id and element, independent
217    // provider-owned geometry, each drop through its own typed callback.
218    let a = use_bridge_world::<A>(
219        zone_id,
220        parent,
221        label.clone(),
222        accepts_a,
223        on_drop_a,
224        geometry.clone(),
225    );
226    let b = use_bridge_world::<B>(
227        zone_id,
228        parent,
229        label,
230        accepts_b,
231        on_drop_b,
232        geometry.clone(),
233    );
234
235    rsx! {
236        div {
237            "data-active": if a.active || b.active { "true" },
238            "data-over": if a.over || b.over { "true" },
239            onmounted: move |evt: Event<MountedData>| {
240                let m: Rc<MountedData> = evt.data();
241                geometry.set_mounted(&m);
242                // Same as DropZone: measure at mount so a bridge appearing
243                // mid-drag is immediately hit-testable in both worlds. One
244                // DOM read fans out into both provider-owned registries.
245                let geometry = geometry.clone();
246                spawn(async move {
247                    if let Ok(r) = m.get_client_rect().await {
248                        let rect = Rect::new(
249                            r.origin.x,
250                            r.origin.y,
251                            r.size.width,
252                            r.size.height,
253                        );
254                        geometry.set_rect_if_present(rect);
255                    }
256                });
257            },
258            ..attributes,
259            {children}
260        }
261    }
262}
263
264/// Generate a bridge drop-zone component for **any number** of coexisting
265/// payload worlds - [`BridgeDropZone`]'s recipe, packaged for N > 2 without
266/// `dyn Any` (Rust has no variadic generics, so the component is generated
267/// per concrete type list rather than parameterized over one).
268///
269/// Each `(Type, accepts_prop, on_drop_prop)` row becomes one world: an
270/// optional `accepts_prop: Callback<Type, bool>` filter and a required
271/// `on_drop_prop: EventHandler<DropOutcome<Type>>`. The generated component
272/// also takes the shared `id`/`label` props, forwards extra attributes to
273/// its div, and carries the same styling hooks as [`DropZone`]
274/// (`data-active` / `data-over`, lit by whichever world's drag qualifies).
275///
276/// Requires `use dioxus::prelude::*;` in scope, and an ancestor
277/// `DndProvider` for every listed type. Before reaching for three worlds,
278/// consider whether one provider with an enum payload reads better.
279///
280/// ```text
281/// use dioxus::prelude::*;
282/// use dioxus_dnd::prelude::*;
283///
284/// dioxus_dnd::bridge_drop_zone!(pub StandupZone {
285///     (Ticket, accepts_ticket, on_drop_ticket),
286///     (Person, accepts_person, on_drop_person),
287///     (Alert, accepts_alert, on_drop_alert),
288/// });
289///
290/// rsx! {
291///     StandupZone {
292///         label: "agenda",
293///         accepts_ticket: move |t: Ticket| !t.done,
294///         on_drop_ticket: move |o: DropOutcome<Ticket>| { /* … */ },
295///         on_drop_person: move |o: DropOutcome<Person>| { /* … */ },
296///         on_drop_alert: move |o: DropOutcome<Alert>| { /* … */ },
297///         "standup agenda"
298///     }
299/// }
300/// ```
301#[macro_export]
302macro_rules! bridge_drop_zone {
303    (
304        $(#[$meta:meta])*
305        $vis:vis $name:ident {
306            $( ($ty:ty, $accepts:ident, $on_drop:ident) ),+ $(,)?
307        }
308    ) => {
309        $(#[$meta])*
310        #[::dioxus::prelude::component]
311        #[allow(non_snake_case)]
312        $vis fn $name(
313            /// Stable identity for this zone, valid in every world.
314            /// Auto-generated if omitted.
315            #[props(default)]
316            id: ::std::option::Option<$crate::core::ZoneId>,
317            /// Human label for screen-reader announcements, used by every
318            /// world.
319            #[props(default)]
320            label: ::std::option::Option<::std::string::String>,
321            $(
322                #[props(default)]
323                $accepts: ::std::option::Option<::dioxus::prelude::Callback<$ty, bool>>,
324                $on_drop: ::dioxus::prelude::EventHandler<$crate::core::DropOutcome<$ty>>,
325            )+
326            #[props(extends = div, extends = GlobalAttributes)]
327            attributes: ::std::vec::Vec<::dioxus::prelude::Attribute>,
328            children: ::dioxus::prelude::Element,
329        ) -> ::dioxus::prelude::Element {
330            use ::dioxus::prelude::*;
331
332            let auto_id = $crate::core::use_zone_id();
333            let zone_id = id.unwrap_or(auto_id);
334            let parent = try_use_context::<$crate::core::ParentZone>().map(|p| p.0);
335            // One unambiguous parent id that resolves in every registry, so
336            // nested zones of any listed type ascend correctly.
337            use_context_provider(|| $crate::core::ParentZone(zone_id));
338            let geometry = use_hook($crate::core::BridgeGeometry::default);
339            let mut active = false;
340            let mut over = false;
341            $(
342                let world = $crate::core::use_bridge_world::<$ty>(
343                    zone_id,
344                    parent,
345                    label.clone(),
346                    $accepts,
347                    $on_drop,
348                    geometry.clone(),
349                );
350                active |= world.active;
351                over |= world.over;
352            )+
353
354            rsx! {
355                div {
356                    "data-active": if active { "true" },
357                    "data-over": if over { "true" },
358                    onmounted: move |evt: Event<::dioxus::html::MountedData>| {
359                        let m = evt.data();
360                        geometry.set_mounted(&m);
361                        // Same as DropZone: measure at mount so a bridge
362                        // appearing mid-drag is immediately hit-testable in
363                        // every world. One DOM read fans out into every
364                        // provider-owned registry.
365                        let geometry = geometry.clone();
366                        spawn(async move {
367                            if let Ok(r) = m.get_client_rect().await {
368                                let rect = $crate::core::Rect::new(
369                                    r.origin.x,
370                                    r.origin.y,
371                                    r.size.width,
372                                    r.size.height,
373                                );
374                                geometry.set_rect_if_present(rect);
375                            }
376                        });
377                    },
378                    ..attributes,
379                    {children}
380                }
381            }
382        }
383    };
384}