1use dioxus::prelude::*;
2
3use crate::context::{SelectContext, init_select_context};
4use crate::types::*;
5
6#[derive(Clone)]
10pub(crate) struct ItemContext {
11 pub value: String,
12}
13
14#[derive(Clone)]
18pub(crate) struct GroupContext {
19 pub id: String,
20}
21
22#[component]
40pub fn Root(
41 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
42 #[props(default)]
44 default_value: Option<String>,
45 #[props(default)]
47 value: Option<Signal<String>>,
48 #[props(default)]
50 on_value_change: Option<EventHandler<String>>,
51 #[props(default)]
53 default_values: Option<Vec<String>>,
54 #[props(default)]
56 values: Option<Signal<Vec<String>>>,
57 #[props(default)]
59 on_values_change: Option<EventHandler<Vec<String>>>,
60 #[props(default)]
62 multiple: bool,
63 #[props(default)]
65 disabled: bool,
66 #[props(default)]
68 default_open: bool,
69 #[props(default)]
71 open: Option<Signal<bool>>,
72 #[props(default)]
74 on_open_change: Option<EventHandler<bool>>,
75 #[props(default)]
77 autocomplete: AutoComplete,
78 #[props(default = true)]
80 open_on_focus: bool,
81 #[props(default)]
83 filter: Option<CustomFilter>,
84 children: Element,
85) -> Element {
86 let ctx = init_select_context(
87 default_value,
88 value,
89 on_value_change,
90 default_values,
91 values,
92 on_values_change,
93 multiple,
94 disabled,
95 default_open,
96 open,
97 on_open_change,
98 autocomplete,
99 open_on_focus,
100 filter,
101 );
102
103 let state = if ctx.is_open() { "open" } else { "closed" };
104
105 rsx! {
106 div {
107 "data-select-state": state,
108 "data-select-disabled": disabled.then_some("true"),
109 ..attributes,
110 {children}
111 }
112 }
113}
114
115#[component]
128pub fn Trigger(
129 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
130 #[props(default)]
132 disabled: bool,
133 children: Element,
134) -> Element {
135 let ctx: SelectContext = use_context();
136
137 let is_open = ctx.is_open();
138 let state = if is_open { "open" } else { "closed" };
139 let trigger_id = ctx.trigger_id();
140 let listbox_id = ctx.listbox_id();
141 let active_desc = ctx.active_descendant();
142 let is_disabled = disabled || ctx.disabled;
143
144 let onkeydown = move |event: KeyboardEvent| {
145 if is_disabled {
146 return;
147 }
148 let mut ctx: SelectContext = consume_context();
149 let was_open = ctx.is_open();
150
151 match event.key() {
152 Key::Enter => {
153 event.prevent_default();
154 if was_open {
155 ctx.confirm_highlighted();
156 } else {
157 ctx.set_open(true);
158 let current = ctx.current_value();
159 if !current.is_empty() {
160 ctx.highlighted.set(Some(current));
161 } else {
162 ctx.highlight_first();
163 }
164 }
165 }
166 Key::Character(ref c) if c == " " => {
167 event.prevent_default();
168 if was_open {
169 ctx.confirm_highlighted();
170 } else {
171 ctx.set_open(true);
172 let current = ctx.current_value();
173 if !current.is_empty() {
174 ctx.highlighted.set(Some(current));
175 } else {
176 ctx.highlight_first();
177 }
178 }
179 }
180 Key::ArrowDown => {
181 event.prevent_default();
182 if !was_open {
183 ctx.set_open(true);
184 let current = ctx.current_value();
185 if !current.is_empty() {
186 ctx.highlighted.set(Some(current));
187 }
188 ctx.highlight_next();
189 } else {
190 ctx.highlight_next();
191 }
192 }
193 Key::ArrowUp => {
194 event.prevent_default();
195 if !was_open {
196 ctx.set_open(true);
197 let current = ctx.current_value();
198 if !current.is_empty() {
199 ctx.highlighted.set(Some(current));
200 }
201 ctx.highlight_prev();
202 } else {
203 ctx.highlight_prev();
204 }
205 }
206 Key::Home => {
207 event.prevent_default();
208 if !was_open {
209 ctx.set_open(true);
210 }
211 ctx.highlight_first();
212 }
213 Key::End => {
214 event.prevent_default();
215 if !was_open {
216 ctx.set_open(true);
217 }
218 ctx.highlight_last();
219 }
220 Key::Escape => {
221 if was_open {
222 event.prevent_default();
223 ctx.set_open(false);
224 }
225 }
226 Key::Tab => {
227 if was_open {
229 ctx.confirm_highlighted();
230 }
231 }
232 Key::Character(ref c) if c != " " => {
234 event.prevent_default();
235 if !was_open {
236 ctx.set_open(true);
237 }
238 ctx.type_ahead(c);
239 }
240 _ => {}
241 }
242 };
243
244 let onclick = move |_: MouseEvent| {
245 if !is_disabled {
246 let mut ctx: SelectContext = consume_context();
247 ctx.toggle_open();
248 if ctx.is_open() {
249 let current = ctx.current_value();
250 if !current.is_empty() {
251 ctx.highlighted.set(Some(current));
252 }
253 }
254 }
255 };
256
257 let onblur = move |_: FocusEvent| {
262 let mut ctx: SelectContext = consume_context();
263 ctx.set_open(false);
264 };
265
266 rsx! {
267 button {
268 id: "{trigger_id}",
269 role: "combobox",
270 r#type: "button",
271 aria_expanded: if is_open { "true" } else { "false" },
272 aria_haspopup: "listbox",
273 aria_controls: "{listbox_id}",
274 aria_activedescendant: if !active_desc.is_empty() { "{active_desc}" },
275 tabindex: "0",
276 "data-state": state,
277 "data-disabled": is_disabled.then_some("true"),
278 disabled: is_disabled,
279 onkeydown,
280 onclick,
281 onblur,
282 ..attributes,
283 {children}
284 }
285 }
286}
287
288#[component]
300pub fn Value(
301 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
302 #[props(default)]
304 placeholder: Option<String>,
305 #[props(default)] children: Element,
306) -> Element {
307 let ctx: SelectContext = use_context();
308
309 let has_children = children != VNode::empty();
310
311 let (display_text, is_placeholder) = if !has_children {
313 if ctx.multiple {
314 let vals = ctx.current_values();
315 if vals.is_empty() {
316 (placeholder.clone().unwrap_or_default(), true)
317 } else {
318 let items = ctx.items.read();
320 let labels: Vec<String> = vals
321 .iter()
322 .filter_map(|v| {
323 items
324 .iter()
325 .find(|e| &e.value == v)
326 .map(|e| e.label.clone())
327 })
328 .collect();
329 if labels.is_empty() {
330 (vals.join(", "), false)
331 } else {
332 (labels.join(", "), false)
333 }
334 }
335 } else {
336 let current = ctx.current_value();
337 if current.is_empty() {
338 (placeholder.clone().unwrap_or_default(), true)
339 } else {
340 let items = ctx.items.read();
342 let label = items
343 .iter()
344 .find(|e| e.value == current)
345 .map(|e| e.label.clone())
346 .unwrap_or(current);
347 (label, false)
348 }
349 }
350 } else {
351 (String::new(), false)
352 };
353
354 rsx! {
355 span {
356 "data-select-placeholder": is_placeholder.then_some("true"),
357 ..attributes,
358 if has_children {
359 {children}
360 } else {
361 "{display_text}"
362 }
363 }
364 }
365}
366
367#[component]
379pub fn Input(
380 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
381 #[props(default)]
383 placeholder: Option<String>,
384) -> Element {
385 let mut ctx: SelectContext = use_context();
386
387 use_hook(|| {
389 ctx.mark_has_input();
390 });
391
392 let is_open = ctx.is_open();
393 let input_id = ctx.input_id();
394 let listbox_id = ctx.listbox_id();
395 let active_desc = ctx.active_descendant();
396 let autocomplete_attr = ctx.autocomplete.as_aria_attr();
397 let is_disabled = ctx.disabled;
398
399 let oninput = move |evt: Event<FormData>| {
400 let mut ctx: SelectContext = consume_context();
401 ctx.search_query.set(evt.value());
402 if !ctx.is_open() {
403 ctx.set_open(true);
404 }
405 ctx.highlight_first();
407 };
408
409 let onkeydown = move |event: KeyboardEvent| {
410 if is_disabled {
411 return;
412 }
413 let mut ctx: SelectContext = consume_context();
414 let was_open = ctx.is_open();
415
416 match event.key() {
417 Key::ArrowDown => {
418 event.prevent_default();
419 if event.modifiers().alt() {
420 if !was_open {
422 ctx.set_open(true);
423 }
424 } else if !was_open {
425 ctx.set_open(true);
426 ctx.highlight_first();
427 } else {
428 ctx.highlight_next();
429 }
430 }
431 Key::ArrowUp => {
432 if was_open {
433 event.prevent_default();
434 ctx.highlight_prev();
435 }
436 }
437 Key::Enter => {
438 if was_open && ctx.highlighted.read().is_some() {
439 event.prevent_default();
440 ctx.confirm_highlighted();
441 }
442 }
443 Key::Escape => {
444 if was_open {
445 event.prevent_default();
446 ctx.set_open(false);
447 }
448 }
449 Key::Tab => {
451 if was_open {
452 ctx.set_open(false);
453 }
454 }
455 _ => {}
456 }
457 };
458
459 let onfocus = move |_: FocusEvent| {
460 let mut ctx: SelectContext = consume_context();
461 if ctx.open_on_focus && !ctx.disabled && !ctx.is_open() {
462 ctx.set_open(true);
463 ctx.highlight_first();
464 }
465 };
466
467 let onblur = move |_: FocusEvent| {
468 let mut ctx: SelectContext = consume_context();
469 ctx.set_open(false);
470 };
471
472 rsx! {
473 input {
474 id: "{input_id}",
475 r#type: "text",
476 role: "combobox",
477 aria_expanded: if is_open { "true" } else { "false" },
478 aria_haspopup: "listbox",
479 aria_controls: "{listbox_id}",
480 aria_activedescendant: if !active_desc.is_empty() { "{active_desc}" },
481 aria_autocomplete: "{autocomplete_attr}",
482 disabled: is_disabled,
483 placeholder: placeholder,
484 value: "{ctx.search_query}",
485 "data-select-input": "true",
486 oninput,
487 onkeydown,
488 onfocus,
489 onblur,
490 ..attributes,
491 }
492 }
493}
494
495#[component]
503pub fn ClearButton(
504 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
505 children: Element,
506) -> Element {
507 let onclick = move |evt: MouseEvent| {
508 evt.prevent_default();
509 let mut ctx: SelectContext = consume_context();
510 ctx.search_query.set(String::new());
511 ctx.focus_combobox();
512 };
513
514 rsx! {
515 button {
516 r#type: "button",
517 aria_label: "Clear",
518 tabindex: "-1",
519 "data-select-clear": "true",
520 onclick,
521 ..attributes,
522 {children}
523 }
524 }
525}
526
527#[component]
538pub fn Content(
539 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
540 #[props(default)]
542 aria_label: Option<String>,
543 children: Element,
544) -> Element {
545 let ctx: SelectContext = use_context();
546
547 if !ctx.is_open() {
548 return rsx! {};
549 }
550
551 let listbox_id = ctx.listbox_id();
552 let multi = ctx.multiple;
553
554 rsx! {
555 div {
556 id: "{listbox_id}",
557 role: "listbox",
558 aria_label: aria_label,
559 aria_multiselectable: if multi { "true" } else { "false" },
560 "data-select-content": "true",
561 "data-state": "open",
562 onmousedown: |evt: MouseEvent| { evt.prevent_default(); },
564 ..attributes,
565 {children}
566 }
567 }
568}
569
570#[component]
583pub fn Item(
584 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
585 value: String,
587 #[props(default)]
589 label: Option<String>,
590 #[props(default)]
592 keywords: Option<String>,
593 #[props(default)]
595 disabled: bool,
596 children: Element,
597) -> Element {
598 let mut ctx: SelectContext = use_context();
599 let display_label = label.clone().unwrap_or_else(|| value.clone());
600 let val = value.clone();
601
602 let group_id = try_use_context::<GroupContext>().map(|g| g.id.clone());
604
605 use_hook(|| {
607 ctx.register_item(ItemEntry {
608 value: val.clone(),
609 label: display_label.clone(),
610 keywords: keywords.clone().unwrap_or_default(),
611 disabled,
612 group_id: group_id.clone(),
613 });
614 });
615
616 {
618 let val_sync = value.clone();
619 use_effect(move || {
620 let disabled = disabled; let mut ctx: SelectContext = consume_context();
622 let mut items = ctx.items.write();
623 if let Some(item) = items.iter_mut().find(|e| e.value == val_sync) {
624 item.disabled = disabled;
625 }
626 });
627 }
628
629 let val_drop = value.clone();
630 use_drop(move || {
631 let mut ctx: SelectContext = consume_context();
632 ctx.deregister_item(&val_drop);
633 });
634
635 let visible = ctx.visible_values.read();
637 if !visible.iter().any(|v| v == &value) {
638 return rsx! {};
639 }
640
641 let is_selected = ctx.is_selected(&value);
642 let is_highlighted = ctx.highlighted.read().as_deref() == Some(value.as_str());
643 let item_id = ctx.item_id(&value);
644 let state = if is_selected { "checked" } else { "unchecked" };
645
646 let item_ctx = ItemContext {
648 value: value.clone(),
649 };
650 use_context_provider(|| item_ctx);
651
652 let val_click = value.clone();
653 let onmousedown = move |evt: MouseEvent| {
654 evt.prevent_default();
655 if !disabled {
656 let mut ctx: SelectContext = consume_context();
657 if ctx.multiple {
658 ctx.toggle_value(&val_click);
659 } else {
660 ctx.select_single(&val_click);
661 }
662 }
663 };
664
665 let val_enter = value.clone();
666 let onpointerenter = move |_| {
667 let mut ctx: SelectContext = consume_context();
668 ctx.highlighted.set(Some(val_enter.clone()));
669 };
670
671 rsx! {
672 div {
673 id: "{item_id}",
674 role: "option",
675 aria_selected: if is_selected { "true" } else { "false" },
676 aria_disabled: disabled.then_some("true"),
677 "data-state": state,
678 "data-highlighted": is_highlighted.then_some("true"),
679 "data-disabled": disabled.then_some("true"),
680 onmousedown,
681 onpointerenter,
682 ..attributes,
683 {children}
684 }
685 }
686}
687
688#[component]
696pub fn ItemText(
697 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
698 children: Element,
699) -> Element {
700 rsx! {
701 span {
702 "data-select-item-text": "true",
703 ..attributes,
704 {children}
705 }
706 }
707}
708
709#[component]
719pub fn ItemIndicator(
720 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
721 children: Element,
722) -> Element {
723 let ctx: SelectContext = use_context();
724 let item_ctx: ItemContext = use_context();
725
726 if !ctx.is_selected(&item_ctx.value) {
727 return rsx! {};
728 }
729
730 rsx! {
731 span {
732 "data-select-item-indicator": "true",
733 ..attributes,
734 {children}
735 }
736 }
737}
738
739#[component]
747pub fn Group(
748 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
749 id: String,
751 #[props(default)]
753 label: Option<String>,
754 children: Element,
755) -> Element {
756 let mut ctx: SelectContext = use_context();
757 let group_id = id.clone();
758
759 use_hook(|| {
761 ctx.register_group(GroupEntry {
762 id: group_id.clone(),
763 label: label.clone(),
764 });
765 });
766 let id_drop = id.clone();
767 use_drop(move || {
768 let mut ctx: SelectContext = consume_context();
769 ctx.deregister_group(&id_drop);
770 });
771
772 let group_ctx = GroupContext { id: id.clone() };
774 use_context_provider(|| group_ctx);
775
776 let label_id = if label.is_some() {
777 Some(ctx.group_label_id(&id))
778 } else {
779 None
780 };
781
782 rsx! {
783 div {
784 role: "group",
785 aria_labelledby: label_id,
786 "data-select-group": "true",
787 ..attributes,
788 {children}
789 }
790 }
791}
792
793#[component]
801pub fn Label(
802 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
803 children: Element,
804) -> Element {
805 let ctx: SelectContext = use_context();
806 let group_ctx: GroupContext = use_context();
807 let label_id = ctx.group_label_id(&group_ctx.id);
808
809 rsx! {
810 div {
811 id: "{label_id}",
812 "data-select-label": "true",
813 ..attributes,
814 {children}
815 }
816 }
817}
818
819#[component]
827pub fn Separator(#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>) -> Element {
828 rsx! {
829 div {
830 role: "separator",
831 aria_orientation: "horizontal",
832 "data-select-separator": "true",
833 ..attributes,
834 }
835 }
836}
837
838#[component]
846pub fn Empty(
847 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
848 children: Element,
849) -> Element {
850 let ctx: SelectContext = use_context();
851
852 if !ctx.visible_values.read().is_empty() {
853 return rsx! {};
854 }
855
856 if ctx.search_query.read().is_empty() {
858 return rsx! {};
859 }
860
861 if ctx.items.read().is_empty() {
863 return rsx! {};
864 }
865
866 rsx! {
867 div {
868 role: "status",
869 "data-select-empty": "true",
870 ..attributes,
871 {children}
872 }
873 }
874}