Command

Fast, composable, unstyled command menu for Leptos.

  • Rust UI Icons - CopyCopy Demo
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 --force
ui add demo_command
ui 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

  • Rust UI Icons - CopyCopy Demo
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>
    }
}