1use crate::theme::use_theme;
6use dioxus::prelude::*;
7
8pub fn default_width() -> String {
9 "100%".to_string()
10}
11
12#[derive(Default, Clone, PartialEq, Debug)]
14pub enum ProgressVariant {
15 #[default]
16 Linear,
17 Circular,
18}
19
20#[derive(Default, Clone, PartialEq, Debug)]
22pub enum ProgressSize {
23 #[default]
24 Sm, Md, Lg, }
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#[derive(Props, Clone, PartialEq)]
57pub struct ProgressProps {
58 pub value: Option<f32>,
60 #[props(default = 100.0)]
62 pub max: f32,
63 #[props(default = ProgressVariant::Linear)]
65 pub variant: ProgressVariant,
66 #[props(default = ProgressSize::Md)]
68 pub size: ProgressSize,
69 pub color: Option<String>,
71 pub track_color: Option<String>,
73 #[props(default = false)]
75 pub show_label: bool,
76 #[props(default = LabelPosition::Right)]
78 pub label_position: LabelPosition,
79 #[props(default = default_width())]
81 pub width: String,
82 #[props(default)]
84 pub class: Option<String>,
85 #[props(default = false)]
87 pub indeterminate: bool,
88}
89
90#[derive(Default, Clone, PartialEq, Debug)]
92pub enum LabelPosition {
93 #[default]
94 Right,
95 Inside,
96 Bottom,
97}
98
99#[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 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 circle {
198 cx: "{center}",
199 cy: "{center}",
200 r: "{radius}",
201 fill: "none",
202 stroke: "{track_color}",
203 stroke_width: "{stroke}",
204 }
205
206 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#[derive(Props, Clone, PartialEq)]
241pub struct StepProgressProps {
242 pub total_steps: usize,
244 pub current_step: usize,
246 #[props(default)]
248 pub labels: Vec<String>,
249 pub color: Option<String>,
251 #[props(default = true)]
253 pub show_labels: bool,
254}
255
256#[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 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 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 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}