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