adui_dioxus/components/
select.rs

1//! Select component aligned with Ant Design 6.0.
2//!
3//! Supports single select, multiple select, and tags mode.
4
5use crate::components::config_provider::{ComponentSize, use_config};
6use crate::components::control::{ControlStatus, push_status_class};
7use crate::components::form::{FormItemControlContext, use_form_item_control};
8use crate::components::icon::{Icon, IconKind};
9use crate::components::select_base::{
10    DropdownLayer, OptionKey, SelectOption, handle_option_list_key_event, option_key_to_value,
11    option_keys_to_value, toggle_option_key, use_dropdown_layer, value_to_option_key,
12    value_to_option_keys,
13};
14use crate::foundation::{
15    ClassListExt, SelectClassNames, SelectSemantic, SelectStyles, StyleStringExt, Variant,
16    variant_from_bordered,
17};
18use dioxus::events::KeyboardEvent;
19use dioxus::prelude::*;
20use serde_json::Value;
21use std::rc::Rc;
22
23/// Re-export of the shared option type so that callers can build option lists
24/// without depending on the internal `select_base` module path.
25pub use crate::components::select_base::SelectOption as PublicSelectOption;
26
27/// Select mode determining single/multiple selection behavior.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum SelectMode {
30    /// Single selection (default).
31    #[default]
32    Single,
33    /// Multiple selection.
34    Multiple,
35    /// Tags mode - allows creating new options from input.
36    Tags,
37    /// Combobox mode - allows free text input with autocomplete.
38    Combobox,
39}
40
41impl SelectMode {
42    /// Whether this mode allows multiple selections.
43    pub fn is_multiple(&self) -> bool {
44        matches!(self, SelectMode::Multiple | SelectMode::Tags)
45    }
46
47    /// Whether this mode allows free text input.
48    pub fn allows_input(&self) -> bool {
49        matches!(self, SelectMode::Tags | SelectMode::Combobox)
50    }
51}
52
53/// Placement of the dropdown relative to the select trigger.
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum SelectPlacement {
56    #[default]
57    BottomLeft,
58    BottomRight,
59    TopLeft,
60    TopRight,
61}
62
63impl SelectPlacement {
64    fn as_style(&self) -> &'static str {
65        match self {
66            SelectPlacement::BottomLeft => "top: 100%; left: 0;",
67            SelectPlacement::BottomRight => "top: 100%; right: 0;",
68            SelectPlacement::TopLeft => "bottom: 100%; left: 0;",
69            SelectPlacement::TopRight => "bottom: 100%; right: 0;",
70        }
71    }
72}
73
74/// Props for the Select component.
75#[derive(Props, Clone)]
76pub struct SelectProps {
77    /// Controlled value for single-select mode.
78    #[props(optional)]
79    pub value: Option<String>,
80    /// Controlled values for multi-select mode.
81    #[props(optional)]
82    pub values: Option<Vec<String>>,
83    /// Option list rendered in the dropdown.
84    pub options: Vec<SelectOption>,
85    /// Selection mode: single, multiple, or tags.
86    #[props(default)]
87    pub mode: SelectMode,
88    /// @deprecated Use `mode` instead. When true, allow selecting multiple options.
89    #[props(default)]
90    pub multiple: bool,
91    /// Whether to show a clear icon when there is a selection.
92    #[props(default)]
93    pub allow_clear: bool,
94    /// Placeholder text displayed when there is no selection.
95    #[props(optional)]
96    pub placeholder: Option<String>,
97    /// Disable user interaction.
98    #[props(default)]
99    pub disabled: bool,
100    /// Enable simple client-side search by option label.
101    #[props(default)]
102    pub show_search: bool,
103    /// Custom filter function: (input, option) -> bool
104    /// When provided, overrides the default label-based filtering.
105    #[props(optional)]
106    pub filter_option: Option<Rc<dyn Fn(&str, &SelectOption) -> bool>>,
107    /// Token separators for tags mode (e.g., [",", " "]).
108    /// When user types these characters, a new tag is created.
109    #[props(optional)]
110    pub token_separators: Option<Vec<String>>,
111    /// Optional visual status applied to the wrapper.
112    #[props(optional)]
113    pub status: Option<ControlStatus>,
114    /// Override component size; falls back to ConfigProvider when `None`.
115    #[props(optional)]
116    pub size: Option<ComponentSize>,
117    /// Visual variant (outlined/filled/borderless).
118    #[props(optional)]
119    pub variant: Option<Variant>,
120    /// @deprecated Use `variant="borderless"` instead.
121    #[props(optional)]
122    pub bordered: Option<bool>,
123    /// Prefix element displayed before the selection.
124    #[props(optional)]
125    pub prefix: Option<Element>,
126    /// Custom suffix icon (defaults to down arrow).
127    #[props(optional)]
128    pub suffix_icon: Option<Element>,
129    /// Dropdown placement relative to the select.
130    #[props(default)]
131    pub placement: SelectPlacement,
132    /// Whether dropdown width should match select width.
133    #[props(default = true)]
134    pub popup_match_select_width: bool,
135    #[props(optional)]
136    pub class: Option<String>,
137    /// Extra class applied to root element.
138    #[props(optional)]
139    pub root_class_name: Option<String>,
140    #[props(optional)]
141    pub style: Option<String>,
142    /// Semantic class names for sub-parts.
143    #[props(optional)]
144    pub class_names: Option<SelectClassNames>,
145    /// Semantic styles for sub-parts.
146    #[props(optional)]
147    pub styles: Option<SelectStyles>,
148    /// Optional extra classes/styles for the dropdown popup.
149    #[props(optional)]
150    pub dropdown_class: Option<String>,
151    #[props(optional)]
152    pub dropdown_style: Option<String>,
153    /// @deprecated Please use `dropdown_class` instead.
154    #[props(optional)]
155    pub dropdown_class_name: Option<String>,
156    /// @deprecated Please use `dropdown_style` instead.
157    #[props(optional)]
158    pub dropdown_style_deprecated: Option<String>,
159    /// @deprecated Please use `popup_match_select_width` instead.
160    #[props(optional)]
161    pub dropdown_match_select_width: Option<bool>,
162    /// Custom render function for the dropdown popup: (menu) -> Element
163    #[props(optional)]
164    pub popup_render: Option<Rc<dyn Fn(Element) -> Element>>,
165    /// Change event emitted with the full set of selected keys.
166    #[props(optional)]
167    pub on_change: Option<EventHandler<Vec<String>>>,
168    /// Called when dropdown visibility changes.
169    #[props(optional)]
170    pub on_dropdown_visible_change: Option<EventHandler<bool>>,
171    /// @deprecated Please use `on_dropdown_visible_change` instead.
172    #[props(optional)]
173    pub on_open_change: Option<EventHandler<bool>>,
174}
175
176impl PartialEq for SelectProps {
177    fn eq(&self, other: &Self) -> bool {
178        // Compare all fields except function pointers
179        self.value == other.value
180            && self.values == other.values
181            && self.options == other.options
182            && self.mode == other.mode
183            && self.multiple == other.multiple
184            && self.allow_clear == other.allow_clear
185            && self.placeholder == other.placeholder
186            && self.disabled == other.disabled
187            && self.show_search == other.show_search
188            && self.status == other.status
189            && self.size == other.size
190            && self.variant == other.variant
191            && self.bordered == other.bordered
192            && self.prefix == other.prefix
193            && self.suffix_icon == other.suffix_icon
194            && self.placement == other.placement
195            && self.popup_match_select_width == other.popup_match_select_width
196            && self.class == other.class
197            && self.root_class_name == other.root_class_name
198            && self.style == other.style
199            && self.class_names == other.class_names
200            && self.styles == other.styles
201            && self.dropdown_class == other.dropdown_class
202            && self.dropdown_style == other.dropdown_style
203            && self.dropdown_class_name == other.dropdown_class_name
204            && self.dropdown_style_deprecated == other.dropdown_style_deprecated
205            && self.dropdown_match_select_width == other.dropdown_match_select_width
206            && self.on_change == other.on_change
207            && self.on_dropdown_visible_change == other.on_dropdown_visible_change
208            && self.on_open_change == other.on_open_change
209            && self.token_separators == other.token_separators
210        // Function pointers cannot be compared for equality
211    }
212}
213
214/// Ant Design flavored Select.
215#[allow(clippy::collapsible_if)]
216#[component]
217pub fn Select(props: SelectProps) -> Element {
218    let SelectProps {
219        value,
220        values,
221        options,
222        mode,
223        multiple,
224        allow_clear,
225        placeholder,
226        disabled,
227        show_search,
228        status,
229        size,
230        variant,
231        bordered,
232        prefix,
233        suffix_icon,
234        placement,
235        popup_match_select_width,
236        class,
237        root_class_name,
238        style,
239        class_names,
240        styles,
241        dropdown_class,
242        dropdown_style,
243        on_change,
244        on_dropdown_visible_change,
245        filter_option: _,
246        token_separators: _,
247        dropdown_class_name: _,
248        dropdown_style_deprecated: _,
249        dropdown_match_select_width: _,
250        popup_render: _,
251        on_open_change: _,
252    } = props;
253
254    let config = use_config();
255    let form_control = use_form_item_control();
256
257    // Resolve if multiple selection is enabled (mode takes precedence over deprecated `multiple`)
258    let is_multiple = mode.is_multiple() || multiple;
259
260    // Resolve variant
261    let resolved_variant = variant_from_bordered(bordered, variant);
262
263    let final_size = size.unwrap_or(config.size);
264
265    let is_disabled =
266        disabled || config.disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
267
268    // Internal selection state used only when the component is not controlled
269    // by Form or external props.
270    let internal_selected: Signal<Vec<OptionKey>> = use_signal(Vec::new);
271
272    let has_form = form_control.is_some();
273    let prop_single = value.clone();
274    let prop_multi = values.clone();
275    let multiple_flag = is_multiple;
276
277    // Snapshot of currently selected keys for this render.
278    let selected_keys: Vec<OptionKey> = if let Some(ctx) = form_control.as_ref() {
279        if multiple_flag {
280            value_to_option_keys(ctx.value())
281        } else {
282            match value_to_option_key(ctx.value()) {
283                Some(k) => vec![k],
284                None => Vec::new(),
285            }
286        }
287    } else if let Some(vs) = prop_multi {
288        vs
289    } else if let Some(v) = prop_single {
290        vec![v]
291    } else {
292        internal_selected.read().clone()
293    };
294
295    let controlled_by_prop = has_form || value.is_some() || values.is_some();
296
297    // Dropdown open/close state and active index for keyboard navigation.
298    let open_state: Signal<bool> = use_signal(|| false);
299    let active_index: Signal<Option<usize>> = use_signal(|| None);
300
301    // Tags mode: input for creating new tags
302    let tags_input: Signal<String> = use_signal(String::new);
303
304    // Flag used to distinguish between internal clicks (on the select trigger
305    // or dropdown) and genuine outside clicks. This allows us to install a
306    // document-level click handler for "click outside to close" without
307    // interfering with normal selection.
308    let internal_click_flag: Signal<bool> = use_signal(|| false);
309
310    // Document-level click handler for closing the dropdown when clicking
311    // outside of the select. This is only compiled for wasm32 targets.
312    #[cfg(target_arch = "wasm32")]
313    {
314        let mut open_for_global = open_state;
315        let mut internal_flag = internal_click_flag;
316        let on_visible_cb = on_dropdown_visible_change;
317        use_effect(move || {
318            use wasm_bindgen::{JsCast, closure::Closure};
319
320            if let Some(window) = web_sys::window() {
321                if let Some(document) = window.document() {
322                    let target: web_sys::EventTarget = document.into();
323                    let handler = Closure::<dyn FnMut(web_sys::MouseEvent)>::wrap(Box::new(
324                        move |_evt: web_sys::MouseEvent| {
325                            let mut flag = internal_flag;
326                            if *flag.read() {
327                                // Internal click: consume the flag and skip
328                                // closing.
329                                flag.set(false);
330                                return;
331                            }
332                            let mut open_signal = open_for_global;
333                            if *open_signal.read() {
334                                open_signal.set(false);
335                                if let Some(cb) = on_visible_cb {
336                                    cb.call(false);
337                                }
338                            }
339                        },
340                    ));
341                    let _ = target.add_event_listener_with_callback(
342                        "click",
343                        handler.as_ref().unchecked_ref(),
344                    );
345                    // Leak the handler for the lifetime of the page; this keeps
346                    // the implementation simple and matches the typical app
347                    // lifetime.
348                    handler.forget();
349                }
350            }
351        });
352    }
353
354    // Search query (when show_search = true).
355    let search_query: Signal<String> = use_signal(String::new);
356
357    let open_flag = *open_state.read();
358    let DropdownLayer { z_index, .. } = use_dropdown_layer(open_flag);
359    let current_z = *z_index.read();
360
361    let placeholder_str = placeholder.unwrap_or_default();
362
363    // Apply search filtering when enabled.
364    let filtered_options: Vec<SelectOption> = if show_search {
365        let query = search_query.read().clone();
366        crate::components::select_base::filter_options_by_query(&options, &query)
367    } else {
368        options.clone()
369    };
370
371    // Build wrapper classes.
372    let mut class_list = vec!["adui-select".to_string()];
373    if is_multiple {
374        class_list.push("adui-select-multiple".into());
375    }
376    if matches!(mode, SelectMode::Tags) {
377        class_list.push("adui-select-tags".into());
378    }
379    if is_disabled {
380        class_list.push("adui-select-disabled".into());
381    }
382    if open_flag {
383        class_list.push("adui-select-open".into());
384    }
385    match final_size {
386        ComponentSize::Small => class_list.push("adui-select-sm".into()),
387        ComponentSize::Large => class_list.push("adui-select-lg".into()),
388        ComponentSize::Middle => {}
389    }
390    class_list.push(resolved_variant.class_for("adui-select"));
391    push_status_class(&mut class_list, status);
392    class_list.push_semantic(&class_names, SelectSemantic::Root);
393    if let Some(extra) = class {
394        class_list.push(extra);
395    }
396    if let Some(extra) = root_class_name {
397        class_list.push(extra);
398    }
399    let class_attr = class_list
400        .into_iter()
401        .filter(|s| !s.is_empty())
402        .collect::<Vec<_>>()
403        .join(" ");
404
405    let mut style_attr = style.unwrap_or_default();
406    style_attr.append_semantic(&styles, SelectSemantic::Root);
407
408    // Helper to find the label for a given key.
409    let find_label = |key: &str| -> String {
410        options
411            .iter()
412            .find(|opt| opt.key == key)
413            .map(|opt| opt.label.clone())
414            .unwrap_or_else(|| key.to_string())
415    };
416
417    // Clone form_control and selected_keys early for use in display_node closures
418    let form_for_tags = form_control.clone();
419    let selected_for_tags = selected_keys.clone();
420    let selected_for_clear = selected_keys.clone();
421
422    let display_node = if is_multiple {
423        if selected_keys.is_empty() {
424            rsx! { span { class: "adui-select-selection-placeholder", "{placeholder_str}" } }
425        } else {
426            rsx! {
427                div { class: "adui-select-selection-overflow",
428                    {selected_keys.iter().map(|k| {
429                        let label = find_label(k);
430                        let key_for_remove = k.clone();
431                        let form_for_remove = form_control.clone();
432                        let internal_selected_for_remove = internal_selected;
433                        let selected_snapshot = selected_keys.clone();
434
435                        rsx! {
436                            span { class: "adui-select-selection-item",
437                                "{label}"
438                                span {
439                                    class: "adui-select-selection-item-remove",
440                                    onclick: move |evt| {
441                                        evt.stop_propagation();
442                                        let next_keys = selected_snapshot.iter()
443                                            .filter(|k| **k != key_for_remove)
444                                            .cloned()
445                                            .collect();
446                                        apply_selected_keys(
447                                            &form_for_remove,
448                                            multiple_flag,
449                                            controlled_by_prop,
450                                            &internal_selected_for_remove,
451                                            on_change,
452                                            next_keys,
453                                        );
454                                    },
455                                    "×"
456                                }
457                            }
458                        }
459                    })}
460                    if matches!(mode, SelectMode::Tags) {
461                        input {
462                            class: "adui-select-selection-search-input",
463                            value: "{tags_input.read()}",
464                            oninput: move |evt| {
465                                let mut sig = tags_input;
466                                sig.set(evt.value());
467                            },
468                            onkeydown: move |evt: KeyboardEvent| {
469                                use dioxus::prelude::Key;
470                                if matches!(evt.key(), Key::Enter) {
471                                    let input_val = tags_input.read().trim().to_string();
472                                    if !input_val.is_empty() && !selected_for_tags.contains(&input_val) {
473                                        let mut next_keys = selected_for_tags.clone();
474                                        next_keys.push(input_val);
475                                        apply_selected_keys(
476                                            &form_for_tags,
477                                            multiple_flag,
478                                            controlled_by_prop,
479                                            &internal_selected,
480                                            on_change,
481                                            next_keys,
482                                        );
483                                        let mut sig = tags_input;
484                                        sig.set(String::new());
485                                    }
486                                }
487                            }
488                        }
489                    }
490                }
491            }
492        }
493    } else if let Some(first) = selected_keys.first() {
494        let label = find_label(first);
495        rsx! { span { class: "adui-select-selection-item", "{label}" } }
496    } else {
497        rsx! { span { class: "adui-select-selection-placeholder", "{placeholder_str}" } }
498    };
499
500    // Shared helpers for event handlers.
501    let form_for_handlers = form_control.clone();
502    let internal_selected_for_handlers = internal_selected;
503    let on_change_cb = on_change;
504
505    let open_for_toggle = open_state;
506    let is_disabled_flag = is_disabled;
507
508    let search_for_input = search_query;
509
510    let active_for_keydown = active_index;
511    let internal_selected_for_keydown = internal_selected;
512    let form_for_keydown = form_for_handlers.clone();
513    let open_for_keydown = open_for_toggle;
514
515    // Local copies of the internal click flag for different handlers.
516    let internal_click_for_toggle = internal_click_flag;
517    let internal_click_for_keydown = internal_click_flag;
518
519    let dropdown_class_attr = {
520        let mut list = vec!["adui-select-dropdown".to_string()];
521        if let Some(extra) = dropdown_class {
522            list.push(extra);
523        }
524        list.join(" ")
525    };
526
527    let min_width_style = if popup_match_select_width {
528        "min-width: 100%;"
529    } else {
530        ""
531    };
532
533    let dropdown_style_attr = format!(
534        "position: absolute; {} {} z-index: {}; {}",
535        placement.as_style(),
536        min_width_style,
537        current_z,
538        dropdown_style.unwrap_or_default()
539    );
540
541    // Default suffix icon
542    let suffix_element = suffix_icon.unwrap_or_else(|| {
543        rsx! {
544            span { class: "adui-select-arrow",
545                Icon { kind: IconKind::ArrowDown, size: 12.0 }
546            }
547        }
548    });
549
550    let on_visible_cb = on_dropdown_visible_change;
551
552    rsx! {
553        div {
554            class: "adui-select-root",
555            style: "position: relative; display: inline-block;",
556            div {
557                class: "{class_attr}",
558                style: "{style_attr}",
559                role: "combobox",
560                tabindex: 0,
561                "aria-expanded": open_flag,
562                "aria-disabled": is_disabled_flag,
563                onclick: move |_| {
564                    if is_disabled_flag {
565                        return;
566                    }
567                    // Mark as internal click so the document-level handler does
568                    // not immediately close the dropdown.
569                    let mut flag = internal_click_for_toggle;
570                    flag.set(true);
571
572                    let mut open_signal = open_for_toggle;
573                    let current = *open_signal.read();
574                    let next = !current;
575                    open_signal.set(next);
576                    if let Some(cb) = on_visible_cb {
577                        cb.call(next);
578                    }
579                },
580                onkeydown: move |evt: KeyboardEvent| {
581                    if is_disabled_flag {
582                        return;
583                    }
584                    use dioxus::prelude::Key;
585
586                    let open_now = *open_for_keydown.read();
587                    if !open_now {
588                        match evt.key() {
589                            Key::Enter | Key::ArrowDown => {
590                                evt.prevent_default();
591                                let mut open_signal = open_for_keydown;
592                                open_signal.set(true);
593                                if let Some(cb) = on_visible_cb {
594                                    cb.call(true);
595                                }
596                            }
597                            Key::Escape => {
598                                // 没有打开时按 Escape 不做任何事。
599                            }
600                            _ => {}
601                        }
602                        return;
603                    }
604
605                    if matches!(evt.key(), Key::Escape) {
606                        let mut open_signal = open_for_keydown;
607                        open_signal.set(false);
608                        if let Some(cb) = on_visible_cb {
609                            cb.call(false);
610                        }
611                        return;
612                    }
613
614                    let opts_len = filtered_options.len();
615                    if opts_len == 0 {
616                        return;
617                    }
618
619                    // Keyboard interactions inside the select should not be
620                    // treated as outside clicks, so mark this as internal.
621                    let mut flag = internal_click_for_keydown;
622                    flag.set(true);
623
624                    if let Some(idx) = handle_option_list_key_event(&evt, opts_len, &active_for_keydown) {
625                        if idx < opts_len {
626                            let opt = &filtered_options[idx];
627                            if opt.disabled {
628                                return;
629                            }
630
631                            let key = opt.key.clone();
632                            let current_keys = selected_keys.clone();
633                            let next_keys = if multiple_flag {
634                                toggle_option_key(&current_keys, &key)
635                            } else {
636                                vec![key.clone()]
637                            };
638
639                            apply_selected_keys(
640                                &form_for_keydown,
641                                multiple_flag,
642                                controlled_by_prop,
643                                &internal_selected_for_keydown,
644                                on_change_cb,
645                                next_keys,
646                            );
647
648                            if !multiple_flag {
649                                let mut open_signal = open_for_keydown;
650                                open_signal.set(false);
651                                if let Some(cb) = on_visible_cb {
652                                    cb.call(false);
653                                }
654                            }
655                        }
656                    }
657                },
658                if let Some(prefix_el) = prefix {
659                    span { class: "adui-select-prefix", {prefix_el} }
660                }
661                div { class: "adui-select-selector", {display_node} }
662                {suffix_element}
663                if allow_clear && !selected_for_clear.is_empty() && !is_disabled_flag {
664                    span {
665                        class: "adui-select-clear",
666                        onclick: move |evt| {
667                            evt.stop_propagation();
668                            apply_selected_keys(
669                                &form_for_handlers,
670                                multiple_flag,
671                                controlled_by_prop,
672                                &internal_selected_for_handlers,
673                                on_change_cb,
674                                Vec::new(),
675                            );
676                        },
677                        "×"
678                    }
679                }
680            }
681            if open_flag {
682                div {
683                    class: "{dropdown_class_attr}",
684                    style: "{dropdown_style_attr}",
685                    role: "listbox",
686                    "aria-multiselectable": multiple_flag,
687                    onclick: move |_| {
688                        // Prevent clicks inside dropdown from closing it
689                        let mut flag = internal_click_flag;
690                        flag.set(true);
691                    },
692                    if show_search {
693                        div { class: "adui-select-search",
694                            input {
695                                class: "adui-select-search-input",
696                                value: "{search_for_input.read()}",
697                                oninput: move |evt| {
698                                    let mut signal = search_for_input;
699                                    signal.set(evt.value());
700                                }
701                            }
702                        }
703                    }
704                    ul { class: "adui-select-item-list",
705                        {filtered_options.iter().enumerate().map(|(index, opt)| {
706                            let key = opt.key.clone();
707                            let label = opt.label.clone();
708                            let disabled_opt = opt.disabled || is_disabled_flag;
709                            let is_selected = selected_keys.contains(&key);
710                            let is_active = active_index
711                                .read()
712                                .as_ref()
713                                .map(|i| *i == index)
714                                .unwrap_or(false);
715                            let selected_snapshot = selected_keys.clone();
716                            let form_for_click = form_control.clone();
717                            let internal_selected_for_click = internal_selected;
718                            let open_for_click = open_state;
719                            let internal_click_for_item = internal_click_flag;
720
721                            rsx! {
722                                li {
723                                    class: {
724                                        let mut classes = vec!["adui-select-item".to_string()];
725                                        if is_selected {
726                                            classes.push("adui-select-item-option-selected".into());
727                                        }
728                                        if disabled_opt {
729                                            classes.push("adui-select-item-option-disabled".into());
730                                        }
731                                        if is_active {
732                                            classes.push("adui-select-item-option-active".into());
733                                        }
734                                        classes.join(" ")
735                                    },
736                                    role: "option",
737                                    "aria-selected": is_selected,
738                                    onclick: move |_| {
739                                        if disabled_opt {
740                                            return;
741                                        }
742                                        // Mark as internal click so the document-level
743                                        // handler does not treat this as outside.
744                                        let mut flag = internal_click_for_item;
745                                        flag.set(true);
746
747                                        let current_keys = selected_snapshot.clone();
748                                        let next_keys = if multiple_flag {
749                                            toggle_option_key(&current_keys, &key)
750                                        } else {
751                                            vec![key.clone()]
752                                        };
753
754                                        apply_selected_keys(
755                                            &form_for_click,
756                                            multiple_flag,
757                                            controlled_by_prop,
758                                            &internal_selected_for_click,
759                                            on_change_cb,
760                                            next_keys,
761                                        );
762
763                                        if !multiple_flag {
764                                            let mut open_signal = open_for_click;
765                                            open_signal.set(false);
766                                            if let Some(cb) = on_visible_cb {
767                                                cb.call(false);
768                                            }
769                                        }
770                                    },
771                                    "{label}"
772                                    if is_selected {
773                                        span { class: "adui-select-item-option-state",
774                                            Icon { kind: IconKind::Check, size: 12.0 }
775                                        }
776                                    }
777                                }
778                            }
779                        })}
780                    }
781                }
782            }
783        }
784    }
785}
786
787fn apply_selected_keys(
788    form_control: &Option<FormItemControlContext>,
789    multiple: bool,
790    controlled_by_prop: bool,
791    selected_signal: &Signal<Vec<OptionKey>>,
792    on_change: Option<EventHandler<Vec<String>>>,
793    new_keys: Vec<OptionKey>,
794) {
795    if let Some(ctx) = form_control {
796        if multiple {
797            let json = option_keys_to_value(&new_keys);
798            ctx.set_value(json);
799        } else if let Some(first) = new_keys.first() {
800            let json = option_key_to_value(first);
801            ctx.set_value(json);
802        } else {
803            ctx.set_value(Value::Null);
804        }
805    } else if !controlled_by_prop {
806        let mut signal = *selected_signal;
807        signal.set(new_keys.clone());
808    }
809
810    if let Some(cb) = on_change {
811        cb.call(new_keys);
812    }
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    #[test]
820    fn select_mode_is_multiple() {
821        assert!(!SelectMode::Single.is_multiple());
822        assert!(SelectMode::Multiple.is_multiple());
823        assert!(SelectMode::Tags.is_multiple());
824    }
825
826    #[test]
827    fn select_placement_styles() {
828        assert!(SelectPlacement::BottomLeft.as_style().contains("top: 100%"));
829        assert!(SelectPlacement::TopLeft.as_style().contains("bottom: 100%"));
830    }
831
832    #[test]
833    fn select_mode_allows_input() {
834        assert!(!SelectMode::Single.allows_input());
835        assert!(!SelectMode::Multiple.allows_input());
836        assert!(SelectMode::Tags.allows_input());
837        assert!(SelectMode::Combobox.allows_input());
838    }
839
840    #[test]
841    fn select_mode_default() {
842        assert_eq!(SelectMode::default(), SelectMode::Single);
843    }
844
845    #[test]
846    fn select_mode_all_variants() {
847        assert_eq!(SelectMode::Single, SelectMode::Single);
848        assert_eq!(SelectMode::Multiple, SelectMode::Multiple);
849        assert_eq!(SelectMode::Tags, SelectMode::Tags);
850        assert_eq!(SelectMode::Combobox, SelectMode::Combobox);
851        assert_ne!(SelectMode::Single, SelectMode::Multiple);
852    }
853
854    #[test]
855    fn select_placement_all_variants() {
856        assert_eq!(SelectPlacement::BottomLeft, SelectPlacement::BottomLeft);
857        assert_eq!(SelectPlacement::BottomRight, SelectPlacement::BottomRight);
858        assert_eq!(SelectPlacement::TopLeft, SelectPlacement::TopLeft);
859        assert_eq!(SelectPlacement::TopRight, SelectPlacement::TopRight);
860        assert_ne!(SelectPlacement::BottomLeft, SelectPlacement::TopLeft);
861    }
862
863    #[test]
864    fn select_placement_default() {
865        assert_eq!(SelectPlacement::default(), SelectPlacement::BottomLeft);
866    }
867
868    #[test]
869    fn select_placement_all_styles() {
870        let bottom_left = SelectPlacement::BottomLeft.as_style();
871        let bottom_right = SelectPlacement::BottomRight.as_style();
872        let top_left = SelectPlacement::TopLeft.as_style();
873        let top_right = SelectPlacement::TopRight.as_style();
874
875        assert!(bottom_left.contains("top: 100%"));
876        assert!(bottom_left.contains("left: 0"));
877        assert!(bottom_right.contains("top: 100%"));
878        assert!(bottom_right.contains("right: 0"));
879        assert!(top_left.contains("bottom: 100%"));
880        assert!(top_left.contains("left: 0"));
881        assert!(top_right.contains("bottom: 100%"));
882        assert!(top_right.contains("right: 0"));
883    }
884
885    #[test]
886    fn select_mode_multiple_variants() {
887        // Test that Multiple and Tags are both multiple
888        assert!(SelectMode::Multiple.is_multiple());
889        assert!(SelectMode::Tags.is_multiple());
890        assert!(!SelectMode::Single.is_multiple());
891        assert!(!SelectMode::Combobox.is_multiple());
892    }
893
894    #[test]
895    fn select_mode_input_variants() {
896        // Test that Tags and Combobox allow input
897        assert!(SelectMode::Tags.allows_input());
898        assert!(SelectMode::Combobox.allows_input());
899        assert!(!SelectMode::Single.allows_input());
900        assert!(!SelectMode::Multiple.allows_input());
901    }
902}