1use crate::styles::Style;
6use crate::theme::{use_style, use_theme};
7use dioxus::prelude::*;
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(
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 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#[derive(Props, Clone, PartialEq)]
160pub struct MultiSelectProps {
161 #[props(default)]
163 pub values: Vec<String>,
164 #[props(default)]
166 pub onchange: Option<EventHandler<Vec<String>>>,
167 pub options: Vec<SelectOption>,
169 #[props(default)]
171 pub placeholder: Option<String>,
172 #[props(default)]
174 pub disabled: bool,
175 #[props(default)]
177 pub max_selections: Option<usize>,
178}
179
180#[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 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 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 span {
293 style: "margin-left: auto; color: #64748b;",
294 if is_open() { "▲" } else { "▼" }
295 }
296 }
297
298 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 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}