Skip to main content

dioxus_ui_system/atoms/
progress.rs

1//! Progress atom component
2//!
3//! Linear and circular progress indicators.
4
5use crate::theme::use_theme;
6use dioxus::prelude::*;
7
8pub fn default_width() -> String {
9    "100%".to_string()
10}
11
12/// Progress indicator variant
13#[derive(Default, Clone, PartialEq, Debug)]
14pub enum ProgressVariant {
15    #[default]
16    Linear,
17    Circular,
18}
19
20/// Progress size
21#[derive(Default, Clone, PartialEq, Debug)]
22pub enum ProgressSize {
23    #[default]
24    Sm, // Small
25    Md, // Medium
26    Lg, // Large
27}
28
29impl ProgressSize {
30    fn to_height(&self) -> u8 {
31        match self {
32            ProgressSize::Sm => 4,
33            ProgressSize::Md => 8,
34            ProgressSize::Lg => 12,
35        }
36    }
37
38    fn to_diameter(&self) -> u8 {
39        match self {
40            ProgressSize::Sm => 16,
41            ProgressSize::Md => 32,
42            ProgressSize::Lg => 48,
43        }
44    }
45
46    fn to_stroke(&self) -> u8 {
47        match self {
48            ProgressSize::Sm => 2,
49            ProgressSize::Md => 3,
50            ProgressSize::Lg => 4,
51        }
52    }
53}
54
55/// Progress indicator properties
56#[derive(Props, Clone, PartialEq)]
57pub struct ProgressProps {
58    /// Current progress value (0-100)
59    pub value: Option<f32>,
60    /// Maximum value (default: 100)
61    #[props(default = 100.0)]
62    pub max: f32,
63    /// Variant (linear or circular)
64    #[props(default = ProgressVariant::Linear)]
65    pub variant: ProgressVariant,
66    /// Size variant
67    #[props(default = ProgressSize::Md)]
68    pub size: ProgressSize,
69    /// Color for the progress bar/track
70    pub color: Option<String>,
71    /// Background track color
72    pub track_color: Option<String>,
73    /// Show percentage label
74    #[props(default = false)]
75    pub show_label: bool,
76    /// Label position (inline for linear, inside for circular)
77    #[props(default = LabelPosition::Right)]
78    pub label_position: LabelPosition,
79    /// Width for linear progress
80    #[props(default = default_width())]
81    pub width: String,
82    /// Additional CSS classes
83    #[props(default)]
84    pub class: Option<String>,
85    /// Indeterminate state (loading without specific progress)
86    #[props(default = false)]
87    pub indeterminate: bool,
88}
89
90/// Label position
91#[derive(Default, Clone, PartialEq, Debug)]
92pub enum LabelPosition {
93    #[default]
94    Right,
95    Inside,
96    Bottom,
97}
98
99/// Progress indicator component
100#[component]
101pub fn Progress(props: ProgressProps) -> Element {
102    let theme = use_theme();
103
104    let color = props
105        .color
106        .unwrap_or_else(|| theme.tokens.read().colors.primary.to_rgba());
107
108    let track_color = props
109        .track_color
110        .unwrap_or_else(|| theme.tokens.read().colors.muted.to_rgba());
111
112    let class_css = props
113        .class
114        .as_ref()
115        .map(|c| format!(" {}", c))
116        .unwrap_or_default();
117
118    // Calculate percentage
119    let percentage = if props.indeterminate || props.value.is_none() {
120        0.0
121    } else {
122        let val = props.value.unwrap();
123        ((val / props.max) * 100.0).clamp(0.0, 100.0)
124    };
125
126    let label_text = format!("{:.0}%", percentage);
127
128    match props.variant {
129        ProgressVariant::Linear => {
130            let height = props.size.to_height();
131            let _indeterminate_css = if props.indeterminate {
132                "background: linear-gradient(90deg, transparent, {color}, transparent); background-size: 200% 100%; animation: progress-indeterminate 1.5s ease-in-out infinite;"
133            } else {
134                ""
135            };
136
137            rsx! {
138                div {
139                    class: "progress progress-linear{class_css}",
140                    style: "display: flex; align-items: center; gap: 8px; width: {props.width};",
141
142                    div {
143                        style: "flex: 1; height: {height}px; background: {track_color}; border-radius: 9999px; overflow: hidden;",
144
145                        if props.indeterminate {
146                            div {
147                                style: "height: 100%; width: 50%; background: {color}; border-radius: 9999px; animation: progress-indeterminate 1.5s ease-in-out infinite;",
148                            }
149                        } else {
150                            div {
151                                style: "height: 100%; width: {percentage}%; background: {color}; border-radius: 9999px; transition: width 0.3s ease;",
152                            }
153                        }
154                    }
155
156                    if props.show_label {
157                        span {
158                            style: "font-size: 12px; color: {theme.tokens.read().colors.foreground.to_rgba()}; min-width: 40px; text-align: right;",
159                            "{label_text}"
160                        }
161                    }
162                }
163
164                style { "{{"
165                    "@keyframes progress-indeterminate {{"
166                    "0% {{ transform: translateX(-100%); }}"
167                    "100% {{ transform: translateX(200%); }}"
168                    "}}"
169                "}}" }
170            }
171        }
172        ProgressVariant::Circular => {
173            let diameter = props.size.to_diameter();
174            let stroke = props.size.to_stroke();
175            let radius = (diameter as f32 - stroke as f32) / 2.0;
176            let circumference = 2.0 * std::f32::consts::PI * radius;
177            let stroke_dashoffset = if props.indeterminate {
178                circumference * 0.25
179            } else {
180                circumference * (1.0 - percentage / 100.0)
181            };
182
183            let center = diameter as f32 / 2.0;
184
185            rsx! {
186                div {
187                    class: "progress progress-circular{class_css}",
188                    style: "display: inline-flex; align-items: center; justify-content: center; position: relative;",
189
190                    svg {
191                        width: "{diameter}px",
192                        height: "{diameter}px",
193                        view_box: "0 0 {diameter} {diameter}",
194                        style: if props.indeterminate { "animation: rotate 1s linear infinite;" } else { "" },
195
196                        // Background track
197                        circle {
198                            cx: "{center}",
199                            cy: "{center}",
200                            r: "{radius}",
201                            fill: "none",
202                            stroke: "{track_color}",
203                            stroke_width: "{stroke}",
204                        }
205
206                        // Progress arc
207                        circle {
208                            cx: "{center}",
209                            cy: "{center}",
210                            r: "{radius}",
211                            fill: "none",
212                            stroke: "{color}",
213                            stroke_width: "{stroke}",
214                            stroke_linecap: "round",
215                            stroke_dasharray: "{circumference}",
216                            stroke_dashoffset: "{stroke_dashoffset}",
217                            style: if props.indeterminate {
218                                "animation: circular-progress 1.5s ease-in-out infinite;"
219                            } else {
220                                "transition: stroke-dashoffset 0.3s ease; transform: rotate(-90deg); transform-origin: center;"
221                            },
222                        }
223                    }
224
225                    if props.show_label {
226                        span {
227                            style: format!("position: absolute; font-size: {}px; color: {}; font-weight: 500;", diameter / 4, theme.tokens.read().colors.foreground.to_rgba()),
228                            "{label_text}"
229                        }
230                    }
231                }
232
233                style { "@keyframes rotate {{ from {{ transform: rotate(0deg); }} to {{ transform: rotate(360deg); }} }} @keyframes circular-progress {{ 0% {{ stroke-dasharray: 1, 200; stroke-dashoffset: 0; }} 50% {{ stroke-dasharray: 89, 200; stroke-dashoffset: -35; }} 100% {{ stroke-dasharray: 89, 200; stroke-dashoffset: -124; }} }}" }
234            }
235        }
236    }
237}
238
239/// Step progress properties (for multi-step processes)
240#[derive(Props, Clone, PartialEq)]
241pub struct StepProgressProps {
242    /// Total number of steps
243    pub total_steps: usize,
244    /// Current step (0-indexed)
245    pub current_step: usize,
246    /// Step labels
247    #[props(default)]
248    pub labels: Vec<String>,
249    /// Color for completed/current steps
250    pub color: Option<String>,
251    /// Show step labels
252    #[props(default = true)]
253    pub show_labels: bool,
254}
255
256/// Step progress indicator
257#[component]
258pub fn StepProgress(props: StepProgressProps) -> Element {
259    let theme = use_theme();
260
261    let color = props
262        .color
263        .unwrap_or_else(|| theme.tokens.read().colors.primary.to_rgba());
264
265    let muted_color = theme.tokens.read().colors.muted.to_rgba();
266
267    rsx! {
268        div {
269            class: "step-progress",
270            style: "display: flex; align-items: center; width: 100%;",
271
272            for i in 0..props.total_steps {
273                {
274                    let is_completed = i < props.current_step;
275                    let is_current = i == props.current_step;
276                    let step_color = if is_completed || is_current { color.clone() } else { muted_color.clone() };
277                    let bg_color = if is_completed || is_current { color.clone() } else { "transparent".to_string() };
278                    let border_color = step_color.clone();
279                    let text_color = if is_completed || is_current { "white".to_string() } else { muted_color.clone() };
280
281                    rsx! {
282                        div {
283                            key: "step-{i}",
284                            style: "display: flex; flex-direction: column; align-items: center; flex: 1; position: relative;",
285
286                            // Step circle
287                            div {
288                                style: "width: 32px; height: 32px; border-radius: 50%; background: {bg_color}; border: 2px solid {border_color}; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; color: {text_color}; transition: all 0.3s ease;",
289
290                                if is_completed {
291                                    "✓"
292                                } else {
293                                    "{i + 1}"
294                                }
295                            }
296
297                            // Label
298                            if props.show_labels && i < props.labels.len() {
299                                span {
300                                    style: "margin-top: 8px; font-size: 12px; color: {step_color}; text-align: center;",
301                                    "{props.labels[i]}"
302                                }
303                            }
304
305                            // Connector line (except for last step)
306                            if i < props.total_steps - 1 {
307                                div {
308                                    style: format!("position: absolute; top: 16px; left: calc(50% + 20px); right: calc(-50% + 20px); height: 2px; background: {}; z-index: -1;", if i < props.current_step { color.clone() } else { muted_color.clone() }),
309                                }
310                            }
311                        }
312                    }
313                }
314            }
315        }
316    }
317}