1#![doc = include_str!("../docs/api/boards.md")]
2
3use std::collections::HashMap;
4
5use dioxus::html::MountedData;
6use dioxus::prelude::*;
7
8use crate::core::{
9 use_dnd, use_joined_window, use_zone_id, use_zone_registry, Draggable, DropOutcome, DropZone,
10 ParentZone, ZoneId, ZoneRecord,
11};
12
13pub type ContainerId = ZoneId;
15
16#[derive(Debug, Clone, PartialEq)]
18pub struct BoardPayload<T> {
19 pub item: T,
20 pub from: ContainerId,
22 pub index: usize,
24}
25
26struct ColumnAccepts<T: Clone + 'static>(Option<Callback<BoardPayload<T>, bool>>);
30
31impl<T: Clone + 'static> Clone for ColumnAccepts<T> {
34 fn clone(&self) -> Self {
35 *self
36 }
37}
38impl<T: Clone + 'static> Copy for ColumnAccepts<T> {}
39
40#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub struct MoveEvent<T> {
47 pub item: T,
48 pub from: (ContainerId, usize),
50 pub to: (ContainerId, Option<usize>),
52}
53
54impl<T> MoveEvent<T> {
55 pub fn new(item: T, from: (ContainerId, usize), to: (ContainerId, Option<usize>)) -> Self {
58 Self { item, from, to }
59 }
60}
61
62pub fn apply_move<T>(board: &mut HashMap<ContainerId, Vec<T>>, mv: MoveEvent<T>) {
66 let (from_col, from_ix) = mv.from;
67 let mut removed = false;
68 if let Some(src) = board.get_mut(&from_col) {
69 if from_ix < src.len() {
70 src.remove(from_ix);
71 removed = true;
72 }
73 }
74 let (to_col, to_ix) = mv.to;
75 let adjusted_to_ix = match to_ix {
76 Some(ix) if removed && from_col == to_col && from_ix < ix => Some(ix - 1),
77 other => other,
78 };
79 let dst = board.entry(to_col).or_default();
80 match adjusted_to_ix {
81 Some(ix) if ix <= dst.len() => dst.insert(ix, mv.item),
82 _ => dst.push(mv.item),
83 }
84}
85
86#[component]
89pub fn BoardItem<T: Clone + PartialEq + 'static>(
90 item: T,
91 column: ContainerId,
93 index: usize,
95 #[props(default)]
97 label: Option<String>,
98 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
99 children: Element,
100) -> Element {
101 rsx! {
102 Draggable::<BoardPayload<T>> {
103 payload: BoardPayload { item, from: column, index },
104 zone: column,
105 label,
106 attributes,
107 {children}
108 }
109 }
110}
111
112#[component]
116pub fn BoardColumn<T: Clone + PartialEq + 'static>(
117 id: ContainerId,
118 #[props(default)]
120 label: Option<String>,
121 on_move: EventHandler<MoveEvent<T>>,
122 #[props(default)]
124 accepts: Option<Callback<BoardPayload<T>, bool>>,
125 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
126 children: Element,
127) -> Element {
128 use_context_provider(|| ColumnAccepts(accepts));
131 rsx! {
132 DropZone::<BoardPayload<T>> {
133 id,
134 label,
135 accepts,
136 on_drop: move |outcome: DropOutcome<BoardPayload<T>>| {
137 let p = outcome.payload;
138 on_move.call(MoveEvent {
139 item: p.item,
140 from: (p.from, p.index),
141 to: (id, None),
142 });
143 },
144 attributes,
145 {children}
146 }
147 }
148}
149
150#[component]
160pub fn BoardSlot<T: Clone + PartialEq + 'static>(
161 column: ContainerId,
163 index: usize,
165 #[props(default)]
167 label: Option<String>,
168 on_move: EventHandler<MoveEvent<T>>,
169 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
170 children: Element,
171) -> Element {
172 let dnd = use_dnd::<BoardPayload<T>>();
173 let joined = use_joined_window::<BoardPayload<T>>();
174 let mut registry = use_zone_registry::<BoardPayload<T>>();
175 let zone_id = use_zone_id();
176 let parent = try_use_context::<ParentZone>().map(|p| p.0);
177 let column_accepts = try_use_context::<ColumnAccepts<T>>().and_then(|c| c.0);
182 let accepts = move |p: BoardPayload<T>| column_accepts.map(|cb| cb.call(p)).unwrap_or(true);
183
184 let mut column_now = use_signal(|| column);
188 let mut index_now = use_signal(|| index);
189 let mut on_move_now = use_signal(|| on_move);
190 if *column_now.peek() != column {
191 column_now.set(column);
192 }
193 if *index_now.peek() != index {
194 index_now.set(index);
195 }
196 if *on_move_now.peek() != on_move {
197 on_move_now.set(on_move);
198 }
199
200 let slot_label = label
201 .clone()
202 .or_else(|| Some(format!("Insert at position {index}")));
203
204 let registered_accepts = Callback::new(move |p: BoardPayload<T>| accepts(p));
205 let registered_drop = Callback::new(move |outcome: DropOutcome<BoardPayload<T>>| {
206 let p = outcome.payload;
207 if !accepts(p.clone()) {
208 return;
209 }
210 on_move_now.peek().call(MoveEvent {
211 item: p.item,
212 from: (p.from, p.index),
213 to: (*column_now.peek(), Some(*index_now.peek())),
214 });
215 });
216 let registered_label = slot_label.clone();
217 let registration = use_hook(move || {
218 registry.register(ZoneRecord {
219 id: zone_id,
220 parent,
221 label: registered_label.clone(),
222 on_drop: registered_drop,
223 accepts: Some(registered_accepts),
224 mounted: None,
225 rect: None,
226 })
227 });
228 use_drop(move || {
229 registry.unregister(zone_id);
230 });
231 registry.sync_label(zone_id, slot_label);
232
233 let acceptable = move || dnd.payload().map(accepts).unwrap_or(false);
235 let is_over = move || match joined {
236 Some(joined) => joined.is_over(zone_id),
237 None => dnd.over() == Some(zone_id),
238 };
239
240 rsx! {
241 div {
242 "data-active": if acceptable() { "true" },
243 "data-over": if is_over() && acceptable() { "true" },
244 onmounted: move |evt: Event<MountedData>| {
245 let mut registry = registry;
246 registry.set_mounted(registration, evt.data());
247 },
248 ..attributes,
249 {children}
250 }
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn move_between_columns() {
260 let a = crate::core::ZoneId(1);
261 let b = crate::core::ZoneId(2);
262 let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
263 board.insert(a, vec!["x", "y"]);
264 board.insert(b, vec!["z"]);
265
266 apply_move(
268 &mut board,
269 MoveEvent {
270 item: "y",
271 from: (a, 1),
272 to: (b, Some(0)),
273 },
274 );
275 assert_eq!(board[&a], vec!["x"]);
276 assert_eq!(board[&b], vec!["y", "z"]);
277
278 let c = crate::core::ZoneId(3);
280 apply_move(
281 &mut board,
282 MoveEvent {
283 item: "x",
284 from: (a, 0),
285 to: (c, None),
286 },
287 );
288 assert!(board[&a].is_empty());
289 assert_eq!(board[&c], vec!["x"]);
290 }
291
292 #[test]
293 fn move_within_column_adjusts_forward_insert_after_removal() {
294 let a = crate::core::ZoneId(1);
295 let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
296 board.insert(a, vec!["a", "b", "c", "d"]);
297
298 apply_move(
299 &mut board,
300 MoveEvent {
301 item: "a",
302 from: (a, 0),
303 to: (a, Some(3)),
304 },
305 );
306
307 assert_eq!(board[&a], vec!["b", "c", "a", "d"]);
308 }
309
310 #[test]
311 fn move_within_column_keeps_backward_insert_index() {
312 let a = crate::core::ZoneId(1);
313 let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
314 board.insert(a, vec!["a", "b", "c", "d"]);
315
316 apply_move(
317 &mut board,
318 MoveEvent {
319 item: "d",
320 from: (a, 3),
321 to: (a, Some(1)),
322 },
323 );
324
325 assert_eq!(board[&a], vec!["a", "d", "b", "c"]);
326 }
327
328 #[test]
329 fn move_within_column_appends_after_removal() {
330 let a = crate::core::ZoneId(1);
331 let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
332 board.insert(a, vec!["a", "b", "c"]);
333
334 apply_move(
335 &mut board,
336 MoveEvent {
337 item: "a",
338 from: (a, 0),
339 to: (a, None),
340 },
341 );
342
343 assert_eq!(board[&a], vec!["b", "c", "a"]);
344 }
345}