MultiSelect

Rust/UI component that displays a dropdown menu that allows the user to select several options.

select
No fruits selected
use std::collections::HashSet;

use leptos::prelude::*;

use crate::components::ui::multi_select::{
    MultiSelect, MultiSelectContent, MultiSelectGroup, MultiSelectItem, MultiSelectOption, MultiSelectTrigger,
    MultiSelectValue,
};

const FRUITS: [&str; 5] = ["Apple", "Banana", "Orange", "Strawberry", "Mango"];

#[component]
pub fn DemoMultiSelect() -> impl IntoView {
    let fruits_signal = RwSignal::new(HashSet::<String>::new());

    view! {
        <div class="flex flex-col gap-4">
            <span class="text-sm">
                {move || {
                    let values = fruits_signal.get();
                    if values.is_empty() {
                        "No fruits selected".to_string()
                    } else {
                        format!("Selected: {}", values.iter().cloned().collect::<Vec<_>>().join(", "))
                    }
                }}
            </span>

            <div class="mx-auto">
                <MultiSelect values=fruits_signal>
                    <MultiSelectTrigger class="w-[150px]">
                        <MultiSelectValue placeholder="Select fruits" />
                    </MultiSelectTrigger>

                    <MultiSelectContent>
                        <MultiSelectGroup>
                            {FRUITS
                                .into_iter()
                                .map(|fruit| {
                                    view! {
                                        <MultiSelectItem>
                                            <MultiSelectOption value=fruit>{fruit}</MultiSelectOption>
                                        </MultiSelectItem>
                                    }
                                })
                                .collect_view()}
                        </MultiSelectGroup>
                    </MultiSelectContent>
                </MultiSelect>
            </div>
        </div>
    }
}

Installation

You can run either of the following commands:

# cargo install ui-cli --force
ui add demo_multi_select
ui add multi_select

Update the imports to match your project setup.

Copy and paste the following code into your project:

components/ui/multi_select.rs

use std::collections::HashSet;

use icons::ChevronDown;
use leptos::context::Provider;
use leptos::prelude::*;
use strum::{AsRefStr, Display};
use tw_merge::*;

use crate::registry::hooks::use_random::use_random_id_for;
// * Reuse @select.rs
pub use crate::registry::ui::select::{
    SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
pub enum MultiSelectPosition {
    #[default]
    Below,
    Above,
}

/* ========================================================== */
/*                     ✨ FUNCTIONS ✨                        */
/* ========================================================== */

#[component]
pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
    let multi_select_ctx = use_context::<MultiSelectContext>().expect("MultiSelectValue must be inside a MultiSelect");

    view! {
        <span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
            {move || {
                let values = multi_select_ctx.values_signal.get();
                if values.is_empty() {
                    placeholder.clone()
                } else {
                    let count = values.len();
                    if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) }
                }
            }}
        </span>
    }
}

#[component]
pub fn MultiSelectOption(
    children: Children,
    #[prop(optional, into)] class: String,
    #[prop(optional, into)] value: Option<String>,
) -> impl IntoView {
    let multi_select_ctx =
        use_context::<MultiSelectContext>().expect("MultiSelectOption must be inside a MultiSelectContext");

    let value_clone = value.clone();
    let is_selected = Signal::derive(move || {
        if let Some(ref val) = value_clone {
            multi_select_ctx.values_signal.with(|values| values.contains(val))
        } else {
            false
        }
    });

    let class = tw_merge!(
        "auto__check__icon", // * @utils in style/tailwind.css
        "inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50",
        class
    );

    let aria_selected_attr = move || is_selected.get().to_string();

    view! {
        <button
            data-name="MultiSelectOption"
            class=class
            role="option"
            aria-selected=aria_selected_attr
            on:click=move |ev: web_sys::MouseEvent| {
                ev.prevent_default();
                ev.stop_propagation();
                if let Some(val) = value.clone() {
                    multi_select_ctx
                        .values_signal
                        .update(|values| {
                            if values.contains(&val) {
                                values.remove(&val);
                            } else {
                                values.insert(val);
                            }
                        });
                }
            }
        >
            {children()}
        </button>
    }
}

/* ========================================================== */
/*                     ✨ FUNCTIONS ✨                        */
/* ========================================================== */

#[derive(Clone)]
struct MultiSelectContext {
    target_id: String,
    values_signal: RwSignal<HashSet<String>>,
}

#[component]
pub fn MultiSelect(
    children: Children,
    #[prop(optional, into)] values: Option<RwSignal<HashSet<String>>>,
) -> impl IntoView {
    let multi_select_target_id = use_random_id_for("multi_select");
    let values_signal = values.unwrap_or_else(|| RwSignal::new(HashSet::<String>::new()));

    let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal };

    view! {
        <Provider value=multi_select_ctx>
            <div data-name="MultiSelect" class="relative w-fit">
                {children()}
            </div>
        </Provider>
    }
}

