Skip to main content

dioxus_dnd/
board.rs

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
13/// Columns are just zones.
14pub type ContainerId = ZoneId;
15
16/// What travels through the context while a board item is dragged.
17#[derive(Debug, Clone, PartialEq)]
18pub struct BoardPayload<T> {
19    pub item: T,
20    /// Column the item was picked up from.
21    pub from: ContainerId,
22    /// Index within that column.
23    pub index: usize,
24}
25
26/// Context a [`BoardColumn`] provides so nested [`BoardSlot`]s inherit its
27/// acceptance filter (WIP limits) with no extra wiring - a precise-insert slot
28/// then honors the same limit as an append to the column.
29struct ColumnAccepts<T: Clone + 'static>(Callback<BoardPayload<T>, bool>);
30
31// Manual impls: `derive` would demand `T: Copy`, but the field is just a
32// `Callback` handle (Copy) wrapped in an `Option`.
33impl<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/// A completed cross-container move.
41///
42/// Non-exhaustive so move context can be added without a major release;
43/// synthesize your own (tests, undo stacks) via [`MoveEvent::new`].
44#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub struct MoveEvent<T> {
47    pub item: T,
48    /// `(column, index)` the item came from.
49    pub from: (ContainerId, usize),
50    /// Target column, and target index - `None` means "append to the end".
51    pub to: (ContainerId, Option<usize>),
52}
53
54impl<T> MoveEvent<T> {
55    /// A move of `item` from `(column, index)` to `(column, index)`, where
56    /// a `None` target index means "append to the end".
57    pub fn new(item: T, from: (ContainerId, usize), to: (ContainerId, Option<usize>)) -> Self {
58        Self { item, from, to }
59    }
60}
61
62/// Apply a [`MoveEvent`] to a `HashMap<ContainerId, Vec<T>>` board model.
63/// Removes from the source (by index, falling back gracefully if the model
64/// drifted) and inserts at the target position.
65pub 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/// A draggable card living in a column. Thin wrapper over
87/// [`crate::core::Draggable`] that packs origin info into the payload.
88#[component]
89pub fn BoardItem<T: Clone + PartialEq + 'static>(
90    item: T,
91    /// Column this item currently lives in.
92    column: ContainerId,
93    /// Index within the column.
94    index: usize,
95    /// Label for screen-reader announcements.
96    #[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/// A column that receives [`BoardItem`]s. Emits [`MoveEvent`] with
113/// `to.1 = None` (append). For precise within-column positions, nest
114/// [`BoardSlot`]s between items.
115#[component]
116pub fn BoardColumn<T: Clone + PartialEq + 'static>(
117    /// Stable identity for this column.
118    id: ContainerId,
119    /// Human label for screen-reader announcements ("Over {label}").
120    #[props(default)]
121    label: Option<String>,
122    on_move: EventHandler<MoveEvent<T>>,
123    /// Reject payloads (e.g. WIP limits). Receives the full payload.
124    #[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    // Share the column's acceptance filter with any nested `BoardSlot`s so
131    // precise inserts respect the same WIP limit as an append.
132    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/// An insertion point between items in a column. Dropping on it produces a
155/// `MoveEvent` targeting exactly `(column, Some(index))`.
156///
157/// Stop-gap-free precise ordering: render one slot before each item and one
158/// at the end. While a drag is in flight the slot carries
159/// `data-active="true"` (absent otherwise) - reveal it without moving
160/// layout, e.g. Tailwind `h-2 opacity-0 data-active:opacity-100`. Growing
161/// the slot itself (`h-0` to `h-2`) reflows the column mid-drag and strands
162/// the cached zone rects.
163#[component]
164pub fn BoardSlot<T: Clone + PartialEq + 'static>(
165    /// The column this slot belongs to.
166    column: ContainerId,
167    /// The index an item dropped here should be inserted at.
168    index: usize,
169    /// Human label for screen-reader announcements.
170    #[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    // The enclosing column's acceptance filter (WIP limits), inherited via
182    // context so a precise-insert honors the same limit as an append. The
183    // `Callback` is a stable handle whose closure reads live state at call
184    // time, so capturing it once (below) still sees the current column.
185    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    // Does the in-flight payload pass the inherited column filter?
232    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        // precise insert at index 0 of column b
276        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        // append (None index) into a brand-new column
288        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}