adui_dioxus/components/
tree_select.rs

1use crate::components::config_provider::{ComponentSize, use_config};
2use crate::components::control::{ControlStatus, push_status_class};
3use crate::components::form::{FormItemControlContext, use_form_item_control};
4use crate::components::select_base::{
5    DropdownLayer, OptionKey, TreeNode, handle_option_list_key_event, option_key_to_value,
6    option_keys_to_value, toggle_option_key, use_dropdown_layer, value_to_option_key,
7    value_to_option_keys,
8};
9use dioxus::events::KeyboardEvent;
10use dioxus::prelude::*;
11use serde_json::Value;
12
13/// Props for the TreeSelect component (MVP subset).
14///
15/// MVP 行为说明:
16/// - 支持单选 / 多选(`multiple` 或 `tree_checkable` 任一为 true 即视为多选)
17/// - 简单 label 搜索(show_search,基于节点 label 的本地过滤)
18/// - 树结构默认全部展开,通过缩进展示层级;暂不支持折叠/半选状态
19/// - 与 Form 的值双向绑定,复用 Select 的表单集成逻辑
20#[derive(Props, Clone, PartialEq)]
21pub struct TreeSelectProps {
22    /// 树形数据源。每个节点包含 key / label / disabled / children。
23    #[props(optional)]
24    pub tree_data: Option<Vec<TreeNode>>,
25    /// 单选模式下的受控值。
26    #[props(optional)]
27    pub value: Option<String>,
28    /// 多选模式下的受控值集合。
29    #[props(optional)]
30    pub values: Option<Vec<String>>,
31    /// 是否启用多选模式(结合 tree_checkable 使用)。
32    #[props(default)]
33    pub multiple: bool,
34    /// 是否在树节点前显示复选框(勾选模式)。
35    #[props(default)]
36    pub tree_checkable: bool,
37    /// 是否启用简单搜索(基于 label 的本地过滤)。
38    #[props(default)]
39    pub show_search: bool,
40    /// 占位文案。
41    #[props(optional)]
42    pub placeholder: Option<String>,
43    /// 禁用整个选择器。
44    #[props(default)]
45    pub disabled: bool,
46    /// 视觉状态(success / warning / error)。
47    #[props(optional)]
48    pub status: Option<ControlStatus>,
49    /// 组件尺寸,默认跟随 ConfigProvider。
50    #[props(optional)]
51    pub size: Option<ComponentSize>,
52    /// 自定义类名与样式。
53    #[props(optional)]
54    pub class: Option<String>,
55    #[props(optional)]
56    pub style: Option<String>,
57    /// 弹层额外类名与样式。
58    #[props(optional)]
59    pub dropdown_class: Option<String>,
60    #[props(optional)]
61    pub dropdown_style: Option<String>,
62    /// 选中集合变更回调(单选约定 Vec 长度为 0 或 1)。
63    #[props(optional)]
64    pub on_change: Option<EventHandler<Vec<String>>>,
65}
66
67/// Internal flattened representation of a tree node for rendering and
68/// keyboard navigation.
69#[derive(Clone)]
70struct FlatNode {
71    key: OptionKey,
72    label: String,
73    disabled: bool,
74    depth: usize,
75}
76
77fn flatten_tree(nodes: &[TreeNode], depth: usize, out: &mut Vec<FlatNode>) {
78    for node in nodes {
79        out.push(FlatNode {
80            key: node.key.clone(),
81            label: node.label.clone(),
82            disabled: node.disabled,
83            depth,
84        });
85        if !node.children.is_empty() {
86            flatten_tree(&node.children, depth + 1, out);
87        }
88    }
89}
90
91/// Ant Design flavored TreeSelect (MVP).
92#[component]
93pub fn TreeSelect(props: TreeSelectProps) -> Element {
94    let TreeSelectProps {
95        tree_data,
96        value,
97        values,
98        multiple,
99        tree_checkable,
100        show_search,
101        placeholder,
102        disabled,
103        status,
104        size,
105        class,
106        style,
107        dropdown_class,
108        dropdown_style,
109        on_change,
110    } = props;
111
112    let config = use_config();
113    let form_control = use_form_item_control();
114
115    let final_size = size.unwrap_or(config.size);
116
117    let is_disabled =
118        disabled || config.disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
119
120    // Whether we are effectively in multi-select mode. `tree_checkable` implies
121    // multi-select semantics even if `multiple` is false.
122    let multiple_flag = multiple || tree_checkable;
123
124    // Internal selection state used only when not controlled by Form or props.
125    let internal_selected: Signal<Vec<OptionKey>> = use_signal(Vec::new);
126
127    let has_form = form_control.is_some();
128    let prop_single = value.clone();
129    let prop_multi = values.clone();
130
131    // Snapshot of currently selected keys for this render.
132    let selected_keys: Vec<OptionKey> = if let Some(ctx) = form_control.as_ref() {
133        if multiple_flag {
134            value_to_option_keys(ctx.value())
135        } else {
136            match value_to_option_key(ctx.value()) {
137                Some(k) => vec![k],
138                None => Vec::new(),
139            }
140        }
141    } else if let Some(vs) = prop_multi {
142        vs
143    } else if let Some(v) = prop_single {
144        vec![v]
145    } else {
146        internal_selected.read().clone()
147    };
148
149    let controlled_by_prop = has_form || value.is_some() || values.is_some();
150
151    // Dropdown open/close state and active index for keyboard navigation.
152    let open_state: Signal<bool> = use_signal(|| false);
153    let active_index: Signal<Option<usize>> = use_signal(|| None);
154
155    // Flag to distinguish internal clicks (trigger/dropdown) from real outside
156    // clicks so we can implement click-outside close without interfering with
157    // normal selection.
158    let internal_click_flag: Signal<bool> = use_signal(|| false);
159
160    // Document-level click handler for closing the dropdown when clicking
161    // outside of the tree select. This is only compiled for wasm32 targets.
162    #[cfg(target_arch = "wasm32")]
163    {
164        let mut open_for_global = open_state;
165        let mut internal_flag = internal_click_flag;
166        use_effect(move || {
167            use wasm_bindgen::{JsCast, closure::Closure};
168
169            if let Some(window) = web_sys::window() {
170                if let Some(document) = window.document() {
171                    let target: web_sys::EventTarget = document.into();
172                    let handler = Closure::<dyn FnMut(web_sys::MouseEvent)>::wrap(Box::new(
173                        move |_evt: web_sys::MouseEvent| {
174                            let mut flag = internal_flag;
175                            if *flag.read() {
176                                // Internal click: consume flag and skip closing.
177                                flag.set(false);
178                                return;
179                            }
180                            let mut open_signal = open_for_global;
181                            if *open_signal.read() {
182                                open_signal.set(false);
183                            }
184                        },
185                    ));
186                    let _ = target.add_event_listener_with_callback(
187                        "click",
188                        handler.as_ref().unchecked_ref(),
189                    );
190                    // Leak handler for simplicity; matches app lifetime.
191                    handler.forget();
192                }
193            }
194        });
195    }
196
197    // Search query (when show_search = true).
198    let search_query: Signal<String> = use_signal(String::new);
199
200    let open_flag = *open_state.read();
201    let DropdownLayer { z_index, .. } = use_dropdown_layer(open_flag);
202    let current_z = *z_index.read();
203
204    // Prepare flattened nodes and apply search filter if needed.
205    let nodes: Vec<TreeNode> = tree_data.unwrap_or_else(Vec::new);
206    let mut flat_nodes: Vec<FlatNode> = Vec::new();
207    flatten_tree(&nodes, 0, &mut flat_nodes);
208
209    let placeholder_str = placeholder.unwrap_or_default();
210
211    let filtered_nodes: Vec<FlatNode> = if show_search {
212        let query = search_query.read().clone();
213        let trimmed = query.trim();
214        if trimmed.is_empty() {
215            flat_nodes.clone()
216        } else {
217            let lower = trimmed.to_lowercase();
218            flat_nodes
219                .iter()
220                .filter(|n| n.label.to_lowercase().contains(&lower))
221                .cloned()
222                .collect()
223        }
224    } else {
225        flat_nodes.clone()
226    };
227
228    // Helper to find label for a key.
229    let find_label = |key: &str| -> String {
230        flat_nodes
231            .iter()
232            .find(|n| n.key == key)
233            .map(|n| n.label.clone())
234            .unwrap_or_else(|| key.to_string())
235    };
236
237    let display_node = if multiple_flag {
238        if selected_keys.is_empty() {
239            rsx! { span { class: "adui-select-selection-placeholder", "{placeholder_str}" } }
240        } else {
241            rsx! {
242                div { class: "adui-select-selection-overflow",
243                    {selected_keys.iter().map(|k| {
244                        let label = find_label(k);
245                        rsx! {
246                            span { class: "adui-select-selection-item", "{label}" }
247                        }
248                    })}
249                }
250            }
251        }
252    } else if let Some(first) = selected_keys.first() {
253        let label = find_label(first);
254        rsx! { span { class: "adui-select-selection-item", "{label}" } }
255    } else {
256        rsx! { span { class: "adui-select-selection-placeholder", "{placeholder_str}" } }
257    };
258
259    // Shared helpers for event handlers.
260    let form_for_handlers = form_control.clone();
261    let _internal_selected_for_handlers = internal_selected;
262    let on_change_cb = on_change;
263    let controlled_flag = controlled_by_prop;
264
265    let open_for_toggle = open_state;
266    let is_disabled_flag = is_disabled;
267
268    let search_for_input = search_query;
269
270    let active_for_keydown = active_index;
271    let internal_selected_for_keydown = internal_selected;
272    let form_for_keydown = form_for_handlers.clone();
273    let open_for_keydown = open_for_toggle;
274
275    // Local copies of the internal click flag for different handlers.
276    let internal_click_for_toggle = internal_click_flag;
277    let internal_click_for_keydown = internal_click_flag;
278
279    let dropdown_class_attr = {
280        let mut list = vec!["adui-select-dropdown".to_string()];
281        if let Some(extra) = dropdown_class {
282            list.push(extra);
283        }
284        list.join(" ")
285    };
286    let dropdown_style_attr = format!(
287        "position: absolute; top: 100%; left: 0; min-width: 100%; z-index: {}; {}",
288        current_z,
289        dropdown_style.unwrap_or_default()
290    );
291
292    // Build wrapper classes (reuse select visual tokens for now).
293    let mut class_list = vec!["adui-select".to_string()];
294    if multiple_flag {
295        class_list.push("adui-select-multiple".into());
296    }
297    if is_disabled_flag {
298        class_list.push("adui-select-disabled".into());
299    }
300    if open_flag {
301        class_list.push("adui-select-open".into());
302    }
303    match final_size {
304        ComponentSize::Small => class_list.push("adui-select-sm".into()),
305        ComponentSize::Large => class_list.push("adui-select-lg".into()),
306        ComponentSize::Middle => {}
307    }
308    push_status_class(&mut class_list, status);
309    if let Some(extra) = class {
310        class_list.push(extra);
311    }
312    let class_attr = class_list.join(" ");
313    let style_attr = style.unwrap_or_default();
314
315    rsx! {
316        div {
317            class: "adui-select-root",
318            style: "position: relative; display: inline-block;",
319            div {
320                class: "{class_attr}",
321                style: "{style_attr}",
322                role: "combobox",
323                tabindex: 0,
324                "aria-expanded": open_flag,
325                "aria-disabled": is_disabled_flag,
326                onclick: move |_| {
327                    if is_disabled_flag {
328                        return;
329                    }
330                    // Mark as internal click so the document-level handler does
331                    // not immediately close the dropdown.
332                    let mut flag = internal_click_for_toggle;
333                    flag.set(true);
334
335                    let mut open_signal = open_for_toggle;
336                    let current = *open_signal.read();
337                    open_signal.set(!current);
338                },
339                onkeydown: move |evt: KeyboardEvent| {
340                    if is_disabled_flag {
341                        return;
342                    }
343                    use dioxus::prelude::Key;
344
345                    let open_now = *open_for_keydown.read();
346                    if !open_now {
347                        match evt.key() {
348                            Key::Enter | Key::ArrowDown => {
349                                evt.prevent_default();
350                                let mut open_signal = open_for_keydown;
351                                open_signal.set(true);
352                            }
353                            Key::Escape => {
354                                // When closed, Escape does nothing.
355                            }
356                            _ => {}
357                        }
358                        return;
359                    }
360
361                    if matches!(evt.key(), Key::Escape) {
362                        let mut open_signal = open_for_keydown;
363                        open_signal.set(false);
364                        return;
365                    }
366
367                    let opts_len = filtered_nodes.len();
368                    if opts_len == 0 {
369                        return;
370                    }
371
372                    // Keyboard interactions inside the tree select should not be
373                    // treated as outside clicks.
374                    let mut flag = internal_click_for_keydown;
375                    flag.set(true);
376
377                    if let Some(idx) =
378                        handle_option_list_key_event(&evt, opts_len, &active_for_keydown)
379                        && idx < opts_len
380                    {
381                        let node = &filtered_nodes[idx];
382                        if node.disabled {
383                            return;
384                        }
385
386                        let key = node.key.clone();
387                        let current_keys = selected_keys.clone();
388                        let next_keys = if multiple_flag {
389                            toggle_option_key(&current_keys, &key)
390                        } else {
391                            vec![key.clone()]
392                        };
393
394                        apply_selected_keys(
395                            &form_for_keydown,
396                            multiple_flag,
397                            controlled_flag,
398                            &internal_selected_for_keydown,
399                            on_change_cb,
400                            next_keys,
401                        );
402
403                        if !multiple_flag {
404                            let mut open_signal = open_for_keydown;
405                            open_signal.set(false);
406                        }
407                    }
408                },
409                div { class: "adui-select-selector", {display_node} }
410            }
411            if open_flag {
412                div {
413                    class: "{dropdown_class_attr}",
414                    style: "{dropdown_style_attr}",
415                    role: "tree",
416                    "aria-multiselectable": multiple_flag,
417                    if show_search {
418                        div { class: "adui-select-search",
419                            input {
420                                class: "adui-select-search-input",
421                                value: "{search_for_input.read()}",
422                                oninput: move |evt| {
423                                    let mut signal = search_for_input;
424                                    signal.set(evt.value());
425                                }
426                            }
427                        }
428                    }
429                    ul { class: "adui-select-item-list",
430                        {filtered_nodes.iter().enumerate().map(|(index, node)| {
431                            let key = node.key.clone();
432                            let label = node.label.clone();
433                            let disabled_opt = node.disabled || is_disabled_flag;
434                            let is_selected = selected_keys.contains(&key);
435                            let is_active = active_index
436                                .read()
437                                .as_ref()
438                                .map(|i| *i == index)
439                                .unwrap_or(false);
440                            let selected_snapshot = selected_keys.clone();
441                            let form_for_click = form_control.clone();
442                            let internal_selected_for_click = internal_selected;
443                            let open_for_click = open_state;
444                            let internal_click_for_item = internal_click_flag;
445                            let depth = node.depth;
446
447                            rsx! {
448                                li {
449                                    class: {
450                                        let mut classes = vec!["adui-select-item".to_string()];
451                                        if is_selected {
452                                            classes.push("adui-select-item-option-selected".into());
453                                        }
454                                        if disabled_opt {
455                                            classes.push("adui-select-item-option-disabled".into());
456                                        }
457                                        if is_active {
458                                            classes.push("adui-select-item-option-active".into());
459                                        }
460                                        classes.join(" ")
461                                    },
462                                    style: {format!("padding-left: {}px;", 12 + depth as i32 * 16)},
463                                    role: "treeitem",
464                                    "aria-selected": is_selected,
465                                    onclick: move |_| {
466                                        if disabled_opt {
467                                            return;
468                                        }
469                                        // Mark as internal click so the document-level
470                                        // handler does not treat this as outside.
471                                        let mut flag = internal_click_for_item;
472                                        flag.set(true);
473
474                                        let current_keys = selected_snapshot.clone();
475                                        let next_keys = if multiple_flag {
476                                            toggle_option_key(&current_keys, &key)
477                                        } else {
478                                            vec![key.clone()]
479                                        };
480
481                                        apply_selected_keys(
482                                            &form_for_click,
483                                            multiple_flag,
484                                            controlled_flag,
485                                            &internal_selected_for_click,
486                                            on_change_cb,
487                                            next_keys,
488                                        );
489
490                                        if !multiple_flag {
491                                            let mut open_signal = open_for_click;
492                                            open_signal.set(false);
493                                        }
494                                    },
495                                    "{label}"
496                                }
497                            }
498                        })}
499                    }
500                }
501            }
502        }
503    }
504}
505
506fn apply_selected_keys(
507    form_control: &Option<FormItemControlContext>,
508    multiple: bool,
509    controlled_by_prop: bool,
510    selected_signal: &Signal<Vec<OptionKey>>,
511    on_change: Option<EventHandler<Vec<String>>>,
512    new_keys: Vec<OptionKey>,
513) {
514    if let Some(ctx) = form_control {
515        if multiple {
516            let json = option_keys_to_value(&new_keys);
517            ctx.set_value(json);
518        } else if let Some(first) = new_keys.first() {
519            let json = option_key_to_value(first);
520            ctx.set_value(json);
521        } else {
522            ctx.set_value(Value::Null);
523        }
524    } else if !controlled_by_prop {
525        let mut signal = *selected_signal;
526        signal.set(new_keys.clone());
527    }
528
529    if let Some(cb) = on_change {
530        cb.call(new_keys);
531    }
532}
533
534#[cfg(test)]
535mod tree_select_tests {
536    use super::*;
537    use crate::components::select_base::TreeNode;
538
539    fn flatten_tree(nodes: &[TreeNode], depth: usize, out: &mut Vec<FlatNode>) {
540        for node in nodes {
541            out.push(FlatNode {
542                key: node.key.clone(),
543                label: node.label.clone(),
544                disabled: node.disabled,
545                depth,
546            });
547            if !node.children.is_empty() {
548                flatten_tree(&node.children, depth + 1, out);
549            }
550        }
551    }
552
553    #[test]
554    fn flatten_tree_empty() {
555        let nodes: Vec<TreeNode> = vec![];
556        let mut result = Vec::new();
557        flatten_tree(&nodes, 0, &mut result);
558        assert_eq!(result.len(), 0);
559    }
560
561    #[test]
562    fn flatten_tree_single_node() {
563        let nodes = vec![TreeNode {
564            key: "1".to_string(),
565            label: "Node 1".to_string(),
566            disabled: false,
567            children: vec![],
568        }];
569        let mut result = Vec::new();
570        flatten_tree(&nodes, 0, &mut result);
571        assert_eq!(result.len(), 1);
572        assert_eq!(result[0].key, "1");
573        assert_eq!(result[0].label, "Node 1");
574        assert_eq!(result[0].depth, 0);
575    }
576
577    #[test]
578    fn flatten_tree_nested() {
579        let nodes = vec![TreeNode {
580            key: "1".to_string(),
581            label: "Node 1".to_string(),
582            disabled: false,
583            children: vec![TreeNode {
584                key: "2".to_string(),
585                label: "Node 2".to_string(),
586                disabled: false,
587                children: vec![],
588            }],
589        }];
590        let mut result = Vec::new();
591        flatten_tree(&nodes, 0, &mut result);
592        assert_eq!(result.len(), 2);
593        assert_eq!(result[0].depth, 0);
594        assert_eq!(result[1].depth, 1);
595    }
596
597    #[test]
598    fn flatten_tree_deeply_nested() {
599        let nodes = vec![TreeNode {
600            key: "1".to_string(),
601            label: "Level 1".to_string(),
602            disabled: false,
603            children: vec![TreeNode {
604                key: "2".to_string(),
605                label: "Level 2".to_string(),
606                disabled: false,
607                children: vec![TreeNode {
608                    key: "3".to_string(),
609                    label: "Level 3".to_string(),
610                    disabled: false,
611                    children: vec![],
612                }],
613            }],
614        }];
615        let mut result = Vec::new();
616        flatten_tree(&nodes, 0, &mut result);
617        assert_eq!(result.len(), 3);
618        assert_eq!(result[0].depth, 0);
619        assert_eq!(result[1].depth, 1);
620        assert_eq!(result[2].depth, 2);
621    }
622
623    #[test]
624    fn flatten_tree_with_disabled_nodes() {
625        let nodes = vec![TreeNode {
626            key: "1".to_string(),
627            label: "Node 1".to_string(),
628            disabled: true,
629            children: vec![TreeNode {
630                key: "2".to_string(),
631                label: "Node 2".to_string(),
632                disabled: false,
633                children: vec![],
634            }],
635        }];
636        let mut result = Vec::new();
637        flatten_tree(&nodes, 0, &mut result);
638        assert_eq!(result.len(), 2);
639        assert!(result[0].disabled);
640        assert!(!result[1].disabled);
641    }
642
643    #[test]
644    fn flatten_tree_multiple_siblings() {
645        let nodes = vec![
646            TreeNode {
647                key: "1".to_string(),
648                label: "Node 1".to_string(),
649                disabled: false,
650                children: vec![],
651            },
652            TreeNode {
653                key: "2".to_string(),
654                label: "Node 2".to_string(),
655                disabled: false,
656                children: vec![],
657            },
658            TreeNode {
659                key: "3".to_string(),
660                label: "Node 3".to_string(),
661                disabled: false,
662                children: vec![],
663            },
664        ];
665        let mut result = Vec::new();
666        flatten_tree(&nodes, 0, &mut result);
667        assert_eq!(result.len(), 3);
668        assert_eq!(result[0].key, "1");
669        assert_eq!(result[1].key, "2");
670        assert_eq!(result[2].key, "3");
671        assert_eq!(result[0].depth, 0);
672        assert_eq!(result[1].depth, 0);
673        assert_eq!(result[2].depth, 0);
674    }
675
676    #[test]
677    fn flatten_tree_with_starting_depth() {
678        let nodes = vec![TreeNode {
679            key: "1".to_string(),
680            label: "Node 1".to_string(),
681            disabled: false,
682            children: vec![],
683        }];
684        let mut result = Vec::new();
685        flatten_tree(&nodes, 5, &mut result);
686        assert_eq!(result.len(), 1);
687        assert_eq!(result[0].depth, 5);
688    }
689}