Skip to main content

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}