Skip to main content

dioxus_ui_system/molecules/
multi_select.rs

1//! MultiSelect molecule component
2//!
3//! A dropdown that allows selecting multiple items with tag/chip display.
4
5use crate::atoms::select::SelectOption;
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// MultiSelect properties
11#[derive(Props, Clone, PartialEq)]
12pub struct MultiSelectProps {
13    /// Available options
14    pub options: Vec<SelectOption>,
15    /// Currently selected values
16    #[props(default)]
17    pub value: Vec<String>,
18    /// Callback when selection changes
19    pub on_change: EventHandler<Vec<String>>,
20    /// Placeholder text when no value selected
21    #[props(default)]
22    pub placeholder: Option<String>,
23    /// Whether the select is disabled
24    #[props(default = false)]
25    pub disabled: bool,
26    /// Maximum number of selections allowed
27    #[props(default)]
28    pub max_selected: Option<usize>,
29    /// Allow creating new options
30    #[props(default = false)]
31    pub creatable: bool,
32    /// Enable search/filter functionality
33    #[props(default = true)]
34    pub searchable: bool,
35    /// Additional CSS classes
36    #[props(default)]
37    pub class: Option<String>,
38}
39
40/// MultiSelect molecule component
41#[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    // Sync with props
51    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    // Filter options based on search
62    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    // Check if at max selection
83    let is_at_max = move || {
84        props
85            .max_selected
86            .map(|max| selected_values().len() >= max)
87            .unwrap_or(false)
88    };
89
90    // Toggle option selection
91    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    // Remove a selected value
107    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    // Select all options
119    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            // Respect max_selected limit
132            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    // Clear all selections
142    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    // Create new option
152    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    // Handle keyboard navigation
171    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    // Container styles
229    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    // Container focus shadow style
259    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    // Tag styles for selected items
268    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    // Dropdown styles - uses absolute positioning with high z-index
283    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    // Dropdown item styles
300    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    // Get label for a value
314    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    // Check if all filtered options are selected
326    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    // Check if any option is selected
336    let has_selection = move || !selected_values().is_empty();
337
338    // Can create new option from search
339    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    // Colors for styling
353    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    // Get highlighted background color
360    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            // Selected tags container
368            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                // Selected tags
378                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                // Search input or placeholder
406                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                // Dropdown arrow
433                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            // Dropdown with overlay for click-outside
442            if is_open() && !props.disabled {
443                // Overlay to capture clicks outside
444                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                    // Search bar in dropdown (when not searchable in input)
453                    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                    // Actions bar
473                    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                    // Options list
503                    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                            // Create option
536                            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                    // Selection count footer
556                    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            // Checkbox indicator
624            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}