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///
235/// Renders a `<button class="dropdown-item">` by default. Set `href` to render a
236/// real `<a class="dropdown-item" href=...>` instead — use this for menu entries
237/// that navigate to a URL so the browser's link behaviours work (middle-click /
238/// ctrl-click to open in a background tab, copy-link and open-in-new-window
239/// context actions, and a visible target URL on hover). Add `target` (e.g.
240/// `"_blank"`) to open in a new tab. The same `active`, `disabled`, `class`, and
241/// `onclick` props apply to both forms.
242///
243/// ```rust,no_run
244/// # use dioxus::prelude::*;
245/// # use dioxus_bootstrap_css::prelude::*;
246/// # fn _doctest() -> Element {
247/// rsx! {
248/// DropdownItem { "Action" } // <button>
249/// DropdownItem { href: "/settings", "Settings" } // <a href="/settings">
250/// DropdownItem { href: "https://example.com", target: "_blank", "Docs" }
251/// }
252/// # }
253/// ```
254#[derive(Clone, PartialEq, Props)]
255pub struct DropdownItemProps {
256 /// Active state.
257 #[props(default)]
258 pub active: bool,
259 /// Disabled state.
260 #[props(default)]
261 pub disabled: bool,
262 /// When set, render an `<a class="dropdown-item" href=...>` anchor instead of
263 /// a `<button>` so the item behaves as a real hyperlink. Anchors cannot be
264 /// HTML-`disabled`, so a disabled anchor item carries the `.disabled` class,
265 /// `aria-disabled="true"`, and `tabindex="-1"` (matching `NavLink`).
266 #[props(default)]
267 pub href: Option<String>,
268 /// Anchor `target` (e.g. `"_blank"` to open in a new tab). Only applies when
269 /// `href` is set.
270 #[props(default)]
271 pub target: Option<String>,
272 /// Click event handler.
273 #[props(default)]
274 pub onclick: Option<EventHandler<MouseEvent>>,
275 /// Additional CSS classes.
276 #[props(default)]
277 pub class: String,
278 /// Any additional HTML attributes.
279 #[props(extends = GlobalAttributes)]
280 attributes: Vec<Attribute>,
281 /// Child elements.
282 pub children: Element,
283}
284
285/// Build the class string for a dropdown item (`dropdown-item` + optional
286/// `active`/`disabled` + caller classes). Shared by the `<button>` and `<a>`
287/// render paths so both carry identical classes.
288fn dropdown_item_class(active: bool, disabled: bool, class: &str) -> String {
289 let mut classes = vec!["dropdown-item".to_string()];
290 if active {
291 classes.push("active".to_string());
292 }
293 if disabled {
294 classes.push("disabled".to_string());
295 }
296 if !class.is_empty() {
297 classes.push(class.to_string());
298 }
299 classes.join(" ")
300}
301
302#[component]
303pub fn DropdownItem(props: DropdownItemProps) -> Element {
304 let full_class = dropdown_item_class(props.active, props.disabled, &props.class);
305
306 // Anchor form: a real hyperlink so browser link behaviours work. Anchors
307 // can't be HTML-`disabled`, so disabled state is conveyed by the `.disabled`
308 // class plus `aria-disabled`/`tabindex="-1"` (matching NavLink).
309 if let Some(href) = props.href.clone() {
310 let target = props.target.clone();
311 return rsx! {
312 li {
313 a {
314 class: "{full_class}",
315 href: "{href}",
316 target: target,
317 "aria-disabled": if props.disabled { "true" } else { "" },
318 tabindex: if props.disabled { "-1" } else { "" },
319 onclick: move |evt| {
320 if let Some(handler) = &props.onclick {
321 handler.call(evt);
322 }
323 },
324 ..props.attributes,
325 {props.children}
326 }
327 }
328 };
329 }
330
331 rsx! {
332 li {
333 button {
334 class: "{full_class}",
335 r#type: "button",
336 disabled: props.disabled,
337 onclick: move |evt| {
338 if let Some(handler) = &props.onclick {
339 handler.call(evt);
340 }
341 },
342 ..props.attributes,
343 {props.children}
344 }
345 }
346 }
347}
348
349/// Dropdown menu divider.
350#[component]
351pub fn DropdownDivider() -> Element {
352 rsx! {
353 li { hr { class: "dropdown-divider" } }
354 }
355}
356
357/// Dropdown menu header text.
358#[derive(Clone, PartialEq, Props)]
359pub struct DropdownHeaderProps {
360 /// Any additional HTML attributes.
361 #[props(extends = GlobalAttributes)]
362 attributes: Vec<Attribute>,
363 pub children: Element,
364}
365
366#[component]
367pub fn DropdownHeader(props: DropdownHeaderProps) -> Element {
368 rsx! {
369 li { h6 { class: "dropdown-header", ..props.attributes, {props.children} } }
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn dropdown_item_class_base() {
379 assert_eq!(dropdown_item_class(false, false, ""), "dropdown-item");
380 }
381
382 #[test]
383 fn dropdown_item_class_active_disabled_and_extra() {
384 // Identical class output for the `<button>` and `<a>` render paths.
385 assert_eq!(
386 dropdown_item_class(true, true, "text-danger"),
387 "dropdown-item active disabled text-danger"
388 );
389 }
390
391 #[test]
392 fn dropdown_item_class_active_only() {
393 assert_eq!(dropdown_item_class(true, false, ""), "dropdown-item active");
394 }
395}