Skip to main content

dioxus_dnd/
sortable.rs

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