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