Skip to main content

dioxus_dnd/
board.rs

1//! Cross-container moves — the kanban pattern. Items travel between columns
2//! (and optionally to a position within a column) via the shared
3//! [`crate::core::DndContext`].
4//!
5//! The payload type flowing through the context is [`BoardPayload<T>`], which
6//! remembers where the item came from. Wrap your app (or board) in
7//! `DndProvider::<BoardPayload<Card>>`.
8//!
9//! ```rust,ignore
10//! DndProvider::<BoardPayload<Card>> {
11//!     for (col_id, cards) in columns {
12//!         BoardColumn::<Card> {
13//!             id: col_id,
14//!             on_move: move |mv: MoveEvent<Card>| {
15//!                 apply_move(&mut board.write(), &mv);
16//!             },
17//!             for (ix, card) in cards.iter().enumerate() {
18//!                 BoardItem::<Card> { item: card.clone(), column: col_id, index: ix,
19//!                     CardView { card: card.clone() }
20//!                 }
21//!             }
22//!         }
23//!     }
24//! }
25//! ```
26
27use std::collections::HashMap;
28
29use dioxus::prelude::*;
30
31use crate::core::{use_dnd, DropOutcome, DropZone, ZoneId};
32use crate::pointer::PointerDraggable;
33
34/// Columns are just zones.
35pub type ContainerId = ZoneId;
36
37/// What travels through the context while a board item is dragged.
38#[derive(Debug, Clone, PartialEq)]
39pub struct BoardPayload<T> {
40    pub item: T,
41    /// Column the item was picked up from.
42    pub from: ContainerId,
43    /// Index within that column.
44    pub index: usize,
45}
46
47/// A completed cross-container move.
48#[derive(Debug, Clone, PartialEq)]
49pub struct MoveEvent<T> {
50    pub item: T,
51    /// `(column, index)` the item came from.
52    pub from: (ContainerId, usize),
53    /// Target column, and target index — `None` means "append to the end".
54    pub to: (ContainerId, Option<usize>),
55}
56
57/// Apply a [`MoveEvent`] to a `HashMap<ContainerId, Vec<T>>` board model.
58/// Removes from the source (by index, falling back gracefully if the model
59/// drifted) and inserts at the target position.
60pub fn apply_move<T>(board: &mut HashMap<ContainerId, Vec<T>>, mv: MoveEvent<T>) {
61    let (from_col, from_ix) = mv.from;
62    if let Some(src) = board.get_mut(&from_col) {
63        if from_ix < src.len() {
64            src.remove(from_ix);
65        }
66    }
67    let (to_col, to_ix) = mv.to;
68    let dst = board.entry(to_col).or_default();
69    match to_ix {
70        Some(ix) if ix <= dst.len() => dst.insert(ix, mv.item),
71        _ => dst.push(mv.item),
72    }
73}
74
75/// A draggable card living in a column. Thin wrapper over
76/// [`crate::pointer::PointerDraggable`] (so cards work with mouse, touch,
77/// pen and keyboard) that packs origin info into the payload.
78#[component]
79pub fn BoardItem<T: Clone + PartialEq + 'static>(
80    item: T,
81    /// Column this item currently lives in.
82    column: ContainerId,
83    /// Index within the column.
84    index: usize,
85    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
86    children: Element,
87) -> Element {
88    rsx! {
89        PointerDraggable::<BoardPayload<T>> {
90            payload: BoardPayload { item, from: column, index },
91            zone: column,
92            attributes,
93            {children}
94        }
95    }
96}
97
98/// A column that receives [`BoardItem`]s. Emits [`MoveEvent`] with
99/// `to.1 = None` (append). For precise within-column positions, nest
100/// [`BoardSlot`]s between items.
101#[component]
102pub fn BoardColumn<T: Clone + PartialEq + 'static>(
103    id: ContainerId,
104    /// Human label for screen-reader announcements ("Over {label}").
105    #[props(default)]
106    label: Option<String>,
107    on_move: EventHandler<MoveEvent<T>>,
108    /// Reject payloads (e.g. WIP limits). Receives the full payload.
109    #[props(default)]
110    accepts: Option<Callback<BoardPayload<T>, bool>>,
111    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
112    children: Element,
113) -> Element {
114    rsx! {
115        DropZone::<BoardPayload<T>> {
116            id,
117            label,
118            accepts,
119            on_drop: move |outcome: DropOutcome<BoardPayload<T>>| {
120                let p = outcome.payload;
121                on_move.call(MoveEvent {
122                    item: p.item,
123                    from: (p.from, p.index),
124                    to: (id, None),
125                });
126            },
127            attributes,
128            {children}
129        }
130    }
131}
132
133/// An insertion point between items in a column. Dropping on it produces a
134/// `MoveEvent` targeting exactly `(column, Some(index))`.
135///
136/// Stop-gap-free precise ordering: render one slot before each item and one
137/// at the end.
138#[component]
139pub fn BoardSlot<T: Clone + PartialEq + 'static>(
140    /// The column this slot belongs to.
141    column: ContainerId,
142    /// The index an item dropped here should be inserted at.
143    index: usize,
144    on_move: EventHandler<MoveEvent<T>>,
145    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
146    children: Element,
147) -> Element {
148    let dnd = use_dnd::<BoardPayload<T>>();
149
150    rsx! {
151        div {
152            "data-active": dnd.dragging(),
153            ondragover: move |evt: DragEvent| {
154                if dnd.dragging() {
155                    evt.prevent_default();
156                }
157            },
158            ondrop: {
159                let mut dnd = dnd;
160                move |evt: DragEvent| {
161                    evt.prevent_default();
162                    evt.stop_propagation();
163                    if let Some((p, _)) = dnd.take() {
164                        on_move.call(MoveEvent {
165                            item: p.item,
166                            from: (p.from, p.index),
167                            to: (column, Some(index)),
168                        });
169                    }
170                }
171            },
172            ..attributes,
173            {children}
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn move_between_columns() {
184        let a = crate::core::ZoneId(1);
185        let b = crate::core::ZoneId(2);
186        let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
187        board.insert(a, vec!["x", "y"]);
188        board.insert(b, vec!["z"]);
189
190        // precise insert at index 0 of column b
191        apply_move(
192            &mut board,
193            MoveEvent {
194                item: "y",
195                from: (a, 1),
196                to: (b, Some(0)),
197            },
198        );
199        assert_eq!(board[&a], vec!["x"]);
200        assert_eq!(board[&b], vec!["y", "z"]);
201
202        // append (None index) into a brand-new column
203        let c = crate::core::ZoneId(3);
204        apply_move(
205            &mut board,
206            MoveEvent {
207                item: "x",
208                from: (a, 0),
209                to: (c, None),
210            },
211        );
212        assert!(board[&a].is_empty());
213        assert_eq!(board[&c], vec!["x"]);
214    }
215}