dioxus-dnd 3.1.0

Modular, accessible drag-and-drop for Dioxus: sortable lists, kanban boards, trees, grids, file drops, multi-select, touch support and more
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
#![doc = include_str!("../docs/api/canvas.md")]

use std::rc::Rc;

use dioxus::prelude::*;

use crate::core::{
    use_dnd, use_parent_zone, use_zone_id, use_zone_registry, DragMode, DropEffect, DropOutcome,
    Point, Rect, ZoneId, ZoneRecord,
};

/// A payload dropped at a position on the canvas.
///
/// Non-exhaustive: emitted by the zone, only ever consumed by callers, and
/// likely to grow context fields (modifiers, effect) - destructure with `..`.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct CanvasDrop<T> {
    pub payload: T,
    /// Top-left position for the dropped element, relative to the canvas -
    /// already corrected for grab offset, snapping, and bounds.
    pub position: Point,
    /// The raw pointer position relative to the canvas, untouched.
    pub pointer: Point,
}

/// Snap positions to a square grid.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SnapGrid(pub f64);

impl SnapGrid {
    pub fn snap(&self, p: Point) -> Point {
        if self.0 <= 0.0 {
            return p;
        }
        Point::new(
            (p.x / self.0).round() * self.0,
            (p.y / self.0).round() * self.0,
        )
    }
}

/// Where a keyboard-driven canvas drop should place its pointer.
///
/// Pointer drops use their event geometry. This policy is only applied when
/// the completed drop came from keyboard interaction.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum CanvasKeyboardPlacement {
    /// Use the selected zone geometry supplied by core keyboard navigation.
    #[default]
    Center,
    /// Place at the canvas origin.
    Origin,
    /// Place at a fixed canvas-local point.
    Fixed(Point),
}

/// Clamp reported top-left positions into `0..=width` × `0..=height`.
///
/// Bounds constrain the drop position returned in [`CanvasDrop::position`].
/// They do not account for the dropped element's own width or height; subtract
/// that yourself when you need the whole element to stay inside the canvas.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bounds {
    pub width: f64,
    pub height: f64,
}

impl Bounds {
    pub fn clamp(&self, p: Point) -> Point {
        Point::new(
            clamp_axis(p.x, 0.0, self.width),
            clamp_axis(p.y, 0.0, self.height),
        )
    }

    /// Clamp a top-left position so an item of `width` × `height` stays fully
    /// inside these bounds. If the item is larger than the bounds on an axis,
    /// that axis pins to zero.
    pub fn clamp_item(&self, p: Point, width: f64, height: f64) -> Point {
        Point::new(
            clamp_axis(p.x, 0.0, self.width - width),
            clamp_axis(p.y, 0.0, self.height - height),
        )
    }

    /// Clamp a rectangle by moving its top-left corner so the whole rectangle
    /// stays inside these bounds. The returned point is the corrected
    /// top-left.
    pub fn clamp_rect(&self, rect: Rect) -> Point {
        self.clamp_item(Point::new(rect.x, rect.y), rect.width, rect.height)
    }
}

/// Convert a viewport/client point to canvas-local coordinates.
pub fn client_to_canvas(client: Point, canvas_rect: Rect) -> Point {
    client - canvas_rect.origin()
}

/// Convert a canvas-local point to viewport/client coordinates.
pub fn canvas_to_client(point: Point, canvas_rect: Rect) -> Point {
    point + canvas_rect.origin()
}

/// Compute the corrected top-left canvas placement from a raw canvas-relative
/// pointer position and grab offset, then apply optional snap and bounds.
pub fn canvas_position(
    pointer: Point,
    grab: Point,
    snap: Option<SnapGrid>,
    bounds: Option<Bounds>,
) -> Point {
    let mut position = pointer - grab;
    if let Some(g) = snap {
        position = g.snap(position);
    }
    if let Some(b) = bounds {
        position = b.clamp(position);
    }
    position
}

/// Resolve the canvas-local pointer for a keyboard drop.
pub fn canvas_keyboard_pointer(policy: CanvasKeyboardPlacement, element: Point) -> Point {
    match policy {
        CanvasKeyboardPlacement::Center => element,
        CanvasKeyboardPlacement::Origin => Point::default(),
        CanvasKeyboardPlacement::Fixed(point) => point,
    }
}

