Skip to main content

dioxus_bootstrap_css/
button.rs

1use dioxus::prelude::*;
2
3use crate::types::{Color, Size};
4
5/// Bootstrap Button component.
6///
7/// Renders a `<button>` by default. When `href` is set, renders an `<a>` element
8/// instead (Bootstrap link-button pattern).
9///
10/// Accepts all standard HTML attributes via `extends = GlobalAttributes`.
11/// This means `title`, `data-bs-toggle`, `aria-label`, `id`, etc. all work.
12///
13/// # Bootstrap HTML → Dioxus
14///
15/// | HTML | Dioxus |
16/// |---|---|
17/// | `<button class="btn btn-primary">` | `Button { color: Color::Primary, "Text" }` |
18/// | `<button class="btn btn-outline-danger btn-sm">` | `Button { color: Color::Danger, outline: true, size: Size::Sm, "Text" }` |
19/// | `<button class="btn btn-link btn-sm">` | `Button { link: true, size: Size::Sm, "Text" }` |
20/// | `<button class="btn btn-sm">` (neutral, no color variant) | `Button { plain: true, size: Size::Sm, "Text" }` |
21/// | `<button class="btn btn-success btn-lg" disabled>` | `Button { color: Color::Success, size: Size::Lg, disabled: true, "Text" }` |
22/// | `<a class="btn btn-primary" href="/page">` | `Button { color: Color::Primary, href: "/page", "Link" }` |
23/// | `<a class="btn btn-sm" href="f.json" target="_blank" download="f.json">` | `Button { size: Size::Sm, href: "f.json", target: "_blank", download: "f.json", "DL" }` |
24/// | `<button class="btn btn-primary" title="Tip">` | `Button { color: Color::Primary, title: "Tip", "Text" }` |
25///
26/// ```rust,no_run
27/// # use dioxus::prelude::*;
28/// # use dioxus_bootstrap_css::prelude::*;
29/// # fn _doctest() -> Element {
30/// rsx! {
31///     Button { color: Color::Primary, "Click me" }
32///     Button { color: Color::Danger, outline: true, size: Size::Sm, "Delete" }
33///     Button { color: Color::Success, disabled: true, "Saved" }
34///     Button { color: Color::Warning, onclick: move |_| { /* handler */ }, "Action" }
35///     // Link button — renders <a> instead of <button>:
36///     Button { color: Color::Primary, href: "/page", "Go to Page" }
37///     // HTML attributes work directly:
38///     Button { color: Color::Secondary, title: "Tooltip text", "Hover me" }
39///     Button { color: Color::Primary, "data-bs-toggle": "modal", "Open Modal" }
40/// }
41/// # }
42/// ```
43#[derive(Clone, PartialEq, Props)]
44pub struct ButtonProps {
45    /// Button color variant.
46    #[props(default)]
47    pub color: Color,
48    /// Use outline style instead of filled.
49    #[props(default)]
50    pub outline: bool,
51    /// Use Bootstrap link-button style (`btn-link`).
52    #[props(default)]
53    pub link: bool,
54    /// Render a neutral button with no color variant: bare `.btn` (Bootstrap's
55    /// base button already renders neutral — body-colored text, transparent
56    /// background and border, with the standard focus ring and pointer cursor).
57    /// Pair with utility classes (`border-0`, `p-0`, …) for ghost / borderless /
58    /// text-button styles. Takes precedence over `outline`; ignored when `link`
59    /// is set.
60    #[props(default)]
61    pub plain: bool,
62    /// Button size.
63    #[props(default)]
64    pub size: Size,
65    /// Whether the button is disabled.
66    #[props(default)]
67    pub disabled: bool,
68    /// When set, renders an `<a>` element instead of `<button>` (link-button pattern).
69    #[props(default)]
70    pub href: Option<String>,
71    /// Link target (e.g., `"_blank"`). Only used when `href` is set.
72    #[props(default)]
73    pub target: Option<String>,
74    /// Link relationship (e.g., `"noopener noreferrer"`). Only used when `href`
75    /// is set — and the pairing that makes `target: "_blank"` safe, so the two
76    /// belong together.
77    #[props(default)]
78    pub rel: Option<String>,
79    /// ARIA role. Overrides the `role="button"` a link-button renders by
80    /// default; unset on a real `<button>`, which needs none.
81    ///
82    /// This has to be a prop rather than something the caller passes through
83    /// `attributes`: the spread is *appended*, not merged, so a `role` supplied
84    /// that way emits `role="button" role="link"` — and HTML keeps the first
85    /// duplicate, so the override is silently discarded while looking exactly
86    /// like it worked.
87    #[props(default)]
88    pub role: Option<String>,
89    /// Download filename. Only used when `href` is set.
90    #[props(default)]
91    pub download: Option<String>,
92    /// HTML button type attribute (ignored when `href` is set).
93    #[props(default = "button".to_string())]
94    pub r#type: String,
95    /// Click event handler.
96    #[props(default)]
97    pub onclick: Option<EventHandler<MouseEvent>>,
98    /// Mouse-down handler. A toolbar button needs it to `prevent_default` and
99    /// keep the caret in the field it acts on. `GlobalAttributes` carries
100    /// attributes, not listeners, so this cannot ride along in `attributes`.
101    #[props(default)]
102    pub onmousedown: Option<EventHandler<MouseEvent>>,
103    /// Active (pressed) state.
104    #[props(default)]
105    pub active: bool,
106    /// Additional CSS classes.
107    #[props(default)]
108    pub class: String,
109    /// Any additional HTML attributes (title, data-bs-toggle, aria-*, id, etc.)
110    #[props(extends = GlobalAttributes)]
111    attributes: Vec<Attribute>,
112    /// Child elements.
113    pub children: Element,
114}
115
116#[component]
117pub fn Button(props: ButtonProps) -> Element {
118    // The color/variant segment. A `plain` button is bare `.btn` with no variant
119    // (Bootstrap's base button renders neutral). Each non-empty variant carries
120    // its own leading space, so an empty (plain) segment leaves no double space.
121    let variant_class = if props.plain {
122        String::new()
123    } else if props.link {
124        " btn-link".to_string()
125    } else {
126        let style = if props.outline { "btn-outline" } else { "btn" };
127        let color = props.color;
128        format!(" {style}-{color}")
129    };
130
131    let size_class = match props.size {
132        Size::Md => String::new(),
133        s => format!(" btn-{s}"),
134    };
135
136    let active_class = if props.active { " active" } else { "" };
137
138    let full_class = if props.class.is_empty() {
139        format!("btn{variant_class}{size_class}{active_class}")
140    } else {
141        format!(
142            "btn{variant_class}{size_class}{active_class} {}",
143            props.class
144        )
145    };
146
147    if let Some(href) = &props.href {
148        // Link-button: render <a> with role="button"
149        let disabled_class = if props.disabled { " disabled" } else { "" };
150        let link_class = format!("{full_class}{disabled_class}");
151        let target = props.target.clone();
152        let download = props.download.clone();
153        let rel = props.rel.clone();
154        // A link-button is `role="button"` unless the caller says otherwise;
155        // the default has to survive an unset prop or every existing caller
156        // loses its role.
157        let role = props.role.clone().unwrap_or_else(|| "button".to_string());
158        rsx! {
159            a {
160                class: "{link_class}",
161                href: "{href}",
162                role: "{role}",
163                target: target,
164                rel: rel,
165                download: download,
166                onclick: move |evt| {
167                    if let Some(handler) = &props.onclick {
168                        handler.call(evt);
169                    }
170                },
171                onmousedown: move |evt| {
172                    if let Some(handler) = &props.onmousedown {
173                        handler.call(evt);
174                    }
175                },
176                ..props.attributes,
177                {props.children}
178            }
179        }
180    } else {
181        let role = props.role.clone();
182        rsx! {
183            button {
184                class: "{full_class}",
185                r#type: "{props.r#type}",
186                role: role,
187                disabled: props.disabled,
188                onclick: move |evt| {
189                    if let Some(handler) = &props.onclick {
190                        handler.call(evt);
191                    }
192                },
193                onmousedown: move |evt| {
194                    if let Some(handler) = &props.onmousedown {
195                        handler.call(evt);
196                    }
197                },
198                ..props.attributes,
199                {props.children}
200            }
201        }
202    }
203}
204
205/// Bootstrap ButtonGroup component.
206///
207/// ```rust,no_run
208/// # use dioxus::prelude::*;
209/// # use dioxus_bootstrap_css::prelude::*;
210/// # fn _doctest() -> Element {
211/// rsx! {
212///     ButtonGroup {
213///         Button { color: Color::Primary, "Left" }
214///         Button { color: Color::Primary, "Middle" }
215///         Button { color: Color::Primary, "Right" }
216///     }
217/// }
218/// # }
219/// ```
220#[derive(Clone, PartialEq, Props)]
221pub struct ButtonGroupProps {
222    /// Button group size.
223    #[props(default)]
224    pub size: Size,
225    /// Additional CSS classes.
226    #[props(default)]
227    pub class: String,
228    /// Child elements (buttons).
229    pub children: Element,
230}
231
232#[component]
233pub fn ButtonGroup(props: ButtonGroupProps) -> Element {
234    let size_class = match props.size {
235        Size::Md => String::new(),
236        s => format!(" btn-group-{s}"),
237    };
238
239    let full_class = if props.class.is_empty() {
240        format!("btn-group{size_class}")
241    } else {
242        format!("btn-group{size_class} {}", props.class)
243    };
244
245    rsx! {
246        div {
247            class: "{full_class}",
248            role: "group",
249            {props.children}
250        }
251    }
252}
253
254/// Bootstrap ButtonToolbar — groups multiple ButtonGroups.
255///
256/// ```rust,no_run
257/// # use dioxus::prelude::*;
258/// # use dioxus_bootstrap_css::prelude::*;
259/// # fn _doctest() -> Element {
260/// rsx! {
261///     ButtonToolbar {
262///         ButtonGroup {
263///             Button { color: Color::Primary, "1" }
264///             Button { color: Color::Primary, "2" }
265///         }
266///         ButtonGroup {
267///             Button { color: Color::Secondary, "A" }
268///         }
269///     }
270/// }
271/// # }
272/// ```
273#[derive(Clone, PartialEq, Props)]
274pub struct ButtonToolbarProps {
275    /// Additional CSS classes.
276    #[props(default)]
277    pub class: String,
278    /// Child elements (ButtonGroups).
279    pub children: Element,
280}
281
282#[component]
283pub fn ButtonToolbar(props: ButtonToolbarProps) -> Element {
284    let full_class = if props.class.is_empty() {
285        "btn-toolbar".to_string()
286    } else {
287        format!("btn-toolbar {}", props.class)
288    };
289
290    rsx! {
291        div {
292            class: "{full_class}",
293            role: "toolbar",
294            {props.children}
295        }
296    }
297}