Skip to main content

dioxus_dnd/
grid.rs

1//! 2D tile reorder: [`SortableGrid`] displays a flat `Vec` in `cols`
2//! columns and emits the same [`SortEvent`]s as `SortableList`, applied
3//! with [`crate::sortable::apply_sort`] (insert-and-reflow) or
4//! [`crate::sortable::apply_swap`] (tiles trade places). The full
5//! reference for both components lives with the [`crate::sortable`]
6//! module docs, docs/api/sortable-lists.md.
7
8use std::collections::HashMap;
9use std::rc::Rc;
10
11use dioxus::html::MountedData;
12use dioxus::prelude::*;
13
14use crate::a11y::use_reduced_motion_css;
15use crate::core::components::merge_style;
16use crate::core::hooks::use_rect_refresh_thunk;
17use crate::core::{platform, transition, GestureEffect, GestureEvent, GesturePhase, Point, Rect};
18use crate::sortable::{list_bounds, refresh_rects, ReorderMode, SortEvent};
19
20fn pointer_client(evt: &PointerEvent) -> Point {
21    let c = evt.client_coordinates();
22    Point::new(c.x, c.y)
23}
24
25/// `(row, col)` of a flat index in a grid with `cols` columns.
26pub fn cell_of(index: usize, cols: usize) -> (usize, usize) {
27    let cols = cols.max(1);
28    (index / cols, index % cols)
29}
30
31/// Flat index of `(row, col)` in a grid with `cols` columns, or `None` if
32/// outside `len`.
33pub fn index_of(row: usize, col: usize, cols: usize, len: usize) -> Option<usize> {
34    let cols = cols.max(1);
35    if col >= cols {
36        return None;
37    }
38    let ix = row * cols + col;
39    (ix < len).then_some(ix)
40}
41
42/// A grid of tiles reordered (or swapped) by dragging.
43///
44/// Renders a `display: grid` wrapper with `cols` equal columns - pass your
45/// own `class`/`style` for gaps and sizing. A forwarded `style` is merged
46/// *after* the default, so per-property overrides win (e.g.
47/// `style: "grid-template-columns: 2fr 1fr 1fr;"` for custom tracks) while
48/// `display: grid` stays; spacing needs no override at all (`class:
49/// "gap-2"`).
50/// The hovered tile gets `data-drop-target="true"`, the dragged one
51/// `data-dragging="true"` - both attributes are *absent* otherwise, so
52/// presence-based selectors (CSS `[data-dragging]`, Tailwind
53/// `data-dragging:opacity-50`) work directly. Use `item_class` to put
54/// classes on the tile wrappers.
55#[component]
56pub fn SortableGrid(
57    /// Number of tiles.
58    len: usize,
59    /// Number of columns.
60    cols: usize,
61    /// Renders the tile at the given index.
62    render: Callback<usize, Element>,
63    /// Fired when the user drops a tile on another.
64    on_sort: EventHandler<SortEvent>,
65    /// Insert-and-reflow (gallery) or swap (dashboard). Default: insert.
66    #[props(default)]
67    mode: ReorderMode,
68    /// Classes for each tile's wrapper div - the element that carries
69    /// `data-dragging` / `data-drop-target`.
70    #[props(default)]
71    item_class: Option<String>,
72    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
73) -> Element {
74    // `mode` only affects what the caller does with the SortEvent, but we
75    // surface it as a data attribute so styling can differ (e.g. swap
76    // targets often highlight the whole tile, insert targets show an edge).
77    let mode_str = match mode {
78        ReorderMode::Insert => "insert",
79        ReorderMode::Swap => "swap",
80    };
81    let mut drag_from = use_signal(|| None::<usize>);
82    let mut over = use_signal(|| None::<usize>);
83    let mut press_from = use_signal(|| None::<usize>);
84    let mut attributes = attributes;
85    let style = merge_style(
86        &mut attributes,
87        &format!("display: grid; grid-template-columns: repeat({cols}, 1fr);"),
88    );
89
90    // Pointer path: per-tile rects measured at drag start, hovered tile =
91    // the one containing the pointer.
92    let rects = use_signal(HashMap::<usize, Rect>::new);
93    let mounteds = use_signal(HashMap::<usize, Rc<MountedData>>::new);
94    // Tiles never transform mid-drag, so a scroll ping is a plain
95    // re-measure (see the compensated variant in `sortable` for why lists
96    // differ).
97    use_rect_refresh_thunk(move |_| {
98        if drag_from.peek().is_some() {
99            refresh_rects(mounteds, rects);
100        }
101    });
102    let mut gesture = use_signal(|| GesturePhase::Idle);
103    let mut step = move |event: GestureEvent| -> GestureEffect {
104        let (next, fx) = transition(*gesture.peek(), event, 8.0);
105        gesture.set(next);
106        fx
107    };
108    let mut feed = move |event: GestureEvent, fallback_ix: Option<usize>| match step(event) {
109        GestureEffect::Begin { at, .. } => {
110            let Some(ix) = *press_from.peek() else {
111                return;
112            };
113            drag_from.set(Some(ix));
114            let next = rects
115                .peek()
116                .iter()
117                .find(|(_, r)| r.contains(at))
118                .map(|(&i, _)| i)
119                .or(fallback_ix)
120                .filter(|&i| i != ix);
121            over.set(next);
122            refresh_rects(mounteds, rects);
123        }
124        GestureEffect::Track { at } => {
125            let next = rects
126                .peek()
127                .iter()
128                .find(|(_, r)| r.contains(at))
129                .map(|(&i, _)| i)
130                .or(fallback_ix)
131                .filter(|&i| Some(i) != *drag_from.peek())
132                .or(*over.peek());
133            if next != *over.peek() {
134                over.set(next);
135            }
136        }
137        GestureEffect::Drop { at } => {
138            // A release outside the grid's tile bounds cancels rather than
139            // committing a reorder; inside them, the hovered tile is the
140            // target.
141            let inside = list_bounds(&rects.peek())
142                .map(|b| b.contains(at))
143                .unwrap_or(false);
144            let pair = (*drag_from.peek(), *over.peek());
145            // Clear all drag state BEFORE notifying: `on_sort` mutates the
146            // caller's list and re-renders this component, and observing a
147            // still-active drag mid-apply is the hazard SortableList documents.
148            press_from.set(None);
149            drag_from.set(None);
150            over.set(None);
151            if inside {
152                if let (Some(from), Some(to)) = pair {
153                    if from != to {
154                        on_sort.call(SortEvent { from, to });
155                    }
156                }
157            }
158        }
159        GestureEffect::Abort => {
160            press_from.set(None);
161            drag_from.set(None);
162            over.set(None);
163        }
164        GestureEffect::Tap => {
165            press_from.set(None);
166        }
167        GestureEffect::None => {}
168    };
169    let primary_pointer = move |evt: &PointerEvent| crate::core::components::primary_press(evt);
170    // Consecutive empty-held moves seen mid-drag (lost-release debounce).
171    let mut empty_held_moves = use_signal(|| 0u8);
172    // Did native pointer capture engage for the current press? Decides
173    // whether the capture-substitute layer renders (see `Draggable`).
174    let mut captured = use_signal(|| false);
175    // The grid itself doesn't animate, but its tiles commonly do (FlipItem
176    // siblings can't share context with each other) - anchor the
177    // reduced-motion stylesheet once for the whole subtree.
178    let reduced_motion_css = use_reduced_motion_css();
179
180    rsx! {
181        // Outside the grid container: tooling (and tests) often index the
182        // container's children as tiles, and <style> is layout-neutral
183        // wherever it sits.
184        {reduced_motion_css}
185        div {
186            style: style,
187            "data-mode": mode_str,
188            onpointermove: move |evt: PointerEvent| {
189                let at = pointer_client(&evt);
190                // Capture-free recovery (mirrors SortableList): a mouse that
191                // returns over the grid with no button held was released off
192                // it, so no `pointerup` reached us - finalize the drop instead
193                // of tracking a phantom drag that can never end. No-op with the
194                // `web` feature (capture delivers the real pointerup).
195                // Debounced: move events carry the display server's state
196                // mask, which some pipelines corrupt for isolated events
197                // (see core::components::RELEASE_RECOVERY_MOVES).
198                if drag_from.peek().is_some() && evt.held_buttons().is_empty() {
199                    let streak = empty_held_moves.peek().saturating_add(1);
200                    empty_held_moves.set(streak);
201                    if streak >= crate::core::components::RELEASE_RECOVERY_MOVES {
202                        if let Some(from) = *drag_from.peek() {
203                            if let Some(n) = mounteds.peek().get(&from).cloned() {
204                                platform::release_pointer(&n, evt.pointer_id());
205                            }
206                        }
207                        feed(GestureEvent::Up { at, pointer_id: evt.pointer_id() }, None);
208                        return;
209                    }
210                } else if *empty_held_moves.peek() != 0 {
211                    empty_held_moves.set(0);
212                }
213                feed(GestureEvent::Move { at, pointer_id: evt.pointer_id() }, None);
214            },
215            onpointerup: move |evt: PointerEvent| {
216                if let Some(from) = *drag_from.peek() {
217                    if let Some(n) = mounteds.peek().get(&from).cloned() {
218                        platform::release_pointer(&n, evt.pointer_id());
219                    }
220                }
221                feed(
222                    GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
223                    None,
224                );
225            },
226            onpointercancel: move |evt: PointerEvent| {
227                if let Some(from) = *drag_from.peek() {
228                    if let Some(n) = mounteds.peek().get(&from).cloned() {
229                        platform::release_pointer(&n, evt.pointer_id());
230                    }
231                }
232                feed(GestureEvent::Cancel, None);
233            },
234            onlostpointercapture: move |_| feed(GestureEvent::Cancel, None),
235            ..attributes,
236            // Capture substitute (see `Draggable` for the full story):
237            // keeps moves bubbling to the container while a tile drag is
238            // in flight and native capture did not engage.
239            if drag_from().is_some() && !captured() {
240                div {
241                    style: "position: fixed; inset: 0; z-index: 9998; touch-action: none;",
242                    aria_hidden: true,
243                }
244            }
245            for ix in 0..len {
246                div {
247                    key: "{ix}",
248                    class: item_class.clone(),
249                    style: "touch-action: none;",
250                    "data-dragging": if drag_from() == Some(ix) { "true" },
251                    "data-drop-target": if over() == Some(ix) && drag_from() != Some(ix) { "true" },
252                    onmounted: move |evt: Event<MountedData>| {
253                        let m: Rc<MountedData> = evt.data();
254                        let mut mounteds = mounteds;
255                        let mut rects = rects;
256                        mounteds.write().insert(ix, m.clone());
257                        spawn(async move {
258                            if let Ok(r) = m.get_client_rect().await {
259                                rects.write().insert(
260                                    ix,
261                                    Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
262                                );
263                            }
264                        });
265                    },
266                    oncontextmenu: move |evt: Event<MouseData>| {
267                        // Android's long-press context menu would tear an
268                        // in-flight gesture; idle presses keep the menu.
269                        if !matches!(*gesture.peek(), GesturePhase::Idle) {
270                            evt.prevent_default();
271                        }
272                    },
273                    onpointerdown: move |evt: PointerEvent| {
274                        if !primary_pointer(&evt) { return; }
275                        // Same suppression as Draggable and the sortable
276                        // rows: no press focus, no text-selection start, and
277                        // no native drag hijack from an <img>/<a> inside the
278                        // tile (this module promises no native drag image).
279                        evt.prevent_default();
280                        evt.stop_propagation();
281                        press_from.set(Some(ix));
282                        // Capture on the stable tile so a mouse drag survives
283                        // the cursor leaving it (real capture with the `web`
284                        // feature; the capture-substitute layer covers the
285                        // rest).
286                        captured.set(match mounteds.peek().get(&ix).cloned() {
287                            Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
288                            None => false,
289                        });
290                        feed(
291                            GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
292                            None,
293                        );
294                    },
295                    onpointermove: move |evt: PointerEvent| {
296                        feed(
297                            GestureEvent::Move { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
298                            Some(ix),
299                        );
300                    },
301                    {render.call(ix)}
302                }
303            }
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn grid_coordinates_round_trip() {
314        assert_eq!(cell_of(0, 4), (0, 0));
315        assert_eq!(cell_of(5, 4), (1, 1));
316        assert_eq!(index_of(1, 1, 4, 12), Some(5));
317        assert_eq!(index_of(0, 4, 4, 12), None); // col out of range
318        assert_eq!(index_of(3, 0, 4, 12), None); // beyond len
319        assert_eq!(cell_of(7, 0), (7, 0)); // degenerate cols clamps to 1
320    }
321}