Skip to main content

dioxus_dnd/
sortable.rs

1#![doc = include_str!("../docs/api/sortable-lists.md")]
2
3use std::collections::{HashMap, HashSet};
4use std::fmt;
5use std::rc::Rc;
6
7use dioxus::html::MountedData;
8use dioxus::prelude::*;
9
10use crate::a11y::use_reduced_motion_css;
11use crate::core::components::{overlay_style, touch_style, HoldTimer};
12use crate::core::hooks::use_rect_refresh_thunk;
13use crate::core::{
14    platform, transition_with, GestureEffect, GestureEvent, GesturePhase, Point, Promotion, Rect,
15    TouchSense,
16};
17
18pub use crate::sortable_kernel::{
19    apply_reorder, project_layout, DropPlacement, ItemTransform, Placement, ReorderEvent,
20    SortStrategy, SortableCollection, SortableGroup, SortableGroupId, SortableHandle, SortableItem,
21    SortablePayload, SortableProvider,
22};
23
24fn pointer_client(evt: &PointerEvent) -> Point {
25    let c = evt.client_coordinates();
26    Point::new(c.x, c.y)
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub(crate) struct RenderKey(String);
31
32impl fmt::Display for RenderKey {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        self.0.fmt(formatter)
35    }
36}
37
38pub(crate) fn build_render_keys(
39    len: usize,
40    item_key: Option<Callback<usize, String>>,
41) -> Vec<RenderKey> {
42    let keys: Vec<_> = (0..len)
43        .map(|index| {
44            RenderKey(
45                item_key
46                    .map(|key| key.call(index))
47                    .unwrap_or_else(|| index.to_string()),
48            )
49        })
50        .collect();
51    if item_key.is_some() {
52        let mut unique = HashSet::with_capacity(keys.len());
53        for key in &keys {
54            assert!(
55                unique.insert(key.clone()),
56                "SortableList/Grid item_key returned the duplicate key `{key}`"
57            );
58        }
59    }
60    keys
61}
62
63pub(crate) fn project_rects(
64    keys: &[RenderKey],
65    rects: &HashMap<RenderKey, Rect>,
66) -> HashMap<usize, Rect> {
67    keys.iter()
68        .enumerate()
69        .filter_map(|(index, key)| rects.get(key).copied().map(|rect| (index, rect)))
70        .collect()
71}
72
73pub(crate) fn current_rects(
74    keys: Signal<Vec<RenderKey>>,
75    rects: Signal<HashMap<RenderKey, Rect>>,
76) -> HashMap<usize, Rect> {
77    project_rects(&keys.peek(), &rects.peek())
78}
79
80pub(crate) fn mounted_at(
81    index: usize,
82    keys: Signal<Vec<RenderKey>>,
83    mounteds: Signal<HashMap<RenderKey, Rc<MountedData>>>,
84) -> Option<Rc<MountedData>> {
85    let key = keys.peek().get(index)?.clone();
86    mounteds.peek().get(&key).cloned()
87}
88
89/// "Move the item at `from` so it ends up at index `to`."
90///
91/// Non-exhaustive so reorder context can be added without a major release;
92/// synthesize your own (reorder buttons, tests) via [`SortEvent::new`].
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct SortEvent {
96    pub from: usize,
97    pub to: usize,
98}
99
100impl SortEvent {
101    /// "Move the item at `from` so it ends up at index `to`."
102    pub fn new(from: usize, to: usize) -> Self {
103        Self { from, to }
104    }
105}
106
107/// What a completed reorder gesture means.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum ReorderMode {
110    /// Remove the item and insert it at the target index (list reorder).
111    #[default]
112    Insert,
113    /// Exchange the two items' positions (grid/tile swap).
114    Swap,
115}
116
117/// Apply a [`SortEvent`] as a swap: the two items exchange positions.
118pub fn apply_swap<T>(list: &mut [T], ev: SortEvent) {
119    if ev.from != ev.to && ev.from < list.len() && ev.to < list.len() {
120        list.swap(ev.from, ev.to);
121    }
122}
123
124/// The live-preview offset (CSS px along the list axis) for the row at `ix`
125/// while row `from` is dragged over row `over` - the mid-drag preview
126/// dnd-kit and react-beautiful-dnd made the baseline expectation.
127///
128/// Two moves happen at once: rows between the two indices shift by `step`
129/// (the dragged row's size) to close the source slot, and the **source row
130/// itself translates to the target slot** - without that second part the
131/// shifted neighbors would overlap the source, which still occupies its
132/// slot during a drag. Assumes uniform row sizes for the source's
133/// travel distance. Pure, for testability.
134pub fn displacement(ix: usize, from: usize, over: usize, step: f64) -> f64 {
135    if ix == from {
136        (over as f64 - from as f64) * step
137    } else if from < over && ix > from && ix <= over {
138        -step
139    } else if over < from && ix >= over && ix < from {
140        step
141    } else {
142        0.0
143    }
144}
145
146/// Distance between consecutive row origins, including CSS margin/gap.
147fn slot_pitch(rects: &HashMap<usize, Rect>, ix: usize, axis: Axis) -> Option<f64> {
148    let pos = |r: &Rect| match axis {
149        Axis::Vertical => r.y,
150        Axis::Horizontal => r.x,
151    };
152    let cur = rects.get(&ix)?;
153    if let Some(next) = rects.get(&(ix + 1)) {
154        return Some(pos(next) - pos(cur));
155    }
156    if let Some(prev) = ix.checked_sub(1).and_then(|p| rects.get(&p)) {
157        return Some(pos(cur) - pos(prev));
158    }
159    Some(match axis {
160        Axis::Vertical => cur.height,
161        Axis::Horizontal => cur.width,
162    })
163}
164
165pub(crate) fn refresh_rects(
166    mounteds: Signal<HashMap<RenderKey, Rc<MountedData>>>,
167    rects: Signal<HashMap<RenderKey, Rect>>,
168    generations: Signal<HashMap<RenderKey, u64>>,
169) {
170    for (key, mounted) in mounteds.peek().clone() {
171        measure_rect(key, mounted, mounteds, rects, generations);
172    }
173}
174
175pub(crate) fn measure_rect(
176    key: RenderKey,
177    mounted: Rc<MountedData>,
178    mounteds: Signal<HashMap<RenderKey, Rc<MountedData>>>,
179    mut rects: Signal<HashMap<RenderKey, Rect>>,
180    mut generations: Signal<HashMap<RenderKey, u64>>,
181) {
182    let generation = {
183        let mut current = generations.write();
184        let next = current.get(&key).copied().unwrap_or(0).wrapping_add(1);
185        current.insert(key.clone(), next);
186        next
187    };
188    spawn(async move {
189        let Ok(measured) = mounted.get_client_rect().await else {
190            return;
191        };
192        let still_current = measurement_is_current(
193            &mounteds.peek(),
194            &generations.peek(),
195            &key,
196            &mounted,
197            generation,
198        );
199        if still_current {
200            rects.write().insert(
201                key,
202                Rect::new(
203                    measured.origin.x,
204                    measured.origin.y,
205                    measured.size.width,
206                    measured.size.height,
207                ),
208            );
209        }
210    });
211}
212
213fn measurement_is_current<K: Eq + std::hash::Hash, T>(
214    mounteds: &HashMap<K, Rc<T>>,
215    generations: &HashMap<K, u64>,
216    key: &K,
217    mounted: &Rc<T>,
218    generation: u64,
219) -> bool {
220    mounteds
221        .get(key)
222        .is_some_and(|current| Rc::ptr_eq(current, mounted))
223        && generations.get(key) == Some(&generation)
224}
225
226/// Shift every cached rect by `(dx, dy)`. Pure, for testability.
227fn shift_rects<K>(rects: &mut HashMap<K, Rect>, dx: f64, dy: f64) {
228    for rect in rects.values_mut() {
229        rect.x += dx;
230        rect.y += dy;
231    }
232}
233
234/// Track scrolling mid-drag by re-anchoring, not re-measuring. Rows carry
235/// live-preview transforms (often mid-transition), so `get_client_rect` on
236/// a row reads a displaced, interpolated box that no subtraction can
237/// reliably invert. The list *wrapper* never transforms, and rows never
238/// move within it during a drag - so one measurement of the wrapper gives
239/// the exact distance everything shifted, and the cached base slots move
240/// with it. A ping from an unrelated scroll surface measures zero movement
241/// and is a no-op.
242///
243/// `busy`/`pending` coalesce overlapping pings: two concurrent shifts
244/// racing the same anchor would double-count, and simply dropping a ping
245/// could leave the *final* scroll position unapplied.
246fn reanchor_rects(
247    container: Signal<Option<Rc<MountedData>>>,
248    anchor: Signal<Option<Point>>,
249    rects: Signal<HashMap<RenderKey, Rect>>,
250    busy: Signal<bool>,
251    pending: Signal<bool>,
252) {
253    let Some(m) = container.peek().clone() else {
254        return;
255    };
256    if *busy.peek() {
257        let mut pending = pending;
258        pending.set(true);
259        return;
260    }
261    let mut busy = busy;
262    busy.set(true);
263    spawn(async move {
264        let mut anchor = anchor;
265        let mut rects = rects;
266        let mut pending = pending;
267        loop {
268            if let Ok(r) = m.get_client_rect().await {
269                let new = Point::new(r.origin.x, r.origin.y);
270                // Read the anchor *after* the await: another task may have
271                // applied a shift while we were measuring.
272                if let Some(old) = *anchor.peek() {
273                    let (dx, dy) = (new.x - old.x, new.y - old.y);
274                    if dx != 0.0 || dy != 0.0 {
275                        shift_rects(&mut rects.write(), dx, dy);
276                    }
277                }
278                anchor.set(Some(new));
279            }
280            if *pending.peek() {
281                pending.set(false);
282            } else {
283                break;
284            }
285        }
286        busy.set(false);
287    });
288}
289
290/// Capture the wrapper's current origin as the shift baseline. Runs
291/// alongside every full row measurement (drag start), so subsequent
292/// [`reanchor_rects`] pings shift from a matching snapshot.
293fn capture_anchor(container: Signal<Option<Rc<MountedData>>>, anchor: Signal<Option<Point>>) {
294    let Some(m) = container.peek().clone() else {
295        return;
296    };
297    let mut anchor = anchor;
298    spawn(async move {
299        if let Ok(r) = m.get_client_rect().await {
300            anchor.set(Some(Point::new(r.origin.x, r.origin.y)));
301        }
302    });
303}
304
305/// Which row should be the drop target while a pointer drag from row `from`
306/// hovers at `at`, given per-row rects measured at drag start (so the test
307/// runs against the stable, pre-displacement layout). A row is adopted only once the pointer
308/// crosses its center in the travel direction, and while the pointer is
309/// over the source row or outside every rect, the previous target is kept.
310/// Pure, for testability.
311pub fn pointer_target(
312    rects: &HashMap<usize, Rect>,
313    from: usize,
314    current: Option<usize>,
315    at: Point,
316    axis: Axis,
317) -> Option<usize> {
318    let Some((&ix, rect)) = rects.iter().find(|(_, r)| r.contains(at)) else {
319        return current;
320    };
321    if ix == from || Some(ix) == current {
322        return current;
323    }
324    let (pos, size) = match axis {
325        Axis::Vertical => (at.y - rect.y, rect.height),
326        Axis::Horizontal => (at.x - rect.x, rect.width),
327    };
328    let crossed = if from < ix {
329        pos > size * 0.5
330    } else {
331        pos < size * 0.5
332    };
333    if crossed {
334        Some(ix)
335    } else {
336        current
337    }
338}
339
340/// The bounding box of all measured rows - the list's occupied area. Used to
341/// decide whether a pointer release landed on the list at all: a drop outside
342/// this box commits no reorder. `None` when no rows are measured yet.
343pub(crate) fn list_bounds(rects: &HashMap<usize, Rect>) -> Option<Rect> {
344    let mut it = rects.values();
345    let first = it.next()?;
346    let (mut min_x, mut min_y) = (first.x, first.y);
347    let (mut max_x, mut max_y) = (first.x + first.width, first.y + first.height);
348    for r in it {
349        min_x = min_x.min(r.x);
350        min_y = min_y.min(r.y);
351        max_x = max_x.max(r.x + r.width);
352        max_y = max_y.max(r.y + r.height);
353    }
354    Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
355}
356
357/// Layout direction of the list - decides whether the midpoint test uses
358/// the Y axis (vertical lists) or the X axis (horizontal ones).
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
360pub enum Axis {
361    #[default]
362    Vertical,
363    Horizontal,
364}
365
366/// Apply a [`SortEvent`] to a `Vec` in place.
367pub fn apply_sort<T>(list: &mut Vec<T>, ev: SortEvent) {
368    if ev.from == ev.to || ev.from >= list.len() || ev.to >= list.len() {
369        return;
370    }
371    let item = list.remove(ev.from);
372    list.insert(ev.to, item);
373}
374
375/// A list whose items can be dragged to reorder.
376///
377/// Data-agnostic: give it a `len` and a `render` callback keyed by index. It
378/// renders one wrapper per item and emits a [`SortEvent`] on drop. The hovered
379/// drop target gets `data-drop-target` on its wrapper and the dragged item gets
380/// `data-dragging`; both are absent while inactive.
381///
382/// Headless by default - the component ships behavior, you compose the looks.
383/// With `overlay` set, the picked-up row is hidden in place and *your* overlay
384/// element floats at the pointer (the dnd-kit feel); keep it lightweight, it is
385/// your content, not a clone of the row. Without it, the row stays visible and
386/// slides.
387#[component]
388pub fn SortableList(
389    /// Number of items.
390    len: usize,
391    /// Renders the item at the given index.
392    render: Callback<usize, Element>,
393    /// Fired when the user drops an item at a new position.
394    on_sort: EventHandler<SortEvent>,
395    /// List direction: which axis rows are laid out (and shifted) along.
396    #[props(default)]
397    axis: Axis,
398    /// Open a live gap where the drop would land, by translating the rows
399    /// in between. Set `false` for the plain highlight-only behavior.
400    #[props(default = true)]
401    live_preview: bool,
402    /// Duration (ms) of the row-slide transition during live preview.
403    #[props(default = 160)]
404    transition_ms: u32,
405    /// Opt-in floating ghost: renders `overlay(index)` pinned to the pointer
406    /// while dragging, and hides the picked-up row in place so its slot reads
407    /// as the drop gap. Absent → the row itself slides. Keep the ghost
408    /// lightweight; it is your content, not a clone of the row.
409    #[props(default)]
410    overlay: Option<Callback<usize, Element>>,
411    /// Confine touch/pen drags to a leading grip element instead of the
412    /// whole row. The grip carries `touch-action: none` so the rest of the
413    /// row keeps scrolling by finger - use this inside scrollable lists.
414    /// Style it via `[data-sort-handle]`.
415    #[props(default = false)]
416    touch_handle: bool,
417    /// How a finger shares whole rows with native scrolling (ignored under
418    /// `touch_handle`, where the grip owns every touch).
419    /// [`TouchSense::Auto`] (default) keeps vertical swipes scrolling and
420    /// picks a row up on a short hold or a sideways pull;
421    /// [`TouchSense::Immediate`] makes any 8px travel drag.
422    #[props(default)]
423    touch: TouchSense,
424    /// Content for the `touch_handle` grip, keyed by index. Defaults to a
425    /// braille-dots glyph when unset.
426    #[props(default)]
427    handle: Option<Callback<usize, Element>>,
428    /// Stable render identity for each item. Supply this whenever rows own
429    /// hook state or focus and the backing collection can reorder.
430    #[props(default)]
431    item_key: Option<Callback<usize, String>>,
432    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
433) -> Element {
434    let render_keys = build_render_keys(len, item_key);
435    let initial_render_keys = render_keys.clone();
436    let mut index_keys = use_signal(move || initial_render_keys);
437    let mut drag_from = use_signal(|| None::<usize>);
438    let mut over = use_signal(|| None::<usize>);
439    let mut press_from = use_signal(|| None::<usize>);
440    let mut press_at = use_signal(|| None::<Point>);
441    let mut pointer_at = use_signal(|| None::<Point>);
442    // Per-row client rects (measured on mount, re-measured at pointer-drag
443    // start) drive both the displacement step and hit-testing.
444    let rects = use_signal(HashMap::<RenderKey, Rect>::new);
445    let mounteds = use_signal(HashMap::<RenderKey, Rc<MountedData>>::new);
446    let generations = use_signal(HashMap::<RenderKey, u64>::new);
447    let mut rects_for_keys = rects;
448    let mut mounteds_for_keys = mounteds;
449    let mut generations_for_keys = generations;
450    use_effect(use_reactive!(|(render_keys)| {
451        let active: HashSet<_> = render_keys.iter().cloned().collect();
452        index_keys.set(render_keys);
453        rects_for_keys.write().retain(|key, _| active.contains(key));
454        mounteds_for_keys
455            .write()
456            .retain(|key, _| active.contains(key));
457        generations_for_keys
458            .write()
459            .retain(|key, _| active.contains(key));
460    }));
461    let size_of = move |ix: usize| {
462        current_rects(index_keys, rects)
463            .get(&ix)
464            .map(|r| match axis {
465                Axis::Vertical => r.height,
466                Axis::Horizontal => r.width,
467            })
468            .unwrap_or(40.0)
469    };
470
471    // Mid-drag scrolls (an AutoScroll above, or anything pinging the tree's
472    // rect-refresh channel) move the rows under the pointer. Re-anchor the
473    // cached slots against the wrapper's movement - see `reanchor_rects`
474    // for why this beats re-measuring the (transformed) rows.
475    let container = use_signal(|| None::<Rc<MountedData>>);
476    let anchor = use_signal(|| None::<Point>);
477    let reanchor_busy = use_signal(|| false);
478    let reanchor_pending = use_signal(|| false);
479    use_rect_refresh_thunk(move |_| {
480        if drag_from.peek().is_some() {
481            reanchor_rects(container, anchor, rects, reanchor_busy, reanchor_pending);
482        }
483    });
484
485    // Drags run the same formal gesture machine as `Draggable`. `over` (the
486    // drop target) is resolved synchronously on every tracked move - no
487    // derived effect - so the gap never lags the pointer.
488    let mut gesture = use_signal(|| GesturePhase::Idle);
489    // Some(pid) while a whole-row touch press under `Auto` waits on its hold
490    // timer; doubles as the timer element's render condition.
491    let mut hold_pid = use_signal(|| None::<i32>);
492    let mut step = move |event: GestureEvent| -> GestureEffect {
493        let promotion = if hold_pid.peek().is_some() {
494            Promotion::HoldOrSideways
495        } else {
496            Promotion::Distance
497        };
498        let (next, fx) = transition_with(*gesture.peek(), event, 8.0, promotion);
499        gesture.set(next);
500        // Any exit from Pressed retires the pending hold - the drag began,
501        // the press tapped out, or a vertical pull yielded to the scroll.
502        if hold_pid.peek().is_some() && !matches!(next, GesturePhase::Pressed { .. }) {
503            hold_pid.set(None);
504        }
505        fx
506    };
507    // Feed one pointer event and act on the machine's effect. The source row is
508    // latched on pointerdown because mouse move/up events bubble through the
509    // list container rather than staying on the pressed row.
510    let mut feed = move |event: GestureEvent| {
511        match step(event) {
512            GestureEffect::Begin { at, .. } => {
513                let Some(ix) = *press_from.peek() else {
514                    return;
515                };
516                drag_from.set(Some(ix));
517                pointer_at.set(Some(at));
518                over.set(pointer_target(
519                    &current_rects(index_keys, rects),
520                    ix,
521                    None,
522                    at,
523                    axis,
524                ));
525                // Client rects go stale when the list scrolls or layout shifts;
526                // re-measure every row at drag start so hit-testing runs against
527                // the current (pre-displacement) slots, and re-baseline the
528                // wrapper anchor the scroll-tracking shifts run from.
529                refresh_rects(mounteds, rects, generations);
530                capture_anchor(container, anchor);
531            }
532            GestureEffect::Track { at } => {
533                let Some(from) = *drag_from.peek() else {
534                    return;
535                };
536                pointer_at.set(Some(at));
537                let next = pointer_target(
538                    &current_rects(index_keys, rects),
539                    from,
540                    *over.peek(),
541                    at,
542                    axis,
543                );
544                if next != *over.peek() {
545                    over.set(next);
546                }
547            }
548            GestureEffect::Drop { at } => {
549                let from_opt = *drag_from.peek();
550                // A release outside the list's bounds cancels rather than
551                // committing a reorder - dropping a row "nowhere" shouldn't
552                // move it. Inside the bounds, snap to the hovered target.
553                let to = {
554                    let rects_ref = current_rects(index_keys, rects);
555                    if list_bounds(&rects_ref)
556                        .map(|b| b.contains(at))
557                        .unwrap_or(false)
558                    {
559                        from_opt.and_then(|from| {
560                            pointer_target(&rects_ref, from, *over.peek(), at, axis)
561                        })
562                    } else {
563                        None
564                    }
565                };
566                // Clear ALL drag state BEFORE notifying: `on_sort` mutates the
567                // caller's list, which re-renders this component; observing a
568                // still-active drag would re-apply the preview to the already-
569                // reordered rows.
570                press_from.set(None);
571                press_at.set(None);
572                drag_from.set(None);
573                over.set(None);
574                pointer_at.set(None);
575                if let (Some(from), Some(to)) = (from_opt, to) {
576                    if from != to {
577                        on_sort.call(SortEvent { from, to });
578                    }
579                }
580            }
581            GestureEffect::Abort => {
582                press_from.set(None);
583                press_at.set(None);
584                drag_from.set(None);
585                over.set(None);
586                pointer_at.set(None);
587            }
588            GestureEffect::Tap => {
589                press_from.set(None);
590                press_at.set(None);
591                pointer_at.set(None);
592            }
593            GestureEffect::None => {}
594        }
595    };
596
597    // Rows glide via inline transitions; honor prefers-reduced-motion.
598    let reduced_motion_css = use_reduced_motion_css();
599
600    let primary_pointer = move |evt: &PointerEvent| crate::core::components::primary_press(evt);
601    // Consecutive empty-held moves seen mid-drag (lost-release debounce).
602    let mut empty_held_moves = use_signal(|| 0u8);
603    // Did native pointer capture engage for the current press? Decides
604    // whether the capture-substitute layer renders (see `Draggable`).
605    let mut captured = use_signal(|| false);
606    let mut cancel_drag = move || {
607        feed(GestureEvent::Cancel);
608        press_from.set(None);
609        press_at.set(None);
610        drag_from.set(None);
611        over.set(None);
612        pointer_at.set(None);
613    };
614
615    // Opt-in floating ghost (caller-composed). Only computed when we have a
616    // measured source rect and both pointer positions, so the in-flow original
617    // is hidden *only* when a replacement is guaranteed to render - no rect, no
618    // ghost, no disappearing row (it just slides). Carries the callback so the
619    // render below needs nothing else.
620    let overlay_ghost: Option<(Callback<usize, Element>, usize, Point, Rect)> =
621        overlay.zip(drag_from()).and_then(|(cb, from)| {
622            let r = current_rects(index_keys, rects).get(&from).copied()?;
623            let p0 = press_at()?;
624            let p1 = pointer_at()?;
625            Some((
626                cb,
627                from,
628                Point::new(r.x + (p1.x - p0.x), r.y + (p1.y - p0.y)),
629                r,
630            ))
631        });
632    let ghost_from = overlay_ghost.map(|(_, f, _, _)| f);
633    let mut attributes = attributes;
634    crate::core::components::protect_attributes(
635        &mut attributes,
636        &[
637            "onmounted",
638            "onpointermove",
639            "onpointerup",
640            "onpointercancel",
641            "onlostpointercapture",
642            "ontouchmove",
643            "oncontextmenu",
644        ],
645    );
646
647    rsx! {
648        div {
649            onmounted: move |evt: Event<MountedData>| {
650                let mut container = container;
651                container.set(Some(evt.data()));
652            },
653            onpointermove: move |evt: PointerEvent| {
654                let at = pointer_client(&evt);
655                // Recovery for a mouse released while the cursor sat outside the
656                // list. With the `web` feature, `platform::capture_pointer`
657                // routes the release back here; without it (feature off, or a
658                // non-web renderer) no `pointerup` arrives, so when the pointer
659                // returns over the list with no button held we finish the drop
660                // rather than track a phantom drag. Touch/pen hold a button
661                // throughout contact, so this only trips for a released mouse.
662                // Debounced: move events carry the display server's state
663                // mask, which some pipelines corrupt for isolated events
664                // (see core::components::RELEASE_RECOVERY_MOVES).
665                if drag_from.peek().is_some() && evt.held_buttons().is_empty() {
666                    let streak = empty_held_moves.peek().saturating_add(1);
667                    empty_held_moves.set(streak);
668                    if streak >= crate::core::components::RELEASE_RECOVERY_MOVES {
669                        if let Some(from) = *drag_from.peek() {
670                            if let Some(n) = mounted_at(from, index_keys, mounteds) {
671                                platform::release_pointer(&n, evt.pointer_id());
672                            }
673                        }
674                        feed(GestureEvent::Up { at, pointer_id: evt.pointer_id() });
675                        return;
676                    }
677                } else if *empty_held_moves.peek() != 0 {
678                    empty_held_moves.set(0);
679                }
680                feed(GestureEvent::Move { at, pointer_id: evt.pointer_id() });
681            },
682            onpointerup: move |evt: PointerEvent| {
683                if let Some(from) = *drag_from.peek() {
684                    if let Some(n) = mounted_at(from, index_keys, mounteds) {
685                        platform::release_pointer(&n, evt.pointer_id());
686                    }
687                }
688                feed(GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
689            },
690            // Genuine interruptions (touch cancelled, browser stole capture)
691            // abort the drag. Merely leaving the list does NOT: without pointer
692            // capture, cancelling on `pointerleave` would kill every drag that
693            // strays a pixel past an edge.
694            onpointercancel: move |evt: PointerEvent| {
695                if let Some(from) = *drag_from.peek() {
696                    if let Some(n) = mounted_at(from, index_keys, mounteds) {
697                        platform::release_pointer(&n, evt.pointer_id());
698                    }
699                }
700                cancel_drag();
701            },
702            onlostpointercapture: move |_| cancel_drag(),
703            // A promoted drag owns the touch: cancel its moves (they bubble
704            // from the row) so the browser can't start a pan mid-drag -
705            // `touch-action` is only consulted at gesture start, so the
706            // rows' `pan-y` alone can't.
707            ontouchmove: move |evt: TouchEvent| {
708                if matches!(*gesture.peek(), GesturePhase::Dragging { .. }) {
709                    evt.prevent_default();
710                }
711            },
712            // Android pops a context menu on touch long-press; mid-gesture
713            // that would tear the hold or the drag. Idle presses keep it.
714            oncontextmenu: move |evt: Event<MouseData>| {
715                if !matches!(*gesture.peek(), GesturePhase::Idle) {
716                    evt.prevent_default();
717                }
718            },
719            ..attributes,
720            {reduced_motion_css}
721            // Capture substitute (see `Draggable` for the full story):
722            // without native capture, moves die the moment the cursor
723            // leaves the list; this full-viewport child keeps them
724            // bubbling to the container handlers while a row drag is in
725            // flight. Never rendered where real capture engaged.
726            if drag_from().is_some() && !captured() {
727                div {
728                    style: "position: fixed; inset: 0; z-index: 9998; touch-action: none;",
729                    aria_hidden: true,
730                }
731            }
732            // Armed only while a whole-row touch press waits under `Auto`;
733            // the alarm promotes exactly like a threshold crossing.
734            if let Some(pid) = hold_pid() {
735                HoldTimer {
736                    pointer_id: pid,
737                    on_hold: move |pid| feed(GestureEvent::Hold { pointer_id: pid }),
738                }
739            }
740            for (ix, render_key) in (0..len).zip(render_keys) {
741                div {
742                    key: "{render_key}",
743                    "data-dnd-motion": true,
744                    "data-dragging": if drag_from() == Some(ix) { "true" },
745                    "data-drop-target": if over() == Some(ix) && drag_from() != Some(ix) { "true" },
746                    style: {
747                        // Live preview: rows slide so every slot stays filled by
748                        // exactly one box. When `overlay` is set, the picked-up
749                        // row is drawn by the floating ghost, so its in-flow
750                        // original is hidden (opacity 0) while still translating
751                        // to the target slot - that invisible slot is the gap the
752                        // neighbours part around. Without `overlay` the row stays
753                        // visible and slides.
754                        let base = match (live_preview, drag_from()) {
755                            (true, Some(from)) => {
756                                let step = slot_pitch(
757                                    &current_rects(index_keys, rects),
758                                    from,
759                                    axis,
760                                )
761                                    .unwrap_or_else(|| size_of(from));
762                                let o = over().unwrap_or(from);
763                                let d = displacement(ix, from, o, step);
764                                let (x, y) = match axis {
765                                    Axis::Vertical => (0.0, d),
766                                    Axis::Horizontal => (d, 0.0),
767                                };
768                                let hidden = if ghost_from == Some(ix) {
769                                    " opacity: 0;"
770                                } else {
771                                    ""
772                                };
773                                format!("transform: translate({x}px, {y}px); transition: transform {transition_ms}ms ease;{hidden}")
774                            }
775                            _ => format!(
776                                "transform: translate(0px, 0px); transition: transform {transition_ms}ms; opacity: 1;"
777                            ),
778                        };
779                        if touch_handle {
780                            format!("display: flex; align-items: stretch; width: 100%; {base}")
781                        } else {
782                            format!("{} {base}", touch_style(touch))
783                        }
784                    },
785                    // Pointer path (whole-row mode). With `touch_handle` these
786                    // are inert and the grip below owns the gesture.
787                    onpointerdown: move |evt: PointerEvent| {
788                        if touch_handle || !primary_pointer(&evt) {
789                            return;
790                        }
791                        evt.prevent_default();
792                        evt.stop_propagation();
793                        refresh_rects(mounteds, rects, generations);
794                        capture_anchor(container, anchor);
795                        press_from.set(Some(ix));
796                        press_at.set(Some(pointer_client(&evt)));
797                        // Capture on the stable row wrapper so a mouse drag
798                        // survives the cursor leaving the list (real capture with
799                        // the `web` feature; the capture-substitute layer covers
800                        // the rest). Move/up still bubble to the container.
801                        captured.set(match mounted_at(ix, index_keys, mounteds) {
802                            Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
803                            None => false,
804                        });
805                        let pid = evt.pointer_id();
806                        feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: pid });
807                        // Arm the long-press clock: fingers (and pens) under
808                        // `Auto` promote on hold-or-sideways; mice on travel.
809                        if touch == TouchSense::Auto
810                            && evt.pointer_type() != "mouse"
811                            && matches!(*gesture.peek(), GesturePhase::Pressed { pointer_id, .. } if pointer_id == pid)
812                        {
813                            hold_pid.set(Some(pid));
814                        }
815                    },
816                    onmounted: move |evt: Event<MountedData>| {
817                        let m: Rc<MountedData> = evt.data();
818                        let mut mounteds = mounteds;
819                        mounteds.write().insert(render_key.clone(), m.clone());
820                        measure_rect(render_key.clone(), m, mounteds, rects, generations);
821                    },
822                    if touch_handle {
823                        span {
824                            "data-sort-handle": true,
825                            aria_hidden: true,
826                            style: "touch-action: none; user-select: none; -webkit-user-select: none; display: grid; place-items: center;",
827                            onpointerdown: move |evt: PointerEvent| {
828                                if !primary_pointer(&evt) {
829                                    return;
830                                }
831                                evt.prevent_default();
832                                evt.stop_propagation();
833                                refresh_rects(mounteds, rects, generations);
834                                capture_anchor(container, anchor);
835                                press_from.set(Some(ix));
836                                press_at.set(Some(pointer_client(&evt)));
837                                // Capture on the row wrapper (not the grip): it is
838                                // stable across live-preview re-renders, and
839                                // captured events still bubble to the container.
840                                captured.set(match mounted_at(ix, index_keys, mounteds) {
841                                    Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
842                                    None => false,
843                                });
844                                feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
845                            },
846                            if let Some(h) = handle {
847                                {h.call(ix)}
848                            } else {
849                                "⠿"
850                            }
851                        }
852                        div {
853                            "data-sort-content": true,
854                            style: "flex: 1 1 auto; min-width: 0;",
855                            {render.call(ix)}
856                        }
857                    } else {
858                        {render.call(ix)}
859                    }
860                }
861            }
862            if let Some((cb, from, pos, rect)) = overlay_ghost {
863                div {
864                    style: format!(
865                        "{} width: {}px; height: {}px;",
866                        overlay_style(pos),
867                        rect.width,
868                        rect.height
869                    ),
870                    {cb.call(from)}
871                }
872            }
873        }
874    }
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    #[test]
882    fn sort_moves_forward_and_back() {
883        let mut v = vec!["a", "b", "c", "d"];
884        apply_sort(&mut v, SortEvent { from: 0, to: 2 });
885        assert_eq!(v, vec!["b", "c", "a", "d"]);
886        apply_sort(&mut v, SortEvent { from: 3, to: 0 });
887        assert_eq!(v, vec!["d", "b", "c", "a"]);
888    }
889
890    #[test]
891    fn sort_ignores_out_of_bounds_and_noops() {
892        let mut v = vec![1, 2, 3];
893        apply_sort(&mut v, SortEvent { from: 1, to: 1 });
894        apply_sort(&mut v, SortEvent { from: 9, to: 0 });
895        apply_sort(&mut v, SortEvent { from: 0, to: 9 });
896        assert_eq!(v, vec![1, 2, 3]);
897    }
898
899    #[test]
900    fn async_measurements_require_the_current_node_and_generation() {
901        let old = Rc::new(());
902        let replacement = Rc::new(());
903        let mut mounteds = HashMap::from([(2, old.clone())]);
904        let mut generations = HashMap::from([(2, 4)]);
905
906        assert!(measurement_is_current(&mounteds, &generations, &2, &old, 4));
907        assert!(!measurement_is_current(
908            &mounteds,
909            &generations,
910            &2,
911            &old,
912            3
913        ));
914
915        mounteds.insert(2, replacement);
916        generations.insert(2, 5);
917        assert!(!measurement_is_current(
918            &mounteds,
919            &generations,
920            &2,
921            &old,
922            4
923        ));
924
925        mounteds.remove(&2);
926        generations.remove(&2);
927        assert!(!measurement_is_current(
928            &mounteds,
929            &generations,
930            &2,
931            &old,
932            4
933        ));
934    }
935
936    #[test]
937    fn stable_keys_project_mounted_geometry_to_current_indices() {
938        let a = RenderKey("a".to_string());
939        let b = RenderKey("b".to_string());
940        let rects = HashMap::from([
941            (a.clone(), Rect::new(0.0, 0.0, 100.0, 20.0)),
942            (b.clone(), Rect::new(0.0, 20.0, 100.0, 20.0)),
943        ]);
944
945        let projected = project_rects(&[b, a], &rects);
946        assert_eq!(projected[&0], Rect::new(0.0, 20.0, 100.0, 20.0));
947        assert_eq!(projected[&1], Rect::new(0.0, 0.0, 100.0, 20.0));
948    }
949}
950
951#[cfg(test)]
952mod pointer_target_tests {
953    use super::*;
954
955    /// Three 40px rows stacked at y = 0, 40, 80.
956    fn rows() -> HashMap<usize, Rect> {
957        (0..3)
958            .map(|i| (i, Rect::new(0.0, i as f64 * 40.0, 200.0, 40.0)))
959            .collect()
960    }
961
962    #[test]
963    fn adopts_a_row_only_past_its_midpoint() {
964        let r = rows();
965        // dragging row 0 downward into row 1's top half: not yet crossed
966        let t = pointer_target(&r, 0, None, Point::new(50.0, 45.0), Axis::Vertical);
967        assert_eq!(t, None);
968        // past row 1's midpoint (y = 60): adopted
969        let t = pointer_target(&r, 0, None, Point::new(50.0, 65.0), Axis::Vertical);
970        assert_eq!(t, Some(1));
971        // dragging row 2 upward into row 1's bottom half: not yet crossed
972        let t = pointer_target(&r, 2, None, Point::new(50.0, 75.0), Axis::Vertical);
973        assert_eq!(t, None);
974        let t = pointer_target(&r, 2, None, Point::new(50.0, 55.0), Axis::Vertical);
975        assert_eq!(t, Some(1));
976    }
977
978    #[test]
979    fn keeps_current_over_source_row_and_outside_all_rects() {
980        let r = rows();
981        // hovering the source row keeps the previous target
982        let t = pointer_target(&r, 0, Some(2), Point::new(50.0, 10.0), Axis::Vertical);
983        assert_eq!(t, Some(2));
984        // finger wandered off the list entirely: previous target survives
985        let t = pointer_target(&r, 0, Some(2), Point::new(500.0, 500.0), Axis::Vertical);
986        assert_eq!(t, Some(2));
987    }
988
989    #[test]
990    fn horizontal_axis_uses_x() {
991        let r: HashMap<usize, Rect> = (0..3)
992            .map(|i| (i, Rect::new(i as f64 * 60.0, 0.0, 60.0, 40.0)))
993            .collect();
994        let t = pointer_target(&r, 0, None, Point::new(100.0, 20.0), Axis::Horizontal);
995        assert_eq!(t, Some(1)); // past x = 90 midpoint of tile 1
996    }
997
998    #[test]
999    fn list_bounds_covers_all_rows_and_excludes_outside() {
1000        let r = rows(); // three 40px rows spanning y 0..120, x 0..200
1001        let b = list_bounds(&r).unwrap();
1002        assert_eq!(b, Rect::new(0.0, 0.0, 200.0, 120.0));
1003        // a release inside the span (even in a gap) is on the list
1004        assert!(b.contains(Point::new(50.0, 60.0)));
1005        // a release well outside is not - the Drop arm cancels there
1006        assert!(!b.contains(Point::new(500.0, 500.0)));
1007        assert!(!b.contains(Point::new(50.0, 130.0)));
1008        // no measured rows: no bounds
1009        assert_eq!(list_bounds(&HashMap::new()), None);
1010    }
1011}
1012
1013#[cfg(test)]
1014mod swap_tests {
1015    use super::*;
1016
1017    #[test]
1018    fn swap_exchanges_and_guards_bounds() {
1019        let mut v = vec![1, 2, 3, 4];
1020        apply_swap(&mut v, SortEvent { from: 0, to: 3 });
1021        assert_eq!(v, vec![4, 2, 3, 1]);
1022        apply_swap(&mut v, SortEvent { from: 9, to: 0 });
1023        assert_eq!(v, vec![4, 2, 3, 1]);
1024    }
1025}
1026
1027#[cfg(test)]
1028mod shift_rects_tests {
1029    use super::*;
1030
1031    /// Scroll tracking moves every cached base slot by exactly the
1032    /// wrapper's movement, preserving sizes and relative spacing - which is
1033    /// all `pointer_target`'s model needs to stay correct mid-scroll.
1034    #[test]
1035    fn shift_moves_all_slots_uniformly() {
1036        let mut rects: HashMap<usize, Rect> = (0..3)
1037            .map(|i| (i, Rect::new(10.0, i as f64 * 40.0, 200.0, 40.0)))
1038            .collect();
1039        // Container scrolled down 130px: content moved up by 130.
1040        shift_rects(&mut rects, 0.0, -130.0);
1041        for i in 0..3 {
1042            assert_eq!(
1043                rects[&i],
1044                Rect::new(10.0, i as f64 * 40.0 - 130.0, 200.0, 40.0)
1045            );
1046        }
1047        // Pitch is preserved exactly.
1048        assert_eq!(slot_pitch(&rects, 1, Axis::Vertical), Some(40.0));
1049    }
1050}
1051
1052#[cfg(test)]
1053mod slot_pitch_tests {
1054    use super::*;
1055
1056    #[test]
1057    fn pitch_includes_spacing_between_rows() {
1058        let rows: HashMap<usize, Rect> = (0..3)
1059            .map(|i| (i, Rect::new(0.0, i as f64 * 46.0, 200.0, 42.0)))
1060            .collect();
1061
1062        assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(46.0));
1063        assert_eq!(slot_pitch(&rows, 1, Axis::Vertical), Some(46.0));
1064        assert_eq!(slot_pitch(&rows, 2, Axis::Vertical), Some(46.0));
1065    }
1066
1067    #[test]
1068    fn pitch_falls_back_to_size_for_single_row() {
1069        let rows: HashMap<usize, Rect> = [(0, Rect::new(0.0, 0.0, 200.0, 42.0))]
1070            .into_iter()
1071            .collect();
1072
1073        assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(42.0));
1074        assert_eq!(slot_pitch(&rows, 9, Axis::Vertical), None);
1075    }
1076}
1077
1078#[cfg(test)]
1079mod displacement_tests {
1080    use super::*;
1081
1082    #[test]
1083    fn displacement_moves_source_to_target_and_neighbors_aside() {
1084        // dragging row 1 down over row 3, rows are 40px:
1085        // source travels +2 slots; rows 2..=3 shift up into the freed space
1086        let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).collect();
1087        assert_eq!(d, vec![0.0, 80.0, -40.0, -40.0, 0.0]);
1088        // dragging row 3 up over row 1: source travels -2 slots
1089        let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 3, 1, 40.0)).collect();
1090        assert_eq!(d, vec![0.0, 40.0, 40.0, -80.0, 0.0]);
1091        // hovering the source itself: nothing moves
1092        assert!((0..5).all(|ix| displacement(ix, 2, 2, 40.0) == 0.0));
1093        // slot occupancy is conserved: offsets sum to zero
1094        let sum: f64 = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).sum();
1095        assert_eq!(sum, 0.0);
1096    }
1097}