dioxus_dnd/core/components.rs
1//! Ready-made components over the shared drag context.
2//!
3//! ```rust,ignore
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::{
21 client_point, element_point, use_dnd, use_dnd_provider, use_zone_id, use_zone_registry,
22};
23use super::registry::ZoneRecord;
24
25/// Context marker a `DropZone` provides so zones nested inside it can
26/// discover their parent — powering hierarchical keyboard traversal with no
27/// configuration.
28#[derive(Clone, Copy, PartialEq)]
29pub struct ParentZone(pub ZoneId);
30
31/// Internal: which hierarchical move an arrow key requested.
32#[derive(Clone, Copy)]
33enum NavKey {
34 Next,
35 Prev,
36 Descend,
37 Ascend,
38}
39use super::types::{effective_effect, DragMode, DropEffect, DropOutcome, Point, ZoneId};
40
41/// Provides a `DndContext<T>` to its children.
42#[component]
43pub fn DndProvider<T: Clone + PartialEq + 'static>(
44 /// Internal marker; never set this.
45 #[props(default)]
46 phantom: std::marker::PhantomData<T>,
47 children: Element,
48) -> Element {
49 let _ = phantom;
50 use_dnd_provider::<T>();
51 rsx! {
52 {children}
53 }
54}
55
56/// Wraps its children in a `div[draggable]` and pushes `payload` into the
57/// shared context on drag start.
58///
59/// Any extra attributes (`class`, `style`, `id`…) are forwarded to the div.
60#[component]
61pub fn Draggable<T: Clone + PartialEq + 'static>(
62 /// The value delivered to whichever `DropZone` receives this drag.
63 payload: T,
64 /// The zone this item currently lives in (reported in `DropOutcome::from`).
65 #[props(default)]
66 zone: Option<ZoneId>,
67 /// HTML5 drop effect. Defaults to `Move`.
68 #[props(default)]
69 effect: DropEffect,
70 /// Disable dragging without unmounting.
71 #[props(default)]
72 disabled: bool,
73 /// Human label used in screen-reader announcements ("Picked up {label}").
74 #[props(default)]
75 label: Option<String>,
76 /// Fired when a drag begins.
77 #[props(default)]
78 on_drag_start: Option<EventHandler<()>>,
79 /// Fired when the drag ends; `true` if a zone consumed the payload,
80 /// `false` if it was cancelled.
81 #[props(default)]
82 on_drag_end: Option<EventHandler<bool>>,
83 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
84 children: Element,
85) -> Element {
86 let mut dnd = use_dnd::<T>();
87 let registry = use_zone_registry::<T>();
88 // Separate clones for the two closures that need the payload.
89 let kb_payload = payload.clone();
90 let kb_label = label.clone();
91
92 rsx! {
93 div {
94 draggable: !disabled,
95 ondragstart: move |evt: DragEvent| {
96 if disabled {
97 return;
98 }
99 // Nested draggables: the innermost one owns the drag.
100 evt.stop_propagation();
101 let dt = evt.data_transfer();
102 // Firefox refuses to start a drag unless *some* data is set.
103 let _ = dt.set_data("text/plain", "dioxus-dnd");
104 dt.set_effect_allowed(effect.as_str());
105 dnd.start(
106 payload.clone(),
107 zone,
108 client_point(&evt),
109 element_point(&evt),
110 effect,
111 DragMode::Pointer,
112 );
113 if let Some(h) = &on_drag_start {
114 h.call(());
115 }
116 },
117 ondrag: move |evt: DragEvent| {
118 // Keeps DragOverlay tracking the pointer. Coordinates can be
119 // (0,0) on some platforms; update_pointer filters those.
120 dnd.update_pointer(client_point(&evt));
121 },
122 ondragend: move |_| {
123 // If a DropZone consumed the payload, the state is already
124 // idle — that's how we know the drop landed.
125 let dropped = !dnd.dragging();
126 dnd.cancel();
127 if let Some(h) = &on_drag_end {
128 h.call(dropped);
129 }
130 },
131 // --- keyboard interaction ---------------------------------
132 // Space/Enter picks the item up, arrow keys cycle acceptable
133 // zones, Space/Enter drops, Escape cancels. Announcements go
134 // through the context; render `a11y::LiveRegion` to voice them.
135 tabindex: if disabled { -1_i64 } else { 0 },
136 role: "button",
137 aria_roledescription: "draggable",
138 onkeydown: move |evt: KeyboardEvent| {
139 if disabled {
140 return;
141 }
142 let registry = registry;
143 let key = evt.key();
144 let is_activate = matches!(key, Key::Enter)
145 || matches!(&key, Key::Character(c) if c == " ");
146 let kb_drag = dnd.dragging() && dnd.mode() == DragMode::Keyboard;
147
148 if !dnd.dragging() && is_activate {
149 evt.prevent_default();
150 dnd.start(
151 kb_payload.clone(),
152 zone,
153 Point::default(),
154 Point::default(),
155 effect,
156 DragMode::Keyboard,
157 );
158 // Measure zones so arrow-key order can follow visual
159 // (top-to-bottom, left-to-right) layout.
160 registry.refresh_rects();
161 let name = kb_label.clone().unwrap_or_else(|| "item".to_string());
162 dnd.announce(format!(
163 "Picked up {name}. Use arrow keys to choose a drop target, Enter to drop, Escape to cancel."
164 ));
165 if let Some(h) = &on_drag_start {
166 h.call(());
167 }
168 return;
169 }
170
171 if !kb_drag {
172 return;
173 }
174
175 // Hierarchical navigation (WAI-ARIA tree convention):
176 // Up/Down cycle siblings at the current level; Right
177 // descends into the hovered zone's children; Left ascends
178 // to its parent. In flat apps (no nesting) Right/Left fall
179 // back to next/previous, preserving the simple behavior.
180 let nav = match key {
181 Key::ArrowDown => Some(NavKey::Next),
182 Key::ArrowUp => Some(NavKey::Prev),
183 Key::ArrowRight => Some(NavKey::Descend),
184 Key::ArrowLeft => Some(NavKey::Ascend),
185 _ => None,
186 };
187 if let (Some(nav), Some(p)) = (nav, dnd.payload()) {
188 evt.prevent_default();
189 let over = dnd.over();
190 let next = match nav {
191 NavKey::Next => registry.step_sibling(over, &p, 1),
192 NavKey::Prev => registry.step_sibling(over, &p, -1),
193 NavKey::Descend => over
194 .and_then(|z| registry.first_child(z, &p))
195 .or_else(|| registry.step_sibling(over, &p, 1)),
196 NavKey::Ascend => over
197 .and_then(|z| registry.parent_of(z))
198 .or_else(|| registry.step_sibling(over, &p, -1)),
199 };
200 if let Some(next) = next {
201 dnd.enter(next);
202 let record = registry.get(next);
203 let name = record
204 .as_ref()
205 .and_then(|z| z.label.clone())
206 .unwrap_or_else(|| format!("zone {}", next.0));
207 let inside = record
208 .as_ref()
209 .and_then(|z| z.parent)
210 .and_then(|pid| registry.get(pid))
211 .and_then(|pz| pz.label);
212 match inside {
213 Some(parent) => dnd.announce(format!("Over {name}, inside {parent}.")),
214 None => dnd.announce(format!("Over {name}.")),
215 }
216 } else {
217 dnd.announce("No drop targets available.");
218 }
219 return;
220 }
221
222 if is_activate {
223 evt.prevent_default();
224 let target = dnd.over().or_else(|| {
225 dnd.payload().and_then(|p| registry.step_zone(None, &p, 1))
226 });
227 let Some(target) = target else {
228 dnd.announce("No drop target selected.");
229 return;
230 };
231 if let Some(record) = registry.get(target) {
232 if let Some((p, from)) = dnd.take() {
233 let center = (*record.rect.peek())
234 .map(|r| r.center())
235 .unwrap_or_default();
236 record.on_drop.call(DropOutcome {
237 payload: p,
238 from,
239 to: target,
240 effect,
241 client: center,
242 element: Point::default(),
243 });
244 let name = record
245 .label
246 .unwrap_or_else(|| format!("zone {}", target.0));
247 dnd.announce(format!("Dropped in {name}."));
248 if let Some(h) = &on_drag_end {
249 h.call(true);
250 }
251 }
252 }
253 return;
254 }
255
256 if matches!(key, Key::Escape) {
257 evt.prevent_default();
258 dnd.cancel();
259 dnd.announce("Drag cancelled.");
260 if let Some(h) = &on_drag_end {
261 h.call(false);
262 }
263 }
264 },
265 ..attributes,
266 {children}
267 }
268 }
269}
270
271/// A region that accepts drags carrying `T`.
272///
273/// Handles the HTML5 boilerplate for you: `preventDefault` on dragover,
274/// enter/leave depth counting (so child elements don't cause hover flicker),
275/// and acceptance filtering.
276#[component]
277pub fn DropZone<T: Clone + PartialEq + 'static>(
278 /// Stable identity for this zone. Auto-generated if omitted.
279 #[props(default)]
280 id: Option<ZoneId>,
281 /// Human label for screen-reader announcements ("Over {label}").
282 #[props(default)]
283 label: Option<String>,
284 /// Return `false` to reject a payload (zone won't highlight or accept it).
285 #[props(default)]
286 accepts: Option<Callback<T, bool>>,
287 /// Fired on a successful drop.
288 on_drop: EventHandler<DropOutcome<T>>,
289 /// Fired when an acceptable drag first enters the zone.
290 #[props(default)]
291 on_enter: Option<EventHandler<T>>,
292 /// Fired when the drag leaves the zone (or drops).
293 #[props(default)]
294 on_leave: Option<EventHandler<()>>,
295 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
296 children: Element,
297) -> Element {
298 let mut dnd = use_dnd::<T>();
299 let mut registry = use_zone_registry::<T>();
300 let auto_id = use_zone_id();
301 let zone_id = id.unwrap_or(auto_id);
302 // Nesting is automatic: a DropZone inside another discovers its parent
303 // via context, and provides itself to zones deeper down.
304 let parent = try_use_context::<ParentZone>().map(|p| p.0);
305 use_context_provider(|| ParentZone(zone_id));
306 // dragenter/dragleave fire for every child element; a depth counter turns
307 // them into a single logical enter/leave pair.
308 let mut depth = use_signal(|| 0u32);
309 let mounted = use_signal(|| None::<Rc<MountedData>>);
310 let rect = use_signal(|| None::<super::types::Rect>);
311
312 // Register with the zone registry so keyboard navigation and pointer
313 // hit-testing can find this zone. Callbacks are stable handles, so
314 // registering once per mount is enough.
315 use_hook(|| {
316 registry.register(ZoneRecord {
317 id: zone_id,
318 parent,
319 label: label.clone(),
320 on_drop: Callback::new(move |o| on_drop.call(o)),
321 accepts,
322 mounted,
323 rect,
324 });
325 });
326 use_drop(move || {
327 registry.unregister(zone_id);
328 });
329 // Keep the registered label in sync if the prop changes across renders.
330 // Registry readers only `peek`, so this render-time write can't loop.
331 registry.sync_label(zone_id, label.clone());
332
333 let acceptable = move || -> bool {
334 match dnd.payload() {
335 Some(p) => accepts.map(|cb| cb.call(p)).unwrap_or(true),
336 None => false,
337 }
338 };
339
340 rsx! {
341 div {
342 onmounted: move |evt: Event<MountedData>| {
343 let mut mounted = mounted;
344 mounted.set(Some(evt.data()));
345 },
346 ondragover: move |evt: DragEvent| {
347 if acceptable() {
348 // Without this, the browser never fires `drop`.
349 evt.prevent_default();
350 // Ctrl/Cmd = copy, Alt = link (file-manager convention).
351 let eff = effective_effect(dnd.effect(), evt.modifiers());
352 evt.data_transfer().set_drop_effect(eff.as_str());
353 }
354 },
355 ondragenter: move |evt: DragEvent| {
356 if !acceptable() {
357 return;
358 }
359 evt.prevent_default();
360 let d = depth() + 1;
361 depth.set(d);
362 if d == 1 {
363 dnd.enter(zone_id);
364 if let (Some(h), Some(p)) = (&on_enter, dnd.payload()) {
365 h.call(p);
366 }
367 }
368 },
369 ondragleave: move |_| {
370 let d = depth().saturating_sub(1);
371 depth.set(d);
372 if d == 0 {
373 dnd.leave(zone_id);
374 if let Some(h) = &on_leave {
375 h.call(());
376 }
377 }
378 },
379 ondrop: move |evt: DragEvent| {
380 evt.prevent_default();
381 depth.set(0);
382 if !acceptable() {
383 return;
384 }
385 let client = client_point(&evt);
386 let element = element_point(&evt);
387 let effect = effective_effect(dnd.effect(), evt.modifiers());
388 if let Some((payload, from)) = dnd.take() {
389 on_drop.call(DropOutcome {
390 payload,
391 from,
392 to: zone_id,
393 effect,
394 client,
395 element,
396 });
397 if let Some(h) = &on_leave {
398 h.call(());
399 }
400 }
401 },
402 ..attributes,
403 {children}
404 }
405 }
406}
407
408/// Renders its children pinned to the pointer while a drag is in flight —
409/// a custom "ghost" that follows the cursor.
410///
411/// Note: pointer tracking relies on the `drag` event's coordinates, which a
412/// few webviews report as (0,0). The overlay simply won't move there; treat
413/// it as progressive enhancement.
414#[component]
415pub fn DragOverlay<T: Clone + PartialEq + 'static>(
416 /// Internal marker; never set this.
417 #[props(default)]
418 phantom: std::marker::PhantomData<T>,
419 children: Element,
420) -> Element {
421 let _ = phantom;
422 let dnd = use_dnd::<T>();
423 if !dnd.dragging() {
424 return rsx! {};
425 }
426 let p = dnd.pointer() - dnd.grab();
427 rsx! {
428 div {
429 style: "position: fixed; left: {p.x}px; top: {p.y}px; pointer-events: none; z-index: 9999;",
430 {children}
431 }
432 }
433}