MultiSelect
Rust/UI component that displays a dropdown menu that allows the user to select several options.
select
- Copy Demo
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_selectUpdate 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>