1use std::cmp::Ordering;
2use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
3
4use dioxus::prelude::*;
5
6use crate::tag::TagLike;
7
8static INSTANCE_COUNTER: AtomicU32 = AtomicU32::new(0);
9
10#[derive(Clone, PartialEq, Debug)]
12pub struct SuggestionGroup<T: TagLike> {
13 pub label: String,
15 pub items: Vec<T>,
17 pub total_count: usize,
19}
20
21pub struct TagInputGroupConfig<T: TagLike> {
26 pub available_tags: Vec<T>,
27 pub initial_selected: Vec<T>,
28 pub filter: Option<fn(&T, &str) -> bool>,
30 pub sort_items: Option<fn(&T, &T) -> Ordering>,
32 pub sort_groups: Option<fn(&str, &str) -> Ordering>,
34 pub max_items_per_group: Option<usize>,
36 pub value: Option<Signal<Vec<T>>>,
38 pub query: Option<Signal<String>>,
40}
41
42pub struct TagInputConfig<T: TagLike> {
44 pub available_tags: Vec<T>,
45 pub initial_selected: Vec<T>,
46 pub value: Option<Signal<Vec<T>>>,
48 pub query: Option<Signal<String>>,
50}
51
52impl<T: TagLike> TagInputConfig<T> {
53 pub fn new(available_tags: Vec<T>, initial_selected: Vec<T>) -> Self {
54 Self {
55 available_tags,
56 initial_selected,
57 value: None,
58 query: None,
59 }
60 }
61}
62
63pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> {
68 if query.is_empty() {
69 return Vec::new();
70 }
71 let text_lower = text.to_lowercase();
72 let query_lower = query.to_lowercase();
73 let mut ranges = Vec::new();
74 let mut start = 0;
75 while let Some(pos) = text_lower[start..].find(&query_lower) {
76 let abs_start = start + pos;
77 let abs_end = abs_start + query.len();
78 ranges.push((abs_start, abs_end));
79 start = abs_end;
80 }
81 ranges
82}
83
84#[allow(clippy::type_complexity)]
90pub struct TagInputState<T: TagLike> {
91 pub search_query: Signal<String>,
93 pub selected_tags: Signal<Vec<T>>,
95 pub available_tags: Signal<Vec<T>>,
97 pub active_pill: Signal<Option<usize>>,
99 pub popover_pill: Signal<Option<usize>>,
101 pub on_create: Signal<Option<Callback<String, Option<T>>>>,
109 pub on_remove: Signal<Option<Callback<T>>>,
116 pub on_add: Signal<Option<Callback<T>>>,
123 pub on_query_change: Signal<Option<EventHandler<String>>>,
130 pub on_commit: Signal<Option<EventHandler<String>>>,
138 pub is_disabled: Signal<bool>,
144 pub status_message: Signal<String>,
157 pub on_paste: Signal<Option<Callback<String, Vec<T>>>>,
166 pub paste_delimiters: Signal<Option<Vec<char>>>,
174 pub editing_pill: Signal<Option<usize>>,
181 pub on_edit: Signal<Option<Callback<(T, String), Option<T>>>>,
189 pub on_reorder: Signal<Option<Callback<(usize, usize)>>>,
195 pub delimiters: Signal<Option<Vec<char>>>,
205 pub max_tags: Signal<Option<usize>>,
212 pub is_at_limit: Memo<bool>,
217 pub validate: Signal<Option<Callback<T, Result<(), String>>>>,
225 pub validation_error: Signal<Option<String>>,
230 pub allow_duplicates: Signal<bool>,
238 pub on_duplicate: Signal<Option<Callback<T>>>,
244 pub enforce_allow_list: Signal<bool>,
251 pub deny_list: Signal<Option<Vec<String>>>,
258 pub min_tags: Signal<Option<usize>>,
265 pub is_below_minimum: Memo<bool>,
269 pub is_readonly: Signal<bool>,
276 pub max_tag_length: Signal<Option<usize>>,
284 pub filter: Signal<Option<fn(&T, &str) -> bool>>,
292 pub max_visible_tags: Signal<Option<usize>>,
299 pub overflow_count: Memo<usize>,
303 pub visible_tags: Memo<Vec<T>>,
308 pub sort_selected: Signal<Option<fn(&T, &T) -> Ordering>>,
315 pub form_value: Memo<String>,
320 pub select_mode: Signal<bool>,
327 instance_id: u32,
329}
330
331impl<T: TagLike> Clone for TagInputState<T> {
332 fn clone(&self) -> Self {
333 *self
334 }
335}
336
337impl<T: TagLike> Copy for TagInputState<T> {}
338
339impl<T: TagLike> PartialEq for TagInputState<T> {
340 fn eq(&self, other: &Self) -> bool {
341 self.search_query == other.search_query
342 && self.selected_tags == other.selected_tags
343 && self.available_tags == other.available_tags
344 && self.active_pill == other.active_pill
345 && self.popover_pill == other.popover_pill
346 && self.on_create == other.on_create
347 && self.on_remove == other.on_remove
348 && self.on_add == other.on_add
349 && self.on_query_change == other.on_query_change
350 && self.on_commit == other.on_commit
351 && self.is_disabled == other.is_disabled
352 && self.status_message == other.status_message
353 && self.on_paste == other.on_paste
354 && self.paste_delimiters == other.paste_delimiters
355 && self.editing_pill == other.editing_pill
356 && self.on_edit == other.on_edit
357 && self.on_reorder == other.on_reorder
358 && self.delimiters == other.delimiters
359 && self.max_tags == other.max_tags
360 && self.is_at_limit == other.is_at_limit
361 && self.validate == other.validate
362 && self.validation_error == other.validation_error
363 && self.allow_duplicates == other.allow_duplicates
365 && self.on_duplicate == other.on_duplicate
366 && self.enforce_allow_list == other.enforce_allow_list
367 && self.deny_list == other.deny_list
368 && self.min_tags == other.min_tags
369 && self.is_below_minimum == other.is_below_minimum
370 && self.is_readonly == other.is_readonly
371 && self.max_tag_length == other.max_tag_length
373 && self.filter == other.filter
374 && self.max_visible_tags == other.max_visible_tags
375 && self.overflow_count == other.overflow_count
376 && self.visible_tags == other.visible_tags
377 && self.sort_selected == other.sort_selected
378 && self.form_value == other.form_value
380 && self.select_mode == other.select_mode
381 && self.instance_id == other.instance_id
382 }
383}
384
385impl<T: TagLike> TagInputState<T> {
386 pub fn set_query(&mut self, query: String) {
391 if *self.is_disabled.read() || *self.is_readonly.read() {
392 return;
393 }
394 self.search_query.set(query.clone());
395 self.active_pill.set(None);
396 self.popover_pill.set(None);
397 self.validation_error.set(None);
398
399 let cb = *self.on_query_change.read();
401 if let Some(handler) = cb {
402 handler.call(query);
403 }
404 }
405
406 pub fn add_tag(&mut self, tag: T) {
413 if *self.is_disabled.read() || *self.is_readonly.read() {
414 return;
415 }
416
417 if *self.enforce_allow_list.read() {
419 let in_allow_list = self
420 .available_tags
421 .read()
422 .iter()
423 .any(|t| t.id() == tag.id());
424 if !in_allow_list {
425 self.status_message
426 .set("Only suggestions can be selected.".to_string());
427 return;
428 }
429 }
430
431 if let Some(ref bl) = *self.deny_list.read() {
433 let tag_name_lower = tag.name().to_lowercase();
434 if bl.iter().any(|b| b.to_lowercase() == tag_name_lower) {
435 let name = tag.name().to_string();
436 self.status_message.set(format_status_denied(&name));
437 self.validation_error.set(Some(format_status_denied(&name)));
438 return;
439 }
440 }
441
442 if let Some(max_len) = *self.max_tag_length.read()
444 && tag.name().len() > max_len
445 {
446 self.validation_error
447 .set(Some(format_error_max_length(max_len)));
448 return;
449 }
450
451 if *self.select_mode.read()
453 && let Some(1) = *self.max_tags.read()
454 && self.selected_tags.read().len() == 1
455 {
456 let old_id = self.selected_tags.read()[0].id().to_string();
457 self.selected_tags.write().retain(|t| t.id() != old_id);
458 }
459
460 if let Some(max) = *self.max_tags.read()
462 && self.selected_tags.read().len() >= max
463 {
464 self.status_message
465 .set(format!("Maximum of {max} tags reached."));
466 self.search_query.set(String::new());
467 return;
468 }
469
470 let already_selected = self.selected_tags.read().iter().any(|t| t.id() == tag.id());
472 if already_selected && !*self.allow_duplicates.read() {
473 let name = tag.name().to_string();
474 self.status_message.set(format_status_duplicate(&name));
475 if let Some(cb) = *self.on_duplicate.read() {
476 cb.call(tag);
477 }
478 self.search_query.set(String::new());
479 self.active_pill.set(None);
480 self.popover_pill.set(None);
481 return;
482 }
483
484 if !already_selected || *self.allow_duplicates.read() {
485 let validate_cb = *self.validate.read();
487 if let Some(cb) = validate_cb
488 && let Err(msg) = cb.call(tag.clone())
489 {
490 self.validation_error.set(Some(msg));
491 return;
492 }
493 self.validation_error.set(None);
494
495 let name = tag.name().to_string();
496 self.selected_tags.write().push(tag.clone());
497
498 if let Some(sort_fn) = *self.sort_selected.read() {
500 self.selected_tags.write().sort_by(sort_fn);
501 }
502
503 let count = self.selected_tags.read().len();
504 self.status_message.set(format_status_added(&name, count));
505
506 if let Some(cb) = *self.on_add.read() {
507 cb.call(tag);
508 }
509 }
510 self.search_query.set(String::new());
511 self.active_pill.set(None);
512 self.popover_pill.set(None);
513 }
514
515 pub fn remove_tag(&mut self, id: &str) {
521 if *self.is_disabled.read() || *self.is_readonly.read() {
522 return;
523 }
524 let is_locked = self
525 .selected_tags
526 .read()
527 .iter()
528 .any(|t| t.id() == id && t.is_locked());
529 if is_locked {
530 return;
531 }
532
533 let name = self
534 .selected_tags
535 .read()
536 .iter()
537 .find(|t| t.id() == id)
538 .map(|t| t.name().to_string());
539
540 if let Some(cb) = *self.on_remove.read()
541 && let Some(tag) = self
542 .selected_tags
543 .read()
544 .iter()
545 .find(|t| t.id() == id)
546 .cloned()
547 {
548 cb.call(tag);
549 }
550
551 self.selected_tags.write().retain(|t| t.id() != id);
552 if let Some(name) = name {
553 let count = self.selected_tags.read().len();
554 self.status_message.set(format_status_removed(&name, count));
555 }
556 self.popover_pill.set(None);
557 }
558
559 pub fn remove_last_tag(&mut self) {
565 let tags = self.selected_tags.read();
566 if let Some(pos) = tags.iter().rposition(|t| !t.is_locked()) {
567 let tag = tags[pos].clone();
568 let name = tag.name().to_string();
569 drop(tags);
570
571 if let Some(cb) = *self.on_remove.read() {
572 cb.call(tag);
573 }
574
575 self.selected_tags.write().remove(pos);
576 let count = self.selected_tags.read().len();
577 self.status_message.set(format_status_removed(&name, count));
578 }
579 }
580
581 pub fn handle_click(&mut self) {
586 if *self.is_disabled.read() || *self.is_readonly.read() {
587 return;
588 }
589 self.active_pill.set(None);
590 self.popover_pill.set(None);
591 }
592
593 pub fn toggle_popover(&mut self, index: usize) {
597 let current = *self.popover_pill.read();
598 if current == Some(index) {
599 self.popover_pill.set(None);
600 } else {
601 self.popover_pill.set(Some(index));
602 }
603 }
604
605 pub fn close_popover(&mut self) {
607 self.popover_pill.set(None);
608 }
609
610 pub fn suggestion_id(&self, index: usize) -> String {
617 format!("dti-{}-s-{}", self.instance_id, index)
618 }
619
620 pub fn listbox_id(&self) -> String {
625 format!("dti-{}-listbox", self.instance_id)
626 }
627
628 pub fn pill_id(&self, index: usize) -> String {
634 format!("dti-{}-p-{}", self.instance_id, index)
635 }
636
637 pub fn create_tag(&mut self, tag: T) {
646 self.available_tags.write().push(tag.clone());
647 self.add_tag(tag);
648 }
649
650 pub fn handle_paste(&mut self, text: String) {
663 if *self.is_disabled.read() || *self.is_readonly.read() {
664 return;
665 }
666 if text.is_empty() {
667 return;
668 }
669
670 let paste_cb = *self.on_paste.read();
672 if let Some(cb) = paste_cb {
673 let tags = cb.call(text);
674 let added = tags.len();
675 for tag in tags {
676 self.add_tag(tag);
677 }
678 if added > 0 {
679 let count = self.selected_tags.read().len();
680 self.status_message.set(format_status_pasted(added, count));
681 }
682 return;
683 }
684
685 let delimiters = self.paste_delimiters.read().clone();
687 let create_cb = *self.on_create.read();
688 if let Some(delimiters) = delimiters
689 && let Some(cb) = create_cb
690 {
691 let tokens = split_by_delimiters(&text, &delimiters);
692 let mut added = 0;
693 for token in tokens {
694 if let Some(tag) = cb.call(token) {
695 self.create_tag(tag);
696 added += 1;
697 }
698 }
699 if added > 0 {
700 let count = self.selected_tags.read().len();
701 self.status_message.set(format_status_pasted(added, count));
702 }
703 }
704
705 }
707
708 pub fn announce(&mut self, message: String) {
713 self.status_message.set(message);
714 }
715
716 pub fn start_editing(&mut self, index: usize) {
726 if *self.is_disabled.read() || *self.is_readonly.read() {
727 return;
728 }
729 if self.on_edit.read().is_none() {
730 return;
731 }
732 if index >= self.selected_tags.read().len() {
733 return;
734 }
735 if self.selected_tags.read()[index].is_locked() {
737 return;
738 }
739 self.editing_pill.set(Some(index));
740 self.popover_pill.set(None);
741 self.active_pill.set(Some(index));
742 }
743
744 pub fn commit_edit(&mut self, new_name: String) {
752 let idx = match *self.editing_pill.read() {
753 Some(i) => i,
754 None => return,
755 };
756 let edit_cb = *self.on_edit.read();
757 if let Some(cb) = edit_cb {
758 let current = self.selected_tags.read().get(idx).cloned();
759 if let Some(tag) = current
760 && let Some(updated) = cb.call((tag, new_name))
761 {
762 self.selected_tags.write()[idx] = updated;
763 }
764 }
765 self.editing_pill.set(None);
766 }
767
768 pub fn cancel_edit(&mut self) {
770 self.editing_pill.set(None);
771 }
772
773 pub fn move_tag(&mut self, from: usize, to: usize) {
781 if *self.is_disabled.read() || *self.is_readonly.read() {
782 return;
783 }
784 let len = self.selected_tags.read().len();
785 if from >= len || to >= len || from == to {
786 return;
787 }
788 let tag = self.selected_tags.write().remove(from);
789 let name = tag.name().to_string();
790 self.selected_tags.write().insert(to, tag);
791 self.status_message
792 .set(format!("{name} moved to position {}.", to + 1));
793
794 if let Some(cb) = *self.on_reorder.read() {
795 cb.call((from, to));
796 }
797 }
798
799 pub fn clear_all(&mut self) {
806 if *self.is_disabled.read() || *self.is_readonly.read() {
807 return;
808 }
809 let tags = self.selected_tags.read().clone();
810 let to_remove: Vec<T> = tags.into_iter().filter(|t| !t.is_locked()).collect();
811 let removed_count = to_remove.len();
812
813 let remove_cb = *self.on_remove.read();
814 for tag in &to_remove {
815 if let Some(cb) = remove_cb {
816 cb.call(tag.clone());
817 }
818 }
819
820 self.selected_tags.write().retain(|t| t.is_locked());
821 self.active_pill.set(None);
822 self.popover_pill.set(None);
823 self.editing_pill.set(None);
824
825 let locked_count = self.selected_tags.read().len();
826 if locked_count > 0 {
827 self.status_message.set(format!(
828 "All tags cleared. {locked_count} locked tag{} remain{}.",
829 if locked_count == 1 { "" } else { "s" },
830 if locked_count == 1 { "s" } else { "" }
831 ));
832 } else {
833 self.status_message.set(format!(
834 "{removed_count} tag{} cleared.",
835 if removed_count == 1 { "" } else { "s" }
836 ));
837 }
838 }
839
840 pub fn select_all(&mut self) {
845 if *self.is_disabled.read() || *self.is_readonly.read() {
846 return;
847 }
848 let available = self.available_tags.read().clone();
849 let mut added = 0;
850 for tag in available {
851 if let Some(max) = *self.max_tags.read()
852 && self.selected_tags.read().len() >= max
853 {
854 break;
855 }
856 let already = self.selected_tags.read().iter().any(|t| t.id() == tag.id());
857 if !already {
858 self.selected_tags.write().push(tag.clone());
859 added += 1;
860 if let Some(cb) = *self.on_add.read() {
861 cb.call(tag);
862 }
863 }
864 }
865 if added > 0 {
866 let count = self.selected_tags.read().len();
867 self.status_message.set(format!(
868 "{added} tag{} added. {count} tag{} selected.",
869 if added == 1 { "" } else { "s" },
870 if count == 1 { "" } else { "s" }
871 ));
872 }
873 }
874
875 pub fn handle_keydown(&mut self, event: Event<KeyboardData>) {
877 if *self.is_disabled.read() {
878 return;
879 }
880 let pill = *self.active_pill.read();
881 if let Some(i) = pill {
882 self.handle_pill_keydown(event, i);
883 } else {
884 self.handle_input_keydown(event);
885 }
886 }
887
888 pub fn handle_pill_keydown(&mut self, event: Event<KeyboardData>, pill_index: usize) {
893 let key = event.key();
894 let readonly = *self.is_readonly.read();
895
896 match key {
897 Key::Enter => {
898 if readonly {
899 return;
900 }
901 event.prevent_default();
902 self.toggle_popover(pill_index);
903 }
904 Key::ArrowLeft => {
905 event.prevent_default();
906 self.popover_pill.set(None);
907 if pill_index > 0 {
908 self.active_pill.set(Some(pill_index - 1));
909 }
910 }
911 Key::ArrowRight => {
912 event.prevent_default();
913 self.popover_pill.set(None);
914 let len = self.selected_tags.read().len();
915 if pill_index < len - 1 {
916 self.active_pill.set(Some(pill_index + 1));
917 } else {
918 self.active_pill.set(None); }
920 }
921 Key::Backspace | Key::Delete => {
922 if readonly {
923 return;
924 }
925 event.prevent_default();
926 if self.popover_pill.read().is_some() {
927 self.popover_pill.set(None);
929 } else {
930 let is_locked = self
932 .selected_tags
933 .read()
934 .get(pill_index)
935 .is_some_and(|t| t.is_locked());
936 if !is_locked {
937 let id = self.selected_tags.read()[pill_index].id().to_string();
938 self.remove_tag(&id);
939 let new_len = self.selected_tags.read().len();
940 if new_len == 0 {
941 self.active_pill.set(None);
942 } else if pill_index >= new_len {
943 self.active_pill.set(Some(new_len - 1));
944 }
945 }
947 }
948 }
949 Key::Home => {
950 event.prevent_default();
951 self.popover_pill.set(None);
952 self.active_pill.set(Some(0));
953 }
954 Key::End => {
955 event.prevent_default();
956 self.popover_pill.set(None);
957 let len = self.selected_tags.read().len();
958 if len > 0 {
959 self.active_pill.set(Some(len - 1));
960 }
961 }
962 Key::Escape => {
963 if self.popover_pill.read().is_some() {
965 self.popover_pill.set(None);
966 } else {
967 self.active_pill.set(None);
968 }
969 }
970 _ => {
971 if readonly {
972 return;
973 }
974 self.active_pill.set(None);
976 self.popover_pill.set(None);
977 }
978 }
979 }
980
981 pub fn handle_input_keydown(&mut self, event: Event<KeyboardData>) {
986 let key = event.key();
987 let readonly = *self.is_readonly.read();
988
989 if readonly {
991 match key {
992 Key::ArrowLeft => {
993 if self.search_query.read().is_empty() {
994 let len = self.selected_tags.read().len();
995 if len > 0 {
996 event.prevent_default();
997 self.active_pill.set(Some(len - 1));
998 }
999 }
1000 }
1001 Key::Escape => {
1002 self.active_pill.set(None);
1004 }
1005 _ => {}
1006 }
1007 return;
1008 }
1009
1010 match key {
1012 Key::ArrowLeft => {
1013 if self.search_query.read().is_empty() {
1015 let len = self.selected_tags.read().len();
1016 if len > 0 {
1017 event.prevent_default();
1018 self.active_pill.set(Some(len - 1));
1019 }
1020 }
1021 }
1022 Key::Enter => {
1023 event.prevent_default();
1024 let query = self.search_query.read().clone();
1025 let callback = *self.on_create.read();
1026 if !query.is_empty() {
1027 if *self.enforce_allow_list.read() {
1029 } else if let Some(cb) = callback {
1031 if let Some(tag) = cb.call(query) {
1032 self.create_tag(tag);
1033 }
1034 } else {
1035 let commit_cb = *self.on_commit.read();
1037 if let Some(handler) = commit_cb {
1038 handler.call(query);
1039 }
1040 }
1041 }
1042 }
1043 Key::Backspace => {
1044 if self.search_query.read().is_empty() {
1046 let tags = self.selected_tags.read();
1047 if let Some(pos) = tags.iter().rposition(|t| !t.is_locked()) {
1048 drop(tags);
1049 self.active_pill.set(Some(pos));
1050 }
1051 }
1052 }
1053 Key::Escape => {
1054 self.active_pill.set(None);
1056 }
1057 Key::Character(ref c) => {
1058 let delims = self.delimiters.read().clone();
1060 if let Some(delimiters) = delims
1061 && let Some(ch) = c.chars().next()
1062 && delimiters.contains(&ch)
1063 {
1064 event.prevent_default();
1065 if !*self.enforce_allow_list.read() {
1067 let query = self.search_query.read().clone();
1068 let callback = *self.on_create.read();
1069 if !query.is_empty() {
1070 if let Some(cb) = callback {
1071 if let Some(tag) = cb.call(query) {
1072 self.create_tag(tag);
1073 }
1074 } else {
1075 let commit_cb = *self.on_commit.read();
1077 if let Some(handler) = commit_cb {
1078 handler.call(query);
1079 }
1080 }
1081 }
1082 }
1083 }
1084 }
1085 _ => {}
1086 }
1087 }
1088}
1089
1090#[allow(clippy::type_complexity)]
1097pub fn use_tag_input<T: TagLike>(
1098 available_tags: Vec<T>,
1099 initial_selected: Vec<T>,
1100) -> TagInputState<T> {
1101 use_tag_input_with(TagInputConfig::new(available_tags, initial_selected))
1102}
1103
1104#[allow(clippy::type_complexity)]
1111pub fn use_tag_input_with<T: TagLike>(config: TagInputConfig<T>) -> TagInputState<T> {
1112 let instance_id = use_hook(|| INSTANCE_COUNTER.fetch_add(1, AtomicOrdering::Relaxed));
1113
1114 let internal_query = use_signal(String::new);
1117 let internal_selected = use_signal(|| config.initial_selected);
1118 let internal_available = use_signal(|| config.available_tags);
1119
1120 let search_query = config.query.unwrap_or(internal_query);
1121 let selected_tags = config.value.unwrap_or(internal_selected);
1122 let available_tags = internal_available;
1123
1124 let deny_list: Signal<Option<Vec<String>>> = use_signal(|| None);
1126 let filter: Signal<Option<fn(&T, &str) -> bool>> = use_signal(|| None);
1128
1129 let active_pill = use_signal(|| None);
1130 let popover_pill = use_signal(|| None);
1131 let on_create = use_signal(|| None);
1132 let on_remove = use_signal(|| None);
1133 let on_add = use_signal(|| None);
1134 let on_query_change: Signal<Option<EventHandler<String>>> = use_signal(|| None);
1135 let on_commit: Signal<Option<EventHandler<String>>> = use_signal(|| None);
1136 let is_disabled = use_signal(|| false);
1137 let status_message = use_signal(String::new);
1138 let on_paste = use_signal(|| None);
1139 let paste_delimiters = use_signal(|| None);
1140 let editing_pill = use_signal(|| None);
1142 let on_edit = use_signal(|| None);
1143 let on_reorder = use_signal(|| None);
1144 let delimiters = use_signal(|| None);
1146 let max_tags: Signal<Option<usize>> = use_signal(|| None);
1147 let is_at_limit = use_memo(move || match *max_tags.read() {
1148 Some(max) => selected_tags.read().len() >= max,
1149 None => false,
1150 });
1151 let validate = use_signal(|| None);
1152 let validation_error = use_signal(|| None);
1153 let allow_duplicates = use_signal(|| false);
1155 let on_duplicate = use_signal(|| None);
1156 let enforce_allow_list = use_signal(|| false);
1157 let min_tags: Signal<Option<usize>> = use_signal(|| None);
1158 let is_below_minimum = use_memo(move || match *min_tags.read() {
1159 Some(min) => selected_tags.read().len() < min,
1160 None => false,
1161 });
1162 let is_readonly = use_signal(|| false);
1163 let max_tag_length = use_signal(|| None);
1165 let max_visible_tags: Signal<Option<usize>> = use_signal(|| None);
1166 let overflow_count = use_memo(move || match *max_visible_tags.read() {
1167 Some(max) => {
1168 let len = selected_tags.read().len();
1169 len.saturating_sub(max)
1170 }
1171 None => 0,
1172 });
1173 let visible_tags = use_memo(move || {
1174 let tags = selected_tags.read().clone();
1175 match *max_visible_tags.read() {
1176 Some(max) => tags.into_iter().take(max).collect(),
1177 None => tags,
1178 }
1179 });
1180 let sort_selected: Signal<Option<fn(&T, &T) -> Ordering>> = use_signal(|| None);
1181 let form_value = use_memo(move || {
1183 let tags = selected_tags.read();
1184 let ids: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t.id())).collect();
1185 format!("[{}]", ids.join(","))
1186 });
1187 let select_mode = use_signal(|| false);
1188
1189 TagInputState {
1190 search_query,
1191 selected_tags,
1192 available_tags,
1193 active_pill,
1194 popover_pill,
1195 on_create,
1196 on_remove,
1197 on_add,
1198 on_query_change,
1199 on_commit,
1200 is_disabled,
1201 status_message,
1202 on_paste,
1203 paste_delimiters,
1204 editing_pill,
1205 on_edit,
1206 on_reorder,
1207 delimiters,
1208 max_tags,
1209 is_at_limit,
1210 validate,
1211 validation_error,
1212 allow_duplicates,
1214 on_duplicate,
1215 enforce_allow_list,
1216 deny_list,
1217 min_tags,
1218 is_below_minimum,
1219 is_readonly,
1220 max_tag_length,
1222 filter,
1223 max_visible_tags,
1224 overflow_count,
1225 visible_tags,
1226 sort_selected,
1227 form_value,
1229 select_mode,
1230 instance_id,
1231 }
1232}
1233
1234#[allow(clippy::type_complexity)]
1240pub fn use_tag_input_grouped<T: TagLike>(config: TagInputGroupConfig<T>) -> TagInputState<T> {
1241 let instance_id = use_hook(|| INSTANCE_COUNTER.fetch_add(1, AtomicOrdering::Relaxed));
1242
1243 let internal_query = use_signal(String::new);
1246 let internal_selected = use_signal(|| config.initial_selected);
1247 let internal_available = use_signal(|| config.available_tags);
1248
1249 let search_query = config.query.unwrap_or(internal_query);
1250 let selected_tags = config.value.unwrap_or(internal_selected);
1251 let available_tags = internal_available;
1252
1253 let deny_list: Signal<Option<Vec<String>>> = use_signal(|| None);
1255
1256 let active_pill = use_signal(|| None);
1257 let popover_pill = use_signal(|| None);
1258 let on_create = use_signal(|| None);
1259 let on_remove = use_signal(|| None);
1260 let on_add = use_signal(|| None);
1261 let on_query_change: Signal<Option<EventHandler<String>>> = use_signal(|| None);
1262 let on_commit: Signal<Option<EventHandler<String>>> = use_signal(|| None);
1263 let is_disabled = use_signal(|| false);
1264 let status_message = use_signal(String::new);
1265 let on_paste = use_signal(|| None);
1266 let paste_delimiters = use_signal(|| None);
1267 let editing_pill = use_signal(|| None);
1269 let on_edit = use_signal(|| None);
1270 let on_reorder = use_signal(|| None);
1271 let delimiters = use_signal(|| None);
1273 let max_tags: Signal<Option<usize>> = use_signal(|| None);
1274 let is_at_limit = use_memo(move || match *max_tags.read() {
1275 Some(max) => selected_tags.read().len() >= max,
1276 None => false,
1277 });
1278 let validate = use_signal(|| None);
1279 let validation_error = use_signal(|| None);
1280 let allow_duplicates = use_signal(|| false);
1282 let on_duplicate = use_signal(|| None);
1283 let enforce_allow_list = use_signal(|| false);
1284 let min_tags: Signal<Option<usize>> = use_signal(|| None);
1285 let is_below_minimum = use_memo(move || match *min_tags.read() {
1286 Some(min) => selected_tags.read().len() < min,
1287 None => false,
1288 });
1289 let is_readonly = use_signal(|| false);
1290 let max_tag_length = use_signal(|| None);
1292 let filter: Signal<Option<fn(&T, &str) -> bool>> = use_signal(|| None);
1293 let max_visible_tags: Signal<Option<usize>> = use_signal(|| None);
1294 let overflow_count = use_memo(move || match *max_visible_tags.read() {
1295 Some(max) => {
1296 let len = selected_tags.read().len();
1297 len.saturating_sub(max)
1298 }
1299 None => 0,
1300 });
1301 let visible_tags = use_memo(move || {
1302 let tags = selected_tags.read().clone();
1303 match *max_visible_tags.read() {
1304 Some(max) => tags.into_iter().take(max).collect(),
1305 None => tags,
1306 }
1307 });
1308 let sort_selected: Signal<Option<fn(&T, &T) -> Ordering>> = use_signal(|| None);
1309 let form_value = use_memo(move || {
1311 let tags = selected_tags.read();
1312 let ids: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t.id())).collect();
1313 format!("[{}]", ids.join(","))
1314 });
1315 let select_mode = use_signal(|| false);
1316
1317 TagInputState {
1318 search_query,
1319 selected_tags,
1320 available_tags,
1321 active_pill,
1322 popover_pill,
1323 on_create,
1324 on_remove,
1325 on_add,
1326 on_query_change,
1327 on_commit,
1328 is_disabled,
1329 status_message,
1330 on_paste,
1331 paste_delimiters,
1332 editing_pill,
1333 on_edit,
1334 on_reorder,
1335 delimiters,
1336 max_tags,
1337 is_at_limit,
1338 validate,
1339 validation_error,
1340 allow_duplicates,
1342 on_duplicate,
1343 enforce_allow_list,
1344 deny_list,
1345 min_tags,
1346 is_below_minimum,
1347 is_readonly,
1348 max_tag_length,
1350 filter,
1351 max_visible_tags,
1352 overflow_count,
1353 visible_tags,
1354 sort_selected,
1355 form_value,
1357 select_mode,
1358 instance_id,
1359 }
1360}
1361
1362pub(crate) fn format_status_added(name: &str, total: usize) -> String {
1367 format!(
1368 "{name} added. {total} tag{} selected.",
1369 if total == 1 { "" } else { "s" }
1370 )
1371}
1372
1373pub(crate) fn format_status_removed(name: &str, total: usize) -> String {
1374 format!(
1375 "{name} removed. {total} tag{} selected.",
1376 if total == 1 { "" } else { "s" }
1377 )
1378}
1379
1380pub(crate) fn format_status_pasted(added: usize, total: usize) -> String {
1381 format!(
1382 "{added} tag{} pasted. {total} tag{} selected.",
1383 if added == 1 { "" } else { "s" },
1384 if total == 1 { "" } else { "s" }
1385 )
1386}
1387
1388#[cfg(test)]
1389pub(crate) fn format_status_suggestions(count: usize) -> String {
1390 format!(
1391 "{count} suggestion{} available.",
1392 if count == 1 { "" } else { "s" }
1393 )
1394}
1395
1396pub(crate) fn split_by_delimiters(text: &str, delimiters: &[char]) -> Vec<String> {
1398 text.split(|c: char| delimiters.contains(&c))
1399 .map(|s| s.trim().to_string())
1400 .filter(|s| !s.is_empty())
1401 .collect()
1402}
1403
1404pub(crate) fn format_status_duplicate(name: &str) -> String {
1410 format!("{name} already exists.")
1411}
1412
1413pub(crate) fn format_status_denied(name: &str) -> String {
1415 format!("{name} is not allowed.")
1416}
1417
1418pub(crate) fn format_error_max_length(max_len: usize) -> String {
1420 format!("Tag must be {max_len} characters or fewer.")
1421}
1422
1423pub fn is_denied(name: &str, deny_list: &[String]) -> bool {
1429 let name_lower = name.to_lowercase();
1430 deny_list.iter().any(|b| b.to_lowercase() == name_lower)
1431}
1432
1433#[cfg(test)]
1434pub(crate) fn is_in_allow_list<T: TagLike>(id: &str, available: &[T]) -> bool {
1435 available.iter().any(|t| t.id() == id)
1436}
1437
1438#[cfg(test)]
1439pub(crate) fn filter_denied<T: TagLike>(items: &[T], deny_list: &[String]) -> Vec<T> {
1440 items
1441 .iter()
1442 .filter(|tag| !is_denied(tag.name(), deny_list))
1443 .cloned()
1444 .collect()
1445}
1446
1447#[cfg(test)]
1448pub(crate) fn compute_auto_complete_text(query: &str, suggestion_name: &str) -> String {
1449 if query.is_empty() {
1450 return String::new();
1451 }
1452 if suggestion_name
1453 .to_lowercase()
1454 .starts_with(&query.to_lowercase())
1455 {
1456 suggestion_name[query.len()..].to_string()
1457 } else {
1458 String::new()
1459 }
1460}
1461
1462#[cfg(test)]
1463pub(crate) fn format_form_value(ids: &[&str]) -> String {
1464 let quoted: Vec<String> = ids.iter().map(|id| format!("\"{}\"", id)).collect();
1465 format!("[{}]", quoted.join(","))
1466}
1467
1468#[cfg(test)]
1469pub(crate) fn compute_overflow(total: usize, max_visible: Option<usize>) -> usize {
1470 match max_visible {
1471 Some(max) => total.saturating_sub(max),
1472 None => 0,
1473 }
1474}
1475
1476#[cfg(test)]
1477pub(crate) fn is_below_min(count: usize, min_tags: Option<usize>) -> bool {
1478 match min_tags {
1479 Some(min) => count < min,
1480 None => false,
1481 }
1482}
1483
1484#[cfg(test)]
1485pub(crate) fn format_status_truncated(shown: usize, total: usize) -> String {
1486 format!("Showing {shown} of {total} suggestions. Type to refine.")
1487}
1488
1489#[cfg(target_arch = "wasm32")]
1509pub fn extract_clipboard_text(
1510 event: &dioxus::prelude::Event<dioxus::prelude::ClipboardData>,
1511) -> Option<String> {
1512 use wasm_bindgen::JsCast;
1513 let clip: &dioxus::prelude::ClipboardData = &event.data();
1514 let web_event: web_sys::Event = clip.downcast::<web_sys::Event>()?.clone();
1515 let clipboard_event: web_sys::ClipboardEvent = web_event.dyn_into().ok()?;
1516 let data_transfer = clipboard_event.clipboard_data()?;
1517 data_transfer.get_data("text/plain").ok()
1518}
1519
1520#[cfg(not(target_arch = "wasm32"))]
1521pub fn extract_clipboard_text(
1522 _event: &dioxus::prelude::Event<dioxus::prelude::ClipboardData>,
1523) -> Option<String> {
1524 None
1525}
1526
1527#[cfg(test)]
1529pub(crate) fn build_groups<T: TagLike>(
1530 items: &[T],
1531 sort_items: Option<fn(&T, &T) -> Ordering>,
1532 sort_groups: Option<fn(&str, &str) -> Ordering>,
1533 max_items_per_group: Option<usize>,
1534) -> Vec<SuggestionGroup<T>> {
1535 let mut group_order: Vec<String> = Vec::new();
1537 let mut group_map: Vec<(String, Vec<T>)> = Vec::new();
1538
1539 for item in items {
1540 let label = item.group().unwrap_or("").to_string();
1541 if let Some(pos) = group_order.iter().position(|l| l == &label) {
1542 group_map[pos].1.push(item.clone());
1543 } else {
1544 group_order.push(label.clone());
1545 group_map.push((label, vec![item.clone()]));
1546 }
1547 }
1548
1549 if let Some(cmp) = sort_groups {
1551 group_map.sort_by(|(a, _), (b, _)| cmp(a, b));
1552 }
1553
1554 group_map
1556 .into_iter()
1557 .map(|(label, mut items)| {
1558 if let Some(cmp) = sort_items {
1559 items.sort_by(cmp);
1560 }
1561 let total_count = items.len();
1562 if let Some(max) = max_items_per_group {
1563 items.truncate(max);
1564 }
1565 SuggestionGroup {
1566 label,
1567 items,
1568 total_count,
1569 }
1570 })
1571 .collect()
1572}