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