adui_dioxus/components/
badge.rs

1use dioxus::prelude::*;
2
3/// Status style for Badge (MVP subset).
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum BadgeStatus {
6    Default,
7    Success,
8    Warning,
9    Error,
10}
11
12impl BadgeStatus {
13    fn as_class(&self) -> &'static str {
14        match self {
15            BadgeStatus::Default => "adui-badge-status-default",
16            BadgeStatus::Success => "adui-badge-status-success",
17            BadgeStatus::Warning => "adui-badge-status-warning",
18            BadgeStatus::Error => "adui-badge-status-error",
19        }
20    }
21}
22
23fn compute_badge_indicator(
24    count: Option<u32>,
25    overflow_count: u32,
26    dot: bool,
27    show_zero: bool,
28) -> (bool, bool, String) {
29    if dot {
30        (true, true, String::new())
31    } else if let Some(c) = count {
32        if c == 0 && !show_zero {
33            (false, false, String::new())
34        } else {
35            let text = if c > overflow_count {
36                format!("{}+", overflow_count)
37            } else {
38                c.to_string()
39            };
40            (true, false, text)
41        }
42    } else {
43        (false, false, String::new())
44    }
45}
46
47/// Badge color configuration.
48#[derive(Clone, Debug, PartialEq)]
49pub enum BadgeColor {
50    /// Preset color (primary, success, warning, error, etc.).
51    Preset(String),
52    /// Custom color (hex, rgb, etc.).
53    Custom(String),
54}
55
56/// Badge size.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
58pub enum BadgeSize {
59    #[default]
60    Default,
61    Small,
62}
63
64/// Props for the Badge component.
65#[derive(Props, Clone, PartialEq)]
66pub struct BadgeProps {
67    /// Number or custom element to show in badge.
68    /// Can be a number (u32) or a custom Element.
69    #[props(optional)]
70    pub count: Option<Element>,
71    /// Numeric count (for backward compatibility and simple cases).
72    #[props(optional)]
73    pub count_number: Option<u32>,
74    /// Max count to show before displaying "overflow+".
75    #[props(default = 99)]
76    pub overflow_count: u32,
77    /// Whether to show red dot without number.
78    #[props(default)]
79    pub dot: bool,
80    /// Whether to show badge when count is zero.
81    #[props(default)]
82    pub show_zero: bool,
83    /// Optional semantic status (default/success/warning/error).
84    #[props(optional)]
85    pub status: Option<BadgeStatus>,
86    /// Badge color (preset or custom).
87    #[props(optional)]
88    pub color: Option<BadgeColor>,
89    /// Text shown next to status indicator (for status mode).
90    #[props(optional)]
91    pub text: Option<String>,
92    /// Badge size.
93    #[props(default)]
94    pub size: BadgeSize,
95    /// Offset position [x, y] for badge placement.
96    #[props(optional)]
97    pub offset: Option<(f32, f32)>,
98    /// Title attribute for badge (tooltip).
99    #[props(optional)]
100    pub title: Option<String>,
101    /// Extra class on the root element.
102    #[props(optional)]
103    pub class: Option<String>,
104    /// Inline style for the root element.
105    #[props(optional)]
106    pub style: Option<String>,
107    /// Wrapped element to display the badge on.
108    pub children: Option<Element>,
109}
110
111/// Ant Design flavored Badge.
112#[component]
113pub fn Badge(props: BadgeProps) -> Element {
114    let BadgeProps {
115        count,
116        count_number,
117        overflow_count,
118        dot,
119        show_zero,
120        status,
121        color,
122        text,
123        size,
124        offset,
125        title,
126        class,
127        style,
128        children,
129    } = props;
130
131    let mut class_list = vec!["adui-badge".to_string()];
132    if let Some(st) = status {
133        class_list.push(st.as_class().into());
134    }
135    if matches!(size, BadgeSize::Small) {
136        class_list.push("adui-badge-sm".into());
137    }
138    if let Some(extra) = class {
139        class_list.push(extra);
140    }
141    let class_attr = class_list.join(" ");
142
143    let mut style_attr = style.unwrap_or_default();
144    if let Some((x, y)) = offset {
145        style_attr.push_str(&format!(
146            "--adui-badge-offset-x: {}px; --adui-badge-offset-y: {}px;",
147            x, y
148        ));
149    }
150    if let Some(BadgeColor::Custom(color_str)) = color {
151        style_attr.push_str(&format!("--adui-badge-color: {};", color_str));
152    } else if let Some(BadgeColor::Preset(preset)) = color {
153        class_list.push(format!("adui-badge-{}", preset));
154    }
155
156    // Determine what to render as indicator.
157    let count_value = count_number;
158    let (show_indicator, is_dot, display_text) =
159        compute_badge_indicator(count_value, overflow_count, dot, show_zero);
160
161    let title_attr = title.unwrap_or_default();
162
163    rsx! {
164        span {
165            class: "{class_attr}",
166            style: "{style_attr}",
167            title: "{title_attr}",
168            if let Some(node) = children { {node} }
169            if show_indicator {
170                if is_dot {
171                    span { class: "adui-badge-dot" }
172                } else {
173                    span {
174                        class: "adui-badge-count",
175                        if let Some(custom_count) = count {
176                            {custom_count}
177                        } else {
178                            "{display_text}"
179                        }
180                    }
181                }
182            }
183            if let Some(status_text) = text {
184                if status.is_some() {
185                    span { class: "adui-badge-status-text", "{status_text}" }
186                }
187            }
188        }
189    }
190}
191
192/// Ribbon badge component (sub-component of Badge).
193#[derive(Props, Clone, PartialEq)]
194pub struct RibbonProps {
195    /// Ribbon text content.
196    pub text: String,
197    /// Ribbon color.
198    #[props(optional)]
199    pub color: Option<BadgeColor>,
200    /// Placement of the ribbon.
201    #[props(default)]
202    pub placement: RibbonPlacement,
203    #[props(optional)]
204    pub class: Option<String>,
205    #[props(optional)]
206    pub style: Option<String>,
207    pub children: Element,
208}
209
210/// Ribbon placement.
211#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
212pub enum RibbonPlacement {
213    #[default]
214    End,
215    Start,
216}
217
218/// Ribbon badge component.
219#[component]
220pub fn Ribbon(props: RibbonProps) -> Element {
221    let RibbonProps {
222        text,
223        color,
224        placement,
225        class,
226        style,
227        children,
228    } = props;
229
230    let mut class_list = vec!["adui-badge-ribbon".to_string()];
231    if matches!(placement, RibbonPlacement::Start) {
232        class_list.push("adui-badge-ribbon-start".into());
233    } else {
234        class_list.push("adui-badge-ribbon-end".into());
235    }
236    if let Some(extra) = class {
237        class_list.push(extra);
238    }
239    let class_attr = class_list.join(" ");
240    let mut style_attr = style.unwrap_or_default();
241    if let Some(BadgeColor::Custom(color_str)) = color {
242        style_attr.push_str(&format!("--adui-badge-ribbon-color: {};", color_str));
243    } else if let Some(BadgeColor::Preset(preset)) = color {
244        class_list.push(format!("adui-badge-ribbon-{}", preset));
245    }
246
247    rsx! {
248        div { class: "adui-badge-ribbon-wrapper",
249            {children}
250            div { class: "{class_attr}", style: "{style_attr}",
251                span { class: "adui-badge-ribbon-text", "{text}" }
252            }
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn dot_mode_ignores_count_and_shows_dot() {
263        let (show, is_dot, text) = compute_badge_indicator(Some(5), 99, true, false);
264        assert!(show);
265        assert!(is_dot);
266        assert!(text.is_empty());
267    }
268
269    #[test]
270    fn zero_count_respects_show_zero_flag() {
271        let (show1, _, _) = compute_badge_indicator(Some(0), 99, false, false);
272        assert!(!show1);
273
274        let (show2, is_dot2, text2) = compute_badge_indicator(Some(0), 99, false, true);
275        assert!(show2);
276        assert!(!is_dot2);
277        assert_eq!(text2, "0");
278    }
279
280    #[test]
281    fn count_overflow_is_capped() {
282        let (show, is_dot, text) = compute_badge_indicator(Some(120), 99, false, true);
283        assert!(show);
284        assert!(!is_dot);
285        assert_eq!(text, "99+");
286    }
287
288    #[test]
289    fn compute_badge_indicator_no_count() {
290        let (show, is_dot, text) = compute_badge_indicator(None, 99, false, false);
291        assert!(!show);
292        assert!(!is_dot);
293        assert!(text.is_empty());
294    }
295
296    #[test]
297    fn compute_badge_indicator_exact_overflow() {
298        let (show, is_dot, text) = compute_badge_indicator(Some(99), 99, false, true);
299        assert!(show);
300        assert!(!is_dot);
301        assert_eq!(text, "99");
302    }
303
304    #[test]
305    fn compute_badge_indicator_one_over_overflow() {
306        let (show, is_dot, text) = compute_badge_indicator(Some(100), 99, false, true);
307        assert!(show);
308        assert!(!is_dot);
309        assert_eq!(text, "99+");
310    }
311
312    #[test]
313    fn compute_badge_indicator_normal_count() {
314        let (show, is_dot, text) = compute_badge_indicator(Some(5), 99, false, true);
315        assert!(show);
316        assert!(!is_dot);
317        assert_eq!(text, "5");
318    }
319
320    #[test]
321    fn badge_status_class_mapping() {
322        assert_eq!(BadgeStatus::Default.as_class(), "adui-badge-status-default");
323        assert_eq!(BadgeStatus::Success.as_class(), "adui-badge-status-success");
324        assert_eq!(BadgeStatus::Warning.as_class(), "adui-badge-status-warning");
325        assert_eq!(BadgeStatus::Error.as_class(), "adui-badge-status-error");
326    }
327
328    #[test]
329    fn badge_status_all_variants() {
330        let variants = [
331            BadgeStatus::Default,
332            BadgeStatus::Success,
333            BadgeStatus::Warning,
334            BadgeStatus::Error,
335        ];
336        for variant in variants.iter() {
337            let class = variant.as_class();
338            assert!(!class.is_empty());
339            assert!(class.starts_with("adui-badge-status-"));
340        }
341    }
342
343    #[test]
344    fn badge_status_equality() {
345        assert_eq!(BadgeStatus::Default, BadgeStatus::Default);
346        assert_eq!(BadgeStatus::Success, BadgeStatus::Success);
347        assert_ne!(BadgeStatus::Default, BadgeStatus::Error);
348    }
349
350    #[test]
351    fn badge_status_clone() {
352        let original = BadgeStatus::Warning;
353        let cloned = original;
354        assert_eq!(original, cloned);
355        assert_eq!(original.as_class(), cloned.as_class());
356    }
357
358    #[test]
359    fn badge_color_preset() {
360        let color = BadgeColor::Preset("primary".to_string());
361        match color {
362            BadgeColor::Preset(s) => assert_eq!(s, "primary"),
363            _ => panic!("Expected Preset variant"),
364        }
365    }
366
367    #[test]
368    fn badge_color_custom() {
369        let color = BadgeColor::Custom("#ff0000".to_string());
370        match color {
371            BadgeColor::Custom(s) => assert_eq!(s, "#ff0000"),
372            _ => panic!("Expected Custom variant"),
373        }
374    }
375
376    #[test]
377    fn badge_color_equality() {
378        let preset1 = BadgeColor::Preset("primary".to_string());
379        let preset2 = BadgeColor::Preset("primary".to_string());
380        let preset3 = BadgeColor::Preset("success".to_string());
381        assert_eq!(preset1, preset2);
382        assert_ne!(preset1, preset3);
383
384        let custom1 = BadgeColor::Custom("#ff0000".to_string());
385        let custom2 = BadgeColor::Custom("#ff0000".to_string());
386        let custom3 = BadgeColor::Custom("#00ff00".to_string());
387        assert_eq!(custom1, custom2);
388        assert_ne!(custom1, custom3);
389
390        assert_ne!(preset1, custom1);
391    }
392
393    #[test]
394    fn badge_color_clone() {
395        let original = BadgeColor::Preset("primary".to_string());
396        let cloned = original.clone();
397        assert_eq!(original, cloned);
398    }
399
400    #[test]
401    fn badge_size_default_value() {
402        assert_eq!(BadgeSize::Default, BadgeSize::default());
403    }
404
405    #[test]
406    fn badge_size_all_variants() {
407        assert_eq!(BadgeSize::Default, BadgeSize::Default);
408        assert_eq!(BadgeSize::Small, BadgeSize::Small);
409        assert_ne!(BadgeSize::Default, BadgeSize::Small);
410    }
411
412    #[test]
413    fn badge_size_equality() {
414        let size1 = BadgeSize::Default;
415        let size2 = BadgeSize::Default;
416        let size3 = BadgeSize::Small;
417        assert_eq!(size1, size2);
418        assert_ne!(size1, size3);
419    }
420
421    #[test]
422    fn badge_size_clone() {
423        let original = BadgeSize::Small;
424        let cloned = original;
425        assert_eq!(original, cloned);
426    }
427
428    #[test]
429    fn ribbon_placement_default() {
430        assert_eq!(RibbonPlacement::End, RibbonPlacement::default());
431    }
432
433    #[test]
434    fn ribbon_placement_all_variants() {
435        assert_eq!(RibbonPlacement::End, RibbonPlacement::End);
436        assert_eq!(RibbonPlacement::Start, RibbonPlacement::Start);
437        assert_ne!(RibbonPlacement::End, RibbonPlacement::Start);
438    }
439
440    #[test]
441    fn ribbon_placement_equality() {
442        let placement1 = RibbonPlacement::End;
443        let placement2 = RibbonPlacement::End;
444        let placement3 = RibbonPlacement::Start;
445        assert_eq!(placement1, placement2);
446        assert_ne!(placement1, placement3);
447    }
448
449    #[test]
450    fn ribbon_placement_clone() {
451        let original = RibbonPlacement::Start;
452        let cloned = original;
453        assert_eq!(original, cloned);
454    }
455
456    #[test]
457    fn badge_props_defaults() {
458        // BadgeProps doesn't require any fields
459        // overflow_count defaults to 99
460        // dot defaults to false
461        // show_zero defaults to false
462        // size defaults to BadgeSize::Default
463    }
464
465    #[test]
466    fn compute_badge_indicator_edge_cases() {
467        // Test with very large count
468        let (show, is_dot, text) = compute_badge_indicator(Some(999999), 99, false, true);
469        assert!(show);
470        assert!(!is_dot);
471        assert_eq!(text, "99+");
472    }
473
474    #[test]
475    fn compute_badge_indicator_one_below_overflow() {
476        let (show, is_dot, text) = compute_badge_indicator(Some(98), 99, false, true);
477        assert!(show);
478        assert!(!is_dot);
479        assert_eq!(text, "98");
480    }
481
482    #[test]
483    fn compute_badge_indicator_dot_with_show_zero() {
484        // Dot mode should ignore show_zero
485        let (show, is_dot, text) = compute_badge_indicator(Some(0), 99, true, false);
486        assert!(show);
487        assert!(is_dot);
488        assert!(text.is_empty());
489    }
490
491    #[test]
492    fn badge_status_all_variants_equality() {
493        let statuses = [
494            BadgeStatus::Default,
495            BadgeStatus::Success,
496            BadgeStatus::Warning,
497            BadgeStatus::Error,
498        ];
499        for (i, status1) in statuses.iter().enumerate() {
500            for (j, status2) in statuses.iter().enumerate() {
501                if i == j {
502                    assert_eq!(status1, status2);
503                } else {
504                    assert_ne!(status1, status2);
505                }
506            }
507        }
508    }
509
510    #[test]
511    fn badge_color_preset_vs_custom() {
512        let preset = BadgeColor::Preset("primary".to_string());
513        let custom = BadgeColor::Custom("#ff0000".to_string());
514        assert_ne!(preset, custom);
515    }
516
517    #[test]
518    fn compute_badge_indicator_negative_overflow() {
519        // Test with custom overflow count
520        let (show, is_dot, text) = compute_badge_indicator(Some(50), 10, false, true);
521        assert!(show);
522        assert!(!is_dot);
523        assert_eq!(text, "10+");
524    }
525}