Command
Fast, composable, unstyled command menu for Leptos.
- ButtonCheckboxDialogAlert Dialog
- CSS PillsCard Removal
Components
Extensions
use leptos::prelude::*; use crate::components::ui::command::{ Command, CommandContext, CommandGroup, CommandInput, CommandItem, 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! { <CommandItem href=href>{name}</CommandItem> } }) .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! { <CommandItem href=href>{name}</CommandItem> } }) .collect::<Vec<_>>() }} </CommandGroup> </CommandList> </Command> </CommandProvider> </div> } }
Demos
1. Command Dialog
use leptos::prelude::*; use crate::components::ui::command::{ CommandContext, CommandDialog, CommandGroup, CommandInput, CommandItem, CommandList, CommandTrigger, }; use crate::components::ui::dialog::DialogProvider; use crate::components::ui::separator::Separator; #[component] pub fn DemoCommandDialog() -> impl IntoView { // List of demo items with name and href let items_components = [ ("Button", "/demos-core/button"), ("Checkbox", "/demos-core/checkbox"), ("Input", "/demos-core/input"), ("Textarea", "/demos-core/textarea"), ("Dialog", "/demos-core/dialog"), ("Alert Dialog", "/demos-core/alert-dialog"), ]; let items_hooks = [ ("Use Hover", "/demos-hooks/use-hover"), ("Use Cycle List", "/demos-hooks/use-cycle-list"), ]; view! { <div class="p-4"> <DialogProvider> <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! { <CommandItem href=href>{name}</CommandItem> } }) .collect::<Vec<_>>() }} </CommandGroup> <Separator class="my-1" /> <CommandGroup heading="Hooks"> {move || { let context = use_context::<CommandContext>() .expect("CommandContext not found"); let query = (context.search_query)().to_lowercase(); items_hooks .iter() .filter(|(name, _)| name.to_lowercase().contains(&query)) .map(|&(name, href)| { view! { <CommandItem href=href>{name}</CommandItem> } }) .collect::<Vec<_>>() }} </CommandGroup> </CommandList> </CommandDialog> </DialogProvider> </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::ui::_styles::STYLES; use crate::components::ui::dialog::{use_dialog_context, DialogComponent, DialogContent}; // TODO UI. If the list of CommandItems 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 CommandItem (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) -> impl IntoView { view! { <DialogComponent> <DialogContent class="overflow-hidden p-0 shadow-lg h-[300px]"> <CommandProvider> <Command>{children()}</Command> </CommandProvider> </DialogContent> </DialogComponent> } } #[component] pub fn CommandTrigger(children: Children) -> impl IntoView { let dialog_context = use_dialog_context(); let dialog_ref = dialog_context.dialog_ref; let show_command_dialog = move |_| { if let Some(dialog) = dialog_ref.get() { dialog.show_modal().expect("dialog should be available"); } }; view! { <button on:click=show_command_dialog // variant=buttonVariant::Outline class="inline-flex items-center gap-2 whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input hover:bg-accent hover:text-accent-foreground px-4 py-2 relative h-8 w-full justify-start rounded-[0.5rem] bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-56 xl:w-64" > {children()} </button> } } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* ✨ 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="px-2 py-1 text-xs font-semibold text-muted-foreground"> {heading} </h3> } })} <li class=class>{children()}</li> </> } } #[component] pub fn CommandItem( #[prop(into, optional)] class: Signal<String>, href: &'static str, children: Children, ) -> impl IntoView { let class = Memo::new(move |_| { tw_merge!( // STYLES::RING_FOCUS_VISIBLE, STYLES::FLEX_ITEMS_CENTER, STYLES::DISABLED_EVENTS_NONE, STYLES::FOCUS_VISIBLE_BG_ACCCENT_70, STYLES::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. let dialog_context = use_dialog_context(); let dialog_ref = dialog_context.dialog_ref; let on_click = move |_| { if let Some(dialog) = dialog_ref.get() { dialog.close(); } }; view! { <a class=class href=href on:click=on_click> {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!( STYLES::PLACEHOLDER_MUTED_FOREGROUND, STYLES::FILE_STYLES, STYLES::DISABLED_NOT_ALLOWED, STYLES::RING_OFFSET_BG, STYLES::BORDER_INPUT, STYLES::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 🦀