Skip to main content

dioxus_dnd/
sortable.rs

1//! Reordering items within a single list.
2//!
3//! Self-contained: `SortableList` manages its own drag state (indices only),
4//! so it needs no `DndProvider`. You keep ownership of the data — the
5//! component just tells you *"move index 3 to index 0"* and you apply it
6//! (usually with [`apply_sort`]).
7//!
8//! Touch and pen work instantly in every browser: alongside the native
9//! HTML5 drag path (mouse), each row runs the same pointer-event gesture
10//! machine as [`crate::pointer::PointerDraggable`]. By default the whole
11//! row is the touch target, which sets `touch-action: none` on it — fine
12//! for short lists, but it stops finger-scrolling through the rows. Inside
13//! a scrollable list, set `touch_handle: true` to confine touch drags to a
14//! leading grip (style it via `[data-sort-handle]`) so the rows themselves
15//! still scroll.
16//!
17//! ```rust,ignore
18//! let mut items = use_signal(|| vec!["a".to_string(), "b".into(), "c".into()]);
19//! rsx! {
20//!     SortableList {
21//!         len: items.read().len(),
22//!         on_sort: move |ev: SortEvent| apply_sort(&mut items.write(), ev),
23//!         render: move |ix: usize| rsx! { li { "{items.read()[ix]}" } },
24//!     }
25//! }
26//! ```
27
28use std::collections::HashMap;
29use std::rc::Rc;
30
31use dioxus::html::MountedData;
32use dioxus::prelude::*;
33
34use crate::core::{transition, GestureEffect, GestureEvent, GesturePhase, Point, Rect};
35use crate::pointer::pointer_client;
36
37/// "Move the item at `from` so it ends up at index `to`."
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct SortEvent {
40    pub from: usize,
41    pub to: usize,
42}
43
44/// What a completed reorder gesture means.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum ReorderMode {
47    /// Remove the item and insert it at the target index (list reorder).
48    #[default]
49    Insert,
50    /// Exchange the two items' positions (grid/tile swap).
51    Swap,
52}
53
54/// Apply a [`SortEvent`] as a swap: the two items exchange positions.
55pub fn apply_swap<T>(list: &mut [T], ev: SortEvent) {
56    if ev.from != ev.to && ev.from < list.len() && ev.to < list.len() {
57        list.swap(ev.from, ev.to);
58    }
59}
60
61/// The live-preview offset (CSS px along the list axis) for the row at `ix`
62/// while row `from` is dragged over row `over` — the mid-drag preview
63/// dnd-kit and react-beautiful-dnd made the baseline expectation.
64///
65/// Two moves happen at once: rows between the two indices shift by `step`
66/// (the dragged row's size) to close the source slot, and the **source row
67/// itself translates to the target slot** — without that second part the
68/// shifted neighbors would overlap the source, which still occupies its
69/// slot during a native drag. Assumes uniform row sizes for the source's
70/// travel distance. Pure, for testability.
71pub fn displacement(ix: usize, from: usize, over: usize, step: f64) -> f64 {
72    if ix == from {
73        (over as f64 - from as f64) * step
74    } else if from < over && ix > from && ix <= over {
75        -step
76    } else if over < from && ix >= over && ix < from {
77        step
78    } else {
79        0.0
80    }
81}
82
83/// Which row should be the drop target while a pointer drag from row `from`
84/// hovers at `at`, given per-row rects measured at drag start (so the test
85/// runs against the stable, pre-displacement layout). Mirrors the native
86/// path's midpoint hysteresis: a row is adopted only once the pointer
87/// crosses its center in the travel direction, and while the pointer is
88/// over the source row or outside every rect, the previous target is kept.
89/// Pure, for testability.
90pub fn pointer_target(
91    rects: &HashMap<usize, Rect>,
92    from: usize,
93    current: Option<usize>,
94    at: Point,
95    axis: Axis,
96) -> Option<usize> {
97    let Some((&ix, rect)) = rects.iter().find(|(_, r)| r.contains(at)) else {
98        return current;
99    };
100    if ix == from || Some(ix) == current {
101        return current;
102    }
103    let (pos, size) = match axis {
104        Axis::Vertical => (at.y - rect.y, rect.height),
105        Axis::Horizontal => (at.x - rect.x, rect.width),
106    };
107    let crossed = if from < ix {
108        pos > size * 0.5
109    } else {
110        pos < size * 0.5
111    };
112    if crossed {
113        Some(ix)
114    } else {
115        current
116    }
117}
118
119/// Layout direction of the list — decides whether the midpoint test uses
120/// the Y axis (vertical lists) or the X axis (horizontal ones).
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum Axis {
123    #[default]
124    Vertical,
125    Horizontal,
126}
127
128/// Apply a [`SortEvent`] to a `Vec` in place.
129pub fn apply_sort<T>(list: &mut Vec<T>, ev: SortEvent) {
130    if ev.from == ev.to || ev.from >= list.len() || ev.to >= list.len() {
131        return;
132    }
133    let item = list.remove(ev.from);
134    list.insert(ev.to, item);
135}
136
137/// A list whose items can be dragged to reorder.
138///
139/// The component is data-agnostic: give it a `len` and a `render` callback
140/// keyed by index. It renders one wrapper `div[draggable]` per item and emits
141/// a [`SortEvent`] when the user drops. The item currently hovered as a drop
142/// target gets `data-drop-target="true"` on its wrapper for styling, and the
143/// dragged item gets `data-dragging="true"`.
144#[component]
145pub fn SortableList(
146    /// Number of items.
147    len: usize,
148    /// Renders the item at the given index.
149    render: Callback<usize, Element>,
150    /// Fired when the user drops an item at a new position.
151    on_sort: EventHandler<SortEvent>,
152    /// List direction: which axis rows are laid out (and shifted) along.
153    #[props(default)]
154    axis: Axis,
155    /// Open a live gap where the drop would land, by translating the rows
156    /// in between. Set `false` for the plain highlight-only behavior.
157    #[props(default = true)]
158    live_preview: bool,
159    /// Confine touch/pen drags to a leading grip element instead of the
160    /// whole row. The grip carries `touch-action: none` so the rest of the
161    /// row keeps scrolling by finger — use this inside scrollable lists.
162    /// Style it via `[data-sort-handle]`.
163    #[props(default = false)]
164    touch_handle: bool,
165    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
166) -> Element {
167    let mut drag_from = use_signal(|| None::<usize>);
168    let mut over = use_signal(|| None::<usize>);
169    // Per-row client rects (measured on mount, re-measured at pointer-drag
170    // start) drive both the displacement step and touch hit-testing.
171    let rects = use_signal(HashMap::<usize, Rect>::new);
172    let mounteds = use_signal(HashMap::<usize, Rc<MountedData>>::new);
173    let size_of = move |ix: usize| {
174        rects
175            .peek()
176            .get(&ix)
177            .map(|r| match axis {
178                Axis::Vertical => r.height,
179                Axis::Horizontal => r.width,
180            })
181            .unwrap_or(40.0)
182    };
183
184    // Touch/pen drags run the same formal gesture machine as
185    // `PointerDraggable`; mouse input keeps the native HTML5 path below.
186    let mut gesture = use_signal(|| GesturePhase::Idle);
187    let mut step = move |event: GestureEvent| -> GestureEffect {
188        let (next, fx) = transition(*gesture.peek(), event, 8.0);
189        gesture.set(next);
190        fx
191    };
192    // Feed one pointer event for row `ix` and act on the machine's effect.
193    let mut feed = move |ix: usize, event: GestureEvent| {
194        match step(event) {
195            GestureEffect::Begin { .. } => {
196                drag_from.set(Some(ix));
197                over.set(None);
198                // Client rects go stale when the list scrolls or layout
199                // shifts; re-measure every row at drag start so hit-testing
200                // runs against the current (pre-displacement) slots.
201                for (i, m) in mounteds.peek().clone() {
202                    let mut rects = rects;
203                    spawn(async move {
204                        if let Ok(r) = m.get_client_rect().await {
205                            rects.write().insert(
206                                i,
207                                Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
208                            );
209                        }
210                    });
211                }
212            }
213            GestureEffect::Track { at } => {
214                if let Some(from) = *drag_from.peek() {
215                    let next = pointer_target(&rects.peek(), from, *over.peek(), at, axis);
216                    if next != *over.peek() {
217                        over.set(next);
218                    }
219                }
220            }
221            GestureEffect::Drop { .. } => {
222                if let (Some(from), Some(to)) = (*drag_from.peek(), *over.peek()) {
223                    if from != to {
224                        on_sort.call(SortEvent { from, to });
225                    }
226                }
227                drag_from.set(None);
228                over.set(None);
229            }
230            GestureEffect::Abort => {
231                drag_from.set(None);
232                over.set(None);
233            }
234            GestureEffect::Tap | GestureEffect::None => {}
235        }
236    };
237    let touch_pointer = |evt: &PointerEvent| evt.pointer_type() != "mouse" && evt.is_primary();
238
239    rsx! {
240        div {
241            ..attributes,
242            for ix in 0..len {
243                div {
244                    key: "{ix}",
245                    draggable: true,
246                    "data-dragging": drag_from() == Some(ix),
247                    "data-drop-target": over() == Some(ix) && drag_from() != Some(ix),
248                    style: {
249                        let base = match (live_preview, drag_from(), over()) {
250                            (true, Some(from), Some(o)) => {
251                                let d = displacement(ix, from, o, size_of(from));
252                                let (x, y) = match axis {
253                                    Axis::Vertical => (0.0, d),
254                                    Axis::Horizontal => (d, 0.0),
255                                };
256                                format!("transform: translate({x}px, {y}px); transition: transform 160ms ease;")
257                            }
258                            (true, Some(_), None) => {
259                                "transform: none; transition: transform 160ms ease;".to_string()
260                            }
261                            _ => String::new(),
262                        };
263                        if touch_handle {
264                            format!("display: flex; align-items: stretch; width: 100%; {base}")
265                        } else {
266                            format!("touch-action: none; {base}")
267                        }
268                    },
269                    // Touch/pen path (whole-row mode). With `touch_handle`
270                    // these are inert and the grip below owns the gesture.
271                    onpointerdown: move |evt: PointerEvent| {
272                        if touch_handle || !touch_pointer(&evt) { return; }
273                        feed(ix, GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
274                    },
275                    onpointermove: move |evt: PointerEvent| {
276                        if touch_handle { return; }
277                        feed(ix, GestureEvent::Move { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
278                    },
279                    onpointerup: move |evt: PointerEvent| {
280                        if touch_handle { return; }
281                        feed(ix, GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
282                    },
283                    onpointercancel: move |_| {
284                        if touch_handle { return; }
285                        feed(ix, GestureEvent::Cancel);
286                    },
287                    onlostpointercapture: move |_| {
288                        // Fires benignly after every pointerup (the machine is
289                        // Idle then and ignores it) and protectively when the
290                        // browser rips capture away mid-drag.
291                        if touch_handle { return; }
292                        feed(ix, GestureEvent::Cancel);
293                    },
294                    onmounted: move |evt: Event<MountedData>| {
295                        let m: Rc<MountedData> = evt.data();
296                        let mut mounteds = mounteds;
297                        let mut rects = rects;
298                        mounteds.write().insert(ix, m.clone());
299                        spawn(async move {
300                            if let Ok(r) = m.get_client_rect().await {
301                                rects.write().insert(
302                                    ix,
303                                    Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
304                                );
305                            }
306                        });
307                    },
308                    ondragstart: move |evt: DragEvent| {
309                        // Nested sortables: the innermost list owns the drag.
310                        // The outer list's `drag_from` stays `None`, so its
311                        // dragover/drop guards no-op for this gesture.
312                        evt.stop_propagation();
313                        let _ = evt.data_transfer().set_data("text/plain", "dioxus-dnd-sort");
314                        drag_from.set(Some(ix));
315                    },
316                    ondragover: move |evt: DragEvent| {
317                        let Some(from) = drag_from() else { return };
318                        evt.prevent_default();
319                        if from == ix || over() == Some(ix) {
320                            return;
321                        }
322                        // Midpoint hysteresis: only adopt this row as the
323                        // target once the pointer crosses its center in the
324                        // travel direction — prevents the gap from
325                        // oscillating as displaced rows slide under the
326                        // cursor.
327                        let pos = match axis {
328                            Axis::Vertical => evt.element_coordinates().y,
329                            Axis::Horizontal => evt.element_coordinates().x,
330                        };
331                        let mid = size_of(ix) * 0.5;
332                        let crossed = if from < ix { pos > mid } else { pos < mid };
333                        if crossed {
334                            over.set(Some(ix));
335                        }
336                    },
337                    ondrop: move |evt: DragEvent| {
338                        evt.prevent_default();
339                        evt.stop_propagation();
340                        if let Some(from) = drag_from() {
341                            if from != ix {
342                                on_sort.call(SortEvent { from, to: ix });
343                            }
344                        }
345                        drag_from.set(None);
346                        over.set(None);
347                    },
348                    ondragend: move |_| {
349                        drag_from.set(None);
350                        over.set(None);
351                    },
352                    if touch_handle {
353                        span {
354                            "data-sort-handle": true,
355                            aria_hidden: true,
356                            style: "touch-action: none; cursor: grab; user-select: none; -webkit-user-select: none; flex: 0 0 1.35rem; display: grid; place-items: center;",
357                            onpointerdown: move |evt: PointerEvent| {
358                                if !touch_pointer(&evt) { return; }
359                                feed(ix, GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
360                            },
361                            onpointermove: move |evt: PointerEvent| {
362                                feed(ix, GestureEvent::Move { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
363                            },
364                            onpointerup: move |evt: PointerEvent| {
365                                feed(ix, GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
366                            },
367                            onpointercancel: move |_| feed(ix, GestureEvent::Cancel),
368                            onlostpointercapture: move |_| feed(ix, GestureEvent::Cancel),
369                            "⠿"
370                        }
371                        div {
372                            "data-sort-content": true,
373                            style: "flex: 1 1 auto; min-width: 0;",
374                            {render.call(ix)}
375                        }
376                    } else {
377                        {render.call(ix)}
378                    }
379                }
380            }
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn sort_moves_forward_and_back() {
391        let mut v = vec!["a", "b", "c", "d"];
392        apply_sort(&mut v, SortEvent { from: 0, to: 2 });
393        assert_eq!(v, vec!["b", "c", "a", "d"]);
394        apply_sort(&mut v, SortEvent { from: 3, to: 0 });
395        assert_eq!(v, vec!["d", "b", "c", "a"]);
396    }
397
398    #[test]
399    fn sort_ignores_out_of_bounds_and_noops() {
400        let mut v = vec![1, 2, 3];
401        apply_sort(&mut v, SortEvent { from: 1, to: 1 });
402        apply_sort(&mut v, SortEvent { from: 9, to: 0 });
403        apply_sort(&mut v, SortEvent { from: 0, to: 9 });
404        assert_eq!(v, vec![1, 2, 3]);
405    }
406}
407
408#[cfg(test)]
409mod pointer_target_tests {
410    use super::*;
411
412    /// Three 40px rows stacked at y = 0, 40, 80.
413    fn rows() -> HashMap<usize, Rect> {
414        (0..3)
415            .map(|i| (i, Rect::new(0.0, i as f64 * 40.0, 200.0, 40.0)))
416            .collect()
417    }
418
419    #[test]
420    fn adopts_a_row_only_past_its_midpoint() {
421        let r = rows();
422        // dragging row 0 downward into row 1's top half: not yet crossed
423        let t = pointer_target(&r, 0, None, Point::new(50.0, 45.0), Axis::Vertical);
424        assert_eq!(t, None);
425        // past row 1's midpoint (y = 60): adopted
426        let t = pointer_target(&r, 0, None, Point::new(50.0, 65.0), Axis::Vertical);
427        assert_eq!(t, Some(1));
428        // dragging row 2 upward into row 1's bottom half: not yet crossed
429        let t = pointer_target(&r, 2, None, Point::new(50.0, 75.0), Axis::Vertical);
430        assert_eq!(t, None);
431        let t = pointer_target(&r, 2, None, Point::new(50.0, 55.0), Axis::Vertical);
432        assert_eq!(t, Some(1));
433    }
434
435    #[test]
436    fn keeps_current_over_source_row_and_outside_all_rects() {
437        let r = rows();
438        // hovering the source row keeps the previous target
439        let t = pointer_target(&r, 0, Some(2), Point::new(50.0, 10.0), Axis::Vertical);
440        assert_eq!(t, Some(2));
441        // finger wandered off the list entirely: previous target survives
442        let t = pointer_target(&r, 0, Some(2), Point::new(500.0, 500.0), Axis::Vertical);
443        assert_eq!(t, Some(2));
444    }
445
446    #[test]
447    fn horizontal_axis_uses_x() {
448        let r: HashMap<usize, Rect> = (0..3)
449            .map(|i| (i, Rect::new(i as f64 * 60.0, 0.0, 60.0, 40.0)))
450            .collect();
451        let t = pointer_target(&r, 0, None, Point::new(100.0, 20.0), Axis::Horizontal);
452        assert_eq!(t, Some(1)); // past x = 90 midpoint of tile 1
453    }
454}
455
456#[cfg(test)]
457mod swap_tests {
458    use super::*;
459
460    #[test]
461    fn swap_exchanges_and_guards_bounds() {
462        let mut v = vec![1, 2, 3, 4];
463        apply_swap(&mut v, SortEvent { from: 0, to: 3 });
464        assert_eq!(v, vec![4, 2, 3, 1]);
465        apply_swap(&mut v, SortEvent { from: 9, to: 0 });
466        assert_eq!(v, vec![4, 2, 3, 1]);
467    }
468}
469
470#[cfg(test)]
471mod displacement_tests {
472    use super::*;
473
474    #[test]
475    fn displacement_moves_source_to_target_and_neighbors_aside() {
476        // dragging row 1 down over row 3, rows are 40px:
477        // source travels +2 slots; rows 2..=3 shift up into the freed space
478        let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).collect();
479        assert_eq!(d, vec![0.0, 80.0, -40.0, -40.0, 0.0]);
480        // dragging row 3 up over row 1: source travels -2 slots
481        let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 3, 1, 40.0)).collect();
482        assert_eq!(d, vec![0.0, 40.0, 40.0, -80.0, 0.0]);
483        // hovering the source itself: nothing moves
484        assert!((0..5).all(|ix| displacement(ix, 2, 2, 40.0) == 0.0));
485        // slot occupancy is conserved: offsets sum to zero
486        let sum: f64 = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).sum();
487        assert_eq!(sum, 0.0);
488    }
489}