adui_dioxus/components/
transfer.rs

1//! Transfer component for moving items between two lists.
2//!
3//! A double-column layout component that allows selecting items from a source
4//! list and moving them to a target list.
5
6use dioxus::prelude::*;
7use std::collections::HashSet;
8
9/// Direction of a Transfer list.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum TransferDirection {
12    Left,
13    Right,
14}
15
16/// A single item in the Transfer component.
17#[derive(Clone, Debug, PartialEq)]
18pub struct TransferItem {
19    /// Unique identifier for the item.
20    pub key: String,
21    /// Display title for the item.
22    pub title: String,
23    /// Optional description shown below the title.
24    pub description: Option<String>,
25    /// Whether this item is disabled.
26    pub disabled: bool,
27}
28
29impl TransferItem {
30    /// Create a new transfer item with key and title.
31    pub fn new(key: impl Into<String>, title: impl Into<String>) -> Self {
32        Self {
33            key: key.into(),
34            title: title.into(),
35            description: None,
36            disabled: false,
37        }
38    }
39
40    /// Builder method to set description.
41    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
42        self.description = Some(desc.into());
43        self
44    }
45
46    /// Builder method to set disabled state.
47    pub fn with_disabled(mut self, disabled: bool) -> Self {
48        self.disabled = disabled;
49        self
50    }
51}
52
53/// Props for the Transfer component.
54#[derive(Props, Clone)]
55pub struct TransferProps {
56    /// Data source for the transfer lists.
57    pub data_source: Vec<TransferItem>,
58    /// Keys of items that should be in the right (target) list.
59    #[props(optional)]
60    pub target_keys: Option<Vec<String>>,
61    /// Keys of items that are currently selected.
62    #[props(optional)]
63    pub selected_keys: Option<Vec<String>>,
64    /// Titles for the left and right lists.
65    #[props(optional)]
66    pub titles: Option<(String, String)>,
67    /// Text for the transfer operation buttons.
68    #[props(optional)]
69    pub operations: Option<(String, String)>,
70    /// Whether to show search input in the lists.
71    #[props(default)]
72    pub show_search: bool,
73    /// Placeholder text for the search input.
74    #[props(optional)]
75    pub search_placeholder: Option<String>,
76    /// Whether the component is disabled.
77    #[props(default)]
78    pub disabled: bool,
79    /// Whether to show select all checkbox.
80    #[props(default = true)]
81    pub show_select_all: bool,
82    /// One-way mode: items can only be moved from left to right.
83    #[props(default)]
84    pub one_way: bool,
85    /// Callback when target keys change.
86    #[props(optional)]
87    pub on_change: Option<EventHandler<(Vec<String>, TransferDirection, Vec<String>)>>,
88    /// Callback when selection changes.
89    #[props(optional)]
90    pub on_select_change: Option<EventHandler<(Vec<String>, Vec<String>)>>,
91    /// Callback when search text changes.
92    #[props(optional)]
93    pub on_search: Option<EventHandler<(TransferDirection, String)>>,
94    /// Custom filter function for search.
95    #[props(optional)]
96    pub filter_option: Option<fn(&str, &TransferItem, TransferDirection) -> bool>,
97    /// Extra class for the root element.
98    #[props(optional)]
99    pub class: Option<String>,
100    /// Inline style for the root element.
101    #[props(optional)]
102    pub style: Option<String>,
103}
104
105impl PartialEq for TransferProps {
106    fn eq(&self, other: &Self) -> bool {
107        self.data_source == other.data_source
108            && self.target_keys == other.target_keys
109            && self.selected_keys == other.selected_keys
110            && self.titles == other.titles
111            && self.operations == other.operations
112            && self.show_search == other.show_search
113            && self.search_placeholder == other.search_placeholder
114            && self.disabled == other.disabled
115            && self.show_select_all == other.show_select_all
116            && self.one_way == other.one_way
117            && self.class == other.class
118            && self.style == other.style
119        // filter_option and event handlers cannot be compared for equality
120    }
121}
122
123/// Transfer component for moving items between two columns.
124#[component]
125pub fn Transfer(props: TransferProps) -> Element {
126    let TransferProps {
127        data_source,
128        target_keys,
129        selected_keys,
130        titles,
131        operations,
132        show_search,
133        search_placeholder,
134        disabled,
135        show_select_all,
136        one_way,
137        on_change,
138        on_select_change,
139        on_search,
140        filter_option,
141        class,
142        style,
143    } = props;
144
145    // Internal state for target keys if not controlled
146    let internal_target_keys: Signal<Vec<String>> = use_signal(Vec::new);
147    let current_target_keys = target_keys.unwrap_or_else(|| internal_target_keys.read().clone());
148
149    // Internal state for selected keys
150    let internal_selected: Signal<Vec<String>> = use_signal(Vec::new);
151    let current_selected = selected_keys.unwrap_or_else(|| internal_selected.read().clone());
152
153    // Search state
154    let left_search: Signal<String> = use_signal(String::new);
155    let right_search: Signal<String> = use_signal(String::new);
156
157    // Split data into left and right lists (clone to avoid lifetime issues)
158    let target_set: HashSet<String> = current_target_keys.iter().cloned().collect();
159    let left_items: Vec<TransferItem> = data_source
160        .iter()
161        .filter(|item| !target_set.contains(&item.key))
162        .cloned()
163        .collect();
164    let right_items: Vec<TransferItem> = data_source
165        .iter()
166        .filter(|item| target_set.contains(&item.key))
167        .cloned()
168        .collect();
169
170    // Filter items based on search
171    let filter_fn = filter_option.unwrap_or(default_filter);
172    let left_search_val = left_search.read().clone();
173    let right_search_val = right_search.read().clone();
174
175    let filtered_left: Vec<TransferItem> = if show_search && !left_search_val.is_empty() {
176        left_items
177            .into_iter()
178            .filter(|item| filter_fn(&left_search_val, item, TransferDirection::Left))
179            .collect()
180    } else {
181        left_items
182    };
183
184    let filtered_right: Vec<TransferItem> = if show_search && !right_search_val.is_empty() {
185        right_items
186            .into_iter()
187            .filter(|item| filter_fn(&right_search_val, item, TransferDirection::Right))
188            .collect()
189    } else {
190        right_items
191    };
192
193    // Selected items in each list
194    let selected_set: HashSet<String> = current_selected.iter().cloned().collect();
195    let left_selected: Vec<String> = filtered_left
196        .iter()
197        .filter(|item| selected_set.contains(&item.key) && !item.disabled)
198        .map(|item| item.key.clone())
199        .collect();
200    let right_selected: Vec<String> = filtered_right
201        .iter()
202        .filter(|item| selected_set.contains(&item.key) && !item.disabled)
203        .map(|item| item.key.clone())
204        .collect();
205
206    // Titles
207    let (left_title, right_title) = titles.unwrap_or(("Source".into(), "Target".into()));
208    let (to_right_text, to_left_text) = operations.unwrap_or((">".into(), "<".into()));
209    let placeholder = search_placeholder.unwrap_or_else(|| "Search here".into());
210
211    // Build class list
212    let mut class_list = vec!["adui-transfer".to_string()];
213    if disabled {
214        class_list.push("adui-transfer-disabled".into());
215    }
216    if let Some(extra) = class {
217        class_list.push(extra);
218    }
219    let class_attr = class_list.join(" ");
220    let style_attr = style.unwrap_or_default();
221
222    // Handler for moving items to right
223    let move_to_right = {
224        let current_target = current_target_keys.clone();
225        let left_sel = left_selected.clone();
226        let on_change = on_change.clone();
227        let mut internal_target = internal_target_keys;
228        let mut internal_sel = internal_selected;
229        move |_| {
230            if left_sel.is_empty() || disabled {
231                return;
232            }
233            let mut new_targets = current_target.clone();
234            for key in &left_sel {
235                if !new_targets.contains(key) {
236                    new_targets.push(key.clone());
237                }
238            }
239            let moved = left_sel.clone();
240            internal_target.set(new_targets.clone());
241            // Clear selection
242            internal_sel.set(Vec::new());
243            if let Some(handler) = &on_change {
244                handler.call((new_targets, TransferDirection::Right, moved));
245            }
246        }
247    };
248
249    // Handler for moving items to left
250    let move_to_left = {
251        let current_target = current_target_keys.clone();
252        let right_sel = right_selected.clone();
253        let on_change = on_change.clone();
254        let mut internal_target = internal_target_keys;
255        let mut internal_sel = internal_selected;
256        move |_| {
257            if right_sel.is_empty() || disabled || one_way {
258                return;
259            }
260            let sel_set: HashSet<&str> = right_sel.iter().map(|s| s.as_str()).collect();
261            let new_targets: Vec<String> = current_target
262                .iter()
263                .filter(|k| !sel_set.contains(k.as_str()))
264                .cloned()
265                .collect();
266            let moved = right_sel.clone();
267            internal_target.set(new_targets.clone());
268            internal_sel.set(Vec::new());
269            if let Some(handler) = &on_change {
270                handler.call((new_targets, TransferDirection::Left, moved));
271            }
272        }
273    };
274
275    // Item selection handler
276    let handle_select = {
277        let current_sel = current_selected.clone();
278        let on_select_change = on_select_change.clone();
279        let mut internal_sel = internal_selected;
280        let target_set = target_set.clone();
281        move |key: String| {
282            if disabled {
283                return;
284            }
285            let mut new_selected = current_sel.clone();
286            if new_selected.contains(&key) {
287                new_selected.retain(|k| k != &key);
288            } else {
289                new_selected.push(key.clone());
290            }
291
292            // Calculate left and right selections
293            let left_sel: Vec<String> = new_selected
294                .iter()
295                .filter(|k| !target_set.contains(*k))
296                .cloned()
297                .collect();
298            let right_sel: Vec<String> = new_selected
299                .iter()
300                .filter(|k| target_set.contains(*k))
301                .cloned()
302                .collect();
303
304            internal_sel.set(new_selected);
305            if let Some(handler) = &on_select_change {
306                handler.call((left_sel, right_sel));
307            }
308        }
309    };
310
311    // Select all handler for a direction
312    let handle_select_all_left = {
313        let current_sel = current_selected.clone();
314        let filtered_left = filtered_left.clone();
315        let on_select_change = on_select_change.clone();
316        let mut internal_sel = internal_selected;
317        let target_set_clone = target_set.clone();
318        move |select_all: bool| {
319            if disabled {
320                return;
321            }
322            let item_keys: HashSet<String> = filtered_left
323                .iter()
324                .filter(|i| !i.disabled)
325                .map(|i| i.key.clone())
326                .collect();
327
328            let mut new_selected: Vec<String> = current_sel
329                .iter()
330                .filter(|k| !item_keys.contains(*k))
331                .cloned()
332                .collect();
333
334            if select_all {
335                for key in item_keys {
336                    new_selected.push(key);
337                }
338            }
339
340            let left_sel: Vec<String> = new_selected
341                .iter()
342                .filter(|k| !target_set_clone.contains(*k))
343                .cloned()
344                .collect();
345            let right_sel: Vec<String> = new_selected
346                .iter()
347                .filter(|k| target_set_clone.contains(*k))
348                .cloned()
349                .collect();
350
351            internal_sel.set(new_selected);
352            if let Some(handler) = &on_select_change {
353                handler.call((left_sel, right_sel));
354            }
355        }
356    };
357
358    let handle_select_all_right = {
359        let current_sel = current_selected.clone();
360        let filtered_right = filtered_right.clone();
361        let on_select_change = on_select_change.clone();
362        let mut internal_sel = internal_selected;
363        let target_set_clone = target_set.clone();
364        move |select_all: bool| {
365            if disabled {
366                return;
367            }
368            let item_keys: HashSet<String> = filtered_right
369                .iter()
370                .filter(|i| !i.disabled)
371                .map(|i| i.key.clone())
372                .collect();
373
374            let mut new_selected: Vec<String> = current_sel
375                .iter()
376                .filter(|k| !item_keys.contains(*k))
377                .cloned()
378                .collect();
379
380            if select_all {
381                for key in item_keys {
382                    new_selected.push(key);
383                }
384            }
385
386            let left_sel: Vec<String> = new_selected
387                .iter()
388                .filter(|k| !target_set_clone.contains(*k))
389                .cloned()
390                .collect();
391            let right_sel: Vec<String> = new_selected
392                .iter()
393                .filter(|k| target_set_clone.contains(*k))
394                .cloned()
395                .collect();
396
397            internal_sel.set(new_selected);
398            if let Some(handler) = &on_select_change {
399                handler.call((left_sel, right_sel));
400            }
401        }
402    };
403
404    // Search handlers
405    let on_left_search = {
406        let mut search = left_search;
407        let on_search = on_search.clone();
408        move |evt: Event<FormData>| {
409            let value = evt.value();
410            search.set(value.clone());
411            if let Some(handler) = &on_search {
412                handler.call((TransferDirection::Left, value));
413            }
414        }
415    };
416
417    let on_right_search = {
418        let mut search = right_search;
419        let on_search = on_search.clone();
420        move |evt: Event<FormData>| {
421            let value = evt.value();
422            search.set(value.clone());
423            if let Some(handler) = &on_search {
424                handler.call((TransferDirection::Right, value));
425            }
426        }
427    };
428
429    rsx! {
430        div { class: "{class_attr}", style: "{style_attr}",
431            // Left list
432            TransferList {
433                title: left_title,
434                items: filtered_left.clone(),
435                selected_keys: left_selected.clone(),
436                disabled: disabled,
437                show_search: show_search,
438                search_placeholder: placeholder.clone(),
439                search_value: left_search_val.clone(),
440                on_search: on_left_search,
441                on_select: handle_select.clone(),
442                on_select_all: handle_select_all_left,
443                show_select_all: show_select_all,
444            }
445
446            // Operations
447            div { class: "adui-transfer-operations",
448                button {
449                    class: "adui-transfer-operation-btn",
450                    r#type: "button",
451                    disabled: left_selected.is_empty() || disabled,
452                    onclick: move_to_right,
453                    "{to_right_text}"
454                }
455                if !one_way {
456                    button {
457                        class: "adui-transfer-operation-btn",
458                        r#type: "button",
459                        disabled: right_selected.is_empty() || disabled,
460                        onclick: move_to_left,
461                        "{to_left_text}"
462                    }
463                }
464            }
465
466            // Right list
467            TransferList {
468                title: right_title,
469                items: filtered_right.clone(),
470                selected_keys: right_selected.clone(),
471                disabled: disabled,
472                show_search: show_search,
473                search_placeholder: placeholder.clone(),
474                search_value: right_search_val.clone(),
475                on_search: on_right_search,
476                on_select: handle_select.clone(),
477                on_select_all: handle_select_all_right,
478                show_select_all: show_select_all,
479            }
480        }
481    }
482}
483
484/// Default filter function for search.
485fn default_filter(query: &str, item: &TransferItem, _direction: TransferDirection) -> bool {
486    let query_lower = query.to_lowercase();
487    item.title.to_lowercase().contains(&query_lower)
488        || item
489            .description
490            .as_ref()
491            .map(|d| d.to_lowercase().contains(&query_lower))
492            .unwrap_or(false)
493}
494
495/// Props for the internal TransferList component.
496#[derive(Props, Clone, PartialEq)]
497struct TransferListProps {
498    title: String,
499    items: Vec<TransferItem>,
500    selected_keys: Vec<String>,
501    disabled: bool,
502    show_search: bool,
503    search_placeholder: String,
504    search_value: String,
505    on_search: EventHandler<Event<FormData>>,
506    on_select: EventHandler<String>,
507    on_select_all: EventHandler<bool>,
508    show_select_all: bool,
509}
510
511/// Internal list component for one side of the Transfer.
512#[component]
513fn TransferList(props: TransferListProps) -> Element {
514    let TransferListProps {
515        title,
516        items,
517        selected_keys,
518        disabled,
519        show_search,
520        search_placeholder,
521        search_value,
522        on_search,
523        on_select,
524        on_select_all,
525        show_select_all,
526    } = props;
527
528    let selected_set: HashSet<&str> = selected_keys.iter().map(|s| s.as_str()).collect();
529    let selectable_count = items.iter().filter(|i| !i.disabled).count();
530    let selected_count = selected_keys.len();
531    let all_selected = selectable_count > 0 && selected_count == selectable_count;
532    let some_selected = selected_count > 0 && selected_count < selectable_count;
533
534    let mut header_checkbox_class = vec!["adui-transfer-list-header-checkbox".to_string()];
535    if all_selected {
536        header_checkbox_class.push("adui-checkbox-checked".into());
537    } else if some_selected {
538        header_checkbox_class.push("adui-checkbox-indeterminate".into());
539    }
540
541    rsx! {
542        div { class: "adui-transfer-list",
543            // Header
544            div { class: "adui-transfer-list-header",
545                if show_select_all {
546                    span {
547                        class: "{header_checkbox_class.join(\" \")}",
548                        onclick: move |_| {
549                            if !disabled {
550                                on_select_all.call(!all_selected);
551                            }
552                        },
553                        span { class: "adui-checkbox-inner" }
554                    }
555                }
556                span { class: "adui-transfer-list-header-selected",
557                    "{selected_count}/{items.len()} items"
558                }
559                span { class: "adui-transfer-list-header-title", "{title}" }
560            }
561
562            // Search
563            if show_search {
564                div { class: "adui-transfer-list-search",
565                    input {
566                        class: "adui-input",
567                        r#type: "text",
568                        placeholder: "{search_placeholder}",
569                        value: "{search_value}",
570                        disabled: disabled,
571                        oninput: move |evt| on_search.call(evt),
572                    }
573                }
574            }
575
576            // Body
577            div { class: "adui-transfer-list-body",
578                ul { class: "adui-transfer-list-content",
579                    for item in items.iter() {
580                        TransferListItem {
581                            key: "{item.key}",
582                            item: item.clone(),
583                            selected: selected_set.contains(item.key.as_str()),
584                            disabled: disabled || item.disabled,
585                            on_select: on_select.clone(),
586                        }
587                    }
588                    if items.is_empty() {
589                        li { class: "adui-transfer-list-empty", "No data" }
590                    }
591                }
592            }
593        }
594    }
595}
596
597/// Props for a single transfer list item.
598#[derive(Props, Clone, PartialEq)]
599struct TransferListItemProps {
600    item: TransferItem,
601    selected: bool,
602    disabled: bool,
603    on_select: EventHandler<String>,
604}
605
606/// Single item in the transfer list.
607#[component]
608fn TransferListItem(props: TransferListItemProps) -> Element {
609    let TransferListItemProps {
610        item,
611        selected,
612        disabled,
613        on_select,
614    } = props;
615
616    let mut class_list = vec!["adui-transfer-list-item".to_string()];
617    if selected {
618        class_list.push("adui-transfer-list-item-selected".into());
619    }
620    if disabled {
621        class_list.push("adui-transfer-list-item-disabled".into());
622    }
623
624    let mut checkbox_class = vec!["adui-checkbox".to_string()];
625    if selected {
626        checkbox_class.push("adui-checkbox-checked".into());
627    }
628    if disabled {
629        checkbox_class.push("adui-checkbox-disabled".into());
630    }
631
632    let key = item.key.clone();
633
634    rsx! {
635        li {
636            class: "{class_list.join(\" \")}",
637            onclick: move |_| {
638                if !disabled {
639                    on_select.call(key.clone());
640                }
641            },
642            span { class: "{checkbox_class.join(\" \")}",
643                span { class: "adui-checkbox-inner" }
644            }
645            span { class: "adui-transfer-list-item-content",
646                span { class: "adui-transfer-list-item-title", "{item.title}" }
647                if let Some(desc) = &item.description {
648                    span { class: "adui-transfer-list-item-description", "{desc}" }
649                }
650            }
651        }
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn transfer_item_builder_works() {
661        let item = TransferItem::new("key1", "Title 1")
662            .with_description("Description")
663            .with_disabled(true);
664        assert_eq!(item.key, "key1");
665        assert_eq!(item.title, "Title 1");
666        assert_eq!(item.description, Some("Description".into()));
667        assert!(item.disabled);
668    }
669
670    #[test]
671    fn transfer_item_minimal() {
672        let item = TransferItem::new("key2", "Title 2");
673        assert_eq!(item.key, "key2");
674        assert_eq!(item.title, "Title 2");
675        assert!(item.description.is_none());
676        assert!(!item.disabled);
677    }
678
679    #[test]
680    fn transfer_item_with_description_only() {
681        let item = TransferItem::new("key3", "Title 3").with_description("Description only");
682        assert_eq!(item.description, Some("Description only".into()));
683        assert!(!item.disabled);
684    }
685
686    #[test]
687    fn transfer_item_with_disabled_only() {
688        let item = TransferItem::new("key4", "Title 4").with_disabled(true);
689        assert!(item.disabled);
690        assert!(item.description.is_none());
691    }
692
693    #[test]
694    fn transfer_item_clone() {
695        let item1 = TransferItem::new("key5", "Title 5")
696            .with_description("Desc")
697            .with_disabled(true);
698        let item2 = item1.clone();
699        assert_eq!(item1, item2);
700    }
701
702    #[test]
703    fn default_filter_matches_title() {
704        let item = TransferItem::new("1", "Hello World");
705        assert!(default_filter("hello", &item, TransferDirection::Left));
706        assert!(default_filter("WORLD", &item, TransferDirection::Left));
707        assert!(!default_filter("xyz", &item, TransferDirection::Left));
708    }
709
710    #[test]
711    fn default_filter_matches_description() {
712        let item = TransferItem::new("1", "Title").with_description("Some description here");
713        assert!(default_filter(
714            "description",
715            &item,
716            TransferDirection::Right
717        ));
718        assert!(!default_filter("notfound", &item, TransferDirection::Right));
719    }
720
721    #[test]
722    fn default_filter_case_insensitive() {
723        let item = TransferItem::new("1", "Hello World");
724        assert!(default_filter("HELLO", &item, TransferDirection::Left));
725        assert!(default_filter("world", &item, TransferDirection::Left));
726        assert!(default_filter("HeLLo", &item, TransferDirection::Left));
727    }
728
729    #[test]
730    fn default_filter_empty_string() {
731        let item = TransferItem::new("1", "Hello World");
732        assert!(default_filter("", &item, TransferDirection::Left));
733    }
734
735    #[test]
736    fn default_filter_partial_match() {
737        let item = TransferItem::new("1", "Hello World");
738        assert!(default_filter("ello", &item, TransferDirection::Left));
739        assert!(default_filter("World", &item, TransferDirection::Left));
740    }
741
742    #[test]
743    fn default_filter_no_match() {
744        let item = TransferItem::new("1", "Hello World");
745        assert!(!default_filter("xyz", &item, TransferDirection::Left));
746        assert!(!default_filter("abc", &item, TransferDirection::Right));
747    }
748
749    #[test]
750    fn default_filter_with_description_preference() {
751        let item = TransferItem::new("1", "Title")
752            .with_description("Description text");
753        assert!(default_filter("description", &item, TransferDirection::Left));
754        assert!(default_filter("title", &item, TransferDirection::Left));
755    }
756
757    #[test]
758    fn transfer_direction_variants() {
759        assert_eq!(TransferDirection::Left, TransferDirection::Left);
760        assert_eq!(TransferDirection::Right, TransferDirection::Right);
761        assert_ne!(TransferDirection::Left, TransferDirection::Right);
762    }
763
764    #[test]
765    fn transfer_direction_clone() {
766        let dir1 = TransferDirection::Left;
767        let dir2 = dir1;
768        assert_eq!(dir1, dir2);
769    }
770}