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}