fn clamp_axis(v: f64, min: f64, max: f64) -> f64 {
    // std `f64::clamp` panics when a bound is NaN or when min > max. Treat a
    // NaN bound as "unconstrained" on that side (rather than snapping the item
    // to the origin), and an inverted *finite* range - e.g. an item larger than
    // the container - pins to `min`, matching KeepInside's oversized behavior.
    let lo = if min.is_nan() { f64::NEG_INFINITY } else { min };
    let hi = if max.is_nan() { f64::INFINITY } else { max };
    if lo > hi {
        return if min.is_nan() { v } else { min };
    }
    v.clamp(lo, hi)
}

/// A canvas that reports drop positions.
///
/// Uses the shared `DndContext<T>`; start drags with the core `Draggable`
/// (its recorded grab offset is what makes the drop position feel exact -
/// the element lands where its ghost was, not where the pointer tip was).
///
/// While a drag is in flight the div carries `data-active="true"` (absent
/// otherwise) - style the canvas as a target then, e.g. Tailwind
/// `data-active:outline-dashed`.
#[component]
pub fn CanvasDropZone<T: Clone + PartialEq + 'static>(
    /// Stable identity; auto-generated if omitted.
    #[props(default)]
    id: Option<ZoneId>,
    /// Snap the corrected position to a grid.
    #[props(default)]
    snap: Option<SnapGrid>,
    /// Clamp the corrected top-left position into these bounds.
    #[props(default)]
    bounds: Option<Bounds>,
    /// Placement policy for keyboard-driven canvas drops.
    #[props(default)]
    keyboard: CanvasKeyboardPlacement,
    /// Announced to screen readers when a keyboard drag targets the canvas.
    #[props(default)]
    label: Option<String>,
    on_drop: EventHandler<CanvasDrop<T>>,
    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
    children: Element,
) -> Element {
    let auto_id = use_zone_id();
    let zone_id = id.unwrap_or(auto_id);
    rsx! {
        for keyed_zone_id in [zone_id] {
            CanvasDropZoneInstance::<T> {
                key: "{keyed_zone_id.0}",
                zone_id: keyed_zone_id,
                snap,
                bounds,
                keyboard,
                label: label.clone(),
                on_drop,
                attributes: attributes.clone(),
                {children.clone()}
            }
        }
    }
}

