dioxus_ui_system/molecules/
command.rs1use crate::styles::Style;
51use crate::theme::{use_style, use_theme};
52use dioxus::prelude::*;
53
54#[derive(Clone, Copy)]
60struct CommandContext {
61 value: Signal<String>,
63 highlighted_index: Signal<usize>,
65 item_count: Signal<usize>,
67 _on_select: Callback<()>,
69 focused: Signal<bool>,
71 on_item_select: Callback<()>,
73}
74
75#[derive(Props, Clone, PartialEq)]
81pub struct CommandProps {
82 pub children: Element,
84 #[props(default)]
86 pub class: Option<String>,
87 #[props(default)]
89 pub on_select: Option<EventHandler<()>>,
90}
91
92#[component]
96pub fn Command(props: CommandProps) -> Element {
97 let _theme = use_theme();
98 let value = use_signal(|| String::new());
99 let highlighted_index = use_signal(|| 0usize);
100 let item_count = use_signal(|| 0usize);
101 let focused = use_signal(|| false);
102
103 let on_item_select = props.on_select.clone();
104 let context = CommandContext {
105 value,
106 highlighted_index,
107 item_count,
108 _on_select: Callback::new(move |()| {}),
109 focused,
110 on_item_select: Callback::new(move |()| {
111 if let Some(ref handler) = on_item_select {
112 handler.call(());
113 }
114 }),
115 };
116
117 let class_css = props
118 .class
119 .as_ref()
120 .map(|c| format!(" {}", c))
121 .unwrap_or_default();
122
123 let container_style = use_style(|t| {
124 Style::new()
125 .flex()
126 .flex_col()
127 .w_full()
128 .rounded(&t.radius, "lg")
129 .border(1, &t.colors.border)
130 .bg(&t.colors.background)
131 .shadow(&t.shadows.lg)
132 .overflow_hidden()
133 .build()
134 });
135
136 use_context_provider(|| context);
137
138 rsx! {
139 div {
140 class: "command{class_css}",
141 style: "{container_style}",
142 {props.children}
143 }
144 }
145}
146
147#[derive(Props, Clone, PartialEq)]
153pub struct CommandInputProps {
154 #[props(default)]
156 pub placeholder: Option<String>,
157 #[props(default)]
159 pub value: String,
160 pub on_value_change: EventHandler<String>,
162}
163
164#[component]
168pub fn CommandInput(props: CommandInputProps) -> Element {
169 let theme = use_theme();
170 let mut context: CommandContext = use_context();
171 let value_ref = props.value.clone();
172
173 use_effect(move || {
175 context.value.set(value_ref.clone());
176 });
177
178 let input_style = use_style(|t| {
179 Style::new()
180 .w_full()
181 .px_px(16)
182 .py_px(12)
183 .font_size(16)
184 .bg(&t.colors.background)
185 .text_color(&t.colors.foreground)
186 .border_bottom(1, &t.colors.border)
187 .outline("none")
188 .build()
189 });
190
191 let handle_key_down = move |e: Event<dioxus::html::KeyboardData>| {
192 use dioxus::html::input_data::keyboard_types::Key;
193
194 match e.key() {
195 Key::ArrowDown => {
196 e.prevent_default();
197 let count = context.item_count.read().clone();
198 if count > 0 {
199 let current = context.highlighted_index.read().clone();
200 context.highlighted_index.set((current + 1).min(count - 1));
201 }
202 }
203 Key::ArrowUp => {
204 e.prevent_default();
205 let current = context.highlighted_index.read().clone();
206 context.highlighted_index.set(current.saturating_sub(1));
207 }
208 Key::Enter => {
209 }
211 Key::Escape => {
212 context.highlighted_index.set(0);
213 }
214 _ => {}
215 }
216 };
217
218 let placeholder_text = props
219 .placeholder
220 .clone()
221 .unwrap_or_else(|| "Type a command or search...".to_string());
222 let value_for_input = props.value.clone();
223
224 rsx! {
225 div {
226 class: "command-input-wrapper",
227 style: "position: relative; display: flex; align-items: center;",
228
229 span {
231 class: "command-input-icon",
232 style: "position: absolute; left: 16px; font-size: 16px; color: {theme.tokens.read().colors.muted.to_rgba()}; pointer-events: none;",
233 "🔍"
234 }
235
236 input {
237 class: "command-input",
238 style: "{input_style} padding-left: 44px;",
239 type: "text",
240 placeholder: "{placeholder_text}",
241 value: "{value_for_input}",
242 oninput: move |e: Event<FormData>| {
243 let new_value = e.value();
244 context.value.set(new_value.clone());
245 context.highlighted_index.set(0);
246 props.on_value_change.call(new_value);
247 },
248 onkeydown: handle_key_down,
249 onfocus: move |_| context.focused.set(true),
250 onblur: move |_| context.focused.set(false),
251 }
252 }
253 }
254}
255
256#[derive(Props, Clone, PartialEq)]
262pub struct CommandListProps {
263 pub children: Element,
265}
266
267#[component]
271pub fn CommandList(props: CommandListProps) -> Element {
272 let _theme = use_theme();
273
274 let list_style = use_style(|t| {
275 Style::new()
276 .flex()
277 .flex_col()
278 .max_h_px(300)
279 .overflow_auto()
280 .p(&t.spacing, "sm")
281 .gap(&t.spacing, "xs")
282 .build()
283 });
284
285 rsx! {
286 div {
287 class: "command-list",
288 style: "{list_style}",
289 {props.children}
290 }
291 }
292}
293
294#[derive(Props, Clone, PartialEq)]
300pub struct CommandGroupProps {
301 #[props(default)]
303 pub heading: Option<String>,
304 pub children: Element,
306}
307
308#[component]
312pub fn CommandGroup(props: CommandGroupProps) -> Element {
313 let theme = use_theme();
314
315 let group_style = use_style(|t| {
316 Style::new()
317 .flex()
318 .flex_col()
319 .gap(&t.spacing, "xs")
320 .mb(&t.spacing, "sm")
321 .build()
322 });
323
324 rsx! {
325 div {
326 class: "command-group",
327 style: "{group_style}",
328
329 if let Some(heading) = props.heading {
330 div {
331 class: "command-group-heading",
332 style: "padding: 8px 12px; font-size: 12px; font-weight: 500; color: {theme.tokens.read().colors.muted.to_rgba()}; text-transform: uppercase; letter-spacing: 0.05em;",
333 "{heading}"
334 }
335 }
336
337 {props.children}
338 }
339 }
340}
341
342#[derive(Props, Clone, PartialEq)]
348pub struct CommandItemProps {
349 pub value: String,
351 pub on_select: EventHandler<()>,
353 pub children: Element,
355 #[props(default = false)]
357 pub disabled: bool,
358}
359
360#[component]
364pub fn CommandItem(props: CommandItemProps) -> Element {
365 let theme = use_theme();
366 let mut context: CommandContext = use_context();
367
368 let mut item_index = use_signal(|| 0usize);
370
371 use_hook(|| {
372 let index = context.item_count.read().clone();
373 item_index.set(index);
374 let current_count = context.item_count.read().clone();
375 context.item_count.set(current_count + 1);
376 });
377
378 let search_value = context.value.read().clone().to_lowercase();
380 let item_value = props.value.to_lowercase();
381 let is_match = search_value.is_empty() || item_value.contains(&search_value);
382
383 let is_highlighted = context.highlighted_index.read().clone() == item_index.read().clone();
385
386 let item_index_for_effect = item_index.read().clone();
388 use_effect(move || {
389 if is_match && search_value == item_value {
390 context.highlighted_index.set(item_index_for_effect);
391 }
392 });
393
394 if !is_match {
395 return rsx! {};
396 }
397
398 let bg_color = if is_highlighted && !props.disabled {
399 theme.tokens.read().colors.accent.to_rgba()
400 } else {
401 "transparent".to_string()
402 };
403
404 let text_color = if props.disabled {
405 theme.tokens.read().colors.muted.to_rgba()
406 } else {
407 theme.tokens.read().colors.foreground.to_rgba()
408 };
409
410 let is_disabled = props.disabled;
411 let item_style = use_style(move |t| {
412 Style::new()
413 .flex()
414 .items_center()
415 .gap(&t.spacing, "sm")
416 .px(&t.spacing, "sm")
417 .py(&t.spacing, "sm")
418 .rounded(&t.radius, "md")
419 .cursor(if is_disabled {
420 "not-allowed"
421 } else {
422 "pointer"
423 })
424 .opacity(if is_disabled { 0.5 } else { 1.0 })
425 .build()
426 });
427
428 let handle_click = move |_| {
429 if !props.disabled {
430 props.on_select.call(());
431 context.on_item_select.call(());
432 }
433 };
434
435 let handle_mouse_enter = move |_| {
436 if !props.disabled {
437 context.highlighted_index.set(item_index.read().clone());
438 }
439 };
440
441 rsx! {
442 div {
443 class: "command-item",
444 style: "{item_style} background: {bg_color}; color: {text_color};",
445 onclick: handle_click,
446 onmouseenter: handle_mouse_enter,
447
448 {props.children}
449 }
450 }
451}
452
453#[component]
461pub fn CommandSeparator() -> Element {
462 let _theme = use_theme();
463
464 let separator_style = use_style(|t| {
465 Style::new()
466 .h_px(1)
467 .my(&t.spacing, "sm")
468 .bg(&t.colors.border)
469 .build()
470 });
471
472 rsx! {
473 div {
474 class: "command-separator",
475 style: "{separator_style}",
476 }
477 }
478}
479
480#[derive(Props, Clone, PartialEq)]
486pub struct CommandEmptyProps {
487 pub children: Element,
489}
490
491#[component]
495pub fn CommandEmpty(props: CommandEmptyProps) -> Element {
496 let _theme = use_theme();
497 let context: CommandContext = use_context();
498
499 let _search_value = context.value.read().clone();
503
504 let empty_style = use_style(|t| {
505 Style::new()
506 .p(&t.spacing, "lg")
507 .text_center()
508 .text_color(&t.colors.muted)
509 .font_size(14)
510 .build()
511 });
512
513 rsx! {
514 div {
515 class: "command-empty",
516 style: "{empty_style}",
517 {props.children}
518 }
519 }
520}
521
522#[derive(Props, Clone, PartialEq)]
528pub struct CommandShortcutProps {
529 pub children: Element,
531}
532
533#[component]
537pub fn CommandShortcut(props: CommandShortcutProps) -> Element {
538 let _theme = use_theme();
539
540 let shortcut_style = use_style(|t| {
541 Style::new()
542 .pl(&t.spacing, "md")
543 .font_size(12)
544 .text_color(&t.colors.muted)
545 .build()
546 });
547
548 rsx! {
549 span {
550 class: "command-shortcut",
551 style: "{shortcut_style} margin-left: auto;",
552 {props.children}
553 }
554 }
555}
556
557#[component]
565pub fn CommandLoading() -> Element {
566 let _theme = use_theme();
567
568 let loading_style = use_style(|t| {
569 Style::new()
570 .p(&t.spacing, "lg")
571 .text_center()
572 .text_color(&t.colors.muted)
573 .font_size(14)
574 .build()
575 });
576
577 rsx! {
578 div {
579 class: "command-loading",
580 style: "{loading_style}",
581 "Loading..."
582 }
583 }
584}