1use dioxus::prelude::*;
7use std::collections::HashSet;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum TransferDirection {
12 Left,
13 Right,
14}
15
16#[derive(Clone, Debug, PartialEq)]
18pub struct TransferItem {
19 pub key: String,
21 pub title: String,
23 pub description: Option<String>,
25 pub disabled: bool,
27}
28
29impl TransferItem {
30 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 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
42 self.description = Some(desc.into());
43 self
44 }
45
46 pub fn with_disabled(mut self, disabled: bool) -> Self {
48 self.disabled = disabled;
49 self
50 }
51}
52
53#[derive(Props, Clone)]
55pub struct TransferProps {
56 pub data_source: Vec<TransferItem>,
58 #[props(optional)]
60 pub target_keys: Option<Vec<String>>,
61 #[props(optional)]
63 pub selected_keys: Option<Vec<String>>,
64 #[props(optional)]
66 pub titles: Option<(String, String)>,
67 #[props(optional)]
69 pub operations: Option<(String, String)>,
70 #[props(default)]
72 pub show_search: bool,
73 #[props(optional)]
75 pub search_placeholder: Option<String>,
76 #[props(default)]
78 pub disabled: bool,
79 #[props(default = true)]
81 pub show_select_all: bool,
82 #[props(default)]
84 pub one_way: bool,
85 #[props(optional)]
87 pub on_change: Option<EventHandler<(Vec<String>, TransferDirection, Vec<String>)>>,
88 #[props(optional)]
90 pub on_select_change: Option<EventHandler<(Vec<String>, Vec<String>)>>,
91 #[props(optional)]
93 pub on_search: Option<EventHandler<(TransferDirection, String)>>,
94 #[props(optional)]
96 pub filter_option: Option<fn(&str, &TransferItem, TransferDirection) -> bool>,
97 #[props(optional)]
99 pub class: Option<String>,
100 #[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 }
121}
122
123#[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 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 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 let left_search: Signal<String> = use_signal(String::new);
155 let right_search: Signal<String> = use_signal(String::new);
156
157 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
484fn 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#[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#[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 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 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 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#[derive(Props, Clone, PartialEq)]
599struct TransferListItemProps {
600 item: TransferItem,
601 selected: bool,
602 disabled: bool,
603 on_select: EventHandler<String>,
604}
605
606#[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}