1use dioxus::prelude::*;
6use crate::theme::{use_theme, use_style};
7use crate::styles::Style;
8
9#[derive(Clone, PartialEq, Debug)]
11pub struct SelectOption {
12 pub value: String,
14 pub label: String,
16 pub disabled: bool,
18}
19
20impl SelectOption {
21 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 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#[derive(Props, Clone, PartialEq)]
42pub struct SelectProps {
43 #[props(default)]
45 pub value: String,
46 #[props(default)]
48 pub onchange: Option<EventHandler<String>>,
49 pub options: Vec<SelectOption>,
51 #[props(default)]
53 pub placeholder: Option<String>,
54 #[props(default)]
56 pub disabled: bool,
57 #[props(default)]
59 pub error: bool,
60 #[props(default)]
62 pub style: Option<String>,
63 #[props(default)]
65 pub class: Option<String>,
66}
67
68#[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 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#[derive(Props, Clone, PartialEq)]
154pub struct MultiSelectProps {
155 #[props(default)]
157 pub values: Vec<String>,
158 #[props(default)]
160 pub onchange: Option<EventHandler<Vec<String>>>,
161 pub options: Vec<SelectOption>,
163 #[props(default)]
165 pub placeholder: Option<String>,
166 #[props(default)]
168 pub disabled: bool,
169 #[props(default)]
171 pub max_selections: Option<usize>,
172}
173
174#[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 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 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 span {
283 style: "margin-left: auto; color: #64748b;",
284 if is_open() { "▲" } else { "▼" }
285 }
286 }
287
288 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 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}