Skip to main content

dioxus_bootstrap_css/
dropdown.rs

1use dioxus::prelude::*;
2
3/// Bootstrap Dropdown component — signal-driven, no JavaScript.
4///
5/// Replaces Bootstrap's dropdown JavaScript plugin with signal-controlled open/close.
6/// Supports split buttons, drop directions, and auto-closes on outside click.
7///
8/// # Bootstrap HTML → Dioxus
9///
10/// ```html
11/// <!-- Bootstrap HTML (requires JavaScript) -->
12/// <div class="dropdown">
13///   <button class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown">Menu</button>
14///   <ul class="dropdown-menu">
15///     <li><button class="dropdown-item">Action</button></li>
16///     <li><hr class="dropdown-divider"></li>
17///     <li><button class="dropdown-item">Other</button></li>
18///   </ul>
19/// </div>
20/// ```
21///
22/// ```rust,no_run
23/// // Dioxus equivalent
24/// let open = use_signal(|| false);
25/// rsx! {
26///     Dropdown { open: open,
27///         toggle: rsx! { "Menu" },
28///         menu: rsx! {
29///             DropdownItem { "Action" }
30///             DropdownDivider {}
31///             DropdownItem { "Other" }
32///         },
33///     }
34///     // Split button variant
35///     Dropdown { open: open, split: true, color: Color::Danger,
36///         toggle: rsx! { "Delete" },
37///         menu: rsx! { DropdownItem { "Confirm Delete" } },
38///     }
39/// }
40/// ```
41///
42/// # Props
43///
44/// - `open` — `Signal<bool>` controlling open state
45/// - `toggle` — toggle button content (Element)
46/// - `menu` — dropdown menu content (Element)
47/// - `split` — split button mode (separate action button + caret toggle)
48/// - `color` — button color in split mode
49/// - `direction` — `DropDirection::Down`, `Up`, `Start`, `End`
50/// - `align_end` — align menu to the right
51#[derive(Clone, PartialEq, Props)]
52pub struct DropdownProps {
53    /// Signal controlling dropdown open state.
54    pub open: Signal<bool>,
55    /// Toggle button content.
56    pub toggle: Element,
57    /// Dropdown menu content (DropdownItem components).
58    pub menu: Element,
59    /// Additional CSS classes for the dropdown container.
60    #[props(default)]
61    pub class: String,
62    /// Additional CSS classes for the toggle button.
63    #[props(default)]
64    pub toggle_class: String,
65    /// Drop direction.
66    #[props(default)]
67    pub direction: DropDirection,
68    /// Align menu to the end (right).
69    #[props(default)]
70    pub align_end: bool,
71    /// Split button mode — toggle is a separate caret-only button.
72    #[props(default)]
73    pub split: bool,
74    /// Color for split button mode (used for the main button).
75    #[props(default)]
76    pub color: Option<crate::types::Color>,
77    /// Any additional HTML attributes.
78    #[props(extends = GlobalAttributes)]
79    attributes: Vec<Attribute>,
80}
81
82/// Dropdown direction.
83#[derive(Clone, Copy, Debug, Default, PartialEq)]
84pub enum DropDirection {
85    #[default]
86    Down,
87    Up,
88    Start,
89    End,
90}
91
92#[component]
93pub fn Dropdown(props: DropdownProps) -> Element {
94    let is_open = *props.open.read();
95    let mut open_signal = props.open;
96
97    let dir_class = match props.direction {
98        DropDirection::Down => "dropdown",
99        DropDirection::Up => "dropup",
100        DropDirection::Start => "dropstart",
101        DropDirection::End => "dropend",
102    };
103
104    let container_class = if props.class.is_empty() {
105        dir_class.to_string()
106    } else {
107        format!("{dir_class} {}", props.class)
108    };
109
110    let color_name = match &props.color {
111        Some(c) => format!("{c}"),
112        None => "secondary".to_string(),
113    };
114
115    let toggle_class = if props.split {
116        format!("btn btn-{color_name} dropdown-toggle dropdown-toggle-split")
117    } else if props.toggle_class.is_empty() {
118        format!("btn btn-{color_name} dropdown-toggle")
119    } else {
120        format!("btn dropdown-toggle {}", props.toggle_class)
121    };
122
123    let menu_class = if is_open {
124        if props.align_end {
125            "dropdown-menu dropdown-menu-end show"
126        } else {
127            "dropdown-menu show"
128        }
129    } else if props.align_end {
130        "dropdown-menu dropdown-menu-end"
131    } else {
132        "dropdown-menu"
133    };
134
135    rsx! {
136        // Invisible overlay to close on outside click (only when open)
137        if is_open {
138            div {
139                style: "position: fixed; inset: 0; z-index: 990;",
140                onclick: move |_| open_signal.set(false),
141            }
142        }
143        div { class: "{container_class}",
144            style: if is_open { "position: relative; z-index: 991;" } else { "" },
145            ..props.attributes,
146            // Split mode: main button + separate toggle caret
147            if props.split {
148                button {
149                    class: "btn btn-{color_name}",
150                    r#type: "button",
151                    {props.toggle.clone()}
152                }
153            }
154            button {
155                class: "{toggle_class}",
156                r#type: "button",
157                "aria-expanded": if is_open { "true" } else { "false" },
158                onclick: move |evt| {
159                    evt.stop_propagation();
160                    open_signal.set(!is_open);
161                },
162                if !props.split {
163                    {props.toggle}
164                }
165                if props.split {
166                    span { class: "visually-hidden", "Toggle Dropdown" }
167                }
168            }
169            ul { class: "{menu_class}",
170                // Close dropdown when clicking an item
171                onclick: move |_| open_signal.set(false),
172                {props.menu}
173            }
174        }
175    }
176}
177
178/// A single item in a Dropdown menu.
179#[derive(Clone, PartialEq, Props)]
180pub struct DropdownItemProps {
181    /// Active state.
182    #[props(default)]
183    pub active: bool,
184    /// Disabled state.
185    #[props(default)]
186    pub disabled: bool,
187    /// Click event handler.
188    #[props(default)]
189    pub onclick: Option<EventHandler<MouseEvent>>,
190    /// Additional CSS classes.
191    #[props(default)]
192    pub class: String,
193    /// Any additional HTML attributes.
194    #[props(extends = GlobalAttributes)]
195    attributes: Vec<Attribute>,
196    /// Child elements.
197    pub children: Element,
198}
199
200#[component]
201pub fn DropdownItem(props: DropdownItemProps) -> Element {
202    let mut classes = vec!["dropdown-item".to_string()];
203    if props.active {
204        classes.push("active".to_string());
205    }
206    if props.disabled {
207        classes.push("disabled".to_string());
208    }
209    if !props.class.is_empty() {
210        classes.push(props.class.clone());
211    }
212    let full_class = classes.join(" ");
213
214    rsx! {
215        li {
216            button {
217                class: "{full_class}",
218                r#type: "button",
219                disabled: props.disabled,
220                onclick: move |evt| {
221                    if let Some(handler) = &props.onclick {
222                        handler.call(evt);
223                    }
224                },
225                ..props.attributes,
226                {props.children}
227            }
228        }
229    }
230}
231
232/// Dropdown menu divider.
233#[component]
234pub fn DropdownDivider() -> Element {
235    rsx! {
236        li { hr { class: "dropdown-divider" } }
237    }
238}
239
240/// Dropdown menu header text.
241#[derive(Clone, PartialEq, Props)]
242pub struct DropdownHeaderProps {
243    /// Any additional HTML attributes.
244    #[props(extends = GlobalAttributes)]
245    attributes: Vec<Attribute>,
246    pub children: Element,
247}
248
249#[component]
250pub fn DropdownHeader(props: DropdownHeaderProps) -> Element {
251    rsx! {
252        li { h6 { class: "dropdown-header", ..props.attributes, {props.children} } }
253    }
254}