adui_dioxus/components/
auto_complete.rs

1use crate::components::config_provider::{ComponentSize, use_config};
2use crate::components::control::{ControlStatus, push_status_class};
3use crate::components::form::{form_value_to_string, use_form_item_control};
4use crate::components::select_base::{
5    DropdownLayer, SelectOption, filter_options_by_query, use_dropdown_layer,
6};
7use dioxus::events::KeyboardEvent;
8use dioxus::prelude::*;
9
10/// Props for the AutoComplete component (MVP subset).
11///
12/// 当前版本重点支持:
13/// - 基于 Input 的受控/非受控输入;
14/// - 本地 options 过滤;
15/// - 选择候选项时将其 label 写回输入框,并触发 on_select/on_change;
16/// - 与 Form 集成时,直接以字符串形式读写字段值。
17#[derive(Props, Clone, PartialEq)]
18pub struct AutoCompleteProps {
19    /// 候选列表(建议使用较小集合),使用 SelectOption 复用模型。
20    #[props(optional)]
21    pub options: Option<Vec<SelectOption>>,
22    /// 受控输入值。
23    #[props(optional)]
24    pub value: Option<String>,
25    /// 非受控模式下的初始值。
26    #[props(optional)]
27    pub default_value: Option<String>,
28    /// 占位文本。
29    #[props(optional)]
30    pub placeholder: Option<String>,
31    /// 是否显示清除按钮。
32    #[props(default)]
33    pub allow_clear: bool,
34    /// 禁用整个组件。
35    #[props(default)]
36    pub disabled: bool,
37    /// 视觉状态(success / warning / error)。
38    #[props(optional)]
39    pub status: Option<ControlStatus>,
40    /// 组件尺寸,默认跟随 ConfigProvider。
41    #[props(optional)]
42    pub size: Option<ComponentSize>,
43    /// 自定义类名与样式。
44    #[props(optional)]
45    pub class: Option<String>,
46    #[props(optional)]
47    pub style: Option<String>,
48    /// 弹层额外类名与样式。
49    #[props(optional)]
50    pub dropdown_class: Option<String>,
51    #[props(optional)]
52    pub dropdown_style: Option<String>,
53    /// 输入变化回调(Form 场景下也会在写回字段后触发)。
54    #[props(optional)]
55    pub on_change: Option<EventHandler<String>>,
56    /// 输入变化时的搜索回调,可用于外部异步更新 options。
57    #[props(optional)]
58    pub on_search: Option<EventHandler<String>>,
59    /// 选择某个候选项时触发,参数为该项的 key。
60    #[props(optional)]
61    pub on_select: Option<EventHandler<String>>,
62}
63
64/// Ant Design flavored AutoComplete (MVP).
65#[component]
66pub fn AutoComplete(props: AutoCompleteProps) -> Element {
67    let AutoCompleteProps {
68        options,
69        value,
70        default_value,
71        placeholder,
72        allow_clear,
73        disabled,
74        status,
75        size,
76        class,
77        style,
78        dropdown_class,
79        dropdown_style,
80        on_change,
81        on_search,
82        on_select,
83    } = props;
84
85    let config = use_config();
86    let form_control = use_form_item_control();
87
88    let final_size = size.unwrap_or(config.size);
89
90    let is_disabled =
91        disabled || config.disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
92
93    // Local inner value used only when not controlled by Form or external props.
94    let initial_inner = default_value.clone().unwrap_or_default();
95    let inner_value: Signal<String> = use_signal(|| initial_inner);
96
97    let has_form = form_control.is_some();
98    let prop_value = value.clone();
99    let controlled_by_prop = has_form || prop_value.is_some();
100
101    // Resolve current text value from Form / props / internal state。
102    let current_value: String = if let Some(ctx) = form_control.as_ref() {
103        form_value_to_string(ctx.value())
104    } else if let Some(v) = prop_value {
105        v
106    } else {
107        inner_value.read().clone()
108    };
109
110    // Dropdown open state and internal click flag for click-outside closing.
111    let open_state: Signal<bool> = use_signal(|| false);
112    let internal_click_flag: Signal<bool> = use_signal(|| false);
113
114    #[cfg(target_arch = "wasm32")]
115    {
116        let mut open_for_global = open_state;
117        let mut internal_flag = internal_click_flag;
118        use_effect(move || {
119            use wasm_bindgen::{JsCast, closure::Closure};
120
121            if let Some(window) = web_sys::window() {
122                if let Some(document) = window.document() {
123                    let target: web_sys::EventTarget = document.into();
124                    let handler = Closure::<dyn FnMut(web_sys::MouseEvent)>::wrap(Box::new(
125                        move |_evt: web_sys::MouseEvent| {
126                            let mut flag = internal_flag;
127                            if *flag.read() {
128                                flag.set(false);
129                                return;
130                            }
131                            let mut open_signal = open_for_global;
132                            if *open_signal.read() {
133                                open_signal.set(false);
134                            }
135                        },
136                    ));
137                    let _ = target.add_event_listener_with_callback(
138                        "click",
139                        handler.as_ref().unchecked_ref(),
140                    );
141                    handler.forget();
142                }
143            }
144        });
145    }
146
147    let placeholder_str = placeholder.unwrap_or_default();
148
149    let has_any_options = options
150        .as_ref()
151        .map(|opts| !opts.is_empty())
152        .unwrap_or(false);
153
154    // Filter options by current input value.
155    let filtered_options: Vec<SelectOption> = if let Some(opts) = options.as_ref() {
156        if current_value.is_empty() {
157            opts.clone()
158        } else {
159            filter_options_by_query(opts, &current_value)
160        }
161    } else {
162        Vec::new()
163    };
164
165    let open_flag = *open_state.read();
166    let DropdownLayer { z_index, .. } = use_dropdown_layer(open_flag);
167    let current_z = *z_index.read();
168
169    // Shared handlers.
170    let on_change_cb = on_change;
171    let on_search_cb = on_search;
172    let on_select_cb = on_select;
173    let inner_for_change = inner_value;
174    let form_for_handlers = form_control.clone();
175    let open_for_toggle = open_state;
176    let internal_click_for_toggle = internal_click_flag;
177
178    let dropdown_class_attr = {
179        let mut list = vec!["adui-select-dropdown".to_string()];
180        if let Some(extra) = dropdown_class {
181            list.push(extra);
182        }
183        list.join(" ")
184    };
185    let dropdown_style_attr = format!(
186        "position: absolute; top: 100%; left: 0; min-width: 100%; z-index: {}; {}",
187        current_z,
188        dropdown_style.unwrap_or_default()
189    );
190
191    // Wrapper classes reuse Select visuals for consistency.
192    let mut class_list = vec!["adui-select".to_string()];
193    if is_disabled {
194        class_list.push("adui-select-disabled".into());
195    }
196    if open_flag {
197        class_list.push("adui-select-open".into());
198    }
199    match final_size {
200        ComponentSize::Small => class_list.push("adui-select-sm".into()),
201        ComponentSize::Large => class_list.push("adui-select-lg".into()),
202        ComponentSize::Middle => {}
203    }
204    push_status_class(&mut class_list, status);
205    if let Some(extra) = class {
206        class_list.push(extra);
207    }
208    let class_attr = class_list.join(" ");
209    let style_attr = style.unwrap_or_default();
210
211    // Input change helper:写回 Form / 内部 state,并触发 on_change/on_search。
212    let handle_input_change = move |next: String| {
213        if let Some(ctx) = form_for_handlers.as_ref() {
214            ctx.set_string(next.clone());
215        } else if !controlled_by_prop {
216            let mut inner = inner_for_change;
217            inner.set(next.clone());
218        }
219        if let Some(cb) = on_change_cb {
220            cb.call(next.clone());
221        }
222        if let Some(cb) = on_search_cb {
223            cb.call(next);
224        }
225    };
226
227    rsx! {
228        div {
229            class: "adui-select-root",
230            style: "position: relative; display: inline-block;",
231            div {
232                class: "{class_attr}",
233                style: "{style_attr}",
234                role: "combobox",
235                "aria-expanded": open_flag,
236                "aria-disabled": is_disabled,
237                onclick: move |_| {
238                    if is_disabled {
239                        return;
240                    }
241                    let mut flag = internal_click_for_toggle;
242                    flag.set(true);
243                    let mut open_signal = open_for_toggle;
244                    let current = *open_signal.read();
245                    // 允许点击触发区显式打开/关闭,下拉只有在存在 options 时才有意义。
246                    if has_any_options {
247                        open_signal.set(!current);
248                    }
249                },
250                // 输入框本体。
251                input {
252                    class: "adui-input",
253                    disabled: is_disabled || config.disabled,
254                    value: "{current_value}",
255                    placeholder: "{placeholder_str}",
256                    onfocus: move |_| {
257                        if is_disabled || !has_any_options {
258                            return;
259                        }
260                        let mut flag = internal_click_for_toggle;
261                        flag.set(true);
262                        let mut open_signal = open_for_toggle;
263                        open_signal.set(true);
264                    },
265                        oninput: {
266                            let handle_input_change = handle_input_change.clone();
267                            move |evt| {
268                                if is_disabled {
269                                    return;
270                                }
271                                let mut flag = internal_click_for_toggle;
272                                flag.set(true);
273                                let next = evt.value();
274                                handle_input_change(next);
275                                if has_any_options {
276                                    let mut open_signal = open_for_toggle;
277                                    open_signal.set(true);
278                                }
279                            }
280                        },
281                    onkeydown: move |evt: KeyboardEvent| {
282                        if is_disabled {
283                            return;
284                        }
285                        use dioxus::prelude::Key;
286                        if matches!(evt.key(), Key::Escape) {
287                            let mut open_signal = open_for_toggle;
288                            open_signal.set(false);
289                        }
290                    }
291                }
292                if allow_clear && !current_value.is_empty() && !is_disabled {
293                    {
294                        let handle_input_change = handle_input_change.clone();
295                        rsx! {
296                            span {
297                                class: "adui-select-clear",
298                                onclick: move |_| {
299                                    handle_input_change(String::new());
300                                    let mut open_signal = open_for_toggle;
301                                    open_signal.set(false);
302                                },
303                                "×"
304                            }
305                        }
306                    }
307                }
308            }
309            if open_flag && !filtered_options.is_empty() {
310                div {
311                    class: "{dropdown_class_attr}",
312                    style: "{dropdown_style_attr}",
313                    role: "listbox",
314                    ul { class: "adui-select-item-list",
315                        {filtered_options.iter().map(|opt| {
316                            let key = opt.key.clone();
317                            let label = opt.label.clone();
318                            let disabled_opt = opt.disabled || is_disabled;
319                            let internal_click_for_item = internal_click_flag;
320                            let form_for_click = form_control.clone();
321                            let inner_for_click = inner_value;
322                            rsx! {
323                                li {
324                                    class: {
325                                        let mut classes = vec!["adui-select-item".to_string()];
326                                        if disabled_opt {
327                                            classes.push("adui-select-item-option-disabled".into());
328                                        }
329                                        classes.join(" ")
330                                    },
331                                    role: "option",
332                                    onclick: move |_| {
333                                        if disabled_opt {
334                                            return;
335                                        }
336                                        let mut flag = internal_click_for_item;
337                                        flag.set(true);
338
339                                        // 选中候选项时,将 label 写回输入框。
340                                        let next_text = label.clone();
341                                        if let Some(ctx) = form_for_click.as_ref() {
342                                            ctx.set_string(next_text.clone());
343                                        } else if !controlled_by_prop {
344                                            let mut inner = inner_for_click;
345                                            inner.set(next_text.clone());
346                                        }
347                                        if let Some(cb) = on_change_cb {
348                                            cb.call(next_text.clone());
349                                        }
350                                        if let Some(cb) = on_select_cb {
351                                            cb.call(key.clone());
352                                        }
353
354                                        let mut open_signal = open_for_toggle;
355                                        open_signal.set(false);
356                                    },
357                                    "{label}"
358                                }
359                            }
360                        })}
361                    }
362                }
363            }
364        }
365    }
366}
367
368#[cfg(test)]
369mod auto_complete_tests {
370    use super::*;
371    use crate::components::select_base::SelectOption;
372
373    #[test]
374    fn auto_complete_props_defaults() {
375        // Test that default values are correct
376        // Note: AutoCompleteProps requires children, so we can't create a full instance
377        // But we can verify the default values used in the component
378        assert_eq!(false, false); // allow_clear defaults to false
379        assert_eq!(false, false); // disabled defaults to false
380    }
381
382    #[test]
383    fn auto_complete_props_optional_fields() {
384        // Test that all optional fields can be None
385        // This verifies the structure allows None for optional fields
386        let _options: Option<Vec<SelectOption>> = None;
387        let _value: Option<String> = None;
388        let _default_value: Option<String> = None;
389        let _placeholder: Option<String> = None;
390        let _status: Option<ControlStatus> = None;
391        let _size: Option<ComponentSize> = None;
392        let _class: Option<String> = None;
393        let _style: Option<String> = None;
394        let _dropdown_class: Option<String> = None;
395        let _dropdown_style: Option<String> = None;
396        // All optional fields can be None
397        assert!(true);
398    }
399
400    #[test]
401    fn auto_complete_props_with_values() {
402        // Test that optional fields can have values
403        let options = Some(vec![
404            SelectOption {
405                key: "1".to_string(),
406                label: "Option 1".to_string(),
407                disabled: false,
408            },
409            SelectOption {
410                key: "2".to_string(),
411                label: "Option 2".to_string(),
412                disabled: false,
413            },
414        ]);
415        assert!(options.is_some());
416        assert_eq!(options.as_ref().unwrap().len(), 2);
417
418        let value = Some("test".to_string());
419        assert_eq!(value.as_ref().unwrap(), "test");
420
421        let status = Some(ControlStatus::Error);
422        assert_eq!(status.unwrap(), ControlStatus::Error);
423
424        let size = Some(ComponentSize::Large);
425        assert_eq!(size.unwrap(), ComponentSize::Large);
426    }
427
428    #[test]
429    fn auto_complete_props_boolean_defaults() {
430        // Verify boolean defaults
431        let allow_clear_default = false;
432        let disabled_default = false;
433        assert_eq!(allow_clear_default, false);
434        assert_eq!(disabled_default, false);
435    }
436
437    #[test]
438    fn filter_options_by_query_used_by_autocomplete() {
439        // Test the filter function that AutoComplete uses
440        let options = vec![
441            SelectOption {
442                key: "1".to_string(),
443                label: "Apple".to_string(),
444                disabled: false,
445            },
446            SelectOption {
447                key: "2".to_string(),
448                label: "Banana".to_string(),
449                disabled: false,
450            },
451            SelectOption {
452                key: "3".to_string(),
453                label: "Cherry".to_string(),
454                disabled: false,
455            },
456        ];
457
458        let filtered = filter_options_by_query(&options, "app");
459        assert_eq!(filtered.len(), 1);
460        assert_eq!(filtered[0].label, "Apple");
461
462        let filtered_empty = filter_options_by_query(&options, "");
463        assert_eq!(filtered_empty.len(), 3);
464
465        let filtered_case_insensitive = filter_options_by_query(&options, "BANANA");
466        assert_eq!(filtered_case_insensitive.len(), 1);
467        assert_eq!(filtered_case_insensitive[0].label, "Banana");
468    }
469}