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_parent_zone, use_zone_id, use_zone_registry, Draggable,
10 DropEffect, DropOutcome, DropZone, 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>(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,
119 #[props(default)]
121 label: Option<String>,
122 on_move: EventHandler<MoveEvent<T>>,
123 #[props(default)]
125 accepts: Option<Callback<BoardPayload<T>, bool>>,
126 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
127 children: Element,
128) -> Element {
129 let column_id = id;
130 let inherited_accepts =
133 use_callback(move |payload| accepts.map(|cb| cb.call(payload)).unwrap_or(true));
134 use_context_provider(|| ColumnAccepts(inherited_accepts));
135 rsx! {
136 DropZone::<BoardPayload<T>> {
137 id: column_id,
138 label,
139 accepts,
140 on_drop: move |outcome: DropOutcome<BoardPayload<T>>| {
141 let p = outcome.payload;
142 on_move.call(MoveEvent {
143 item: p.item,
144 from: (p.from, p.index),
145 to: (column_id, None),
146 });
147 },
148 attributes,
149 {children}
150 }
151 }
152}
153
154#[component]
164pub fn BoardSlot<T: Clone + PartialEq + 'static>(
165 column: ContainerId,
167 index: usize,
169 #[props(default)]
171 label: Option<String>,
172 on_move: EventHandler<MoveEvent<T>>,
173 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
174 children: Element,
175) -> Element {
176 let dnd = use_dnd::<BoardPayload<T>>();
177 let joined = use_joined_window::<BoardPayload<T>>();
178 let mut registry = use_zone_registry::<BoardPayload<T>>();
179 let zone_id = use_zone_id();
180 let parent = use_parent_zone();
181 let column_accepts = try_use_context::<ColumnAccepts<T>>();
186 let accepts = use_callback(move |payload: BoardPayload<T>| {
187 column_accepts
188 .map(|accepts| accepts.0.call(payload))
189 .unwrap_or(true)
190 });
191
192 let slot_label = label
193 .clone()
194 .or_else(|| Some(format!("Insert at position {index}")));
195
196 let registered_accepts = accepts;
197 let registered_drop = use_callback(move |outcome: DropOutcome<BoardPayload<T>>| {
198 let p = outcome.payload;
199 if !accepts.call(p.clone()) {
200 return;
201 }
202 on_move.call(MoveEvent {
203 item: p.item,
204 from: (p.from, p.index),
205 to: (column, Some(index)),
206 });
207 });
208 let registered_label = slot_label.clone();
209 let registration = use_hook(move || {
210 registry.register(ZoneRecord {
211 id: zone_id,
212 parent,
213 label: registered_label.clone(),
214 on_drop: registered_drop,
215 accepts: Some(registered_accepts),
216 mounted: None,
217 rect: None,
218 })
219 });
220 use_drop(move || {
221 registry.unregister_registration(registration);
222 });
223 let label_for_sync = slot_label.clone();
224 use_effect(use_reactive!(|(label_for_sync)| {
225 registry.sync_label(zone_id, label_for_sync);
226 }));
227 use_effect(use_reactive!(|(parent)| {
228 registry.sync_parent(registration, parent);
229 }));
230
231 let acceptable = move || {
233 (dnd.proposed_effect() != DropEffect::None)
234 && dnd
235 .payload()
236 .map(|payload| accepts.call(payload))
237 .unwrap_or(false)
238 };
239 let is_over = move || match joined {
240 Some(joined) => joined.is_over(zone_id),
241 None => dnd.over() == Some(zone_id),
242 };
243 let mut attributes = attributes;
244 crate::core::components::protect_attributes(
245 &mut attributes,
246 &["data-active", "data-over", "onmounted"],
247 );
248
249 rsx! {
250 div {
251 "data-active": if acceptable() { "true" },
252 "data-over": if is_over() && acceptable() { "true" },
253 onmounted: move |evt: Event<MountedData>| {
254 let mut registry = registry;
255 registry.set_mounted(registration, evt.data());
256 },
257 ..attributes,
258 {children}
259 }
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn move_between_columns() {
269 let a = crate::core::ZoneId(1);
270 let b = crate::core::ZoneId(2);
271 let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
272 board.insert(a, vec!["x", "y"]);
273 board.insert(b, vec!["z"]);
274
275 apply_move(
277 &mut board,
278 MoveEvent {
279 item: "y",
280 from: (a, 1),
281 to: (b, Some(0)),
282 },
283 );
284 assert_eq!(board[&a], vec!["x"]);
285 assert_eq!(board[&b], vec!["y", "z"]);
286
287 let c = crate::core::ZoneId(3);
289 apply_move(
290 &mut board,
291 MoveEvent {
292 item: "x",
293 from: (a, 0),
294 to: (c, None),
295 },
296 );
297 assert!(board[&a].is_empty());
298 assert_eq!(board[&c], vec!["x"]);
299 }
300
301 #[test]
302 fn move_within_column_adjusts_forward_insert_after_removal() {
303 let a = crate::core::ZoneId(1);
304 let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
305 board.insert(a, vec!["a", "b", "c", "d"]);
306
307 apply_move(
308 &mut board,
309 MoveEvent {
310 item: "a",
311 from: (a, 0),
312 to: (a, Some(3)),
313 },
314 );
315
316 assert_eq!(board[&a], vec!["b", "c", "a", "d"]);
317 }
318
319 #[test]
320 fn move_within_column_keeps_backward_insert_index() {
321 let a = crate::core::ZoneId(1);
322 let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
323 board.insert(a, vec!["a", "b", "c", "d"]);
324
325 apply_move(
326 &mut board,
327 MoveEvent {
328 item: "d",
329 from: (a, 3),
330 to: (a, Some(1)),
331 },
332 );
333
334 assert_eq!(board[&a], vec!["a", "d", "b", "c"]);
335 }
336
337 #[test]
338 fn move_within_column_appends_after_removal() {
339 let a = crate::core::ZoneId(1);
340 let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
341 board.insert(a, vec!["a", "b", "c"]);
342
343 apply_move(
344 &mut board,
345 MoveEvent {
346 item: "a",
347 from: (a, 0),
348 to: (a, None),
349 },
350 );
351
352 assert_eq!(board[&a], vec!["b", "c", "a"]);
353 }
354}