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};
21use super::registry::ZoneRecord;
22use super::{platform, transition, GestureEffect, GestureEvent, GesturePhase};
23
24/// Context marker a `DropZone` provides so zones nested inside it can
25/// discover their parent - powering hierarchical keyboard traversal with no
26/// configuration.
27#[derive(Clone, Copy, PartialEq)]
28pub struct ParentZone(pub ZoneId);
29
30/// Internal: which hierarchical move an arrow key requested.
31#[derive(Debug, Clone, Copy, PartialEq)]
32enum NavKey {
33    Next,
34    Prev,
35    Descend,
36    Ascend,
37}
38use super::types::{
39    effective_effect, Direction, DragMode, DropEffect, DropOutcome, Point, Rect, ZoneId,
40};
41
42/// Map an arrow key to a hierarchical move, honoring layout direction:
43/// horizontal arrows mirror under RTL (the WAI-ARIA tree convention), so
44/// "into" is always the arrow pointing along reading order. Pure, for
45/// testability.
46fn nav_key(key: &Key, dir: Direction) -> Option<NavKey> {
47    Some(match (key, dir) {
48        (Key::ArrowDown, _) => NavKey::Next,
49        (Key::ArrowUp, _) => NavKey::Prev,
50        (Key::ArrowRight, Direction::Ltr) | (Key::ArrowLeft, Direction::Rtl) => NavKey::Descend,
51        (Key::ArrowLeft, Direction::Ltr) | (Key::ArrowRight, Direction::Rtl) => NavKey::Ascend,
52        _ => return None,
53    })
54}
55
56/// Pull a user-provided `style` out of forwarded attributes and append it to
57/// a functional inline style. Spread attributes land after static ones and
58/// replace them wholesale, so without this a caller passing any `style`
59/// would silently delete functional CSS (`touch-action`, overlay
60/// positioning). The user's declarations come last, so they still win on a
61/// per-property basis.
62pub(crate) fn merge_style(attributes: &mut Vec<Attribute>, functional: &str) -> String {
63    let user = attributes
64        .iter()
65        .position(|a| a.name == "style")
66        .map(|i| attributes.remove(i));
67    match user.map(|a| a.value) {
68        Some(dioxus::core::AttributeValue::Text(s)) => format!("{functional} {s}"),
69        _ => functional.to_string(),
70    }
71}
72
73fn keyboard_drop_points(rect: Option<Rect>) -> (Point, Point) {
74    match rect {
75        Some(r) => {
76            let client = r.center();
77            (client, client - r.origin())
78        }
79        None => (Point::default(), Point::default()),
80    }
81}
82
83/// Provides a `DndContext<T>` to its children.
84#[component]
85pub fn DndProvider<T: Clone + PartialEq + 'static>(
86    /// Internal marker; never set this.
87    #[props(default)]
88    phantom: std::marker::PhantomData<T>,
89    /// Layout direction: `Direction::Rtl` mirrors keyboard navigation and
90    /// spatial zone ordering to follow the visual right-to-left flow.
91    #[props(default)]
92    dir: Direction,
93    children: Element,
94) -> Element {
95    let _ = phantom;
96    use_dnd_provider::<T>();
97    // Synced every render (a compare-and-set no-op when unchanged), so a
98    // live direction switch propagates.
99    use_zone_registry::<T>().set_direction(dir);
100    rsx! {
101        {children}
102    }
103}
104
105fn pointer_client(evt: &PointerEvent) -> Point {
106    let c = evt.client_coordinates();
107    Point::new(c.x, c.y)
108}
109
110/// Wraps its children in a focusable pointer/keyboard drag source and pushes
111/// `payload` into the shared context on drag start.
112///
113/// Any extra attributes (`class`, `style`, `id`…) are forwarded to the div.
114///
115/// While this element's payload is in flight the div carries
116/// `data-dragging="true"`, and `data-disabled="true"` when `disabled` -
117/// both are *absent* otherwise, so presence-based selectors (CSS
118/// `[data-dragging]`, Tailwind `data-dragging:opacity-50`) work directly.
119#[component]
120pub fn Draggable<T: Clone + PartialEq + 'static>(
121    /// The value delivered to whichever `DropZone` receives this drag.
122    payload: T,
123    /// The zone this item currently lives in (reported in `DropOutcome::from`).
124    #[props(default)]
125    zone: Option<ZoneId>,
126    /// Drop effect. Defaults to `Move`.
127    #[props(default)]
128    effect: DropEffect,
129    /// Disable dragging without unmounting.
130    #[props(default)]
131    disabled: bool,
132    /// Movement in CSS px before a pointer press becomes a drag.
133    #[props(default = 8.0)]
134    threshold: f64,
135    /// Human label used in screen-reader announcements ("Picked up {label}").
136    #[props(default)]
137    label: Option<String>,
138    /// Fired when a drag begins.
139    #[props(default)]
140    on_drag_start: Option<EventHandler<()>>,
141    /// Fired when the drag ends; `true` if a zone consumed the payload,
142    /// `false` if it was cancelled.
143    #[props(default)]
144    on_drag_end: Option<EventHandler<bool>>,
145    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
146    children: Element,
147) -> Element {
148    let mut dnd = use_dnd::<T>();
149    let registry = use_zone_registry::<T>();
150    // Separate clones for the two closures that need the payload.
151    let kb_payload = payload.clone();
152    let pointer_payload = payload.clone();
153    let kb_label = label.clone();
154    // Comparing against the context payload (rather than a local flag) means
155    // the attribute is also correct when a custom source started the drag.
156    let attr_payload = payload.clone();
157    let mut phase = use_signal(|| GesturePhase::Idle);
158    let mut step = move |event: GestureEvent, threshold: f64| -> GestureEffect {
159        let (next, fx) = transition(*phase.peek(), event, threshold);
160        phase.set(next);
161        fx
162    };
163    let mut node = use_signal(|| None::<Rc<MountedData>>);
164    let mut press_offset = use_signal(Point::default);
165    let mut mods = use_signal(Modifiers::empty);
166    let mut attributes = attributes;
167    let style = merge_style(&mut attributes, "touch-action: none;");
168
169    let mut deliver_to = move |target: ZoneId, point: Point, effect: DropEffect| -> bool {
170        let Some(record) = registry.get(target) else {
171            return false;
172        };
173        let Some(p) = dnd.payload() else {
174            return false;
175        };
176        if !record.accepts_payload(&p) {
177            return false;
178        }
179        let origin = (*record.rect.peek())
180            .map(|r| r.origin())
181            .unwrap_or_default();
182        let mode = dnd.mode();
183        let grab = dnd.grab();
184        if let Some((p, from)) = dnd.take() {
185            record.on_drop.call(DropOutcome {
186                payload: p,
187                from,
188                to: target,
189                effect,
190                mode,
191                client: point,
192                element: point - origin,
193                grab,
194            });
195            return true;
196        }
197        false
198    };
199
200    let mut finish_drop = move |point: Point| {
201        let effect = effective_effect(effect, *mods.peek());
202        if let Some(target) = registry.hit_test(point) {
203            if deliver_to(target, point, effect) {
204                if let Some(h) = &on_drag_end {
205                    h.call(true);
206                }
207                return;
208            }
209        }
210        spawn(async move {
211            registry.measure_all().await;
212            let target = dnd
213                .payload()
214                .and_then(|p| registry.hit_test_closest(point, &p, 48.0));
215            let dropped = match target {
216                Some(t) => deliver_to(t, point, effect),
217                None => false,
218            };
219            if !dropped {
220                dnd.cancel();
221            }
222            if let Some(h) = &on_drag_end {
223                h.call(dropped);
224            }
225        });
226    };
227
228    rsx! {
229        div {
230            style: style,
231            "data-dragging": if dnd.dragging() && dnd.payload().as_ref() == Some(&attr_payload) { "true" },
232            "data-disabled": if disabled { "true" },
233            onmounted: move |evt: Event<MountedData>| node.set(Some(evt.data())),
234            onpointerdown: move |evt: PointerEvent| {
235                if disabled || !evt.is_primary() {
236                    return;
237                }
238                evt.stop_propagation();
239                if let Some(n) = node.peek().clone() {
240                    platform::capture_pointer(&n, evt.pointer_id());
241                }
242                let o = evt.element_coordinates();
243                press_offset.set(Point::new(o.x, o.y));
244                let _ = step(
245                    GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
246                    threshold,
247                );
248            },
249            onpointermove: move |evt: PointerEvent| {
250                let at = pointer_client(&evt);
251                mods.set(evt.modifiers());
252                let event = if matches!(*phase.peek(), GesturePhase::Dragging { .. })
253                    && evt.held_buttons().is_empty()
254                {
255                    if let Some(n) = node.peek().clone() {
256                        platform::release_pointer(&n, evt.pointer_id());
257                    }
258                    GestureEvent::Up { at, pointer_id: evt.pointer_id() }
259                } else {
260                    GestureEvent::Move { at, pointer_id: evt.pointer_id() }
261                };
262                match step(event, threshold) {
263                    GestureEffect::Begin { at, .. } => {
264                        dnd.start(
265                            pointer_payload.clone(),
266                            zone,
267                            at,
268                            *press_offset.peek(),
269                            effect,
270                            DragMode::Pointer,
271                        );
272                        registry.refresh_rects();
273                        if let Some(h) = &on_drag_start {
274                            h.call(());
275                        }
276                    }
277                    GestureEffect::Track { at } => {
278                        dnd.update_pointer(at);
279                        match registry.hit_test(at) {
280                            Some(z) => dnd.enter(z),
281                            None => {
282                                if let Some(over) = dnd.over() {
283                                    dnd.leave(over);
284                                }
285                            }
286                        }
287                    }
288                    GestureEffect::Drop { at: point } => finish_drop(point),
289                    _ => {}
290                }
291            },
292            onpointerup: move |evt: PointerEvent| {
293                if let Some(n) = node.peek().clone() {
294                    platform::release_pointer(&n, evt.pointer_id());
295                }
296                mods.set(evt.modifiers());
297                let GestureEffect::Drop { at: point } = step(
298                    GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
299                    threshold,
300                ) else {
301                    return;
302                };
303                finish_drop(point);
304            },
305            onpointercancel: move |evt: PointerEvent| {
306                if let Some(n) = node.peek().clone() {
307                    platform::release_pointer(&n, evt.pointer_id());
308                }
309                if step(GestureEvent::Cancel, threshold) == GestureEffect::Abort {
310                    dnd.cancel();
311                    if let Some(h) = &on_drag_end {
312                        h.call(false);
313                    }
314                }
315            },
316            onlostpointercapture: move |_| {
317                if step(GestureEvent::Cancel, threshold) == GestureEffect::Abort {
318                    dnd.cancel();
319                    if let Some(h) = &on_drag_end {
320                        h.call(false);
321                    }
322                }
323            },
324            // --- keyboard interaction ---------------------------------
325            // Space/Enter picks the item up, arrow keys cycle acceptable
326            // zones, Space/Enter drops, Escape cancels. Announcements go
327            // through the context; render `a11y::LiveRegion` to voice them.
328            tabindex: if disabled { -1_i64 } else { 0 },
329            role: "button",
330            aria_roledescription: "draggable",
331            onkeydown: move |evt: KeyboardEvent| {
332                if disabled {
333                    return;
334                }
335                let registry = registry;
336                let key = evt.key();
337                let is_activate = matches!(key, Key::Enter)
338                    || matches!(&key, Key::Character(c) if c == " ");
339                let kb_drag = dnd.dragging() && dnd.mode() == DragMode::Keyboard;
340
341                if !dnd.dragging() && is_activate {
342                    evt.prevent_default();
343                    dnd.start(
344                        kb_payload.clone(),
345                        zone,
346                        Point::default(),
347                        Point::default(),
348                        effect,
349                        DragMode::Keyboard,
350                    );
351                    // Measure zones so arrow-key order can follow visual
352                    // (top-to-bottom, left-to-right) layout.
353                    registry.refresh_rects();
354                    let name = kb_label.clone().unwrap_or_else(|| "item".to_string());
355                    dnd.announce(format!(
356                        "Picked up {name}. Use arrow keys to choose a drop target, Enter to drop, Escape to cancel."
357                    ));
358                    if let Some(h) = &on_drag_start {
359                        h.call(());
360                    }
361                    return;
362                }
363
364                if !kb_drag {
365                    return;
366                }
367
368                // Hierarchical navigation (WAI-ARIA tree convention):
369                // Up/Down cycle siblings at the current level; the arrow
370                // along reading order descends into the hovered zone's
371                // children; the opposite one ascends to its parent (both
372                // mirror under RTL). In flat apps (no nesting) they fall
373                // back to next/previous, preserving the simple behavior.
374                let nav = nav_key(&key, registry.direction());
375                if let (Some(nav), Some(p)) = (nav, dnd.payload()) {
376                    evt.prevent_default();
377                    let over = dnd.over();
378                    let next = match nav {
379                        NavKey::Next => registry.step_sibling(over, &p, 1),
380                        NavKey::Prev => registry.step_sibling(over, &p, -1),
381                        NavKey::Descend => over
382                            .and_then(|z| registry.first_child(z, &p))
383                            .or_else(|| registry.step_sibling(over, &p, 1)),
384                        NavKey::Ascend => over
385                            .and_then(|z| registry.ascend(z))
386                            .or_else(|| registry.step_sibling(over, &p, -1)),
387                    };
388                    if let Some(next) = next {
389                        dnd.enter(next);
390                        let record = registry.get(next);
391                        let name = record
392                            .as_ref()
393                            .and_then(|z| z.label.clone())
394                            .unwrap_or_else(|| format!("zone {}", next.0));
395                        let inside = record
396                            .as_ref()
397                            .and_then(|z| z.parent)
398                            .and_then(|pid| registry.get(pid))
399                            .and_then(|pz| pz.label);
400                        match inside {
401                            Some(parent) => dnd.announce(format!("Over {name}, inside {parent}.")),
402                            None => dnd.announce(format!("Over {name}.")),
403                        }
404                    } else {
405                        dnd.announce("No drop targets available.");
406                    }
407                    return;
408                }
409
410                if is_activate {
411                    evt.prevent_default();
412                    // A custom source can enter() an id from another type's
413                    // registry; falling back keeps Enter from dying silently.
414                    let target = dnd.over().filter(|z| registry.contains(*z)).or_else(|| {
415                        dnd.payload().and_then(|p| registry.step_zone(None, &p, 1))
416                    });
417                    let Some(target) = target else {
418                        dnd.announce("No drop target selected.");
419                        return;
420                    };
421                    if let Some(record) = registry.get(target) {
422                        if let Some((p, from)) = dnd.take() {
423                            let (client, element) = keyboard_drop_points(*record.rect.peek());
424                            record.on_drop.call(DropOutcome {
425                                payload: p,
426                                from,
427                                to: target,
428                                effect,
429                                mode: DragMode::Keyboard,
430                                client,
431                                element,
432                                grab: Point::default(),
433                            });
434                            let name = record
435                                .label
436                                .unwrap_or_else(|| format!("zone {}", target.0));
437                            dnd.announce(format!("Dropped in {name}."));
438                            if let Some(h) = &on_drag_end {
439                                h.call(true);
440                            }
441                        }
442                    }
443                    return;
444                }
445
446                if matches!(key, Key::Escape) {
447                    evt.prevent_default();
448                    dnd.cancel();
449                    dnd.announce("Drag cancelled.");
450                    if let Some(h) = &on_drag_end {
451                        h.call(false);
452                    }
453                }
454            },
455            ..attributes,
456            {children}
457        }
458    }
459}
460
461/// A region that accepts drags carrying `T`.
462///
463/// Handles the HTML5 boilerplate for you: `preventDefault` on dragover,
464/// enter/leave depth counting (so child elements don't cause hover flicker),
465/// and acceptance filtering.
466///
467/// Styling hooks: while an acceptable drag is in flight anywhere, the div
468/// carries `data-active="true"` (reveal your drop targets); while that drag
469/// hovers *this* zone it also carries `data-over="true"` (highlight it).
470/// Both are absent otherwise, so presence-based selectors (CSS
471/// `[data-over]`, Tailwind `data-over:ring-2`) work directly. Driven by the
472/// shared context, so they light up for pointer, touch and keyboard drags
473/// alike.
474#[component]
475pub fn DropZone<T: Clone + PartialEq + 'static>(
476    /// Stable identity for this zone. Auto-generated if omitted.
477    #[props(default)]
478    id: Option<ZoneId>,
479    /// Human label for screen-reader announcements ("Over {label}").
480    #[props(default)]
481    label: Option<String>,
482    /// Return `false` to reject a payload (zone won't highlight or accept it).
483    #[props(default)]
484    accepts: Option<Callback<T, bool>>,
485    /// Fired on a successful drop.
486    on_drop: EventHandler<DropOutcome<T>>,
487    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
488    children: Element,
489) -> Element {
490    let dnd = use_dnd::<T>();
491    let mut registry = use_zone_registry::<T>();
492    let auto_id = use_zone_id();
493    let zone_id = id.unwrap_or(auto_id);
494    // Nesting is automatic: a DropZone inside another discovers its parent
495    // via context, and provides itself to zones deeper down.
496    let parent = try_use_context::<ParentZone>().map(|p| p.0);
497    use_context_provider(|| ParentZone(zone_id));
498    let mounted = use_signal(|| None::<Rc<MountedData>>);
499    let rect = use_signal(|| None::<super::types::Rect>);
500
501    // Register with the zone registry so keyboard navigation and pointer
502    // hit-testing can find this zone. Callbacks are stable handles, so
503    // registering once per mount is enough.
504    use_hook(|| {
505        registry.register(ZoneRecord {
506            id: zone_id,
507            parent,
508            label: label.clone(),
509            on_drop: Callback::new(move |o| on_drop.call(o)),
510            accepts,
511            mounted,
512            rect,
513        });
514    });
515    use_drop(move || {
516        registry.unregister(zone_id);
517    });
518    // Keep the registered label in sync if the prop changes across renders.
519    // Registry readers only `peek`, so this render-time write can't loop.
520    registry.sync_label(zone_id, label.clone());
521
522    let acceptable = move || -> bool {
523        match dnd.payload() {
524            Some(p) => accepts.map(|cb| cb.call(p)).unwrap_or(true),
525            None => false,
526        }
527    };
528
529    rsx! {
530        div {
531            "data-active": if dnd.dragging() && acceptable() { "true" },
532            "data-over": if dnd.over() == Some(zone_id) && acceptable() { "true" },
533            onmounted: move |evt: Event<MountedData>| {
534                let mut mounted = mounted;
535                mounted.set(Some(evt.data()));
536            },
537            ..attributes,
538            {children}
539        }
540    }
541}
542
543/// The functional inline style for a pointer-pinned "ghost": fixed to `pos`
544/// (a viewport-space top-left), out of flow, click-through, above the page.
545/// Kept as a single `fn` so this exact rule has one definition, shared by
546/// every overlay in the crate.
547pub(crate) fn overlay_style(pos: Point) -> String {
548    format!(
549        "position: fixed; left: {}px; top: {}px; pointer-events: none; z-index: 9999;",
550        pos.x, pos.y
551    )
552}
553
554/// Renders its children pinned to the pointer while a drag is in flight -
555/// a custom "ghost" that follows the cursor.
556///
557/// Extra attributes (`class`, …) are forwarded to the wrapper div, so the
558/// ghost styles directly - e.g. Tailwind
559/// `class: "rotate-3 scale-105 shadow-xl"`. A forwarded `style` is merged
560/// after the functional positioning rather than replacing it.
561///
562/// Note: the ghost follows the shared context's pointer position, which
563/// pointer drags update on every move. Keyboard drags carry no pointer, so
564/// during one the ghost sits at the viewport origin - check `dnd.mode()`
565/// and skip rendering it if that matters to you.
566#[component]
567pub fn DragOverlay<T: Clone + PartialEq + 'static>(
568    /// Internal marker; never set this.
569    #[props(default)]
570    phantom: std::marker::PhantomData<T>,
571    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
572    children: Element,
573) -> Element {
574    let _ = phantom;
575    let dnd = use_dnd::<T>();
576    if !dnd.dragging() {
577        return rsx! {};
578    }
579    let mut attributes = attributes;
580    let style = merge_style(&mut attributes, &overlay_style(dnd.pointer() - dnd.grab()));
581    rsx! {
582        div {
583            style: style,
584            ..attributes,
585            {children}
586        }
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    /// Horizontal arrows mirror under RTL: "descend into" is always the
595    /// arrow pointing along reading order. Vertical arrows never mirror.
596    #[test]
597    fn nav_keys_mirror_under_rtl() {
598        for dir in [Direction::Ltr, Direction::Rtl] {
599            assert_eq!(nav_key(&Key::ArrowDown, dir), Some(NavKey::Next));
600            assert_eq!(nav_key(&Key::ArrowUp, dir), Some(NavKey::Prev));
601            assert_eq!(nav_key(&Key::Enter, dir), None);
602        }
603        assert_eq!(nav_key(&Key::ArrowRight, Direction::Ltr), Some(NavKey::Descend));
604        assert_eq!(nav_key(&Key::ArrowLeft, Direction::Ltr), Some(NavKey::Ascend));
605        assert_eq!(nav_key(&Key::ArrowRight, Direction::Rtl), Some(NavKey::Ascend));
606        assert_eq!(nav_key(&Key::ArrowLeft, Direction::Rtl), Some(NavKey::Descend));
607    }
608
609    #[test]
610    fn keyboard_drop_points_use_zone_center_and_element_offset() {
611        let rect = Rect::new(40.0, 80.0, 200.0, 100.0);
612        let (client, element) = keyboard_drop_points(Some(rect));
613
614        assert_eq!(client, Point::new(140.0, 130.0));
615        assert_eq!(element, Point::new(100.0, 50.0));
616    }
617
618    #[test]
619    fn keyboard_drop_points_fall_back_to_origin_without_rect() {
620        let (client, element) = keyboard_drop_points(None);
621
622        assert_eq!(client, Point::default());
623        assert_eq!(element, Point::default());
624    }
625}