leptos_shadcn_command/
default.rs1use 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 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 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 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 if item_value_for_search.to_lowercase().contains(&search_lower) {
210 return true;
211 }
212
213 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}