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}