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//! ```text
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;
28use std::rc::Rc;
29
30use dioxus::html::MountedData;
31use dioxus::prelude::*;
32
33use crate::core::{
34    use_dnd, use_zone_id, use_zone_registry, Draggable, DropOutcome, DropZone, ParentZone, ZoneId,
35    ZoneRecord,
36};
37
38/// Columns are just zones.
39pub type ContainerId = ZoneId;
40
41/// What travels through the context while a board item is dragged.
42#[derive(Debug, Clone, PartialEq)]
43pub struct BoardPayload<T> {
44    pub item: T,
45    /// Column the item was picked up from.
46    pub from: ContainerId,
47    /// Index within that column.
48    pub index: usize,
49}
50
51/// Context a [`BoardColumn`] provides so nested [`BoardSlot`]s inherit its
52/// acceptance filter (WIP limits) with no extra wiring - a precise-insert slot
53/// then honors the same limit as an append to the column.
54struct ColumnAccepts<T: Clone + 'static>(Option<Callback<BoardPayload<T>, bool>>);
55
56// Manual impls: `derive` would demand `T: Copy`, but the field is just a
57// `Callback` handle (Copy) wrapped in an `Option`.
58impl<T: Clone + 'static> Clone for ColumnAccepts<T> {
59    fn clone(&self) -> Self {
60        *self
61    }
62}
63impl<T: Clone + 'static> Copy for ColumnAccepts<T> {}
64
65/// A completed cross-container move.
66#[derive(Debug, Clone, PartialEq)]
67pub struct MoveEvent<T> {
68    pub item: T,
69    /// `(column, index)` the item came from.
70    pub from: (ContainerId, usize),
71    /// Target column, and target index - `None` means "append to the end".
72    pub to: (ContainerId, Option<usize>),
73}
74
75/// Apply a [`MoveEvent`] to a `HashMap<ContainerId, Vec<T>>` board model.
76/// Removes from the source (by index, falling back gracefully if the model
77/// drifted) and inserts at the target position.
78pub fn apply_move<T>(board: &mut HashMap<ContainerId, Vec<T>>, mv: MoveEvent<T>) {
79    let (from_col, from_ix) = mv.from;
80    let mut removed = false;
81    if let Some(src) = board.get_mut(&from_col) {
82        if from_ix < src.len() {
83            src.remove(from_ix);
84            removed = true;
85        }
86    }
87    let (to_col, to_ix) = mv.to;
88    let adjusted_to_ix = match to_ix {
89        Some(ix) if removed && from_col == to_col && from_ix < ix => Some(ix - 1),
90        other => other,
91    };
92    let dst = board.entry(to_col).or_default();
93    match adjusted_to_ix {
94        Some(ix) if ix <= dst.len() => dst.insert(ix, mv.item),
95        _ => dst.push(mv.item),
96    }
97}
98
99/// A draggable card living in a column. Thin wrapper over
100/// [`crate::core::Draggable`] that packs origin info into the payload.
101#[component]
102pub fn BoardItem<T: Clone + PartialEq + 'static>(
103    item: T,
104    /// Column this item currently lives in.
105    column: ContainerId,
106    /// Index within the column.
107    index: usize,
108    /// Label for screen-reader announcements.
109    #[props(default)]
110    label: Option<String>,
111    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
112    children: Element,
113) -> Element {
114    rsx! {
115        Draggable::<BoardPayload<T>> {
116            payload: BoardPayload { item, from: column, index },
117            zone: column,
118            label,
119            attributes,
120            {children}
121        }
122    }
123}
124
125/// A column that receives [`BoardItem`]s. Emits [`MoveEvent`] with
126/// `to.1 = None` (append). For precise within-column positions, nest
127/// [`BoardSlot`]s between items.
128#[component]
129pub fn BoardColumn<T: Clone + PartialEq + 'static>(
130    id: ContainerId,
131    /// Human label for screen-reader announcements ("Over {label}").
132    #[props(default)]
133    label: Option<String>,
134    on_move: EventHandler<MoveEvent<T>>,
135    /// Reject payloads (e.g. WIP limits). Receives the full payload.
136    #[props(default)]
137    accepts: Option<Callback<BoardPayload<T>, bool>>,
138    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
139    children: Element,
140) -> Element {
141    // Share the column's acceptance filter with any nested `BoardSlot`s so
142    // precise inserts respect the same WIP limit as an append.
143    use_context_provider(|| ColumnAccepts(accepts));
144    rsx! {
145        DropZone::<BoardPayload<T>> {
146            id,
147            label,
148            accepts,
149            on_drop: move |outcome: DropOutcome<BoardPayload<T>>| {
150                let p = outcome.payload;
151                on_move.call(MoveEvent {
152                    item: p.item,
153                    from: (p.from, p.index),
154                    to: (id, None),
155                });
156            },
157            attributes,
158            {children}
159        }
160    }
161}
162
163/// An insertion point between items in a column. Dropping on it produces a
164/// `MoveEvent` targeting exactly `(column, Some(index))`.
165///
166/// Stop-gap-free precise ordering: render one slot before each item and one
167/// at the end. While a drag is in flight the slot carries
168/// `data-active="true"` (absent otherwise) - style it visible then, e.g.
169/// Tailwind `h-0 data-active:h-2`.
170#[component]
171pub fn BoardSlot<T: Clone + PartialEq + 'static>(
172    /// The column this slot belongs to.
173    column: ContainerId,
174    /// The index an item dropped here should be inserted at.
175    index: usize,
176    /// Human label for screen-reader announcements.
177    #[props(default)]
178    label: Option<String>,
179    on_move: EventHandler<MoveEvent<T>>,
180    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
181    children: Element,
182) -> Element {
183    let dnd = use_dnd::<BoardPayload<T>>();
184    let mut registry = use_zone_registry::<BoardPayload<T>>();
185    let zone_id = use_zone_id();
186    let parent = try_use_context::<ParentZone>().map(|p| p.0);
187    let mounted = use_signal(|| None::<Rc<MountedData>>);
188    let rect = use_signal(|| None);
189    // The enclosing column's acceptance filter (WIP limits), inherited via
190    // context so a precise-insert honors the same limit as an append. The
191    // `Callback` is a stable handle whose closure reads live state at call
192    // time, so capturing it once (below) still sees the current column.
193    let column_accepts = try_use_context::<ColumnAccepts<T>>().and_then(|c| c.0);
194    let accepts = move |p: BoardPayload<T>| column_accepts.map(|cb| cb.call(p)).unwrap_or(true);
195
196    // `index` is positional - it shifts as items move above this slot - so the
197    // registered drop must read the *current* props, not the ones captured when
198    // the zone first registered. Mirror them through signals.
199    let mut column_now = use_signal(|| column);
200    let mut index_now = use_signal(|| index);
201    let mut on_move_now = use_signal(|| on_move);
202    if *column_now.peek() != column {
203        column_now.set(column);
204    }
205    if *index_now.peek() != index {
206        index_now.set(index);
207    }
208    if *on_move_now.peek() != on_move {
209        on_move_now.set(on_move);
210    }
211
212    let slot_label = label
213        .clone()
214        .or_else(|| Some(format!("Insert at position {index}")));
215
216    let registered_accepts = Callback::new(move |p: BoardPayload<T>| accepts(p));
217    let registered_drop = Callback::new(move |outcome: DropOutcome<BoardPayload<T>>| {
218        let p = outcome.payload;
219        if !accepts(p.clone()) {
220            return;
221        }
222        on_move_now.peek().call(MoveEvent {
223            item: p.item,
224            from: (p.from, p.index),
225            to: (*column_now.peek(), Some(*index_now.peek())),
226        });
227    });
228    let registered_label = slot_label.clone();
229    use_hook(move || {
230        registry.register(ZoneRecord {
231            id: zone_id,
232            parent,
233            label: registered_label.clone(),
234            on_drop: registered_drop,
235            accepts: Some(registered_accepts),
236            mounted,
237            rect,
238        });
239    });
240    use_drop(move || {
241        registry.unregister(zone_id);
242    });
243    registry.sync_label(zone_id, slot_label);
244
245    // Does the in-flight payload pass the inherited column filter?
246    let acceptable = move || dnd.payload().map(accepts).unwrap_or(false);
247
248    rsx! {
249        div {
250            "data-active": if acceptable() { "true" },
251            "data-over": if dnd.over() == Some(zone_id) && acceptable() { "true" },
252            onmounted: move |evt: Event<MountedData>| {
253                let mut mounted = mounted;
254                mounted.set(Some(evt.data()));
255            },
256            ..attributes,
257            {children}
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn move_between_columns() {
268        let a = crate::core::ZoneId(1);
269        let b = crate::core::ZoneId(2);
270        let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
271        board.insert(a, vec!["x", "y"]);
272        board.insert(b, vec!["z"]);
273
274        // precise insert at index 0 of column b
275        apply_move(
276            &mut board,
277            MoveEvent {
278                item: "y",
279                from: (a, 1),
280                to: (b, Some(0)),
281            },
282        );
283        assert_eq!(board[&a], vec!["x"]);
284        assert_eq!(board[&b], vec!["y", "z"]);
285
286        // append (None index) into a brand-new column
287        let c = crate::core::ZoneId(3);
288        apply_move(
289            &mut board,
290            MoveEvent {
291                item: "x",
292                from: (a, 0),
293                to: (c, None),
294            },
295        );
296        assert!(board[&a].is_empty());
297        assert_eq!(board[&c], vec!["x"]);
298    }
299
300    #[test]
301    fn move_within_column_adjusts_forward_insert_after_removal() {
302        let a = crate::core::ZoneId(1);
303        let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
304        board.insert(a, vec!["a", "b", "c", "d"]);
305
306        apply_move(
307            &mut board,
308            MoveEvent {
309                item: "a",
310                from: (a, 0),
311                to: (a, Some(3)),
312            },
313        );
314
315        assert_eq!(board[&a], vec!["b", "c", "a", "d"]);
316    }
317
318    #[test]
319    fn move_within_column_keeps_backward_insert_index() {
320        let a = crate::core::ZoneId(1);
321        let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
322        board.insert(a, vec!["a", "b", "c", "d"]);
323
324        apply_move(
325            &mut board,
326            MoveEvent {
327                item: "d",
328                from: (a, 3),
329                to: (a, Some(1)),
330            },
331        );
332
333        assert_eq!(board[&a], vec!["a", "d", "b", "c"]);
334    }
335
336    #[test]
337    fn move_within_column_appends_after_removal() {
338        let a = crate::core::ZoneId(1);
339        let mut board: HashMap<ContainerId, Vec<&str>> = HashMap::new();
340        board.insert(a, vec!["a", "b", "c"]);
341
342        apply_move(
343            &mut board,
344            MoveEvent {
345                item: "a",
346                from: (a, 0),
347                to: (a, None),
348            },
349        );
350
351        assert_eq!(board[&a], vec!["b", "c", "a"]);
352    }
353}