dioxus_ui_system/molecules/
multi_select.rs1use crate::atoms::select::SelectOption;
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Props, Clone, PartialEq)]
12pub struct MultiSelectProps {
13 pub options: Vec<SelectOption>,
15 #[props(default)]
17 pub value: Vec<String>,
18 pub on_change: EventHandler<Vec<String>>,
20 #[props(default)]
22 pub placeholder: Option<String>,
23 #[props(default = false)]
25 pub disabled: bool,
26 #[props(default)]
28 pub max_selected: Option<usize>,
29 #[props(default = false)]
31 pub creatable: bool,
32 #[props(default = true)]
34 pub searchable: bool,
35 #[props(default)]
37 pub class: Option<String>,
38}
39
40#[component]
42pub fn MultiSelect(props: MultiSelectProps) -> Element {
43 let _theme = use_theme();
44 let mut is_open = use_signal(|| false);
45 let mut search_value = use_signal(|| String::new());
46 let mut highlighted_index = use_signal(|| 0usize);
47 let mut selected_values = use_signal(|| props.value.clone());
48 let options = props.options.clone();
49
50 use_effect(move || {
52 selected_values.set(props.value.clone());
53 });
54
55 let class_css = props
56 .class
57 .as_ref()
58 .map(|c| format!(" {}", c))
59 .unwrap_or_default();
60
61 let filtered_options: Memo<Vec<SelectOption>> = use_memo({
63 let options = options.clone();
64 let searchable = props.searchable;
65 move || {
66 let search = search_value().to_lowercase();
67 if search.is_empty() || !searchable {
68 options.clone()
69 } else {
70 options
71 .iter()
72 .filter(|o| {
73 o.label.to_lowercase().contains(&search)
74 || o.value.to_lowercase().contains(&search)
75 })
76 .cloned()
77 .collect()
78 }
79 }
80 });
81
82 let is_at_max = move || {
84 props
85 .max_selected
86 .map(|max| selected_values().len() >= max)
87 .unwrap_or(false)
88 };
89
90 let toggle_option = use_callback({
92 let mut selected_values = selected_values.clone();
93 let on_change = props.on_change.clone();
94 move |value: String| {
95 let mut new_values = selected_values();
96 if new_values.contains(&value) {
97 new_values.retain(|v| v != &value);
98 } else if !is_at_max() {
99 new_values.push(value);
100 }
101 selected_values.set(new_values.clone());
102 on_change.call(new_values);
103 }
104 });
105
106 let remove_value = use_callback({
108 let mut selected_values = selected_values.clone();
109 let on_change = props.on_change.clone();
110 move |value: String| {
111 let mut new_values = selected_values();
112 new_values.retain(|v| v != &value);
113 selected_values.set(new_values.clone());
114 on_change.call(new_values);
115 }
116 });
117
118 let select_all = use_callback({
120 let mut selected_values = selected_values.clone();
121 let on_change = props.on_change.clone();
122 let options = options.clone();
123 let max_selected = props.max_selected;
124 move |()| {
125 let mut new_values: Vec<String> = options
126 .iter()
127 .filter(|o| !o.disabled)
128 .map(|o| o.value.clone())
129 .collect();
130
131 if let Some(max) = max_selected {
133 new_values.truncate(max);
134 }
135
136 selected_values.set(new_values.clone());
137 on_change.call(new_values);
138 }
139 });
140
141 let clear_all = use_callback({
143 let mut selected_values = selected_values.clone();
144 let on_change = props.on_change.clone();
145 move |()| {
146 selected_values.set(Vec::new());
147 on_change.call(Vec::new());
148 }
149 });
150
151 let create_option = use_callback({
153 let mut selected_values = selected_values.clone();
154 let mut search_value = search_value.clone();
155 let on_change = props.on_change.clone();
156 move |()| {
157 let value = search_value().trim().to_string();
158 if !value.is_empty() && !is_at_max() {
159 let mut new_values = selected_values();
160 if !new_values.contains(&value) {
161 new_values.push(value);
162 selected_values.set(new_values.clone());
163 on_change.call(new_values);
164 }
165 search_value.set(String::new());
166 }
167 }
168 });
169
170 let handle_key_down = {
172 let filtered_options = filtered_options.clone();
173 let creatable = props.creatable;
174 move |e: Event<dioxus::html::KeyboardData>| {
175 use dioxus::html::input_data::keyboard_types::Key;
176 let filtered = filtered_options();
177
178 match e.key() {
179 Key::ArrowDown => {
180 e.prevent_default();
181 if !is_open() {
182 is_open.set(true);
183 }
184 let max = if creatable && !search_value().trim().is_empty() {
185 filtered.len()
186 } else {
187 filtered.len().saturating_sub(1)
188 };
189 highlighted_index.with_mut(|i| *i = (*i + 1).min(max));
190 }
191 Key::ArrowUp => {
192 e.prevent_default();
193 highlighted_index.with_mut(|i| *i = i.saturating_sub(1));
194 }
195 Key::Enter => {
196 e.prevent_default();
197 if is_open() {
198 if let Some(option) = filtered.get(highlighted_index()) {
199 if !option.disabled {
200 toggle_option.call(option.value.clone());
201 }
202 } else if creatable
203 && !search_value().trim().is_empty()
204 && highlighted_index() == filtered.len()
205 {
206 create_option.call(());
207 }
208 } else {
209 is_open.set(true);
210 }
211 }
212 Key::Escape => {
213 is_open.set(false);
214 }
215 Key::Backspace => {
216 if search_value().is_empty() && !selected_values().is_empty() {
217 if let Some(last) = selected_values().last() {
218 let last_value = last.clone();
219 remove_value.call(last_value);
220 }
221 }
222 }
223 _ => {}
224 }
225 }
226 };
227
228 let container_style = use_style(move |t| {
230 let border_color = if is_open() {
231 t.colors.ring.to_rgba()
232 } else {
233 t.colors.border.to_rgba()
234 };
235
236 Style::new()
237 .w_full()
238 .min_h_px(40)
239 .px(&t.spacing, "sm")
240 .py(&t.spacing, "xs")
241 .rounded(&t.radius, "md")
242 .border(1, &t.colors.border)
243 .bg(&t.colors.background)
244 .flex()
245 .flex_wrap()
246 .items_center()
247 .gap_px(6)
248 .cursor(if props.disabled {
249 "not-allowed"
250 } else {
251 "pointer"
252 })
253 .transition("all 150ms ease")
254 .build()
255 + &format!("; border-color: {}", border_color)
256 });
257
258 let container_shadow_style = use_style(move |t| {
260 if is_open() {
261 format!("box-shadow: 0 0 0 1px {}", t.colors.ring.to_rgba())
262 } else {
263 String::new()
264 }
265 });
266
267 let tag_style = use_style(|t| {
269 Style::new()
270 .inline_flex()
271 .items_center()
272 .gap_px(4)
273 .px(&t.spacing, "sm")
274 .py(&t.spacing, "xs")
275 .rounded(&t.radius, "sm")
276 .bg(&t.colors.secondary)
277 .text_color(&t.colors.secondary_foreground)
278 .font_size(12)
279 .build()
280 });
281
282 let dropdown_style = use_style(|t| {
284 Style::new()
285 .absolute()
286 .top("calc(100% + 4px)")
287 .left("0")
288 .w_full()
289 .max_h_px(250)
290 .rounded(&t.radius, "md")
291 .border(1, &t.colors.border)
292 .bg(&t.colors.popover)
293 .shadow(&t.shadows.lg)
294 .overflow_auto()
295 .z_index(9999)
296 .build()
297 });
298
299 let item_style_base = use_style(|t| {
301 Style::new()
302 .w_full()
303 .px(&t.spacing, "md")
304 .py(&t.spacing, "sm")
305 .flex()
306 .items_center()
307 .gap_px(8)
308 .cursor("pointer")
309 .transition("all 100ms ease")
310 .build()
311 });
312
313 let get_label = use_callback({
315 let options = options.clone();
316 move |value: String| -> String {
317 options
318 .iter()
319 .find(|o| o.value == value)
320 .map(|o| o.label.clone())
321 .unwrap_or_else(|| value.to_string())
322 }
323 });
324
325 let all_selected = move || {
327 let filtered = filtered_options();
328 let selected = selected_values();
329 !filtered.is_empty()
330 && filtered
331 .iter()
332 .all(|o| o.disabled || selected.contains(&o.value))
333 };
334
335 let has_selection = move || !selected_values().is_empty();
337
338 let can_create = move || {
340 props.creatable
341 && !search_value().trim().is_empty()
342 && !options
343 .iter()
344 .any(|o| o.label.to_lowercase() == search_value().trim().to_lowercase())
345 };
346
347 let placeholder_text = props
348 .placeholder
349 .clone()
350 .unwrap_or_else(|| "Select items...".to_string());
351
352 let muted_color = use_style(|t| t.colors.muted.to_rgba());
354 let border_color = use_style(|t| t.colors.border.to_rgba());
355 let primary_color = use_style(|t| t.colors.primary.to_rgba());
356 let destructive_color = use_style(|t| t.colors.destructive.to_rgba());
357 let foreground_color = use_style(|t| t.colors.foreground.to_rgba());
358
359 let highlighted_bg = use_style(|t| t.colors.muted.to_rgba());
361
362 rsx! {
363 div {
364 class: "multi-select{class_css}",
365 style: "position: relative;",
366
367 div {
369 class: "multi-select-container",
370 style: "{container_style}; {container_shadow_style}",
371 onclick: move |_| {
372 if !props.disabled {
373 is_open.toggle();
374 }
375 },
376
377 for value in selected_values().iter() {
379 {
380 let label = get_label.call(value.clone());
381 let value_clone = value.clone();
382 rsx! {
383 span {
384 key: "{value_clone}",
385 class: "multi-select-tag",
386 style: "{tag_style}",
387
388 "{label}"
389
390 button {
391 r#type: "button",
392 class: "multi-select-tag-remove",
393 style: "display: inline-flex; align-items: center; justify-content: center; margin-left: 4px; padding: 2px; background: none; border: none; cursor: pointer; font-size: 12px; color: inherit; opacity: 0.7; border-radius: 50%; transition: opacity 0.15s;",
394 onclick: move |e: Event<dioxus::html::MouseData>| {
395 e.stop_propagation();
396 remove_value.call(value_clone.clone());
397 },
398 "✕"
399 }
400 }
401 }
402 }
403 }
404
405 if props.searchable && !props.disabled {
407 input {
408 r#type: "text",
409 class: "multi-select-input",
410 style: "flex: 1; min-width: 80px; border: none; outline: none; font-size: 14px; padding: 4px; background: transparent;",
411 placeholder: if selected_values().is_empty() { "{placeholder_text}" } else { "" },
412 value: "{search_value}",
413 disabled: props.disabled,
414 oninput: move |e: Event<FormData>| {
415 search_value.set(e.value());
416 highlighted_index.set(0);
417 is_open.set(true);
418 },
419 onkeydown: handle_key_down,
420 onclick: move |e: Event<dioxus::html::MouseData>| {
421 e.stop_propagation();
422 },
423 }
424 } else if selected_values().is_empty() {
425 span {
426 class: "multi-select-placeholder",
427 style: "color: {muted_color}; font-size: 14px;",
428 "{placeholder_text}"
429 }
430 }
431
432 span {
434 class: "multi-select-arrow",
435 style: "margin-left: auto; color: {muted_color}; transition: transform 0.2s;",
436 style: if is_open() { "transform: rotate(180deg);" } else { "" },
437 "▼"
438 }
439 }
440
441 if is_open() && !props.disabled {
443 div {
445 style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998;",
446 onclick: move |_| is_open.set(false),
447 }
448 div {
449 class: "multi-select-dropdown",
450 style: "{dropdown_style}",
451
452 if !props.searchable {
454 div {
455 class: "multi-select-dropdown-search",
456 style: "padding: 8px 12px; border-bottom: 1px solid {border_color};",
457
458 input {
459 r#type: "text",
460 class: "multi-select-search-input",
461 style: "width: 100%; padding: 8px 12px; border: 1px solid {border_color}; border-radius: 6px; font-size: 14px; outline: none;",
462 placeholder: "Search...",
463 value: "{search_value}",
464 oninput: move |e: Event<FormData>| {
465 search_value.set(e.value());
466 highlighted_index.set(0);
467 },
468 }
469 }
470 }
471
472 div {
474 class: "multi-select-actions",
475 style: "display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid {border_color};",
476
477 button {
478 r#type: "button",
479 class: "multi-select-select-all",
480 style: "font-size: 12px; color: {primary_color}; background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px;",
481 disabled: all_selected() || is_at_max(),
482 onclick: move |e: Event<dioxus::html::MouseData>| {
483 e.stop_propagation();
484 select_all.call(());
485 },
486 "Select All"
487 }
488
489 button {
490 r#type: "button",
491 class: "multi-select-clear-all",
492 style: "font-size: 12px; color: {destructive_color}; background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px;",
493 disabled: !has_selection(),
494 onclick: move |e: Event<dioxus::html::MouseData>| {
495 e.stop_propagation();
496 clear_all.call(());
497 },
498 "Clear All"
499 }
500 }
501
502 div {
504 class: "multi-select-options",
505 style: "max-height: 200px; overflow-y: auto;",
506
507 if filtered_options().is_empty() && !can_create() {
508 div {
509 class: "multi-select-empty",
510 style: "padding: 16px; text-align: center; color: {muted_color}; font-size: 14px;",
511 "No options found"
512 }
513 } else {
514 for (index, option) in filtered_options().iter().enumerate() {
515 MultiSelectOptionItem {
516 key: "{option.value}",
517 option: option.clone(),
518 is_selected: selected_values().contains(&option.value),
519 is_highlighted: index == highlighted_index(),
520 is_disabled: option.disabled || (!selected_values().contains(&option.value) && is_at_max()),
521 item_style_base: item_style_base.clone(),
522 primary_color: primary_color.clone(),
523 border_color: border_color.clone(),
524 foreground_color: foreground_color.clone(),
525 highlighted_bg: highlighted_bg.clone(),
526 on_toggle: toggle_option.clone(),
527 set_highlighted_index: {
528 let mut idx = highlighted_index.clone();
529 Callback::new(move |i: usize| idx.set(i))
530 },
531 index,
532 }
533 }
534
535 if can_create() {
537 MultiSelectCreateOptionItem {
538 search_value: search_value().trim().to_string(),
539 is_highlighted: highlighted_index() == filtered_options().len(),
540 item_style_base: item_style_base.clone(),
541 primary_color: primary_color.clone(),
542 highlighted_bg: highlighted_bg.clone(),
543 border_color: border_color.clone(),
544 on_create: create_option.clone(),
545 set_highlighted_index: {
546 let idx = filtered_options().len();
547 let mut hi = highlighted_index.clone();
548 Callback::new(move |_: ()| hi.set(idx))
549 },
550 }
551 }
552 }
553 }
554
555 if props.max_selected.is_some() || has_selection() {
557 div {
558 class: "multi-select-footer",
559 style: "padding: 8px 12px; border-top: 1px solid {border_color}; font-size: 12px; color: {muted_color}; text-align: center;",
560
561 if let Some(max) = props.max_selected {
562 "{selected_values().len()} / {max} selected"
563 } else {
564 "{selected_values().len()} selected"
565 }
566 }
567 }
568 }
569 }
570 }
571 }
572}
573
574#[derive(Props, Clone, PartialEq)]
575struct MultiSelectOptionItemProps {
576 option: SelectOption,
577 is_selected: bool,
578 is_highlighted: bool,
579 is_disabled: bool,
580 item_style_base: String,
581 primary_color: String,
582 border_color: String,
583 foreground_color: String,
584 highlighted_bg: String,
585 on_toggle: Callback<String>,
586 set_highlighted_index: Callback<usize>,
587 index: usize,
588}
589
590#[component]
591fn MultiSelectOptionItem(props: MultiSelectOptionItemProps) -> Element {
592 let bg_color = if props.is_highlighted {
593 props.highlighted_bg.clone()
594 } else {
595 "transparent".to_string()
596 };
597
598 let opacity = if props.is_disabled { "0.5" } else { "1" };
599 let checkbox_border = if props.is_selected {
600 props.primary_color.clone()
601 } else {
602 props.border_color.clone()
603 };
604 let checkbox_bg = if props.is_selected {
605 props.primary_color.clone()
606 } else {
607 "transparent".to_string()
608 };
609 let value = props.option.value.clone();
610 let idx = props.index;
611
612 rsx! {
613 div {
614 class: "multi-select-option",
615 style: "{props.item_style_base}; background: {bg_color}; opacity: {opacity};",
616 onclick: move |_| {
617 if !props.is_disabled {
618 props.on_toggle.call(value.clone());
619 }
620 },
621 onmouseenter: move |_| props.set_highlighted_index.call(idx),
622
623 div {
625 class: "multi-select-checkbox",
626 style: "width: 16px; height: 16px; border: 1px solid {checkbox_border}; border-radius: 4px; display: flex; align-items: center; justify-content: center; background: {checkbox_bg}; transition: all 0.15s;",
627
628 if props.is_selected {
629 svg {
630 view_box: "0 0 24 24",
631 fill: "none",
632 stroke: "white",
633 stroke_width: "3",
634 stroke_linecap: "round",
635 stroke_linejoin: "round",
636 style: "width: 12px; height: 12px;",
637 polyline { points: "20 6 9 17 4 12" }
638 }
639 }
640 }
641
642 span {
643 class: "multi-select-option-label",
644 style: "flex: 1; font-size: 14px; color: {props.foreground_color};",
645 "{props.option.label}"
646 }
647 }
648 }
649}
650
651#[derive(Props, Clone, PartialEq)]
652struct MultiSelectCreateOptionItemProps {
653 search_value: String,
654 is_highlighted: bool,
655 item_style_base: String,
656 primary_color: String,
657 highlighted_bg: String,
658 border_color: String,
659 on_create: Callback<()>,
660 set_highlighted_index: Callback<()>,
661}
662
663#[component]
664fn MultiSelectCreateOptionItem(props: MultiSelectCreateOptionItemProps) -> Element {
665 let bg_color = if props.is_highlighted {
666 props.highlighted_bg.clone()
667 } else {
668 "transparent".to_string()
669 };
670
671 rsx! {
672 div {
673 class: "multi-select-create",
674 style: "{props.item_style_base}; background: {bg_color}; border-top: 1px solid {props.border_color};",
675 onclick: move |_| props.on_create.call(()),
676 onmouseenter: move |_| props.set_highlighted_index.call(()),
677
678 span {
679 style: "font-size: 14px; color: {props.primary_color};",
680 "+ Create \"{props.search_value}\""
681 }
682 }
683 }
684}