Fast, composable, unstyled command menu for Leptos.
- Copy Demo
- ButtonCheckboxDialogAlert Dialog
- CSS PillsCard Removal
Components
Extensions
use leptos::prelude::*; use crate::components::ui::command::{ Command, CommandContext, CommandGroup, CommandInput, CommandItemLink, CommandList, CommandProvider, }; use crate::components::ui::separator::Separator; #[component] pub fn DemoCommand() -> impl IntoView { // List of demo items with name and href let items_components = [ ("Button", "/core/button"), ("Checkbox", "/core/checkbox"), ("Dialog", "/core/dialog"), ("Alert Dialog", "/core/alert-dialog"), ]; let items_extensions = [("CSS Pills", "/extensions/css-pills"), ("Card Removal", "/extensions/card-removal")]; view! { <div class="p-4"> <CommandProvider> <Command class="rounded-lg border shadow-md w-[250px] md:w-[450px]"> <CommandInput placeholder="Search Components..." /> <CommandList> <CommandGroup heading="Components"> {move || { let context = use_context::<CommandContext>().expect("CommandContext not found"); let query = (context.search_query)().to_lowercase(); items_components .iter() .filter(|(name, _)| name.to_lowercase().contains(&query)) .map(|&(name, href)| { view! { <CommandItemLink href=href>{name}</CommandItemLink> } }) .collect::<Vec<_>>() }} </CommandGroup> <Separator class="my-1" /> <CommandGroup heading="Extensions"> {move || { let context = use_context::<CommandContext>().expect("CommandContext not found"); let query = (context.search_query)().to_lowercase(); items_extensions .iter() .filter(|(name, _)| name.to_lowercase().contains(&query)) .map(|&(name, href)| { view! { <CommandItemLink href=href>{name}</CommandItemLink> } }) .collect::<Vec<_>>() }} </CommandGroup> </CommandList> </Command> </CommandProvider> </div> } }
Installation
You can run either of the following commands:
# cargo install ui-cli --forceui add demo_commandui add command
Update the imports to match your project setup.
Copy and paste the following code into your project:
components/ui/command.rs
use std::sync::Arc; use leptos::html::Input; use leptos::prelude::*; use leptos_ui::clx; use tw_merge::*; use wasm_bindgen::JsCast; use crate::components::_coming_soon::dialog::{DialogContent, DialogTrigger}; const FOCUS_VISIBLE_BG_ACCCENT_70: &str = "focus-visible:outline-hidden focus-visible:bg-accent/70"; const HOVER_BG_ACCENT: &str = "hover:bg-accent"; const DISABLED_EVENTS_NONE: &str = "disabled:pointer-events-none disabled:opacity-50"; const FLEX_ITEMS_CENTER: &str = "flex items-center"; const FLEX_WIDTH_FULL: &str = "flex w-full"; const FILE_STYLES: &str = "file:bg-transparent file:text-sm file:font-medium file:border-0"; const BORDER_INPUT: &str = "border border-input"; const DISABLED_NOT_ALLOWED: &str = "disabled:cursor-not-allowed disabled:opacity-50"; const RING_OFFSET_BG: &str = "ring-offset-background"; const PLACEHOLDER_MUTED_FOREGROUND: &str = "placeholder:text-muted-foreground"; // TODO UI. If the list of CommandItemLinks is empty, do not display the heading. // TODO UI. Handle arrow up / down to select item. mod components { use super::*; clx! {Command, div, "flex size-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground"} clx! {CommandList, ul, "shortfix__sidenav_todo_properly", "max-h-[300px] overflow-y-auto overflow-x-hidden"} } pub use components::*; /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* ✨ FUNCTIONS ✨ */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ #[derive(Clone)] pub struct CommandContext { // TODO. Reset the search_query when we click on a CommandItemLink (that navigates to a new page). pub search_query: ReadSignal<String>, pub set_search_query: Arc<dyn Fn(String) + Send + Sync>, } #[allow(unused_braces)] #[component] pub fn CommandProvider(children: Children) -> impl IntoView { let (search_query, set_search_query) = signal(String::new()); let context = CommandContext { search_query, set_search_query: Arc::new(set_search_query) }; provide_context(context); view! { {children()} } } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* ✨ FUNCTIONS ✨ */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ #[component] pub fn CommandDialog( children: Children, #[prop(into, optional)] class: Signal<String>, #[prop(into, optional)] hide_close_button: Option<bool>, ) -> impl IntoView { let class = tw_merge!("p-O", class()); view! { <DialogContent class=class hide_close_button=hide_close_button.unwrap_or(false)> <CommandProvider> <Command>{children()}</Command> </CommandProvider> </DialogContent> } } #[component] pub fn CommandTrigger(children: Children, #[prop(into, optional)] class: Signal<String>) -> impl IntoView { view! { <DialogTrigger class=class()>{children()}</DialogTrigger> } } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* ✨ FUNCTIONS ✨ */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ #[component] pub fn CommandGroup( #[prop(into, optional)] class: Signal<String>, #[prop(into)] heading: String, children: Children, ) -> impl IntoView { let class = Memo::new(move |_| tw_merge!("", class())); view! { <> {Some(heading) .map(|heading: String| { view! { <h3 class="py-1 px-2 text-xs font-semibold text-muted-foreground">{heading}</h3> } })} <li class=class>{children()}</li> </> } } #[component] pub fn CommandItemLink( #[prop(into, optional)] class: Signal<String>, href: &'static str, children: Children, ) -> impl IntoView { let class = Memo::new(move |_| { tw_merge!( // STYLES::RING_FOCUS_VISIBLE, FLEX_ITEMS_CENTER, DISABLED_EVENTS_NONE, FOCUS_VISIBLE_BG_ACCCENT_70, HOVER_BG_ACCENT, "aria-selected:bg-accent aria-selected:text-accent-foreground", "cursor-pointer outline-hidden", "relative py-1.5 px-2 text-sm rounded-xs", class() ) }); // * 💁 Shortfix to fix issue issue with using CommandDialog in a Navbar for navigation. // TODO: Add proper dialog close functionality with new dialog system view! { <a class=class href=href tabindex="0"> {children()} </a> } } #[component] pub fn CommandInput( #[prop(optional, into)] class: String, #[prop(optional, into, default = "text")] r#type: &'static str, #[prop(optional_no_strip)] value: Option<ReadSignal<String>>, #[prop(optional, into)] placeholder: Option<&'static str>, #[prop(optional, into)] name: Option<&'static str>, #[prop(optional, into)] id: Option<&'static str>, #[prop(optional, into)] autofocus: bool, #[prop(optional, into)] node_ref: NodeRef<Input>, ) -> impl IntoView { let context = use_context::<CommandContext>().expect("CommandContext should be provided."); let class = tw_merge!( PLACEHOLDER_MUTED_FOREGROUND, FILE_STYLES, DISABLED_NOT_ALLOWED, RING_OFFSET_BG, BORDER_INPUT, FLEX_WIDTH_FULL, "outline-hidden", "h-10 rounded-md bg-background px-3 py-2 text-sm", class ); view! { <input type=r#type class=class name=name id=id placeholder=placeholder value=value node_ref=node_ref autofocus=autofocus on:input=move |e| (context .set_search_query)( e .target() .expect("target should be available") .dyn_into::<web_sys::HtmlInputElement>() .expect("target should be available") .value(), ) /> } }
Update the imports to match your project setup.
Usage
// Coming soon 🦀
Examples
1. Command Dialog
- Copy Demo
- ButtonCheckboxInputTextareaDialogAlert Dialog
- Beam BorderBlockquote
Components
Extensions
use leptos::prelude::*; use crate::components::_coming_soon::dialog::Dialog; use crate::components::ui::command::{ CommandContext, CommandDialog, CommandGroup, CommandInput, CommandItemLink, CommandList, CommandTrigger, }; use crate::components::ui::separator::Separator; #[component] pub fn DemoCommandDialog() -> impl IntoView { // List of demo items with name and href let items_components = [ ("Button", "/docs/components/button"), ("Checkbox", "/docs/components/checkbox"), ("Input", "/docs/components/input"), ("Textarea", "/docs/components/textarea"), ("Dialog", "/docs/components/dialog"), ("Alert Dialog", "/docs/components/alert-dialog"), ]; let items_extensions = [("Beam Border", "/docs/extensions/beam-border"), ("Blockquote", "/docs/extensions/blockquote")]; view! { <Dialog> <CommandTrigger>"Open Command Dialog"</CommandTrigger> <CommandDialog> <CommandInput placeholder="Search Components & Hooks..." autofocus=true /> <CommandList> <CommandGroup heading="Components"> {move || { let context = use_context::<CommandContext>().expect("CommandContext not found"); let query = (context.search_query)().to_lowercase(); items_components .iter() .filter(|(name, _)| name.to_lowercase().contains(&query)) .map(|&(name, href)| { view! { <CommandItemLink href=href>{name}</CommandItemLink> } }) .collect::<Vec<_>>() }} </CommandGroup> <Separator class="my-1" /> <CommandGroup heading="Extensions"> {move || { let context = use_context::<CommandContext>().expect("CommandContext not found"); let query = (context.search_query)().to_lowercase(); items_extensions .iter() .filter(|(name, _)| name.to_lowercase().contains(&query)) .map(|&(name, href)| { view! { <CommandItemLink href=href>{name}</CommandItemLink> } }) .collect::<Vec<_>>() }} </CommandGroup> </CommandList> </CommandDialog> </Dialog> } }