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/// # use dioxus::prelude::*;
24/// # use dioxus_bootstrap_css::prelude::*;
25/// # fn _doctest() -> Element {
26/// // Dioxus equivalent
27/// let open = use_signal(|| false);
28/// rsx! {
29///     Dropdown { open: open,
30///         toggle: rsx! { "Menu" },
31///         menu: rsx! {
32///             DropdownItem { "Action" }
33///             DropdownDivider {}
34///             DropdownItem { "Other" }
35///         },
36///     }
37///     // Split button variant
38///     Dropdown { open: open, split: true, color: Color::Danger,
39///         toggle: rsx! { "Delete" },
40///         menu: rsx! { DropdownItem { "Confirm Delete" } },
41///     }
42/// }
43/// # }
44/// ```
45///
46/// # Props
47///
48/// - `open` — `Signal<bool>` controlling open state
49/// - `toggle` — toggle button content (Element)
50/// - `menu` — dropdown menu content (Element)
51/// - `split` — split button mode (separate action button + caret toggle)
52/// - `color` — button color in split mode
53/// - `direction` — `DropDirection::Down`, `Up`, `Start`, `End`
54/// - `align_end` — align menu's right edge to the toggle (works JS-free)
55#[derive(Clone, PartialEq, Props)]
56pub struct DropdownProps {
57    /// Signal controlling dropdown open state.
58    pub open: Signal<bool>,
59    /// Toggle button content.
60    pub toggle: Element,
61    /// Dropdown menu content (DropdownItem components).
62    pub menu: Element,
63    /// Additional CSS classes for the dropdown container.
64    #[props(default)]
65    pub class: String,
66    /// Additional CSS classes for the toggle button.
67    #[props(default)]
68    pub toggle_class: String,
69    /// Drop direction.
70    #[props(default)]
71    pub direction: DropDirection,
72    /// Align menu to the end (right).
73    #[props(default)]
74    pub align_end: bool,
75    /// Split button mode — toggle is a separate caret-only button.
76    #[props(default)]
77    pub split: bool,
78    /// Color for split button mode (used for the main button).
79    #[props(default)]
80    pub color: Option<crate::types::Color>,
81    /// Any additional HTML attributes.
82    #[props(extends = GlobalAttributes)]
83    attributes: Vec<Attribute>,
84}
85
86/// Dropdown direction.
87#[derive(Clone, Copy, Debug, Default, PartialEq)]
88pub enum DropDirection {
89    #[default]
90    Down,
91    Up,
92    Start,
93    End,
94}
95
96#[component]
97pub fn Dropdown(props: DropdownProps) -> Element {
98    let is_open = *props.open.read();
99    let mut open_signal = props.open;
100
101    let dir_class = match props.direction {
102        DropDirection::Down => "dropdown",
103        DropDirection::Up => "dropup",
104        DropDirection::Start => "dropstart",
105        DropDirection::End => "dropend",
106    };
107
108    let container_class = if props.class.is_empty() {
109        dir_class.to_string()
110    } else {
111        format!("{dir_class} {}", props.class)
112    };
113
114    let color_name = match &props.color {
115        Some(c) => format!("{c}"),
116        None => "secondary".to_string(),
117    };
118
119    let toggle_class = if props.split {
120        format!("btn btn-{color_name} dropdown-toggle dropdown-toggle-split")
121    } else if props.toggle_class.is_empty() {
122        format!("btn btn-{color_name} dropdown-toggle")
123    } else {
124        format!("btn dropdown-toggle {}", props.toggle_class)
125    };
126
127    let menu_class = if is_open {
128        if props.align_end {
129            "dropdown-menu dropdown-menu-end show"
130        } else {
131            "dropdown-menu show"
132        }
133    } else if props.align_end {
134        "dropdown-menu dropdown-menu-end"
135    } else {
136        "dropdown-menu"
137    };
138
139    rsx! {
140        // Invisible overlay to close on outside click (only when open)
141        if is_open {
142            div {
143                style: "position: fixed; inset: 0; z-index: 990;",
144                onclick: move |_| open_signal.set(false),
145            }
146        }
147        div { class: "{container_class}",
148            style: if is_open { "position: relative; z-index: 991;" } else { "" },
149            ..props.attributes,
150            // Split mode: main button + separate toggle caret
151            if props.split {
152                button {
153                    class: "btn btn-{color_name}",
154                    r#type: "button",
155                    {props.toggle.clone()}
156                }
157            }
158            button {
159                class: "{toggle_class}",
160                r#type: "button",
161                "aria-expanded": if is_open { "true" } else { "false" },
162                onclick: move |evt| {
163                    evt.stop_propagation();
164                    open_signal.set(!is_open);
165                },
166                if !props.split {
167                    {props.toggle}
168                }
169                if props.split {
170                    span { class: "visually-hidden", "Toggle Dropdown" }
171                }
172            }
173            ul { class: "{menu_class}",
174                // Bootstrap 5.3 gates .dropdown-menu-end right-alignment on
175                // [data-bs-popper], set only by Bootstrap's JS. This crate is
176                // JS-free, so apply Bootstrap's own end values directly.
177                style: if props.align_end { "right: 0; left: auto;" } else { "" },
178                // Close dropdown when clicking an item
179                onclick: move |_| open_signal.set(false),
180                {props.menu}
181            }
182        }
183    }
184}
185
186/// Standalone Bootstrap dropdown menu.
187///
188/// Use this when open/position behavior is owned by a surrounding component
189/// but the menu and items should still use Bootstrap dropdown structure.
190#[derive(Clone, PartialEq, Props)]
191pub struct DropdownMenuProps {
192    /// Whether to show the menu.
193    #[props(default)]
194    pub show: bool,
195    /// Align menu to the end side.
196    #[props(default)]
197    pub align_end: bool,
198    /// Additional CSS classes.
199    #[props(default)]
200    pub class: String,
201    /// Any additional HTML attributes.
202    #[props(extends = GlobalAttributes)]
203    attributes: Vec<Attribute>,
204    /// Child menu items.
205    pub children: Element,
206}
207
208#[component]
209pub fn DropdownMenu(props: DropdownMenuProps) -> Element {
210    let mut classes = vec!["dropdown-menu".to_string()];
211    if props.align_end {
212        classes.push("dropdown-menu-end".to_string());
213    }
214    if props.show {
215        classes.push("show".to_string());
216    }
217    if !props.class.is_empty() {
218        classes.push(props.class.clone());
219    }
220    let full_class = classes.join(" ");
221    // JS-free end-alignment: Bootstrap gates .dropdown-menu-end{right:0;left:auto}
222    // on [data-bs-popper] (set only by its JS), so apply the values directly.
223    let align_style = if props.align_end {
224        "right: 0; left: auto;"
225    } else {
226        ""
227    };
228    rsx! {
229    ul { class: "{full_class}", style: "{align_style}", ..props.attributes, {props.children} }
230    }
231}
232
233/// A single item in a Dropdown menu.
234#[derive(Clone, PartialEq, Props)]
235pub struct DropdownItemProps {
236    /// Active state.
237    #[props(default)]
238    pub active: bool,
239    /// Disabled state.
240    #[props(default)]
241    pub disabled: bool,
242    /// Click event handler.
243    #[props(default)]
244    pub onclick: Option<EventHandler<MouseEvent>>,
245    /// Additional CSS classes.
246    #[props(default)]
247    pub class: String,
248    /// Any additional HTML attributes.
249    #[props(extends = GlobalAttributes)]
250    attributes: Vec<Attribute>,
251    /// Child elements.
252    pub children: Element,
253}
254
255#[component]
256pub fn DropdownItem(props: DropdownItemProps) -> Element {
257    let mut classes = vec!["dropdown-item".to_string()];
258    if props.active {
259        classes.push("active".to_string());
260    }
261    if props.disabled {
262        classes.push("disabled".to_string());
263    }
264    if !props.class.is_empty() {
265        classes.push(props.class.clone());
266    }
267    let full_class = classes.join(" ");
268
269    rsx! {
270        li {
271            button {
272                class: "{full_class}",
273                r#type: "button",
274                disabled: props.disabled,
275                onclick: move |evt| {
276                    if let Some(handler) = &props.onclick {
277                        handler.call(evt);
278                    }
279                },
280                ..props.attributes,
281                {props.children}
282            }
283        }
284    }
285}
286
287/// Dropdown menu divider.
288#[component]
289pub fn DropdownDivider() -> Element {
290    rsx! {
291        li { hr { class: "dropdown-divider" } }
292    }
293}
294
295/// Dropdown menu header text.
296#[derive(Clone, PartialEq, Props)]
297pub struct DropdownHeaderProps {
298    /// Any additional HTML attributes.
299    #[props(extends = GlobalAttributes)]
300    attributes: Vec<Attribute>,
301    pub children: Element,
302}
303
304#[component]
305pub fn DropdownHeader(props: DropdownHeaderProps) -> Element {
306    rsx! {
307        li { h6 { class: "dropdown-header", ..props.attributes, {props.children} } }
308    }
309}