Skip to main content

dioxus_ui_system/atoms/
step.rs

1//! Step atom component
2//!
3//! Individual step indicator with number/icon and state support.
4
5use dioxus::prelude::*;
6
7use crate::atoms::{Icon, IconColor, IconSize};
8
9/// Step state
10#[derive(Clone, PartialEq, Default, Debug)]
11pub enum StepState {
12    /// Step not yet reached
13    #[default]
14    Pending,
15    /// Current active step
16    Active,
17    /// Step completed
18    Completed,
19    /// Step has error
20    Error,
21}
22
23impl StepState {
24    /// Get the color for this state
25    pub fn color(&self) -> &'static str {
26        match self {
27            StepState::Pending => "#94a3b8",
28            StepState::Active => "#0f172a",
29            StepState::Completed => "#22c55e",
30            StepState::Error => "#ef4444",
31        }
32    }
33
34    /// Get the background color for this state
35    pub fn bg_color(&self) -> &'static str {
36        match self {
37            StepState::Pending => "#f1f5f9",
38            StepState::Active => "#0f172a",
39            StepState::Completed => "#22c55e",
40            StepState::Error => "#fef2f2",
41        }
42    }
43
44    /// Get the text color for this state
45    pub fn text_color(&self) -> &'static str {
46        match self {
47            StepState::Pending => "#64748b",
48            StepState::Active => "white",
49            StepState::Completed => "white",
50            StepState::Error => "#ef4444",
51        }
52    }
53}
54
55/// Step size
56#[derive(Clone, PartialEq, Default, Debug)]
57pub enum StepSize {
58    /// Small step indicator
59    Sm,
60    /// Medium step indicator (default)
61    #[default]
62    Md,
63    /// Large step indicator
64    Lg,
65}
66
67impl StepSize {
68    /// Get the size in pixels
69    pub fn size_px(&self) -> u32 {
70        match self {
71            StepSize::Sm => 24,
72            StepSize::Md => 32,
73            StepSize::Lg => 40,
74        }
75    }
76
77    /// Get font size
78    pub fn font_size(&self) -> &'static str {
79        match self {
80            StepSize::Sm => "12px",
81            StepSize::Md => "14px",
82            StepSize::Lg => "16px",
83        }
84    }
85}
86
87/// Step indicator atom
88#[derive(Props, Clone, PartialEq)]
89pub struct StepIndicatorProps {
90    /// Step number (1-indexed)
91    #[props(default = 1)]
92    pub step: u32,
93    /// Step state
94    #[props(default)]
95    pub state: StepState,
96    /// Step size
97    #[props(default)]
98    pub size: StepSize,
99    /// Optional icon to replace number
100    #[props(default)]
101    pub icon: Option<String>,
102    /// Custom background color (overrides state color)
103    #[props(default)]
104    pub bg_color: Option<String>,
105    /// Custom text color
106    #[props(default)]
107    pub text_color: Option<String>,
108    /// Optional click handler
109    #[props(default)]
110    pub on_click: Option<EventHandler<()>>,
111    /// Optional aria label
112    #[props(default)]
113    pub aria_label: Option<String>,
114}
115
116/// Step indicator atom - shows the step number or icon in a circle
117#[component]
118pub fn StepIndicator(props: StepIndicatorProps) -> Element {
119    let size = props.size.size_px();
120    let bg = props
121        .bg_color
122        .clone()
123        .unwrap_or_else(|| props.state.bg_color().to_string());
124    let color = props
125        .text_color
126        .clone()
127        .unwrap_or_else(|| props.state.text_color().to_string());
128    let border = if props.state == StepState::Active {
129        "2px solid #3b82f6"
130    } else {
131        "none"
132    };
133
134    let cursor = if props.on_click.is_some() {
135        "pointer"
136    } else {
137        "default"
138    };
139    let clickable = props.on_click.clone();
140
141    // Content based on state and icon
142    let content: Element = if let Some(icon) = props.icon.clone() {
143        rsx! {
144            Icon {
145                name: icon,
146                size: match props.size {
147                    StepSize::Sm => IconSize::Small,
148                    StepSize::Md => IconSize::Medium,
149                    StepSize::Lg => IconSize::Large,
150                },
151                color: if props.state == StepState::Completed {
152                    IconColor::Success
153                } else if props.state == StepState::Error {
154                    IconColor::Destructive
155                } else {
156                    IconColor::Current
157                },
158            }
159        }
160    } else {
161        match props.state {
162            StepState::Completed => rsx! {
163                Icon {
164                    name: "check".to_string(),
165                    size: match props.size {
166                        StepSize::Sm => IconSize::Small,
167                        StepSize::Md => IconSize::Medium,
168                        StepSize::Lg => IconSize::Large,
169                    },
170                    color: IconColor::Success,
171                }
172            },
173            StepState::Error => rsx! {
174                Icon {
175                    name: "alert-triangle".to_string(),
176                    size: match props.size {
177                        StepSize::Sm => IconSize::Small,
178                        StepSize::Md => IconSize::Medium,
179                        StepSize::Lg => IconSize::Large,
180                    },
181                    color: IconColor::Destructive,
182                }
183            },
184            _ => rsx! { "{props.step}" },
185        }
186    };
187
188    let font_size_val = props.size.font_size();
189
190    rsx! {
191        div {
192            style: "
193                width: {size}px; 
194                height: {size}px; 
195                border-radius: 50%; 
196                background: {bg}; 
197                color: {color}; 
198                border: {border};
199                display: flex; 
200                align-items: center; 
201                justify-content: center; 
202                font-size: {font_size_val}; 
203                font-weight: 600;
204                flex-shrink: 0;
205                cursor: {cursor};
206                transition: all 200ms ease;
207            ",
208            aria_label: props.aria_label.clone().unwrap_or_else(|| format!("Step {}", props.step)),
209            aria_current: if props.state == StepState::Active { Some("step") } else { None },
210            onclick: move |_| {
211                if let Some(handler) = clickable.clone() {
212                    handler.call(());
213                }
214            },
215
216            {content}
217        }
218    }
219}
220
221/// Step connector line between steps
222#[derive(Props, Clone, PartialEq)]
223pub struct StepConnectorProps {
224    /// Connector orientation
225    #[props(default = true)]
226    pub horizontal: bool,
227    /// Whether the connector represents completed progress
228    #[props(default)]
229    pub completed: bool,
230    /// Connector color (overrides default)
231    #[props(default)]
232    pub color: Option<String>,
233    /// Connector thickness
234    #[props(default = "2px".to_string())]
235    pub thickness: String,
236}
237
238/// Step connector atom - line connecting step indicators
239#[component]
240pub fn StepConnector(props: StepConnectorProps) -> Element {
241    let color = props.color.clone().unwrap_or_else(|| {
242        if props.completed {
243            "#22c55e".to_string()
244        } else {
245            "#e2e8f0".to_string()
246        }
247    });
248    let thickness_val = props.thickness.clone();
249
250    if props.horizontal {
251        rsx! {
252            div {
253                style: "
254                    flex: 1;
255                    height: {thickness_val};
256                    background: {color};
257                    min-width: 24px;
258                    transition: background 200ms ease;
259                ",
260                role: "separator",
261                aria_orientation: "horizontal",
262            }
263        }
264    } else {
265        rsx! {
266            div {
267                style: "
268                    width: {thickness_val};
269                    flex: 1;
270                    background: {color};
271                    min-height: 24px;
272                    margin-left: 15px;
273                    transition: background 200ms ease;
274                ",
275                role: "separator",
276                aria_orientation: "vertical",
277            }
278        }
279    }
280}
281
282/// Step label atom
283#[derive(Props, Clone, PartialEq)]
284pub struct StepLabelProps {
285    /// Label text
286    pub label: String,
287    /// Optional description/subtitle
288    #[props(default)]
289    pub description: Option<String>,
290    /// Step state (affects color)
291    #[props(default)]
292    pub state: StepState,
293    /// Text size
294    #[props(default)]
295    pub size: StepSize,
296}
297
298/// Step label atom - displays step title and optional description
299#[component]
300pub fn StepLabel(props: StepLabelProps) -> Element {
301    let label_color = if props.state == StepState::Active {
302        "#0f172a"
303    } else {
304        "#64748b"
305    };
306    let font_size_val = match props.size {
307        StepSize::Sm => "13px",
308        StepSize::Md => "14px",
309        StepSize::Lg => "16px",
310    };
311    let desc_size_val = match props.size {
312        StepSize::Sm => "11px",
313        StepSize::Md => "12px",
314        StepSize::Lg => "13px",
315    };
316    let weight_val = if props.state == StepState::Active {
317        "600"
318    } else {
319        "500"
320    };
321
322    rsx! {
323        div {
324            style: "display: flex; flex-direction: column; gap: 2px;",
325
326            span {
327                style: "
328                    font-size: {font_size_val}; 
329                    font-weight: {weight_val}; 
330                    color: {label_color};
331                    white-space: nowrap;
332                ",
333                "{props.label}"
334            }
335
336            if let Some(desc) = props.description.clone() {
337                span {
338                    style: "
339                        font-size: {desc_size_val}; 
340                        color: #94a3b8;
341                        white-space: nowrap;
342                    ",
343                    "{desc}"
344                }
345            }
346        }
347    }
348}