Skip to main content

dioxus_ui_system/molecules/
command.rs

1//! Command palette molecule component
2//!
3//! A command palette/search component with keyboard navigation.
4//! Similar to VS Code's cmd+palette or shadcn's Command component.
5//!
6//! # Example
7//! ```rust,ignore
8//! use dioxus_ui_system::molecules::command::*;
9//!
10//! fn MyCommand() -> Element {
11//!     let mut value = use_signal(|| "".to_string());
12//!     
13//!     rsx! {
14//!         Command {
15//!             CommandInput {
16//!                 placeholder: "Search commands...",
17//!                 value: value(),
18//!                 on_value_change: move |v| value.set(v),
19//!             }
20//!             CommandList {
21//!                 CommandEmpty { "No results found." }
22//!                 CommandGroup {
23//!                     heading: "Suggestions",
24//!                     CommandItem {
25//!                         value: "calendar",
26//!                         on_select: move |_| println!("Calendar selected"),
27//!                         "Calendar"
28//!                     }
29//!                     CommandItem {
30//!                         value: "search",
31//!                         on_select: move |_| println!("Search selected"),
32//!                         "Search"
33//!                     }
34//!                 }
35//!                 CommandSeparator {}
36//!                 CommandGroup {
37//!                     heading: "Settings",
38//!                     CommandItem {
39//!                         value: "profile",
40//!                         on_select: move |_| println!("Profile selected"),
41//!                         "Profile"
42//!                     }
43//!                 }
44//!             }
45//!         }
46//!     }
47//! }
48//! ```
49
50use crate::styles::Style;
51use crate::theme::{use_style, use_theme};
52use dioxus::prelude::*;
53
54// ============================================================================
55// Context
56// ============================================================================
57
58/// Context shared between Command components
59#[derive(Clone, Copy)]
60struct CommandContext {
61    /// Current search value
62    value: Signal<String>,
63    /// Currently highlighted item index
64    highlighted_index: Signal<usize>,
65    /// Total number of selectable items
66    item_count: Signal<usize>,
67    /// Callback when item is selected via keyboard
68    _on_select: Callback<()>,
69    /// Whether the command palette is focused
70    focused: Signal<bool>,
71    /// Callback when any item is selected (for closing the palette)
72    on_item_select: Callback<()>,
73}
74
75// ============================================================================
76// Command Container
77// ============================================================================
78
79/// Command container properties
80#[derive(Props, Clone, PartialEq)]
81pub struct CommandProps {
82    /// Child elements
83    pub children: Element,
84    /// Additional CSS classes
85    #[props(default)]
86    pub class: Option<String>,
87    /// Callback when an item is selected (can be used to close the command palette)
88    #[props(default)]
89    pub on_select: Option<EventHandler<()>>,
90}
91
92/// Command container component
93///
94/// The root component that provides context for all child command components.
95#[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// ============================================================================
148// Command Input
149// ============================================================================
150
151/// Command input properties
152#[derive(Props, Clone, PartialEq)]
153pub struct CommandInputProps {
154    /// Placeholder text
155    #[props(default)]
156    pub placeholder: Option<String>,
157    /// Current value
158    #[props(default)]
159    pub value: String,
160    /// Callback when value changes
161    pub on_value_change: EventHandler<String>,
162}
163
164/// Command input component
165///
166/// Search input for filtering command items.
167#[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    // Update context value when prop changes
174    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                // Selection is handled by CommandList
210            }
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            // Search icon
230            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// ============================================================================
257// Command List
258// ============================================================================
259
260/// Command list properties
261#[derive(Props, Clone, PartialEq)]
262pub struct CommandListProps {
263    /// Child elements (CommandGroup, CommandItem, CommandSeparator, CommandEmpty)
264    pub children: Element,
265}
266
267/// Command list component
268///
269/// Scrollable container for command items.
270#[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// ============================================================================
295// Command Group
296// ============================================================================
297
298/// Command group properties
299#[derive(Props, Clone, PartialEq)]
300pub struct CommandGroupProps {
301    /// Group heading text
302    #[props(default)]
303    pub heading: Option<String>,
304    /// Child command items
305    pub children: Element,
306}
307
308/// Command group component
309///
310/// Groups related command items with an optional heading.
311#[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// ============================================================================
343// Command Item
344// ============================================================================
345
346/// Command item properties
347#[derive(Props, Clone, PartialEq)]
348pub struct CommandItemProps {
349    /// Search value for filtering
350    pub value: String,
351    /// Callback when item is selected
352    pub on_select: EventHandler<()>,
353    /// Child elements
354    pub children: Element,
355    /// Whether the item is disabled
356    #[props(default = false)]
357    pub disabled: bool,
358}
359
360/// Command item component
361///
362/// Selectable item within a command group.
363#[component]
364pub fn CommandItem(props: CommandItemProps) -> Element {
365    let theme = use_theme();
366    let mut context: CommandContext = use_context();
367
368    // Register this item in the parent context
369    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    // Check if this item matches the current search
379    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    // Check if this item is currently highlighted
384    let is_highlighted = context.highlighted_index.read().clone() == item_index.read().clone();
385
386    // Update highlighted index if this item is the only match
387    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// ============================================================================
454// Command Separator
455// ============================================================================
456
457/// Command separator component
458///
459/// Visual divider between command groups.
460#[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// ============================================================================
481// Command Empty
482// ============================================================================
483
484/// Command empty properties
485#[derive(Props, Clone, PartialEq)]
486pub struct CommandEmptyProps {
487    /// Content to display when no results
488    pub children: Element,
489}
490
491/// Command empty component
492///
493/// Displayed when no command items match the search.
494#[component]
495pub fn CommandEmpty(props: CommandEmptyProps) -> Element {
496    let _theme = use_theme();
497    let context: CommandContext = use_context();
498
499    // Only show if there are no matching items
500    // This is a simplified check - in a real implementation,
501    // we'd count visible items from the context
502    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// ============================================================================
523// Shortcut Display
524// ============================================================================
525
526/// Command shortcut properties
527#[derive(Props, Clone, PartialEq)]
528pub struct CommandShortcutProps {
529    /// Keyboard shortcut text (e.g., "⌘K", "Ctrl+P")
530    pub children: Element,
531}
532
533/// Command shortcut component
534///
535/// Displays keyboard shortcuts for command items.
536#[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// ============================================================================
558// Loading State
559// ============================================================================
560
561/// Command loading component
562///
563/// Displayed while command items are loading.
564#[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}