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}