Skip to main content

dioxus_ui_system/atoms/
select.rs

1//! Select atom component
2//!
3//! Displays a list of options for the user to pick from.
4
5use dioxus::prelude::*;
6use crate::theme::{use_theme, use_style};
7use crate::styles::Style;
8
9/// Select option
10#[derive(Clone, PartialEq, Debug)]
11pub struct SelectOption {
12    /// Option value
13    pub value: String,
14    /// Option label
15    pub label: String,
16    /// Whether this option is disabled
17    pub disabled: bool,
18}
19
20impl SelectOption {
21    /// Create a new select option
22    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
23        Self {
24            value: value.into(),
25            label: label.into(),
26            disabled: false,
27        }
28    }
29    
30    /// Create a new disabled select option
31    pub fn disabled(value: impl Into<String>, label: impl Into<String>) -> Self {
32        Self {
33            value: value.into(),
34            label: label.into(),
35            disabled: true,
36        }
37    }
38}
39
40/// Select properties
41#[derive(Props, Clone, PartialEq)]
42pub struct SelectProps {
43    /// Currently selected value
44    #[props(default)]
45    pub value: String,
46    /// Callback when selection changes
47    #[props(default)]
48    pub onchange: Option<EventHandler<String>>,
49    /// Available options
50    pub options: Vec<SelectOption>,
51    /// Placeholder text when no value selected
52    #[props(default)]
53    pub placeholder: Option<String>,
54    /// Whether the select is disabled
55    #[props(default)]
56    pub disabled: bool,
57    /// Whether the select has an error
58    #[props(default)]
59    pub error: bool,
60    /// Custom inline styles
61    #[props(default)]
62    pub style: Option<String>,
63    /// Custom class name
64    #[props(default)]
65    pub class: Option<String>,
66}
67
68/// Select atom component (native HTML select with styling)
69#[component]
70pub fn Select(props: SelectProps) -> Element {
71    let _theme = use_theme();
72    let mut is_focused = use_signal(|| false);
73    
74    let disabled = props.disabled;
75    let error = props.error;
76    
77    let select_style = use_style(move |t| {
78        let base = Style::new()
79            .w_full()
80            .h_px(40)
81            .px(&t.spacing, "md")
82            .rounded(&t.radius, "md")
83            .border(1, if error { &t.colors.destructive } else { &t.colors.border })
84            .bg(&t.colors.background)
85            .text_color(&t.colors.foreground)
86            .font_size(14)
87            .cursor(if disabled { "not-allowed" } else { "pointer" })
88            .transition("all 150ms ease")
89            .outline("none");
90        
91        let base = if is_focused() {
92            base.border_color(&t.colors.ring)
93                .shadow(&format!("0 0 0 1px {}", t.colors.ring.to_rgba()))
94        } else {
95            base
96        };
97        
98        let base = if disabled {
99            base.opacity(0.5)
100                .bg(&t.colors.muted)
101        } else {
102            base
103        };
104        
105        // Add dropdown arrow
106        let style_str = base.build();
107        format!("{} appearance: none; background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E\"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 40px;", style_str)
108    });
109    
110    let handle_change = move |e: Event<FormData>| {
111        let data = e.data();
112        let new_value = data.value();
113        if let Some(handler) = &props.onchange {
114            handler.call(new_value);
115        }
116    };
117    
118    rsx! {
119        select {
120            value: "{props.value}",
121            disabled: disabled,
122            style: "{select_style} {props.style.clone().unwrap_or_default()}",
123            class: "{props.class.clone().unwrap_or_default()}",
124            onchange: handle_change,
125            onfocus: move |_| is_focused.set(true),
126            onblur: move |_| is_focused.set(false),
127            
128            if let Some(placeholder) = props.placeholder.clone() {
129                if props.value.is_empty() {
130                    option {
131                        value: "",
132                        disabled: true,
133                        selected: true,
134                        "{placeholder}"
135                    }
136                }
137            }
138            
139            for option in props.options {
140                option {
141                    key: "{option.value}",
142                    value: "{option.value}",
143                    disabled: option.disabled,
144                    selected: props.value == option.value,
145                    "{option.label}"
146                }
147            }
148        }
149    }
150}
151
152/// Multi-select properties
153#[derive(Props, Clone, PartialEq)]
154pub struct MultiSelectProps {
155    /// Currently selected values
156    #[props(default)]
157    pub values: Vec<String>,
158    /// Callback when selection changes
159    #[props(default)]
160    pub onchange: Option<EventHandler<Vec<String>>>,
161    /// Available options
162    pub options: Vec<SelectOption>,
163    /// Placeholder text
164    #[props(default)]
165    pub placeholder: Option<String>,
166    /// Whether the select is disabled
167    #[props(default)]
168    pub disabled: bool,
169    /// Maximum number of selections
170    #[props(default)]
171    pub max_selections: Option<usize>,
172}
173
174/// Multi-select component with tags
175#[component]
176pub fn MultiSelect(props: MultiSelectProps) -> Element {
177    let _theme = use_theme();
178    let mut selected = use_signal(|| props.values.clone());
179    let mut is_open = use_signal(|| false);
180    
181    // Sync with props
182    use_effect(move || {
183        selected.set(props.values.clone());
184    });
185    
186    let container_style = use_style(|t| {
187        Style::new()
188            .w_full()
189            .min_h_px(40)
190            .px(&t.spacing, "sm")
191            .py(&t.spacing, "xs")
192            .rounded(&t.radius, "md")
193            .border(1, &t.colors.border)
194            .bg(&t.colors.background)
195            .flex()
196            .flex_wrap()
197            .items_center()
198            .gap_px(6)
199            .cursor("pointer")
200            .relative()
201            .build()
202    });
203    
204    let tag_style = use_style(|t| {
205        Style::new()
206            .inline_flex()
207            .items_center()
208            .gap_px(4)
209            .px(&t.spacing, "sm")
210            .py(&t.spacing, "xs")
211            .rounded(&t.radius, "sm")
212            .bg(&t.colors.secondary)
213            .text_color(&t.colors.secondary_foreground)
214            .font_size(12)
215            .build()
216    });
217    
218    let onchange_clone = props.onchange.clone();
219    let max_selections = props.max_selections;
220    
221    let mut remove_selected = move |value: String| {
222        let onchange = onchange_clone.clone();
223        selected.with_mut(|s| {
224            s.retain(|v| v != &value);
225            if let Some(h) = onchange {
226                h.call(s.clone());
227            }
228        });
229    };
230    
231    let add_selected = move |value: String| {
232        let onchange = onchange_clone.clone();
233        selected.with_mut(|s| {
234            if !s.contains(&value) {
235                if let Some(max) = max_selections {
236                    if s.len() >= max {
237                        return;
238                    }
239                }
240                s.push(value);
241                if let Some(h) = onchange {
242                    h.call(s.clone());
243                }
244            }
245        });
246    };
247    
248    let selected_labels: Vec<_> = selected()
249        .iter()
250        .filter_map(|v| {
251            props.options.iter().find(|o| o.value == *v).map(|o| (v.clone(), o.label.clone()))
252        })
253        .collect();
254    
255    rsx! {
256        div {
257            style: "position: relative;",
258            
259            // Selected tags container
260            div {
261                style: "{container_style}",
262                onclick: move |_| if !props.disabled { is_open.toggle() },
263                
264                if selected_labels.is_empty() {
265                    span {
266                        style: "color: #64748b; font-size: 14px;",
267                        "{props.placeholder.clone().unwrap_or_else(|| \"Select options...\".to_string())}"
268                    }
269                }
270                
271                for (value, label) in selected_labels {
272                    MultiSelectTag {
273                        key: "{value}",
274                        value: value.clone(),
275                        label: label.clone(),
276                        tag_style: tag_style.clone(),
277                        on_remove: remove_selected,
278                    }
279                }
280                
281                // Dropdown arrow
282                span {
283                    style: "margin-left: auto; color: #64748b;",
284                    if is_open() { "▲" } else { "▼" }
285                }
286            }
287            
288            // Dropdown
289            if is_open() && !props.disabled {
290                MultiSelectDropdown {
291                    options: props.options.clone(),
292                    selected: selected(),
293                    on_select: add_selected,
294                    on_close: move || is_open.set(false),
295                }
296            }
297        }
298    }
299}
300
301#[derive(Props, Clone, PartialEq)]
302struct MultiSelectDropdownProps {
303    options: Vec<SelectOption>,
304    selected: Vec<String>,
305    on_select: EventHandler<String>,
306    on_close: EventHandler<()>,
307}
308
309#[derive(Props, Clone, PartialEq)]
310struct CheckBoxIndicatorProps {
311    is_selected: bool,
312}
313
314#[component]
315fn CheckBoxIndicator(props: CheckBoxIndicatorProps) -> Element {
316    let bg_color = if props.is_selected { "#0f172a" } else { "white" };
317    rsx! {
318        div {
319            style: "width: 16px; height: 16px; border: 1px solid #cbd5e1; border-radius: 4px; display: flex; align-items: center; justify-content: center; background: {bg_color}; color: white;",
320            if props.is_selected {
321                "✓"
322            }
323        }
324    }
325}
326
327#[derive(Props, Clone, PartialEq)]
328struct MultiSelectTagProps {
329    value: String,
330    label: String,
331    tag_style: String,
332    on_remove: EventHandler<String>,
333}
334
335#[component]
336fn MultiSelectTag(props: MultiSelectTagProps) -> Element {
337    let value = props.value.clone();
338    rsx! {
339        span {
340            style: "{props.tag_style}",
341            "{props.label}"
342            button {
343                style: "background: none; border: none; cursor: pointer; padding: 0; margin: 0; display: flex; align-items: center;",
344                onclick: move |e: Event<MouseData>| {
345                    e.stop_propagation();
346                    props.on_remove.call(value.clone());
347                },
348                "×"
349            }
350        }
351    }
352}
353
354#[component]
355fn MultiSelectDropdown(props: MultiSelectDropdownProps) -> Element {
356    let _theme = use_theme();
357    
358    let dropdown_style = use_style(|t| {
359        Style::new()
360            .absolute()
361            .top("calc(100% + 4px)")
362            .left("0")
363            .w_full()
364            .max_h_px(200)
365            .rounded(&t.radius, "md")
366            .border(1, &t.colors.border)
367            .bg(&t.colors.popover)
368            .shadow(&t.shadows.lg)
369            .overflow_auto()
370            .z_index(50)
371            .build()
372    });
373    
374    let item_style = use_style(|t| {
375        Style::new()
376            .w_full()
377            .px(&t.spacing, "md")
378            .py(&t.spacing, "sm")
379            .text_left()
380            .cursor("pointer")
381            .transition("all 100ms ease")
382            .build()
383    });
384    
385    rsx! {
386        div {
387            style: "{dropdown_style}",
388            
389            for option in props.options.iter().cloned().collect::<Vec<_>>() {
390                DropdownOptionItem {
391                    key: "{option.value}",
392                    option: option.clone(),
393                    item_style: item_style.clone(),
394                    is_selected: props.selected.contains(&option.value),
395                    on_select: props.on_select,
396                }
397            }
398        }
399    }
400}
401
402#[derive(Props, Clone, PartialEq)]
403struct DropdownOptionItemProps {
404    option: SelectOption,
405    item_style: String,
406    is_selected: bool,
407    on_select: EventHandler<String>,
408}
409
410#[component]
411fn DropdownOptionItem(props: DropdownOptionItemProps) -> Element {
412    let value = props.option.value.clone();
413    rsx! {
414        button {
415            style: "{props.item_style}",
416            disabled: props.option.disabled,
417            onclick: move |_| {
418                props.on_select.call(value.clone());
419            },
420            
421            div {
422                style: "display: flex; align-items: center; gap: 8px;",
423                
424                // Checkbox
425                CheckBoxIndicator { is_selected: props.is_selected }
426                
427                span {
428                    style: if props.option.disabled { "opacity: 0.5;" } else { "" },
429                    "{props.option.label}"
430                }
431            }
432        }
433    }
434}