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}