#[component]
fn CanvasDropZoneInstance<T: Clone + PartialEq + 'static>(
    zone_id: ZoneId,
    snap: Option<SnapGrid>,
    bounds: Option<Bounds>,
    keyboard: CanvasKeyboardPlacement,
    label: Option<String>,
    on_drop: EventHandler<CanvasDrop<T>>,
    attributes: Vec<Attribute>,
    children: Element,
) -> Element {
    let dnd = use_dnd::<T>();
    let mut registry = use_zone_registry::<T>();

    // Register as a zone so pointer and keyboard drags can drop here. The
    // registry delivers a `DropOutcome`; `element` is the pointer relative to
    // the canvas and `grab` is the pickup offset.
    let parent = use_parent_zone();
    let registered_drop = use_callback(move |o: DropOutcome<T>| {
        let pointer = if o.mode == DragMode::Keyboard {
            canvas_keyboard_pointer(keyboard, o.element)
        } else {
            o.element
        };
        let position = canvas_position(pointer, o.grab, snap, bounds);
        on_drop.call(CanvasDrop {
            payload: o.payload,
            position,
            pointer,
        });
    });
    let registered_label = label.clone();
    let registration = use_hook(|| {
        registry.register(ZoneRecord {
            id: zone_id,
            parent,
            label: registered_label.clone(),
            on_drop: registered_drop,
            accepts: None,
            mounted: None,
            rect: None,
        })
    });
    use_drop(move || {
        registry.unregister_registration(registration);
    });
    let label_for_sync = label.clone();
    use_effect(use_reactive!(|(label_for_sync)| {
        registry.sync_label(zone_id, label_for_sync);
    }));
    use_effect(use_reactive!(|(parent)| {
        registry.sync_parent(registration, parent);
    }));
    let mut attributes = attributes;
    crate::core::components::protect_attributes(&mut attributes, &["data-active", "onmounted"]);

    rsx! {
        div {
            "data-active": if dnd.dragging() && dnd.proposed_effect() != DropEffect::None { "true" },
            onmounted: move |evt: Event<MountedData>| {
                let m: Rc<MountedData> = evt.data();
                let mut registry = registry;
                registry.set_mounted(registration, m.clone());
                spawn(async move {
                    if let Ok(r) = m.get_client_rect().await {
                        registry.set_rect_if_present(registration, Rect::new(
                            r.origin.x,
                            r.origin.y,
                            r.size.width,
                            r.size.height,
                        ));
                    }
                });
            },
            ..attributes,
            {children}
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn snap_and_clamp() {
        let g = SnapGrid(10.0);
        let p = g.snap(Point::new(14.9, 15.1));
        assert_eq!((p.x, p.y), (10.0, 20.0));
        assert_eq!(
            SnapGrid(0.0).snap(Point::new(3.3, 4.4)),
            Point::new(3.3, 4.4)
        );

        let b = Bounds {
            width: 100.0,
            height: 50.0,
        };
        let p = b.clamp(Point::new(-5.0, 999.0));
        assert_eq!((p.x, p.y), (0.0, 50.0));

        let corrected = Point::new(107.0, 46.0) - Point::new(9.0, 8.0);
        let positioned = b.clamp(g.snap(corrected));
        assert_eq!(positioned, Point::new(100.0, 40.0));
    }

    #[test]
    fn clamp_does_not_panic_on_non_finite_bounds() {
        // Caller-supplied NaN/inf bounds must not panic (std `f64::clamp`
        // would). A NaN bound is treated as unconstrained on that axis, and a
        // negative position still can't go below the origin.
        let b = Bounds {
            width: f64::NAN,
            height: f64::INFINITY,
        };
        let p = b.clamp(Point::new(25.0, 25.0));
        assert_eq!((p.x, p.y), (25.0, 25.0), "unconstrained, unchanged");
        let p = b.clamp(Point::new(-10.0, -10.0));
        assert_eq!((p.x, p.y), (0.0, 0.0), "still floored at the origin");
        // clamp_item is NaN-guarded via clamp_axis too.
        let q = b.clamp_item(Point::new(-3.0, 10.0), 5.0, 5.0);
        assert_eq!(q.x, 0.0);
    }

    #[test]
    fn bounds_can_clamp_whole_items() {
        let b = Bounds {
            width: 100.0,
            height: 50.0,
        };

        assert_eq!(
            b.clamp_item(Point::new(90.0, 45.0), 20.0, 12.0),
            Point::new(80.0, 38.0)
        );
        assert_eq!(
            b.clamp_rect(Rect::new(-5.0, 60.0, 20.0, 10.0)),
            Point::new(0.0, 40.0)
        );
        assert_eq!(
            b.clamp_item(Point::new(20.0, 20.0), 150.0, 80.0),
            Point::new(0.0, 0.0)
        );
    }

    #[test]
    fn coordinate_helpers_convert_between_client_and_canvas() {
        let rect = Rect::new(40.0, 80.0, 320.0, 200.0);
        let client = Point::new(64.0, 128.0);
        let canvas = client_to_canvas(client, rect);

        assert_eq!(canvas, Point::new(24.0, 48.0));
        assert_eq!(canvas_to_client(canvas, rect), client);
    }

    #[test]
    fn canvas_position_applies_grab_snap_then_bounds() {
        let p = canvas_position(
            Point::new(107.0, 46.0),
            Point::new(9.0, 8.0),
            Some(SnapGrid(10.0)),
            Some(Bounds {
                width: 100.0,
                height: 50.0,
            }),
        );

        assert_eq!(p, Point::new(100.0, 40.0));
    }

    #[test]
    fn canvas_keyboard_pointer_uses_center_element_by_default() {
        assert_eq!(
            canvas_keyboard_pointer(CanvasKeyboardPlacement::default(), Point::new(40.0, 20.0)),
            Point::new(40.0, 20.0)
        );
    }

    #[test]
    fn canvas_keyboard_pointer_can_use_origin() {
        assert_eq!(
            canvas_keyboard_pointer(CanvasKeyboardPlacement::Origin, Point::new(40.0, 20.0)),
            Point::default()
        );
    }

    #[test]
    fn canvas_keyboard_pointer_can_use_fixed_point() {
        assert_eq!(
            canvas_keyboard_pointer(
                CanvasKeyboardPlacement::Fixed(Point::new(12.0, 18.0)),
                Point::new(40.0, 20.0),
            ),
            Point::new(12.0, 18.0)
        );
    }

    #[test]
    fn item_clamp_composes_after_canvas_position() {
        let top_left = canvas_position(Point::new(156.0, 86.0), Point::new(4.0, 5.0), None, None);
        let constrained = Bounds {
            width: 160.0,
            height: 90.0,
        }
        .clamp_item(top_left, 48.0, 32.0);

        assert_eq!(top_left, Point::new(152.0, 81.0));
        assert_eq!(constrained, Point::new(112.0, 58.0));
    }
}