dioxus-dnd 3.0.1

Modular, accessible drag-and-drop for Dioxus: sortable lists, kanban boards, trees, grids, file drops, multi-select, touch support and more
Documentation
//! 2D tile reorder: [`SortableGrid`] displays a flat `Vec` in `cols`
//! columns and emits the same [`SortEvent`]s as `SortableList`, applied
//! with [`crate::sortable::apply_sort`] (insert-and-reflow) or
//! [`crate::sortable::apply_swap`] (tiles trade places). The full
//! reference for both components lives with the [`crate::sortable`]
//! module docs, docs/api/sortable-lists.md.

use std::collections::HashMap;
use std::rc::Rc;

use dioxus::html::MountedData;
use dioxus::prelude::*;

use crate::a11y::use_reduced_motion_css;
use crate::core::components::merge_style;
use crate::core::hooks::use_rect_refresh_thunk;
use crate::core::{platform, transition, GestureEffect, GestureEvent, GesturePhase, Point, Rect};
use crate::sortable::{list_bounds, refresh_rects, ReorderMode, SortEvent};

fn pointer_client(evt: &PointerEvent) -> Point {
    let c = evt.client_coordinates();
    Point::new(c.x, c.y)
}

/// `(row, col)` of a flat index in a grid with `cols` columns.
pub fn cell_of(index: usize, cols: usize) -> (usize, usize) {
    let cols = cols.max(1);
    (index / cols, index % cols)
}

/// Flat index of `(row, col)` in a grid with `cols` columns, or `None` if
/// outside `len`.
pub fn index_of(row: usize, col: usize, cols: usize, len: usize) -> Option<usize> {
    let cols = cols.max(1);
    if col >= cols {
        return None;
    }
    let ix = row * cols + col;
    (ix < len).then_some(ix)
}

