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    // Bootstrap offsets the menu from the toggle by `--bs-dropdown-spacer` (0.125rem);
140    // in stock Bootstrap that offset is applied by Popper. JS-free, we set it directly
141    // so the menu clears the toggle by the same 2px instead of sitting flush against it.
142    let spacer = match props.direction {
143        DropDirection::Down => "margin-top: var(--bs-dropdown-spacer, 0.125rem);",
144        DropDirection::Up => "margin-bottom: var(--bs-dropdown-spacer, 0.125rem);",
145        DropDirection::Start | DropDirection::End => "",
146    };
147    // JS-free end-alignment: Bootstrap gates .dropdown-menu-end{right:0;left:auto} on
148    // [data-bs-popper] (set only by its JS), so apply the end values directly.
149    let menu_style = if props.align_end {
150        format!("{spacer} right: 0; left: auto;")
151    } else {
152        spacer.to_string()
153    };
154
155    rsx! {
156        // Invisible overlay to close on outside click (only when open)
157        if is_open {
158            div {
159                style: "position: fixed; inset: 0; z-index: 990;",
160                onclick: move |_| open_signal.set(false),
161            }
162        }
163        div { class: "{container_class}",
164            style: if is_open { "position: relative; z-index: 991;" } else { "" },
165            ..props.attributes,
166            // Split mode: main button + separate toggle caret
167            if props.split {
168                button {
169                    class: "btn btn-{color_name}",
170                    r#type: "button",
171                    {props.toggle.clone()}
172                }
173            }
174            button {
175                class: "{toggle_class}",
176                r#type: "button",
177                "aria-expanded": if is_open { "true" } else { "false" },
178                onclick: move |evt| {
179                    evt.stop_propagation();
180                    open_signal.set(!is_open);
181                },
182                if !props.split {
183                    {props.toggle}
184                }
185                if props.split {
186                    span { class: "visually-hidden", "Toggle Dropdown" }
187                }
188            }
189            ul { class: "{menu_class}",
190                style: "{menu_style}",
191                // Close dropdown when clicking an item
192                onclick: move |_| open_signal.set(false),
193                {props.menu}
194            }
195        }
196    }
197}
198
199/// Standalone Bootstrap dropdown menu.
200///
201/// Use this when open/position behavior is owned by a surrounding component
202/// but the menu and items should still use Bootstrap dropdown structure.
203#[derive(Clone, PartialEq, Props)]
204pub struct DropdownMenuProps {
205    /// Whether to show the menu.
206    #[props(default)]
207    pub show: bool,
208    /// Align menu to the end side.
209    #[props(default)]
210    pub align_end: bool,
211    /// Menu element. Empty (the default) renders `<ul>`, whose children are the
212    /// `<li>`-wrapped `DropdownItem`/`DropdownDivider` forms; `"div"` renders
213    /// `<div class="dropdown-menu">`, which takes the bare forms
214    /// (`DropdownItem { tag: "div" }`) and, unlike a `<ul>`, may also hold
215    /// arbitrary content.
216    ///
217    /// Bootstrap documents both, and the generic form is the one its own "forms
218    /// and text inside a dropdown" examples use. It is the only legal choice when
219    /// the menu holds anything that is not a menu item — a `<div>` or a `<p>` is
220    /// not a valid child of `<ul>`.
221    #[props(default)]
222    pub tag: String,
223    /// Additional CSS classes.
224    #[props(default)]
225    pub class: String,
226    /// Inline style, appended to the component's own. Declared explicitly rather
227    /// than left to the attribute spread because this element always sets
228    /// `style` itself; see the composition note in the body.
229    #[props(default)]
230    pub style: String,
231    /// Any additional HTML attributes.
232    #[props(extends = GlobalAttributes)]
233    attributes: Vec<Attribute>,
234    /// Child menu items.
235    pub children: Element,
236}
237
238/// The menu's inline style: the end-alignment values the component applies itself
239/// (Bootstrap gates the CSS on a `data-bs-popper` its JS would set) with the
240/// caller's appended. Composed rather than spread, for the reason on `style`.
241fn dropdown_menu_style(align: &str, extra: &str) -> String {
242    match (align.is_empty(), extra.is_empty()) {
243        (true, true) => String::new(),
244        (true, false) => extra.to_string(),
245        (false, true) => align.to_string(),
246        (false, false) => format!("{align} {extra}"),
247    }
248}
249
250#[component]
251pub fn DropdownMenu(props: DropdownMenuProps) -> Element {
252    let mut classes = vec!["dropdown-menu".to_string()];
253    if props.align_end {
254        classes.push("dropdown-menu-end".to_string());
255    }
256    if props.show {
257        classes.push("show".to_string());
258    }
259    if !props.class.is_empty() {
260        classes.push(props.class.clone());
261    }
262    let full_class = classes.join(" ");
263    // JS-free end-alignment: Bootstrap gates .dropdown-menu-end{right:0;left:auto}
264    // on [data-bs-popper] (set only by its JS), so apply the values directly.
265    let align_style = if props.align_end {
266        "right: 0; left: auto;"
267    } else {
268        ""
269    };
270    // The caller's style is COMPOSED with ours, never spread alongside it. This
271    // element already sets `style`, so a caller-supplied one arriving through
272    // `..attributes` would be a second `style` attribute on the same element and
273    // one of the two would be dropped — silently, and whichever way the renderer
274    // resolved it would be wrong for somebody.
275    let full_style = dropdown_menu_style(align_style, &props.style);
276
277    if props.tag == "div" {
278        return rsx! {
279            div { class: "{full_class}", style: "{full_style}", ..props.attributes, {props.children} }
280        };
281    }
282
283    rsx! {
284    ul { class: "{full_class}", style: "{full_style}", ..props.attributes, {props.children} }
285    }
286}
287
288/// A single item in a Dropdown menu.
289///
290/// Renders a `<button class="dropdown-item">` by default. Set `href` to render a
291/// real `<a class="dropdown-item" href=...>` instead — use this for menu entries
292/// that navigate to a URL so the browser's link behaviours work (middle-click /
293/// ctrl-click to open in a background tab, copy-link and open-in-new-window
294/// context actions, and a visible target URL on hover). Add `target` (e.g.
295/// `"_blank"`) to open in a new tab. The same `active`, `disabled`, `class`, and
296/// `onclick` props apply to both forms.
297///
298/// ```rust,no_run
299/// # use dioxus::prelude::*;
300/// # use dioxus_bootstrap_css::prelude::*;
301/// # fn _doctest() -> Element {
302/// rsx! {
303///     DropdownItem { "Action" }                                  // <button>
304///     DropdownItem { href: "/settings", "Settings" }             // <a href="/settings">
305///     DropdownItem { href: "https://example.com", target: "_blank", "Docs" }
306/// }
307/// # }
308/// ```
309#[derive(Clone, PartialEq, Props)]
310pub struct DropdownItemProps {
311    /// Active state.
312    #[props(default)]
313    pub active: bool,
314    /// Disabled state.
315    #[props(default)]
316    pub disabled: bool,
317    /// When set, render an `<a class="dropdown-item" href=...>` anchor instead of
318    /// a `<button>` so the item behaves as a real hyperlink. Anchors cannot be
319    /// HTML-`disabled`, so a disabled anchor item carries the `.disabled` class,
320    /// `aria-disabled="true"`, and `tabindex="-1"` (matching `NavLink`).
321    #[props(default)]
322    pub href: Option<String>,
323    /// Anchor `target` (e.g. `"_blank"` to open in a new tab). Only applies when
324    /// `href` is set.
325    #[props(default)]
326    pub target: Option<String>,
327    /// Click event handler.
328    #[props(default)]
329    pub onclick: Option<EventHandler<MouseEvent>>,
330    /// Item wrapper. Empty (the default) wraps the item in the `<li>` a `<ul>`
331    /// menu requires; `"div"` emits the bare `<button>`/`<a>` for the generic
332    /// `DropdownMenu { tag: "div" }` form, where an `<li>` would have no list to
333    /// belong to. Match it to the menu's `tag`.
334    #[props(default)]
335    pub tag: String,
336    /// Additional CSS classes.
337    #[props(default)]
338    pub class: String,
339    /// Any additional HTML attributes.
340    #[props(extends = GlobalAttributes)]
341    attributes: Vec<Attribute>,
342    /// Child elements.
343    pub children: Element,
344}
345
346/// Build the class string for a dropdown item (`dropdown-item` + optional
347/// `active`/`disabled` + caller classes). Shared by the `<button>` and `<a>`
348/// render paths so both carry identical classes.
349fn dropdown_item_class(active: bool, disabled: bool, class: &str) -> String {
350    let mut classes = vec!["dropdown-item".to_string()];
351    if active {
352        classes.push("active".to_string());
353    }
354    if disabled {
355        classes.push("disabled".to_string());
356    }
357    if !class.is_empty() {
358        classes.push(class.to_string());
359    }
360    classes.join(" ")
361}
362
363#[component]
364pub fn DropdownItem(props: DropdownItemProps) -> Element {
365    let full_class = dropdown_item_class(props.active, props.disabled, &props.class);
366    let bare = props.tag == "div";
367
368    // Anchor form: a real hyperlink so browser link behaviours work. Anchors
369    // can't be HTML-`disabled`, so disabled state is conveyed by the `.disabled`
370    // class plus `aria-disabled`/`tabindex="-1"` (matching NavLink).
371    if let Some(href) = props.href.clone() {
372        let target = props.target.clone();
373        let anchor = rsx! {
374            a {
375                class: "{full_class}",
376                href: "{href}",
377                target: target,
378                "aria-disabled": if props.disabled { "true" } else { "" },
379                tabindex: if props.disabled { "-1" } else { "" },
380                onclick: move |evt| {
381                    if let Some(handler) = &props.onclick {
382                        handler.call(evt);
383                    }
384                },
385                ..props.attributes,
386                {props.children}
387            }
388        };
389        return if bare {
390            anchor
391        } else {
392            rsx! { li { {anchor} } }
393        };
394    }
395
396    let button = rsx! {
397        button {
398            class: "{full_class}",
399            r#type: "button",
400            disabled: props.disabled,
401            onclick: move |evt| {
402                if let Some(handler) = &props.onclick {
403                    handler.call(evt);
404                }
405            },
406            ..props.attributes,
407            {props.children}
408        }
409    };
410    if bare {
411        button
412    } else {
413        rsx! { li { {button} } }
414    }
415}
416
417/// Dropdown menu divider.
418#[derive(Clone, PartialEq, Props)]
419pub struct DropdownDividerProps {
420    /// Divider wrapper. Empty (the default) wraps the `<hr>` in the `<li>` a
421    /// `<ul>` menu requires; `"div"` emits the bare `<hr>` for the generic
422    /// `DropdownMenu { tag: "div" }` form. Match it to the menu's `tag`.
423    #[props(default)]
424    pub tag: String,
425}
426
427#[component]
428pub fn DropdownDivider(props: DropdownDividerProps) -> Element {
429    if props.tag == "div" {
430        return rsx! { hr { class: "dropdown-divider" } };
431    }
432    rsx! {
433        li { hr { class: "dropdown-divider" } }
434    }
435}
436
437/// Dropdown menu header text.
438#[derive(Clone, PartialEq, Props)]
439pub struct DropdownHeaderProps {
440    /// Any additional HTML attributes.
441    #[props(extends = GlobalAttributes)]
442    attributes: Vec<Attribute>,
443    pub children: Element,
444}
445
446#[component]
447pub fn DropdownHeader(props: DropdownHeaderProps) -> Element {
448    rsx! {
449        li { h6 { class: "dropdown-header", ..props.attributes, {props.children} } }
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn dropdown_item_class_base() {
459        assert_eq!(dropdown_item_class(false, false, ""), "dropdown-item");
460    }
461
462    #[test]
463    fn dropdown_item_class_active_disabled_and_extra() {
464        // Identical class output for the `<button>` and `<a>` render paths.
465        assert_eq!(
466            dropdown_item_class(true, true, "text-danger"),
467            "dropdown-item active disabled text-danger"
468        );
469    }
470
471    #[test]
472    fn dropdown_item_class_active_only() {
473        assert_eq!(dropdown_item_class(true, false, ""), "dropdown-item active");
474    }
475
476    #[test]
477    fn menu_style_is_empty_when_neither_side_asks_for_one() {
478        assert_eq!(dropdown_menu_style("", ""), "");
479    }
480
481    #[test]
482    fn menu_style_keeps_the_alignment_when_the_caller_is_silent() {
483        assert_eq!(
484            dropdown_menu_style("right: 0; left: auto;", ""),
485            "right: 0; left: auto;"
486        );
487    }
488
489    #[test]
490    fn menu_style_keeps_both_sides() {
491        // A menu that is both end-aligned and positioned by its page needs the
492        // two together; spreading the caller's through `..attributes` would have
493        // put a second `style` on the element and silently dropped one of them.
494        assert_eq!(
495            dropdown_menu_style("right: 0; left: auto;", "z-index: 1050;"),
496            "right: 0; left: auto; z-index: 1050;"
497        );
498    }
499}