Skip to main content

dioxus_bootstrap_css/
badge.rs

1use dioxus::prelude::*;
2
3use crate::types::{BadgeFill, Color};
4
5/// Bootstrap Badge component.
6///
7/// # Bootstrap HTML → Dioxus
8///
9/// | HTML | Dioxus |
10/// |---|---|
11/// | `<span class="badge text-bg-primary">New</span>` | `Badge { color: Color::Primary, "New" }` |
12/// | `<span class="badge rounded-pill text-bg-danger">99+</span>` | `Badge { color: Color::Danger, pill: true, "99+" }` |
13/// | `<span class="badge bg-secondary">Alias</span>` | `Badge { color: Color::Secondary, fill: BadgeFill::Bg, "Alias" }` |
14/// | `<span class="badge bg-info-subtle text-info-emphasis">2 fields</span>` | `Badge { color: Color::Info, fill: BadgeFill::Subtle, "2 fields" }` |
15/// | `<span class="badge text-bg-secondary" role="button">Open</span>` | `Badge { color: Color::Secondary, onclick: move |_| {}, "Open" }` |
16///
17/// ```rust,no_run
18/// # use dioxus::prelude::*;
19/// # use dioxus_bootstrap_css::prelude::*;
20/// # fn _doctest() -> Element {
21/// rsx! {
22///     Badge { color: Color::Primary, "New" }
23///     Badge { color: Color::Danger, pill: true, "99+" }
24///     Badge { color: Color::Info, fill: BadgeFill::Subtle, "2 fields" }
25///     // Inside a heading
26///     h1 { "Messages " Badge { color: Color::Info, "4" } }
27/// }
28/// # }
29/// ```
30#[derive(Clone, PartialEq, Props)]
31pub struct BadgeProps {
32    /// Badge color variant.
33    #[props(default = Color::Primary)]
34    pub color: Color,
35    /// Which Bootstrap colour idiom to paint the badge with. Defaults to
36    /// `text-bg-<color>`, so existing callers are unchanged.
37    #[props(default)]
38    pub fill: BadgeFill,
39    /// Use pill (rounded) style.
40    #[props(default)]
41    pub pill: bool,
42    /// Additional CSS classes.
43    #[props(default)]
44    pub class: String,
45    /// Click event handler.
46    #[props(default)]
47    pub onclick: Option<EventHandler<MouseEvent>>,
48    /// Render as a real `<a class="badge" href=…>` instead of a `<span>`.
49    ///
50    /// Bootstrap documents badges used as links and buttons, and the anchor is
51    /// not interchangeable with a span carrying a click handler: only the anchor
52    /// is focusable, announced as a link, and openable in a new tab or copied
53    /// from the context menu. A badge that navigates should be one.
54    #[props(default)]
55    pub href: Option<String>,
56    /// Anchor `target` (e.g. `"_blank"`). Only applies when `href` is set.
57    #[props(default)]
58    pub target: Option<String>,
59    /// Any additional HTML attributes.
60    #[props(extends = GlobalAttributes)]
61    attributes: Vec<Attribute>,
62    /// Child elements.
63    pub children: Element,
64}
65
66/// Which element a badge renders as. Bootstrap documents badges used as links;
67/// only a real anchor is focusable, announced as a link, and openable in a new
68/// tab, so a span with a click handler is not a substitute for one.
69fn badge_element(has_href: bool) -> &'static str {
70    if has_href { "a" } else { "span" }
71}
72
73/// The badge's class string. Extracted so the colour idioms are assertable
74/// without rendering — the whole point of the `fill` prop is which classes come
75/// out, so that is what the tests pin.
76fn badge_class(color: Color, fill: BadgeFill, pill: bool, class: &str) -> String {
77    let color_classes = match fill {
78        BadgeFill::TextBg => format!(" text-bg-{color}"),
79        BadgeFill::Bg => format!(" bg-{color}"),
80        BadgeFill::Subtle => format!(" bg-{color}-subtle text-{color}-emphasis"),
81        BadgeFill::None => String::new(),
82    };
83    let pill = if pill { " rounded-pill" } else { "" };
84    if class.is_empty() {
85        format!("badge{color_classes}{pill}")
86    } else {
87        format!("badge{color_classes}{pill} {class}")
88    }
89}
90
91#[component]
92pub fn Badge(props: BadgeProps) -> Element {
93    let full_class = badge_class(props.color, props.fill, props.pill, &props.class);
94
95    if badge_element(props.href.is_some()) == "a" {
96        let href = props.href.clone().expect("anchor form implies an href");
97        let target = props.target.clone();
98        return rsx! {
99            a {
100                class: "{full_class}",
101                href: "{href}",
102                target: target,
103                onclick: move |evt| {
104                    if let Some(handler) = &props.onclick {
105                        handler.call(evt);
106                    }
107                },
108                ..props.attributes,
109                {props.children}
110            }
111        };
112    }
113
114    rsx! {
115        span {
116            class: "{full_class}",
117            onclick: move |evt| {
118                if let Some(handler) = &props.onclick {
119                    handler.call(evt);
120                }
121            },
122            ..props.attributes,
123            {props.children}
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn badge_is_a_span_without_an_href() {
134        assert_eq!(badge_element(false), "span");
135    }
136
137    #[test]
138    fn badge_with_an_href_is_an_anchor() {
139        assert_eq!(badge_element(true), "a");
140    }
141
142    #[test]
143    fn badge_default_fill_is_text_bg() {
144        // The default must stay `text-bg-*`: it is what every existing caller
145        // renders today, so the new prop has to be invisible when unset.
146        assert_eq!(
147            badge_class(Color::Primary, BadgeFill::default(), false, ""),
148            "badge text-bg-primary"
149        );
150    }
151
152    #[test]
153    fn badge_bg_fill_omits_the_foreground() {
154        assert_eq!(
155            badge_class(Color::Secondary, BadgeFill::Bg, false, ""),
156            "badge bg-secondary"
157        );
158    }
159
160    #[test]
161    fn badge_subtle_fill_emits_both_halves_of_the_pair() {
162        // Subtle is one idiom, not two utilities: the background is unreadable
163        // without the emphasis foreground, so both must always appear together.
164        assert_eq!(
165            badge_class(Color::Info, BadgeFill::Subtle, false, ""),
166            "badge bg-info-subtle text-info-emphasis"
167        );
168    }
169
170    #[test]
171    fn badge_none_fill_emits_geometry_only() {
172        assert_eq!(
173            badge_class(Color::Primary, BadgeFill::None, false, ""),
174            "badge"
175        );
176    }
177
178    #[test]
179    fn badge_pill_and_extra_classes_survive_every_fill() {
180        assert_eq!(
181            badge_class(Color::Danger, BadgeFill::TextBg, true, "ms-2"),
182            "badge text-bg-danger rounded-pill ms-2"
183        );
184        assert_eq!(
185            badge_class(Color::Danger, BadgeFill::None, true, "ms-2"),
186            "badge rounded-pill ms-2"
187        );
188    }
189}