leptos_shadcn_command/
default.rs

1use leptos::prelude::*;
2use tailwind_fuse::tw_merge;
3
4const COMMAND_CLASS: &str = "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground";
5const COMMAND_INPUT_CLASS: &str = "flex items-center border-b px-3";
6const COMMAND_INPUT_WRAPPER_CLASS: &str = "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50";
7const COMMAND_LIST_CLASS: &str = "max-h-[300px] overflow-y-auto overflow-x-hidden";
8const COMMAND_EMPTY_CLASS: &str = "py-6 text-center text-sm";
9const COMMAND_GROUP_CLASS: &str = "overflow-hidden p-1 text-foreground";
10const COMMAND_GROUP_HEADING_CLASS: &str = "px-2 py-1.5 text-xs font-medium text-muted-foreground";
11const COMMAND_ITEM_CLASS: &str = "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50";
12const COMMAND_SHORTCUT_CLASS: &str = "ml-auto text-xs tracking-widest text-muted-foreground";
13const COMMAND_SEPARATOR_CLASS: &str = "-mx-1 h-px bg-border";
14
15#[component]
16pub fn Command(
17    #[prop(optional)] value: MaybeProp<String>,
18    #[prop(optional)] on_value_change: Option<Callback<String>>,
19    #[prop(optional)] class: MaybeProp<String>,
20    children: Children,
21) -> impl IntoView {
22    let search = RwSignal::new(String::new());
23    let selected_value = RwSignal::new(value.get().unwrap_or_default());
24    
25    // Update selected value when prop changes
26    Effect::new(move |_| {
27        if let Some(new_value) = value.get() {
28            selected_value.set(new_value);
29        }
30    });
31    
32    let merged_class = tw_merge!(&format!("{} {}", 
33        COMMAND_CLASS,
34        class.get().unwrap_or_default()
35    ));
36    
37    // Create context for child components
38    provide_context(CommandContext {
39        search,
40        selected_value,
41        on_value_change,
42    });
43    
44    view! {
45        <div 
46            class={merged_class}
47            role="combobox"
48            aria-expanded="true"
49        >
50            {children()}
51        </div>
52    }
53}
54
55#[component]
56pub fn CommandInput(
57    #[prop(optional)] placeholder: MaybeProp<String>,
58    #[prop(optional)] value: MaybeProp<String>,
59    #[prop(optional)] on_value_change: Option<Callback<String>>,
60    #[prop(optional)] class: MaybeProp<String>,
61) -> impl IntoView {
62    let context = expect_context::<CommandContext>();
63    let input_ref = NodeRef::<leptos::html::Input>::new();
64    let input_value = RwSignal::new(value.get().unwrap_or_default());
65    
66    let merged_class = tw_merge!(&format!("{} {}", 
67        COMMAND_INPUT_CLASS,
68        class.get().unwrap_or_default()
69    ));
70    
71    view! {
72        <div class={merged_class}>
73            <svg 
74                width="15" 
75                height="15" 
76                viewBox="0 0 15 15" 
77                fill="none" 
78                xmlns="http://www.w3.org/2000/svg"
79                class="mr-2 h-4 w-4 shrink-0 opacity-50"
80            >
81                <path 
82                    d="M10 6.5C10 8.433 8.433 10 6.5 10C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5ZM9.30884 10.0159C8.53901 10.6318 7.56251 11 6.5 11C4.01472 11 2 8.98528 2 6.5C2 4.01472 4.01472 2 6.5 2C8.98528 2 11 4.01472 11 6.5C11 7.56251 10.6318 8.53901 10.0159 9.30884L12.8536 12.1464C13.0488 12.3417 13.0488 12.6583 12.8536 12.8536C12.6583 13.0488 12.3417 13.0488 12.1464 12.8536L9.30884 10.0159Z" 
83                    fill="currentColor" 
84                    fill-rule="evenodd" 
85                    clip-rule="evenodd"
86                />
87            </svg>
88            <input
89                node_ref=input_ref
90                class=COMMAND_INPUT_WRAPPER_CLASS
91                placeholder={placeholder.get().unwrap_or("Type a command or search...".to_string())}
92                prop:value={input_value}
93                on:input=move |evt| {
94                    let value = event_target_value(&evt);
95                    input_value.set(value.clone());
96                    context.search.set(value.clone());
97                    
98                    if let Some(on_value_change) = on_value_change {
99                        on_value_change.run(value);
100                    }
101                }
102                autocomplete="off"
103                spellcheck="false"
104                aria-autocomplete="list"
105                role="combobox"
106                aria-expanded="true"
107            />
108        </div>
109    }
110}
111
112#[component]
113pub fn CommandList(
114    #[prop(optional)] class: MaybeProp<String>,
115    children: Children,
116) -> impl IntoView {
117    let merged_class = tw_merge!(&format!("{} {}", 
118        COMMAND_LIST_CLASS,
119        class.get().unwrap_or_default()
120    ));
121    
122    view! {
123        <div 
124            class={merged_class}
125            role="listbox"
126            aria-label="Suggestions"
127        >
128            {children()}
129        </div>
130    }
131}
132
133#[component]
134pub fn CommandEmpty(
135    #[prop(optional)] class: MaybeProp<String>,
136    children: Children,
137) -> impl IntoView {
138    let context = expect_context::<CommandContext>();
139    let _search = context.search;
140    
141    let merged_class = tw_merge!(&format!("{} {}", 
142        COMMAND_EMPTY_CLASS,
143        class.get().unwrap_or_default()
144    ));
145    
146    view! {
147        <div class={merged_class}>
148            {children()}
149        </div>
150    }
151}
152
153#[component]
154pub fn CommandGroup(
155    #[prop(optional)] heading: MaybeProp<String>,
156    #[prop(optional)] class: MaybeProp<String>,
157    children: Children,
158) -> impl IntoView {
159    let merged_class = tw_merge!(&format!("{} {}", 
160        COMMAND_GROUP_CLASS,
161        class.get().unwrap_or_default()
162    ));
163    
164    view! {
165        <div class={merged_class} role="group">
166            {if let Some(heading_text) = heading.get() {
167                view! {
168                    <div class=COMMAND_GROUP_HEADING_CLASS role="presentation">
169                        {heading_text}
170                    </div>
171                }.into_any()
172            } else {
173                view! {}.into_any()
174            }}
175            {children()}
176        </div>
177    }
178}
179
180#[component]
181pub fn CommandItem(
182    #[prop(optional)] value: MaybeProp<String>,
183    #[prop(optional)] keywords: MaybeProp<Vec<String>>,
184    #[prop(optional)] disabled: MaybeProp<bool>,
185    #[prop(optional)] on_select: Option<Callback<String>>,
186    #[prop(optional)] class: MaybeProp<String>,
187    children: Children,
188) -> impl IntoView {
189    let context = expect_context::<CommandContext>();
190    let search = context.search;
191    let selected_value = context.selected_value;
192    
193    let item_value = value.get().unwrap_or_default();
194    let item_keywords = keywords.get().unwrap_or_default();
195    let is_disabled = disabled.get().unwrap_or(false);
196    
197    // Check if item matches search
198    let item_value_for_search = item_value.clone();
199    let item_keywords_for_search = item_keywords.clone();
200    let matches_search = Memo::new(move |_| {
201        let search_term = search.get();
202        if search_term.is_empty() {
203            return true;
204        }
205        
206        let search_lower = search_term.to_lowercase();
207        
208        // Check value
209        if item_value_for_search.to_lowercase().contains(&search_lower) {
210            return true;
211        }
212        
213        // Check keywords
214        for keyword in &item_keywords_for_search {
215            if keyword.to_lowercase().contains(&search_lower) {
216                return true;
217            }
218        }
219        
220        false
221    });
222    
223    let item_value_for_selected = item_value.clone();
224    let is_selected = Memo::new(move |_| {
225        selected_value.get() == item_value_for_selected
226    });
227    
228    let merged_class = tw_merge!(&format!("{} {}", 
229        COMMAND_ITEM_CLASS,
230        class.get().unwrap_or_default()
231    ));
232    
233    view! {
234        <div
235            class={merged_class}
236            role="option"
237            aria-selected={is_selected.get()}
238            data-disabled={is_disabled}
239            on:click=move |_evt| {
240                if !is_disabled {
241                    selected_value.set(item_value.clone());
242                    
243                    if let Some(on_select) = on_select {
244                        on_select.run(item_value.clone());
245                    }
246                    
247                    if let Some(on_value_change) = context.on_value_change {
248                        on_value_change.run(item_value.clone());
249                    }
250                }
251            }
252            style=("display", if matches_search.get() { "flex" } else { "none" })
253        >
254            {children()}
255        </div>
256    }
257}
258
259#[component]
260pub fn CommandShortcut(
261    #[prop(optional)] class: MaybeProp<String>,
262    children: Children,
263) -> impl IntoView {
264    let merged_class = tw_merge!(&format!("{} {}", 
265        COMMAND_SHORTCUT_CLASS,
266        class.get().unwrap_or_default()
267    ));
268    
269    view! {
270        <span class={merged_class}>
271            {children()}
272        </span>
273    }
274}
275
276#[component]
277pub fn CommandSeparator(
278    #[prop(optional)] class: MaybeProp<String>,
279) -> impl IntoView {
280    let merged_class = tw_merge!(&format!("{} {}", 
281        COMMAND_SEPARATOR_CLASS,
282        class.get().unwrap_or_default()
283    ));
284    
285    view! {
286        <div 
287            class={merged_class} 
288            role="separator" 
289            aria-orientation="horizontal"
290        />
291    }
292}
293
294#[derive(Clone, Copy)]
295struct CommandContext {
296    search: RwSignal<String>,
297    selected_value: RwSignal<String>,
298    on_value_change: Option<Callback<String>>,
299}