adui_dioxus/components/
dropdown.rs

1use crate::components::config_provider::{ComponentSize, use_config};
2use crate::components::floating::use_floating_close_handle;
3use crate::components::overlay::OverlayKind;
4use crate::components::select_base::use_floating_layer;
5use dioxus::events::{KeyboardEvent, MouseEvent};
6use dioxus::prelude::Key;
7use dioxus::prelude::*;
8
9/// Simple menu item model for the Dropdown component.
10#[derive(Clone, Debug, PartialEq)]
11pub struct DropdownItem {
12    pub key: String,
13    pub label: String,
14    pub disabled: bool,
15}
16
17impl DropdownItem {
18    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
19        Self {
20            key: key.into(),
21            label: label.into(),
22            disabled: false,
23        }
24    }
25}
26
27/// Trigger mode for Dropdown.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum DropdownTrigger {
30    #[default]
31    Click,
32    Hover,
33}
34
35/// Placement of the dropdown menu relative to the trigger.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37pub enum DropdownPlacement {
38    #[default]
39    BottomLeft,
40    BottomRight,
41}
42
43/// Props for the lightweight Dropdown component (MVP).
44#[derive(Props, Clone, PartialEq)]
45pub struct DropdownProps {
46    /// Menu items to display in the dropdown.
47    pub items: Vec<DropdownItem>,
48    /// Trigger mode. Defaults to click.
49    #[props(default)]
50    pub trigger: DropdownTrigger,
51    /// Placement of the dropdown menu relative to the trigger.
52    #[props(optional)]
53    pub placement: Option<DropdownPlacement>,
54    /// Controlled open state. When set, the component becomes controlled.
55    #[props(optional)]
56    pub open: Option<bool>,
57    /// Initial open state when used in uncontrolled mode.
58    #[props(optional)]
59    pub default_open: Option<bool>,
60    /// Called when the open state changes due to user interaction.
61    #[props(optional)]
62    pub on_open_change: Option<EventHandler<bool>>,
63    /// Called when a menu item is clicked.
64    #[props(optional)]
65    pub on_click: Option<EventHandler<String>>,
66    /// Disable user interaction.
67    #[props(default)]
68    pub disabled: bool,
69    /// Extra class applied to the trigger wrapper.
70    #[props(optional)]
71    pub class: Option<String>,
72    /// Extra class applied to the dropdown menu.
73    #[props(optional)]
74    pub overlay_class: Option<String>,
75    /// Inline styles applied to the dropdown menu.
76    #[props(optional)]
77    pub overlay_style: Option<String>,
78    /// Custom width for the dropdown menu in pixels (optional).
79    #[props(optional)]
80    pub overlay_width: Option<f32>,
81    /// Trigger element (usually a Button or link).
82    pub children: Element,
83}
84
85/// Lightweight Ant Design flavored Dropdown (menu).
86#[component]
87pub fn Dropdown(props: DropdownProps) -> Element {
88    let DropdownProps {
89        items,
90        trigger,
91        placement,
92        open,
93        default_open,
94        on_open_change,
95        on_click,
96        disabled,
97        class,
98        overlay_class,
99        overlay_style,
100        overlay_width,
101        children,
102    } = props;
103
104    let config = use_config();
105    let global_size = config.size;
106
107    let open_state: Signal<bool> = use_signal(|| default_open.unwrap_or(false));
108    let is_controlled = open.is_some();
109    let current_open = open.unwrap_or(*open_state.read());
110
111    let floating = use_floating_layer(OverlayKind::Dropdown, current_open);
112    let current_z = *floating.z_index.read();
113
114    let close_handle = if !is_controlled && matches!(trigger, DropdownTrigger::Click) {
115        Some(use_floating_close_handle(open_state))
116    } else {
117        None
118    };
119
120    let disabled_flag = disabled;
121    let is_controlled_flag = is_controlled;
122    let open_for_handlers = open_state;
123    let on_open_change_cb = on_open_change;
124    let trigger_mode = trigger;
125    let current_open_flag = current_open;
126    let close_handle_for_click = close_handle;
127    let close_handle_for_menu = close_handle;
128
129    let class_attr = {
130        let mut list = vec!["adui-dropdown-root".to_string()];
131        if let Some(extra) = class {
132            list.push(extra);
133        }
134        list.join(" ")
135    };
136
137    let overlay_class_attr = {
138        let mut list = vec!["adui-dropdown-menu".to_string()];
139        if let Some(extra) = overlay_class {
140            list.push(extra);
141        }
142        list.join(" ")
143    };
144
145    let placement = placement.unwrap_or_default();
146    let width_style = overlay_width
147        .map(|w| format!("min-width: {w}px;"))
148        .unwrap_or_default();
149
150    let align_style = match placement {
151        DropdownPlacement::BottomLeft => "left: 0;",
152        DropdownPlacement::BottomRight => "right: 0;",
153    };
154
155    let overlay_style_attr = {
156        let extra = overlay_style.unwrap_or_default();
157        format!(
158            "position: absolute; top: 100%; margin-top: 4px; z-index: {}; {}; {} {}",
159            current_z, align_style, width_style, extra
160        )
161    };
162
163    let size_class = match global_size {
164        ComponentSize::Small => "adui-dropdown-sm",
165        ComponentSize::Large => "adui-dropdown-lg",
166        ComponentSize::Middle => "adui-dropdown-md",
167    };
168
169    let on_click_cb = on_click;
170
171    rsx! {
172        span {
173            class: "{class_attr}",
174            style: "position: relative; display: inline-block;",
175            onmouseenter: move |_evt: MouseEvent| {
176                if matches!(trigger_mode, DropdownTrigger::Hover) {
177                    crate::components::tooltip::update_open_state(
178                        disabled_flag,
179                        is_controlled_flag,
180                        open_for_handlers,
181                        on_open_change_cb,
182                        true,
183                    );
184                }
185            },
186            onmouseleave: move |_evt: MouseEvent| {
187                if matches!(trigger_mode, DropdownTrigger::Hover) {
188                    crate::components::tooltip::update_open_state(
189                        disabled_flag,
190                        is_controlled_flag,
191                        open_for_handlers,
192                        on_open_change_cb,
193                        false,
194                    );
195                }
196            },
197            onclick: move |_evt: MouseEvent| {
198                if !matches!(trigger_mode, DropdownTrigger::Click) {
199                    return;
200                }
201                if let Some(handle) = close_handle_for_click {
202                    handle.mark_internal_click();
203                }
204                crate::components::tooltip::update_open_state(
205                    disabled_flag,
206                    is_controlled_flag,
207                    open_for_handlers,
208                    on_open_change_cb,
209                    !current_open_flag,
210                );
211            },
212            onkeydown: move |evt: KeyboardEvent| {
213                if matches!(evt.key(), Key::Escape) {
214                    evt.prevent_default();
215                    crate::components::tooltip::update_open_state(
216                        disabled_flag,
217                        is_controlled_flag,
218                        open_for_handlers,
219                        on_open_change_cb,
220                        false,
221                    );
222                }
223            },
224            {children}
225            if current_open {
226                div {
227                    class: "{overlay_class_attr} {size_class}",
228                    style: "{overlay_style_attr}",
229                    onclick: move |_evt| {
230                        if let Some(handle) = close_handle_for_menu {
231                            handle.mark_internal_click();
232                        }
233                    },
234                    ul {
235                        class: "adui-dropdown-menu-list",
236                        {items.iter().map(|item| {
237                            let key = item.key.clone();
238                            let label = item.label.clone();
239                            let disabled_item = item.disabled || disabled_flag;
240                            rsx! {
241                                li {
242                                    class: {
243                                        let mut list = vec!["adui-dropdown-menu-item".to_string()];
244                                        if disabled_item {
245                                            list.push("adui-dropdown-menu-item-disabled".into());
246                                        }
247                                        list.join(" ")
248                                    },
249                                    onclick: move |_evt| {
250                                        if disabled_item {
251                                            return;
252                                        }
253                                        crate::components::tooltip::update_open_state(
254                                            disabled_flag,
255                                            is_controlled_flag,
256                                            open_for_handlers,
257                                            on_open_change_cb,
258                                            false,
259                                        );
260                                        if let Some(cb) = on_click_cb {
261                                            cb.call(key.clone());
262                                        }
263                                    },
264                                    "{label}"
265                                }
266                            }
267                        })}
268                    }
269                }
270            }
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn dropdown_item_new() {
281        let item = DropdownItem::new("key1", "Label 1");
282        assert_eq!(item.key, "key1");
283        assert_eq!(item.label, "Label 1");
284        assert_eq!(item.disabled, false);
285    }
286
287    #[test]
288    fn dropdown_item_new_with_strings() {
289        let item = DropdownItem::new(String::from("key2"), String::from("Label 2"));
290        assert_eq!(item.key, "key2");
291        assert_eq!(item.label, "Label 2");
292        assert_eq!(item.disabled, false);
293    }
294
295    #[test]
296    fn dropdown_item_clone() {
297        let item1 = DropdownItem::new("key1", "Label 1");
298        let item2 = item1.clone();
299        assert_eq!(item1, item2);
300    }
301
302    #[test]
303    fn dropdown_item_partial_eq() {
304        let item1 = DropdownItem::new("key1", "Label 1");
305        let item2 = DropdownItem::new("key1", "Label 1");
306        let item3 = DropdownItem::new("key2", "Label 2");
307        assert_eq!(item1, item2);
308        assert_ne!(item1, item3);
309    }
310
311    #[test]
312    fn dropdown_trigger_default() {
313        assert_eq!(DropdownTrigger::default(), DropdownTrigger::Click);
314    }
315
316    #[test]
317    fn dropdown_trigger_all_variants() {
318        assert_eq!(DropdownTrigger::Click, DropdownTrigger::Click);
319        assert_eq!(DropdownTrigger::Hover, DropdownTrigger::Hover);
320        assert_ne!(DropdownTrigger::Click, DropdownTrigger::Hover);
321    }
322
323    #[test]
324    fn dropdown_trigger_clone() {
325        let original = DropdownTrigger::Hover;
326        let cloned = original;
327        assert_eq!(original, cloned);
328    }
329
330    #[test]
331    fn dropdown_placement_default() {
332        assert_eq!(DropdownPlacement::default(), DropdownPlacement::BottomLeft);
333    }
334
335    #[test]
336    fn dropdown_placement_all_variants() {
337        assert_eq!(DropdownPlacement::BottomLeft, DropdownPlacement::BottomLeft);
338        assert_eq!(
339            DropdownPlacement::BottomRight,
340            DropdownPlacement::BottomRight
341        );
342        assert_ne!(
343            DropdownPlacement::BottomLeft,
344            DropdownPlacement::BottomRight
345        );
346    }
347
348    #[test]
349    fn dropdown_placement_clone() {
350        let original = DropdownPlacement::BottomRight;
351        let cloned = original;
352        assert_eq!(original, cloned);
353    }
354}