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::core::components::merge_style;
40use crate::core::{platform, transition, GestureEffect, GestureEvent, GesturePhase, Point, Rect};
41use crate::sortable::{list_bounds, ReorderMode, SortEvent};
42
43fn pointer_client(evt: &PointerEvent) -> Point {
44 let c = evt.client_coordinates();
45 Point::new(c.x, c.y)
46}
47
48/// `(row, col)` of a flat index in a grid with `cols` columns.
49pub fn cell_of(index: usize, cols: usize) -> (usize, usize) {
50 let cols = cols.max(1);
51 (index / cols, index % cols)
52}
53
54/// Flat index of `(row, col)` in a grid with `cols` columns, or `None` if
55/// outside `len`.
56pub fn index_of(row: usize, col: usize, cols: usize, len: usize) -> Option<usize> {
57 let cols = cols.max(1);
58 if col >= cols {
59 return None;
60 }
61 let ix = row * cols + col;
62 (ix < len).then_some(ix)
63}
64
65/// A grid of tiles reordered (or swapped) by dragging.
66///
67/// Renders a `display: grid` wrapper with `cols` equal columns - pass your
68/// own `class`/`style` for gaps and sizing. A forwarded `style` is merged
69/// *after* the default, so per-property overrides win (e.g.
70/// `style: "grid-template-columns: 2fr 1fr 1fr;"` for custom tracks) while
71/// `display: grid` stays; spacing needs no override at all (`class:
72/// "gap-2"`).
73/// The hovered tile gets `data-drop-target="true"`, the dragged one
74/// `data-dragging="true"` - both attributes are *absent* otherwise, so
75/// presence-based selectors (CSS `[data-dragging]`, Tailwind
76/// `data-dragging:opacity-50`) work directly. Use `item_class` to put
77/// classes on the tile wrappers.
78#[component]
79pub fn SortableGrid(
80 /// Number of tiles.
81 len: usize,
82 /// Number of columns.
83 cols: usize,
84 /// Renders the tile at the given index.
85 render: Callback<usize, Element>,
86 /// Fired when the user drops a tile on another.
87 on_sort: EventHandler<SortEvent>,
88 /// Insert-and-reflow (gallery) or swap (dashboard). Default: insert.
89 #[props(default)]
90 mode: ReorderMode,
91 /// Classes for each tile's wrapper div - the element that carries
92 /// `data-dragging` / `data-drop-target`.
93 #[props(default)]
94 item_class: Option<String>,
95 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
96) -> Element {
97 // `mode` only affects what the caller does with the SortEvent, but we
98 // surface it as a data attribute so styling can differ (e.g. swap
99 // targets often highlight the whole tile, insert targets show an edge).
100 let mode_str = match mode {
101 ReorderMode::Insert => "insert",
102 ReorderMode::Swap => "swap",
103 };
104 let mut drag_from = use_signal(|| None::<usize>);
105 let mut over = use_signal(|| None::<usize>);
106 let mut press_from = use_signal(|| None::<usize>);
107 let mut attributes = attributes;
108 let style = merge_style(
109 &mut attributes,
110 &format!("display: grid; grid-template-columns: repeat({cols}, 1fr);"),
111 );
112
113 // Pointer path: per-tile rects measured at drag start, hovered tile =
114 // the one containing the pointer.
115 let rects = use_signal(HashMap::<usize, Rect>::new);
116 let mounteds = use_signal(HashMap::<usize, Rc<MountedData>>::new);
117 let mut gesture = use_signal(|| GesturePhase::Idle);
118 let mut step = move |event: GestureEvent| -> GestureEffect {
119 let (next, fx) = transition(*gesture.peek(), event, 8.0);
120 gesture.set(next);
121 fx
122 };
123 let mut feed = move |event: GestureEvent, fallback_ix: Option<usize>| match step(event) {
124 GestureEffect::Begin { at, .. } => {
125 let Some(ix) = *press_from.peek() else {
126 return;
127 };
128 drag_from.set(Some(ix));
129 let next = rects
130 .peek()
131 .iter()
132 .find(|(_, r)| r.contains(at))
133 .map(|(&i, _)| i)
134 .or(fallback_ix)
135 .filter(|&i| i != ix);
136 over.set(next);
137 for (i, m) in mounteds.peek().clone() {
138 let mut rects = rects;
139 spawn(async move {
140 if let Ok(r) = m.get_client_rect().await {
141 rects.write().insert(
142 i,
143 Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
144 );
145 }
146 });
147 }
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
196 rsx! {
197 div {
198 style: style,
199 "data-mode": mode_str,
200 onpointermove: move |evt: PointerEvent| {
201 let at = pointer_client(&evt);
202 // Capture-free recovery (mirrors SortableList): a mouse that
203 // returns over the grid with no button held was released off
204 // it, so no `pointerup` reached us - finalize the drop instead
205 // of tracking a phantom drag that can never end. No-op with the
206 // `web` feature (capture delivers the real pointerup).
207 if drag_from.peek().is_some() && evt.held_buttons().is_empty() {
208 if let Some(from) = *drag_from.peek() {
209 if let Some(n) = mounteds.peek().get(&from).cloned() {
210 platform::release_pointer(&n, evt.pointer_id());
211 }
212 }
213 feed(GestureEvent::Up { at, pointer_id: evt.pointer_id() }, None);
214 return;
215 }
216 feed(GestureEvent::Move { at, pointer_id: evt.pointer_id() }, None);
217 },
218 onpointerup: move |evt: PointerEvent| {
219 if let Some(from) = *drag_from.peek() {
220 if let Some(n) = mounteds.peek().get(&from).cloned() {
221 platform::release_pointer(&n, evt.pointer_id());
222 }
223 }
224 feed(
225 GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
226 None,
227 );
228 },
229 onpointercancel: move |evt: PointerEvent| {
230 if let Some(from) = *drag_from.peek() {
231 if let Some(n) = mounteds.peek().get(&from).cloned() {
232 platform::release_pointer(&n, evt.pointer_id());
233 }
234 }
235 feed(GestureEvent::Cancel, None);
236 },
237 onlostpointercapture: move |_| feed(GestureEvent::Cancel, None),
238 ..attributes,
239 for ix in 0..len {
240 div {
241 key: "{ix}",
242 class: item_class.clone(),
243 style: "touch-action: none;",
244 "data-dragging": if drag_from() == Some(ix) { "true" },
245 "data-drop-target": if over() == Some(ix) && drag_from() != Some(ix) { "true" },
246 onmounted: move |evt: Event<MountedData>| {
247 let m: Rc<MountedData> = evt.data();
248 let mut mounteds = mounteds;
249 let mut rects = rects;
250 mounteds.write().insert(ix, m.clone());
251 spawn(async move {
252 if let Ok(r) = m.get_client_rect().await {
253 rects.write().insert(
254 ix,
255 Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
256 );
257 }
258 });
259 },
260 onpointerdown: move |evt: PointerEvent| {
261 if !primary_pointer(&evt) { return; }
262 evt.stop_propagation();
263 press_from.set(Some(ix));
264 // Capture on the stable tile so a mouse drag survives
265 // the cursor leaving it (no-op without the `web`
266 // feature).
267 if let Some(n) = mounteds.peek().get(&ix).cloned() {
268 platform::capture_pointer(&n, evt.pointer_id());
269 }
270 feed(
271 GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
272 None,
273 );
274 },
275 onpointermove: move |evt: PointerEvent| {
276 feed(
277 GestureEvent::Move { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
278 Some(ix),
279 );
280 },
281 {render.call(ix)}
282 }
283 }
284 }
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn grid_coordinates_round_trip() {
294 assert_eq!(cell_of(0, 4), (0, 0));
295 assert_eq!(cell_of(5, 4), (1, 1));
296 assert_eq!(index_of(1, 1, 4, 12), Some(5));
297 assert_eq!(index_of(0, 4, 4, 12), None); // col out of range
298 assert_eq!(index_of(3, 0, 4, 12), None); // beyond len
299 assert_eq!(cell_of(7, 0), (7, 0)); // degenerate cols clamps to 1
300 }
301}