#[component]
pub fn MultiSelectTrigger(
    children: Children,
    #[prop(optional, into)] class: String,
    #[prop(optional, into)] id: String,
) -> impl IntoView {
    let multi_select_ctx =
        use_context::<MultiSelectContext>().expect("MultiSelectTrigger must be inside a MultiSelectContext");

    let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };

    let button_class = tw_merge!(
        "w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4  border bg-background border-input hover:bg-accent hover:text-accent-foreground",
        &peer_class,
        class
    );

    let button_id = if !id.is_empty() { id } else { format!("trigger_{}", multi_select_ctx.target_id) };

    view! {
        <button
            data-name="MultiSelectTrigger"
            class=button_class
            id=button_id
            tabindex="0"
            data-multi-select-trigger=multi_select_ctx.target_id
        >
            {children()}
            <ChevronDown class="text-muted-foreground" />
        </button>
    }
}

#[component]
pub fn MultiSelectContent(
    children: Children,
    #[prop(optional, into)] class: String,
    #[prop(default = MultiSelectPosition::default())] position: MultiSelectPosition,
) -> impl IntoView {
    let multi_select_ctx =
        use_context::<MultiSelectContext>().expect("MultiSelectContent must be inside a MultiSelectContext");

    let class = tw_merge!(
        "w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[position=Below]:top-[calc(100%+4px)] data-[position=Above]:bottom-[calc(100%+4px)]",
        class
    );

    let target_id_for_script = multi_select_ctx.target_id.clone();

    view! {
        <script src="/hooks/lock_scroll.js"></script>

        <div
            data-name="MultiSelectContent"
            class=class
            id=multi_select_ctx.target_id
            data-target="target__multi_select"
            data-state="closed"
            data-position=position.as_ref()
            style="pointer-events: none;"
        >
            {children()}
        </div>

        <script>
            {format!(
                r#"
                (function() {{
                    const setupMultiSelect = () => {{
                        const multiSelect = document.querySelector('#{}');
                        const trigger = document.querySelector('[data-multi-select-trigger="{}"]');

                        if (!multiSelect || !trigger) {{
                            setTimeout(setupMultiSelect, 50);
                            return;
                        }}

                        if (multiSelect.hasAttribute('data-initialized')) {{
                            return;
                        }}
                        multiSelect.setAttribute('data-initialized', 'true');

                        let isOpen = false;

                        const updatePosition = () => {{
                            const triggerRect = trigger.getBoundingClientRect();
                            const viewportHeight = window.innerHeight;
                            const spaceBelow = viewportHeight - triggerRect.bottom;
                            const spaceAbove = triggerRect.top;

                            // Determine if dropdown should go above or below
                            if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
                                multiSelect.setAttribute('data-position', 'Above');
                            }} else {{
                                multiSelect.setAttribute('data-position', 'Below');
                            }}

                            // Set min-width to match trigger
                            multiSelect.style.minWidth = `${{triggerRect.width}}px`;
                        }};

                        const openMultiSelect = () => {{
                            isOpen = true;

                            // Calculate position BEFORE locking scroll
                            updatePosition();

                            // Lock all scrollable elements
                            window.ScrollLock.lock();

                            multiSelect.setAttribute('data-state', 'open');
                            multiSelect.style.pointerEvents = 'auto';

                            // Close on click outside
                            setTimeout(() => {{
                                document.addEventListener('click', handleClickOutside);
                            }}, 0);
                        }};

                        const closeMultiSelect = () => {{
                            isOpen = false;
                            multiSelect.setAttribute('data-state', 'closed');
                            multiSelect.style.pointerEvents = 'none';
                            document.removeEventListener('click', handleClickOutside);

                            // Unlock scroll after animation (200ms delay)
                            window.ScrollLock.unlock(200);
                        }};

                        const handleClickOutside = (e) => {{
                            if (!multiSelect.contains(e.target) && !trigger.contains(e.target)) {{
                                closeMultiSelect();
                            }}
                        }};

                        // Toggle multi-select when trigger is clicked
                        trigger.addEventListener('click', (e) => {{
                            e.stopPropagation();
                            if (isOpen) {{
                                closeMultiSelect();
                            }} else {{
                                openMultiSelect();
                            }}
                        }});

                        // Handle ESC key to close
                        document.addEventListener('keydown', (e) => {{
                            if (e.key === 'Escape' && isOpen) {{
                                e.preventDefault();
                                closeMultiSelect();
                            }}
                        }});
                    }};

                    if (document.readyState === 'loading') {{
                        document.addEventListener('DOMContentLoaded', setupMultiSelect);
                    }} else {{
                        setupMultiSelect();
                    }}
                }})();
                "#,
                target_id_for_script,
                target_id_for_script,
            )}
        </script>
    }
}

Update the imports to match your project setup.

Usage

use crate::components::ui::multi_select::{
    MultiSelect,
    MultiSelectContent,
    MultiSelectGroup,
    MultiSelectItem,
    MultiSelectLabel,
    MultiSelectOption,
    MultiSelectTrigger,
    MultiSelectValue,
};
<MultiSelect>
    <MultiSelectTrigger>
        <MultiSelectValue placeholder="Select options" />
    </MultiSelectTrigger>
    <MultiSelectContent>
        <MultiSelectGroup>
            <MultiSelectLabel>"Options"</MultiSelectLabel>
            <MultiSelectItem>
                <MultiSelectOption value="option1">"Option 1"</MultiSelectOption>
            </MultiSelectItem>
            <MultiSelectItem>
                <MultiSelectOption value="option2">"Option 2"</MultiSelectOption>
            </MultiSelectItem>
        </MultiSelectGroup>
    </MultiSelectContent>
</MultiSelect>

Get notified when new stuff drops.

Rust/UI Icons - Send