adui_dioxus/components/
progress.rs

1use dioxus::prelude::*;
2
3/// Visual status of a Progress bar.
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum ProgressStatus {
6    Normal,
7    Success,
8    Exception,
9    Active,
10}
11
12impl ProgressStatus {
13    fn as_class(&self) -> &'static str {
14        match self {
15            ProgressStatus::Normal => "adui-progress-status-normal",
16            ProgressStatus::Success => "adui-progress-status-success",
17            ProgressStatus::Exception => "adui-progress-status-exception",
18            ProgressStatus::Active => "adui-progress-status-active",
19        }
20    }
21}
22
23/// Progress visual type.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum ProgressType {
26    Line,
27    Circle,
28}
29
30/// Props for the Progress component (MVP subset).
31#[derive(Props, Clone, PartialEq)]
32pub struct ProgressProps {
33    /// Percentage in the range [0.0, 100.0]. Values outside this range
34    /// will be clamped.
35    #[props(default = 0.0)]
36    pub percent: f32,
37    /// Optional status. When omitted and `percent >= 100`, the component
38    /// will treat the status as `Success` for styling.
39    #[props(optional)]
40    pub status: Option<ProgressStatus>,
41    /// Whether to render the textual percentage.
42    #[props(default = true)]
43    pub show_info: bool,
44    /// Visual type of the progress indicator.
45    #[props(default = ProgressType::Line)]
46    pub r#type: ProgressType,
47    /// Optional stroke width (height for line, border width for circle).
48    #[props(optional)]
49    pub stroke_width: Option<f32>,
50    /// Extra CSS class name on the root element.
51    #[props(optional)]
52    pub class: Option<String>,
53    /// Inline style on the root element.
54    #[props(optional)]
55    pub style: Option<String>,
56}
57
58fn clamp_percent(value: f32) -> f32 {
59    if value.is_nan() {
60        0.0
61    } else {
62        value.clamp(0.0, 100.0)
63    }
64}
65
66fn resolve_status(percent: f32, status: Option<ProgressStatus>) -> ProgressStatus {
67    if let Some(s) = status {
68        s
69    } else if percent >= 100.0 {
70        ProgressStatus::Success
71    } else {
72        ProgressStatus::Normal
73    }
74}
75
76/// Ant Design flavored Progress (MVP: line + simple circle).
77#[component]
78pub fn Progress(props: ProgressProps) -> Element {
79    let ProgressProps {
80        percent,
81        status,
82        show_info,
83        r#type,
84        stroke_width,
85        class,
86        style,
87    } = props;
88
89    let percent = clamp_percent(percent);
90    let status_value = resolve_status(percent, status);
91
92    let mut class_list = vec![
93        "adui-progress".to_string(),
94        status_value.as_class().to_string(),
95    ];
96    match r#type {
97        ProgressType::Line => class_list.push("adui-progress-line".into()),
98        ProgressType::Circle => class_list.push("adui-progress-circle".into()),
99    }
100    if let Some(extra) = class {
101        class_list.push(extra);
102    }
103    let class_attr = class_list.join(" ");
104    let style_attr = style.unwrap_or_default();
105
106    let display_text = format!("{}%", percent.round() as i32);
107
108    match r#type {
109        ProgressType::Line => {
110            let height = stroke_width.unwrap_or(6.0);
111            rsx! {
112                div { class: "{class_attr}", style: "{style_attr}",
113                    div { class: "adui-progress-outer",
114                        div { class: "adui-progress-inner",
115                            div {
116                                class: "adui-progress-bg",
117                                style: "width:{percent}%;height:{height}px;",
118                            }
119                        }
120                    }
121                    if show_info {
122                        span { class: "adui-progress-text", "{display_text}" }
123                    }
124                }
125            }
126        }
127        ProgressType::Circle => {
128            let size = 80.0f32;
129            let border = stroke_width.unwrap_or(6.0);
130            let circle_style = format!(
131                "width:{size}px;height:{size}px;border-width:{border}px;background:conic-gradient(currentColor {percent}%, rgba(0,0,0,0.06) 0);",
132            );
133
134            rsx! {
135                div { class: "{class_attr}", style: "{style_attr}",
136                    div { class: "adui-progress-circle-inner", style: "{circle_style}", }
137                    if show_info {
138                        div { class: "adui-progress-text", "{display_text}" }
139                    }
140                }
141            }
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn clamp_percent_bounds_values() {
152        assert_eq!(clamp_percent(-10.0), 0.0);
153        assert_eq!(clamp_percent(0.0), 0.0);
154        assert_eq!(clamp_percent(50.0), 50.0);
155        assert_eq!(clamp_percent(120.0), 100.0);
156    }
157
158    #[test]
159    fn clamp_percent_handles_nan() {
160        assert_eq!(clamp_percent(f32::NAN), 0.0);
161    }
162
163    #[test]
164    fn clamp_percent_handles_infinity() {
165        assert_eq!(clamp_percent(f32::INFINITY), 100.0);
166        assert_eq!(clamp_percent(f32::NEG_INFINITY), 0.0);
167    }
168
169    #[test]
170    fn clamp_percent_boundary_values() {
171        assert_eq!(clamp_percent(0.0), 0.0);
172        assert_eq!(clamp_percent(100.0), 100.0);
173        assert_eq!(clamp_percent(0.1), 0.1);
174        assert_eq!(clamp_percent(99.9), 99.9);
175        assert_eq!(clamp_percent(-0.1), 0.0);
176        assert_eq!(clamp_percent(100.1), 100.0);
177    }
178
179    #[test]
180    fn resolve_status_defaults_to_success_when_full() {
181        assert_eq!(resolve_status(100.0, None), ProgressStatus::Success);
182        assert_eq!(resolve_status(50.0, None), ProgressStatus::Normal);
183        assert_eq!(
184            resolve_status(80.0, Some(ProgressStatus::Exception)),
185            ProgressStatus::Exception
186        );
187    }
188
189    #[test]
190    fn resolve_status_respects_explicit_status() {
191        assert_eq!(
192            resolve_status(0.0, Some(ProgressStatus::Success)),
193            ProgressStatus::Success
194        );
195        assert_eq!(
196            resolve_status(100.0, Some(ProgressStatus::Exception)),
197            ProgressStatus::Exception
198        );
199        assert_eq!(
200            resolve_status(50.0, Some(ProgressStatus::Active)),
201            ProgressStatus::Active
202        );
203        assert_eq!(
204            resolve_status(75.0, Some(ProgressStatus::Normal)),
205            ProgressStatus::Normal
206        );
207    }
208
209    #[test]
210    fn resolve_status_auto_success_at_100() {
211        assert_eq!(resolve_status(100.0, None), ProgressStatus::Success);
212        assert_eq!(resolve_status(100.1, None), ProgressStatus::Success);
213    }
214
215    #[test]
216    fn resolve_status_auto_normal_below_100() {
217        assert_eq!(resolve_status(0.0, None), ProgressStatus::Normal);
218        assert_eq!(resolve_status(50.0, None), ProgressStatus::Normal);
219        assert_eq!(resolve_status(99.9, None), ProgressStatus::Normal);
220    }
221
222    #[test]
223    fn progress_status_class_mapping() {
224        assert_eq!(
225            ProgressStatus::Normal.as_class(),
226            "adui-progress-status-normal"
227        );
228        assert_eq!(
229            ProgressStatus::Success.as_class(),
230            "adui-progress-status-success"
231        );
232        assert_eq!(
233            ProgressStatus::Exception.as_class(),
234            "adui-progress-status-exception"
235        );
236        assert_eq!(
237            ProgressStatus::Active.as_class(),
238            "adui-progress-status-active"
239        );
240    }
241
242    #[test]
243    fn progress_status_all_variants() {
244        let variants = [
245            ProgressStatus::Normal,
246            ProgressStatus::Success,
247            ProgressStatus::Exception,
248            ProgressStatus::Active,
249        ];
250        for variant in variants.iter() {
251            let class = variant.as_class();
252            assert!(!class.is_empty());
253            assert!(class.starts_with("adui-progress-status-"));
254        }
255    }
256
257    #[test]
258    fn progress_status_equality() {
259        assert_eq!(ProgressStatus::Normal, ProgressStatus::Normal);
260        assert_eq!(ProgressStatus::Success, ProgressStatus::Success);
261        assert_ne!(ProgressStatus::Normal, ProgressStatus::Success);
262        assert_ne!(ProgressStatus::Exception, ProgressStatus::Active);
263    }
264
265    #[test]
266    fn progress_status_clone() {
267        let original = ProgressStatus::Active;
268        let cloned = original;
269        assert_eq!(original, cloned);
270        assert_eq!(original.as_class(), cloned.as_class());
271    }
272
273    #[test]
274    fn progress_type_equality() {
275        assert_eq!(ProgressType::Line, ProgressType::Line);
276        assert_eq!(ProgressType::Circle, ProgressType::Circle);
277        assert_ne!(ProgressType::Line, ProgressType::Circle);
278    }
279
280    #[test]
281    fn progress_type_clone() {
282        let original = ProgressType::Circle;
283        let cloned = original;
284        assert_eq!(original, cloned);
285    }
286
287    #[test]
288    fn progress_type_debug() {
289        let line = ProgressType::Line;
290        let circle = ProgressType::Circle;
291        let line_str = format!("{:?}", line);
292        let circle_str = format!("{:?}", circle);
293        assert!(line_str.contains("Line"));
294        assert!(circle_str.contains("Circle"));
295    }
296
297    #[test]
298    fn progress_props_defaults() {
299        // ProgressProps requires no mandatory fields
300        // percent defaults to 0.0
301        // show_info defaults to true
302        // type defaults to ProgressType::Line
303    }
304
305    #[test]
306    fn clamp_percent_edge_cases() {
307        // Test with very small negative values
308        assert_eq!(clamp_percent(-0.1), 0.0);
309        assert_eq!(clamp_percent(-100.0), 0.0);
310
311        // Test with very large positive values
312        assert_eq!(clamp_percent(100.1), 100.0);
313        assert_eq!(clamp_percent(1000.0), 100.0);
314    }
315
316    #[test]
317    fn clamp_percent_precision() {
318        // Test with floating point precision
319        assert_eq!(clamp_percent(0.0001), 0.0001);
320        assert_eq!(clamp_percent(99.9999), 99.9999);
321    }
322
323    #[test]
324    fn resolve_status_explicit_overrides_auto() {
325        // Explicit status should always override auto status
326        assert_eq!(
327            resolve_status(100.0, Some(ProgressStatus::Exception)),
328            ProgressStatus::Exception
329        );
330        assert_eq!(
331            resolve_status(0.0, Some(ProgressStatus::Success)),
332            ProgressStatus::Success
333        );
334    }
335
336    #[test]
337    fn progress_status_all_variants_equality() {
338        let statuses = [
339            ProgressStatus::Normal,
340            ProgressStatus::Success,
341            ProgressStatus::Exception,
342            ProgressStatus::Active,
343        ];
344        for (i, status1) in statuses.iter().enumerate() {
345            for (j, status2) in statuses.iter().enumerate() {
346                if i == j {
347                    assert_eq!(status1, status2);
348                } else {
349                    assert_ne!(status1, status2);
350                }
351            }
352        }
353    }
354
355    #[test]
356    fn progress_status_class_prefix() {
357        // All progress status classes should start with "adui-progress-status-"
358        assert!(
359            ProgressStatus::Normal
360                .as_class()
361                .starts_with("adui-progress-status-")
362        );
363        assert!(
364            ProgressStatus::Success
365                .as_class()
366                .starts_with("adui-progress-status-")
367        );
368        assert!(
369            ProgressStatus::Exception
370                .as_class()
371                .starts_with("adui-progress-status-")
372        );
373        assert!(
374            ProgressStatus::Active
375                .as_class()
376                .starts_with("adui-progress-status-")
377        );
378    }
379
380    #[test]
381    fn progress_status_unique_classes() {
382        // All progress status classes should be unique
383        let classes: Vec<&str> = vec![
384            ProgressStatus::Normal.as_class(),
385            ProgressStatus::Success.as_class(),
386            ProgressStatus::Exception.as_class(),
387            ProgressStatus::Active.as_class(),
388        ];
389        for (i, class1) in classes.iter().enumerate() {
390            for (j, class2) in classes.iter().enumerate() {
391                if i != j {
392                    assert_ne!(class1, class2);
393                }
394            }
395        }
396    }
397
398    #[test]
399    fn progress_type_all_variants() {
400        assert_eq!(ProgressType::Line, ProgressType::Line);
401        assert_eq!(ProgressType::Circle, ProgressType::Circle);
402        assert_ne!(ProgressType::Line, ProgressType::Circle);
403    }
404
405    #[test]
406    fn progress_type_copy_semantics() {
407        // ProgressType should be Copy
408        let progress_type = ProgressType::Circle;
409        let progress_type2 = progress_type;
410        assert_eq!(progress_type, progress_type2);
411    }
412
413    #[test]
414    fn clamp_percent_zero_and_hundred() {
415        // Boundary values should remain unchanged
416        assert_eq!(clamp_percent(0.0), 0.0);
417        assert_eq!(clamp_percent(100.0), 100.0);
418    }
419}