/// A grid of tiles reordered (or swapped) by dragging.
///
/// Renders a `display: grid` wrapper with `cols` equal columns - pass your
/// own `class`/`style` for gaps and sizing. A forwarded `style` is merged
/// *after* the default, so per-property overrides win (e.g.
/// `style: "grid-template-columns: 2fr 1fr 1fr;"` for custom tracks) while
/// `display: grid` stays; spacing needs no override at all (`class:
/// "gap-2"`).
/// The hovered tile gets `data-drop-target="true"`, the dragged one
/// `data-dragging="true"` - both attributes are *absent* otherwise, so
/// presence-based selectors (CSS `[data-dragging]`, Tailwind
/// `data-dragging:opacity-50`) work directly. Use `item_class` to put
/// classes on the tile wrappers.
#[component]
pub fn SortableGrid(
    /// Number of tiles.
    len: usize,
    /// Number of columns.
    cols: usize,
    /// Renders the tile at the given index.
    render: Callback<usize, Element>,
    /// Fired when the user drops a tile on another.
    on_sort: EventHandler<SortEvent>,
    /// Insert-and-reflow (gallery) or swap (dashboard). Default: insert.
    #[props(default)]
    mode: ReorderMode,
    /// Classes for each tile's wrapper div - the element that carries
    /// `data-dragging` / `data-drop-target`.
    #[props(default)]
    item_class: Option<String>,
    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
    // `mode` only affects what the caller does with the SortEvent, but we
    // surface it as a data attribute so styling can differ (e.g. swap
    // targets often highlight the whole tile, insert targets show an edge).
    let mode_str = match mode {
        ReorderMode::Insert => "insert",
        ReorderMode::Swap => "swap",
    };
    let mut drag_from = use_signal(|| None::<usize>);
    let mut over = use_signal(|| None::<usize>);
    let mut press_from = use_signal(|| None::<usize>);
    let mut attributes = attributes;
    let style = merge_style(
        &mut attributes,
        &format!("display: grid; grid-template-columns: repeat({cols}, 1fr);"),
    );

    // Pointer path: per-tile rects measured at drag start, hovered tile =
    // the one containing the pointer.
    let rects = use_signal(HashMap::<usize, Rect>::new);
    let mounteds = use_signal(HashMap::<usize, Rc<MountedData>>::new);
    // Tiles never transform mid-drag, so a scroll ping is a plain
    // re-measure (see the compensated variant in `sortable` for why lists
    // differ).
    use_rect_refresh_thunk(move |_| {
        if drag_from.peek().is_some() {
            refresh_rects(mounteds, rects);
        }
    });
    let mut gesture = use_signal(|| GesturePhase::Idle);
    let mut step = move |event: GestureEvent| -> GestureEffect {
        let (next, fx) = transition(*gesture.peek(), event, 8.0);
        gesture.set(next);
        fx
    };
    let mut feed = move |event: GestureEvent, fallback_ix: Option<usize>| match step(event) {
        GestureEffect::Begin { at, .. } => {
            let Some(ix) = *press_from.peek() else {
                return;
            };
            drag_from.set(Some(ix));
            let next = rects
                .peek()
                .iter()
                .find(|(_, r)| r.contains(at))
                .map(|(&i, _)| i)
                .or(fallback_ix)
                .filter(|&i| i != ix);
            over.set(next);
            refresh_rects(mounteds, rects);
        }
        GestureEffect::Track { at } => {
            let next = rects
                .peek()
                .iter()
                .find(|(_, r)| r.contains(at))
                .map(|(&i, _)| i)
                .or(fallback_ix)
                .filter(|&i| Some(i) != *drag_from.peek())
                .or(*over.peek());
            if next != *over.peek() {
                over.set(next);
            }
        }
        GestureEffect::Drop { at } => {
            // A release outside the grid's tile bounds cancels rather than
            // committing a reorder; inside them, the hovered tile is the
            // target.
            let inside = list_bounds(&rects.peek())
                .map(|b| b.contains(at))
                .unwrap_or(false);
            let pair = (*drag_from.peek(), *over.peek());
            // Clear all drag state BEFORE notifying: `on_sort` mutates the
            // caller's list and re-renders this component, and observing a
            // still-active drag mid-apply is the hazard SortableList documents.
            press_from.set(None);
            drag_from.set(None);
            over.set(None);
            if inside {
                if let (Some(from), Some(to)) = pair {
                    if from != to {
                        on_sort.call(SortEvent { from, to });
                    }
                }
            }
        }
        GestureEffect::Abort => {
            press_from.set(None);
            drag_from.set(None);
            over.set(None);
        }
        GestureEffect::Tap => {
            press_from.set(None);
        }
        GestureEffect::None => {}
    };
    let primary_pointer = move |evt: &PointerEvent| crate::core::components::primary_press(evt);
    // Consecutive empty-held moves seen mid-drag (lost-release debounce).
    let mut empty_held_moves = use_signal(|| 0u8);
    // Did native pointer capture engage for the current press? Decides
    // whether the capture-substitute layer renders (see `Draggable`).
    let mut captured = use_signal(|| false);
    // The grid itself doesn't animate, but its tiles commonly do (FlipItem
    // siblings can't share context with each other) - anchor the
    // reduced-motion stylesheet once for the whole subtree.
    let reduced_motion_css = use_reduced_motion_css();

    rsx! {
        // Outside the grid container: tooling (and tests) often index the
        // container's children as tiles, and <style> is layout-neutral
        // wherever it sits.
        {reduced_motion_css}
        div {
            style: style,
            "data-mode": mode_str,
            onpointermove: move |evt: PointerEvent| {
                let at = pointer_client(&evt);
                // Capture-free recovery (mirrors SortableList): a mouse that
                // returns over the grid with no button held was released off
                // it, so no `pointerup` reached us - finalize the drop instead
                // of tracking a phantom drag that can never end. No-op with the
                // `web` feature (capture delivers the real pointerup).
                // Debounced: move events carry the display server's state
                // mask, which some pipelines corrupt for isolated events
                // (see core::components::RELEASE_RECOVERY_MOVES).
                if drag_from.peek().is_some() && evt.held_buttons().is_empty() {
                    let streak = empty_held_moves.peek().saturating_add(1);
                    empty_held_moves.set(streak);
                    if streak >= crate::core::components::RELEASE_RECOVERY_MOVES {
                        if let Some(from) = *drag_from.peek() {
                            if let Some(n) = mounteds.peek().get(&from).cloned() {
                                platform::release_pointer(&n, evt.pointer_id());
                            }
                        }
                        feed(GestureEvent::Up { at, pointer_id: evt.pointer_id() }, None);
                        return;
                    }
                } else if *empty_held_moves.peek() != 0 {
                    empty_held_moves.set(0);
                }
                feed(GestureEvent::Move { at, pointer_id: evt.pointer_id() }, None);
            },
            onpointerup: move |evt: PointerEvent| {
                if let Some(from) = *drag_from.peek() {
                    if let Some(n) = mounteds.peek().get(&from).cloned() {
                        platform::release_pointer(&n, evt.pointer_id());
                    }
                }
                feed(
                    GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
                    None,
                );
            },
            onpointercancel: move |evt: PointerEvent| {
                if let Some(from) = *drag_from.peek() {
                    if let Some(n) = mounteds.peek().get(&from).cloned() {
                        platform::release_pointer(&n, evt.pointer_id());
                    }
                }
                feed(GestureEvent::Cancel, None);
            },
            onlostpointercapture: move |_| feed(GestureEvent::Cancel, None),
            ..attributes,
            // Capture substitute (see `Draggable` for the full story):
            // keeps moves bubbling to the container while a tile drag is
            // in flight and native capture did not engage.
            if drag_from().is_some() && !captured() {
                div {
                    style: "position: fixed; inset: 0; z-index: 9998; touch-action: none;",
                    aria_hidden: true,
                }
            }
            for ix in 0..len {
                div {
                    key: "{ix}",
                    class: item_class.clone(),
                    style: "touch-action: none;",
                    "data-dragging": if drag_from() == Some(ix) { "true" },
                    "data-drop-target": if over() == Some(ix) && drag_from() != Some(ix) { "true" },
                    onmounted: move |evt: Event<MountedData>| {
                        let m: Rc<MountedData> = evt.data();
                        let mut mounteds = mounteds;
                        let mut rects = rects;
                        mounteds.write().insert(ix, m.clone());
                        spawn(async move {
                            if let Ok(r) = m.get_client_rect().await {
                                rects.write().insert(
                                    ix,
                                    Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
                                );
                            }
                        });
                    },
                    oncontextmenu: move |evt: Event<MouseData>| {
                        // Android's long-press context menu would tear an
                        // in-flight gesture; idle presses keep the menu.
                        if !matches!(*gesture.peek(), GesturePhase::Idle) {
                            evt.prevent_default();
                        }
                    },
                    onpointerdown: move |evt: PointerEvent| {
                        if !primary_pointer(&evt) { return; }
                        // Same suppression as Draggable and the sortable
                        // rows: no press focus, no text-selection start, and
                        // no native drag hijack from an <img>/<a> inside the
                        // tile (this module promises no native drag image).
                        evt.prevent_default();
                        evt.stop_propagation();
                        press_from.set(Some(ix));
                        // Capture on the stable tile so a mouse drag survives
                        // the cursor leaving it (real capture with the `web`
                        // feature; the capture-substitute layer covers the
                        // rest).
                        captured.set(match mounteds.peek().get(&ix).cloned() {
                            Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
                            None => false,
                        });
                        feed(
                            GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
                            None,
                        );
                    },
                    onpointermove: move |evt: PointerEvent| {
                        feed(
                            GestureEvent::Move { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
                            Some(ix),
                        );
                    },
                    {render.call(ix)}
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn grid_coordinates_round_trip() {
        assert_eq!(cell_of(0, 4), (0, 0));
        assert_eq!(cell_of(5, 4), (1, 1));
        assert_eq!(index_of(1, 1, 4, 12), Some(5));
        assert_eq!(index_of(0, 4, 4, 12), None); // col out of range
        assert_eq!(index_of(3, 0, 4, 12), None); // beyond len
        assert_eq!(cell_of(7, 0), (7, 0)); // degenerate cols clamps to 1
    }
}