Skip to main content

dioxus_dnd/
multiselect.rs

1#![doc = include_str!("../docs/api/multiselect.md")]
2
3use dioxus::prelude::*;
4
5use crate::core::{use_dnd, DragMode, Draggable, DropEffect, ZoneId};
6
7fn suppress_click_for_mode(mode: DragMode) -> bool {
8    mode == DragMode::Pointer
9}
10
11/// Selection state for keys of type `K`. Cheap to copy.
12pub struct Selection<K: Clone + PartialEq + 'static> {
13    items: Signal<Vec<K>>,
14    anchor: Option<Signal<Option<K>>>,
15}
16
17impl<K: Clone + PartialEq + 'static> Copy for Selection<K> {}
18impl<K: Clone + PartialEq + 'static> Clone for Selection<K> {
19    fn clone(&self) -> Self {
20        *self
21    }
22}
23impl<K: Clone + PartialEq + 'static> PartialEq for Selection<K> {
24    fn eq(&self, other: &Self) -> bool {
25        self.items == other.items && self.anchor == other.anchor
26    }
27}
28
29impl<K: Clone + PartialEq + 'static> Selection<K> {
30    /// Wrap an existing item signal without allocating hook state.
31    ///
32    /// This retains the pre-range-selection constructor contract and is safe
33    /// outside component renders. Range operations derive their anchor from
34    /// the first selected item; use [`use_selection_from_signal`] or
35    /// [`Self::from_signals`] when the anchor must persist independently.
36    pub fn from_signal(items: Signal<Vec<K>>) -> Self {
37        Self {
38            items,
39            anchor: None,
40        }
41    }
42
43    /// Wrap existing item and anchor signals.
44    ///
45    /// This is the non-hook constructor for state owned outside the current
46    /// component. Supplying both signals makes range-anchor lifetime
47    /// explicit and prevents it from being reset by reconstruction.
48    pub fn from_signals(items: Signal<Vec<K>>, anchor: Signal<Option<K>>) -> Self {
49        Self {
50            items,
51            anchor: Some(anchor),
52        }
53    }
54
55    /// Is `key` currently selected?
56    pub fn is_selected(&self, key: &K) -> bool {
57        self.items.read().contains(key)
58    }
59
60    /// Replace the selection with just `key`.
61    pub fn select_only(&mut self, key: K) {
62        self.items.set(vec![key.clone()]);
63        if let Some(mut anchor) = self.anchor {
64            anchor.set(Some(key));
65        }
66    }
67
68    /// Add or remove `key` (Ctrl/Cmd+click semantics).
69    pub fn toggle(&mut self, key: K) {
70        let mut items = self.items.write();
71        if let Some(ix) = items.iter().position(|k| *k == key) {
72            items.remove(ix);
73        } else {
74            items.push(key);
75        }
76    }
77
78    /// Clear the selection.
79    pub fn clear(&mut self) {
80        self.items.write().clear();
81        if let Some(mut anchor) = self.anchor {
82            anchor.set(None);
83        }
84    }
85
86    /// Snapshot of the selected keys, in selection order.
87    pub fn items(&self) -> Vec<K> {
88        self.items.read().clone()
89    }
90
91    /// Number of selected keys.
92    pub fn len(&self) -> usize {
93        self.items.read().len()
94    }
95
96    /// Is nothing selected?
97    pub fn is_empty(&self) -> bool {
98        self.items.read().is_empty()
99    }
100
101    /// Apply the standard click convention: plain click selects only this
102    /// key; a click with Ctrl or Cmd held toggles it.
103    pub fn click(&mut self, key: K, modifiers: Modifiers) {
104        if modifiers.contains(Modifiers::CONTROL) || modifiers.contains(Modifiers::META) {
105            self.toggle(key);
106        } else {
107            self.select_only(key);
108        }
109    }
110
111    /// Select the inclusive range from the current anchor to `to` in the
112    /// caller's stable visual order. Returns false when either endpoint is
113    /// absent from `ordered`.
114    pub fn select_range(&mut self, ordered: &[K], to: &K, additive: bool) -> bool {
115        let anchor = self
116            .anchor
117            .and_then(|anchor| anchor.peek().clone())
118            .or_else(|| self.items.peek().first().cloned())
119            .unwrap_or_else(|| to.clone());
120        let Some(from_index) = ordered.iter().position(|item| item == &anchor) else {
121            return false;
122        };
123        let Some(to_index) = ordered.iter().position(|item| item == to) else {
124            return false;
125        };
126        let (start, end) = if from_index <= to_index {
127            (from_index, to_index)
128        } else {
129            (to_index, from_index)
130        };
131        let range = &ordered[start..=end];
132        if additive {
133            let mut selected = self.items.write();
134            for item in range {
135                if !selected.contains(item) {
136                    selected.push(item.clone());
137                }
138            }
139        } else {
140            self.items.set(range.to_vec());
141        }
142        if let Some(mut anchor_state) = self.anchor {
143            anchor_state.set(Some(anchor));
144        }
145        true
146    }
147
148    /// Standard click behavior plus Shift-range selection.
149    pub fn click_in_order(&mut self, key: K, modifiers: Modifiers, ordered: &[K]) {
150        if modifiers.contains(Modifiers::SHIFT) {
151            let additive =
152                modifiers.contains(Modifiers::CONTROL) || modifiers.contains(Modifiers::META);
153            if self.select_range(ordered, &key, additive) {
154                return;
155            }
156        }
157        self.click(key, modifiers);
158    }
159
160    /// Move a keyboard focus index and optionally extend selection from the
161    /// anchor. Returns the new index, clamped to the collection.
162    pub fn keyboard_range(
163        &mut self,
164        ordered: &[K],
165        current: usize,
166        step: isize,
167        extend: bool,
168    ) -> Option<usize> {
169        if ordered.is_empty() {
170            return None;
171        }
172        let current = current.min(ordered.len() - 1);
173        let next = current.saturating_add_signed(step).min(ordered.len() - 1);
174        if extend {
175            if let Some(mut anchor) = self.anchor {
176                if anchor.peek().is_none() {
177                    anchor.set(Some(ordered[current].clone()));
178                }
179            } else if self.items.peek().is_empty() {
180                // Give the stateless compatibility wrapper an anchor its
181                // ordinary first-selected fallback can derive.
182                self.items.set(vec![ordered[current].clone()]);
183            }
184            self.select_range(ordered, &ordered[next], false);
185        } else {
186            self.select_only(ordered[next].clone());
187        }
188        Some(next)
189    }
190}
191
192/// Wrap an existing item signal with a range anchor owned by this component.
193/// Call this unconditionally during render, like [`use_selection`].
194pub fn use_selection_from_signal<K: Clone + PartialEq + 'static>(
195    items: Signal<Vec<K>>,
196) -> Selection<K> {
197    Selection::from_signals(items, use_signal(|| None))
198}
199
200/// Create selection state owned by this component.
201pub fn use_selection<K: Clone + PartialEq + 'static>() -> Selection<K> {
202    Selection {
203        items: use_signal(Vec::new),
204        anchor: Some(use_signal(|| None)),
205    }
206}
207
208/// A draggable list/grid item participating in a selection.
209///
210/// - Click / Ctrl+click manage the selection (via [`Selection::click`]).
211/// - Dragging a selected item picks up **the whole selection**; dragging an
212///   unselected one picks up just that item (the selection is unchanged).
213/// - Works with mouse, touch, pen and keyboard.
214/// - The wrapper exposes `data-selected="true"` for styling (absent when
215///   unselected, so presence-based selectors like Tailwind
216///   `data-selected:ring-2` work directly).
217///
218/// Requires a `DndProvider::<Vec<K>>` ancestor.
219#[component]
220pub fn SelectableDraggable<K: Clone + PartialEq + 'static>(
221    /// This item's key.
222    item: K,
223    /// Shared selection state from [`use_selection`].
224    selection: Selection<K>,
225    /// The zone this item lives in.
226    #[props(default)]
227    zone: Option<ZoneId>,
228    /// Drop effect. Defaults to `Move`.
229    #[props(default)]
230    effect: DropEffect,
231    /// Label for screen-reader announcements.
232    #[props(default)]
233    label: Option<String>,
234    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
235    children: Element,
236) -> Element {
237    let dnd = use_dnd::<Vec<K>>(); // fail fast with a clear panic if unprovided
238    let selected = selection.is_selected(&item);
239    // Payload resolved from *current* selection each render: a selected item
240    // drags the group, an unselected one drags itself.
241    let payload = if selected {
242        selection.items()
243    } else {
244        vec![item.clone()]
245    };
246    let click_key = item.clone();
247    let mut selection = selection;
248    // The browser fires a trailing `click` on the source after a completed
249    // pointer drag; letting it through would collapse the just-dragged
250    // multi-selection to this one item. Drag start arms the flag, the next
251    // click consumes it - exactly one trailing click is swallowed.
252    let mut suppress_pointer_click = use_signal(|| false);
253    let mut attributes = attributes;
254    crate::core::components::protect_attributes(
255        &mut attributes,
256        &["data-selected", "onclick", "onpointerdown"],
257    );
258
259    rsx! {
260        div {
261            "data-selected": if selected { "true" },
262            onclick: move |evt: MouseEvent| {
263                if *suppress_pointer_click.peek() {
264                    suppress_pointer_click.set(false);
265                    return;
266                }
267                selection.click(click_key.clone(), evt.modifiers());
268            },
269            ..attributes,
270            Draggable::<Vec<K>> {
271                payload,
272                zone,
273                effect,
274                label,
275                on_drag_start: move |_| {
276                    if suppress_click_for_mode(dnd.mode()) {
277                        suppress_pointer_click.set(true);
278                    }
279                },
280                on_drag_end: move |dropped: bool| {
281                    if !dropped {
282                        suppress_pointer_click.set(false);
283                    }
284                },
285                div {
286                    onpointerdown: move |_| {
287                        // This surface runs before Draggable's root stops
288                        // pointerdown propagation. The outer selection
289                        // surface owns click because pointer capture retargets
290                        // pointerup (and therefore click) to that root.
291                        if !dnd.dragging() && *suppress_pointer_click.peek() {
292                            suppress_pointer_click.set(false);
293                        }
294                    },
295                    {children}
296                }
297            }
298        }
299    }
300}
301
302/// A "N items" badge for the drag ghost. Render inside
303/// `DragOverlay::<Vec<K>>`; shows the size of the payload being dragged.
304#[component]
305pub fn SelectionCount<K: Clone + PartialEq + 'static>(
306    /// Internal marker; never set this.
307    #[props(default)]
308    phantom: std::marker::PhantomData<K>,
309) -> Element {
310    let _ = phantom;
311    let dnd = use_dnd::<Vec<K>>();
312    let strings = crate::core::use_dnd_strings();
313    let n = dnd.payload().map(|p| p.len()).unwrap_or(0);
314    let text = (strings.selection_count)(n);
315    rsx! {
316        span { "{text}" }
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn only_pointer_drags_arm_browser_click_suppression() {
326        assert!(suppress_click_for_mode(DragMode::Pointer));
327        assert!(!suppress_click_for_mode(DragMode::Keyboard));
328    }
329}