Skip to main content

dioxus_dnd/
grid.rs

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