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, IconSize, IconColor};
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.bg_color.clone().unwrap_or_else(|| props.state.bg_color().to_string());
121    let color = props.text_color.clone().unwrap_or_else(|| props.state.text_color().to_string());
122    let border = if props.state == StepState::Active {
123        "2px solid #3b82f6"
124    } else {
125        "none"
126    };
127    
128    let cursor = if props.on_click.is_some() { "pointer" } else { "default" };
129    let clickable = props.on_click.clone();
130    
131    // Content based on state and icon
132    let content: Element = if let Some(icon) = props.icon.clone() {
133        rsx! {
134            Icon {
135                name: icon,
136                size: match props.size {
137                    StepSize::Sm => IconSize::Small,
138                    StepSize::Md => IconSize::Medium,
139                    StepSize::Lg => IconSize::Large,
140                },
141                color: if props.state == StepState::Completed {
142                    IconColor::Success
143                } else if props.state == StepState::Error {
144                    IconColor::Destructive
145                } else {
146                    IconColor::Current
147                },
148            }
149        }
150    } else {
151        match props.state {
152            StepState::Completed => rsx! {
153                Icon {
154                    name: "check".to_string(),
155                    size: match props.size {
156                        StepSize::Sm => IconSize::Small,
157                        StepSize::Md => IconSize::Medium,
158                        StepSize::Lg => IconSize::Large,
159                    },
160                    color: IconColor::Success,
161                }
162            },
163            StepState::Error => rsx! {
164                Icon {
165                    name: "alert-triangle".to_string(),
166                    size: match props.size {
167                        StepSize::Sm => IconSize::Small,
168                        StepSize::Md => IconSize::Medium,
169                        StepSize::Lg => IconSize::Large,
170                    },
171                    color: IconColor::Destructive,
172                }
173            },
174            _ => rsx! { "{props.step}" },
175        }
176    };
177    
178    let font_size_val = props.size.font_size();
179    
180    rsx! {
181        div {
182            style: "
183                width: {size}px; 
184                height: {size}px; 
185                border-radius: 50%; 
186                background: {bg}; 
187                color: {color}; 
188                border: {border};
189                display: flex; 
190                align-items: center; 
191                justify-content: center; 
192                font-size: {font_size_val}; 
193                font-weight: 600;
194                flex-shrink: 0;
195                cursor: {cursor};
196                transition: all 200ms ease;
197            ",
198            aria_label: props.aria_label.clone().unwrap_or_else(|| format!("Step {}", props.step)),
199            aria_current: if props.state == StepState::Active { Some("step") } else { None },
200            onclick: move |_| {
201                if let Some(handler) = clickable.clone() {
202                    handler.call(());
203                }
204            },
205            
206            {content}
207        }
208    }
209}
210
211/// Step connector line between steps
212#[derive(Props, Clone, PartialEq)]
213pub struct StepConnectorProps {
214    /// Connector orientation
215    #[props(default = true)]
216    pub horizontal: bool,
217    /// Whether the connector represents completed progress
218    #[props(default)]
219    pub completed: bool,
220    /// Connector color (overrides default)
221    #[props(default)]
222    pub color: Option<String>,
223    /// Connector thickness
224    #[props(default = "2px".to_string())]
225    pub thickness: String,
226}
227
228/// Step connector atom - line connecting step indicators
229#[component]
230pub fn StepConnector(props: StepConnectorProps) -> Element {
231    let color = props.color.clone().unwrap_or_else(|| {
232        if props.completed { "#22c55e".to_string() } else { "#e2e8f0".to_string() }
233    });
234    let thickness_val = props.thickness.clone();
235    
236    if props.horizontal {
237        rsx! {
238            div {
239                style: "
240                    flex: 1;
241                    height: {thickness_val};
242                    background: {color};
243                    min-width: 24px;
244                    transition: background 200ms ease;
245                ",
246                role: "separator",
247                aria_orientation: "horizontal",
248            }
249        }
250    } else {
251        rsx! {
252            div {
253                style: "
254                    width: {thickness_val};
255                    flex: 1;
256                    background: {color};
257                    min-height: 24px;
258                    margin-left: 15px;
259                    transition: background 200ms ease;
260                ",
261                role: "separator",
262                aria_orientation: "vertical",
263            }
264        }
265    }
266}
267
268/// Step label atom
269#[derive(Props, Clone, PartialEq)]
270pub struct StepLabelProps {
271    /// Label text
272    pub label: String,
273    /// Optional description/subtitle
274    #[props(default)]
275    pub description: Option<String>,
276    /// Step state (affects color)
277    #[props(default)]
278    pub state: StepState,
279    /// Text size
280    #[props(default)]
281    pub size: StepSize,
282}
283
284/// Step label atom - displays step title and optional description
285#[component]
286pub fn StepLabel(props: StepLabelProps) -> Element {
287    let label_color = if props.state == StepState::Active { "#0f172a" } else { "#64748b" };
288    let font_size_val = match props.size {
289        StepSize::Sm => "13px",
290        StepSize::Md => "14px",
291        StepSize::Lg => "16px",
292    };
293    let desc_size_val = match props.size {
294        StepSize::Sm => "11px",
295        StepSize::Md => "12px",
296        StepSize::Lg => "13px",
297    };
298    let weight_val = if props.state == StepState::Active { "600" } else { "500" };
299    
300    rsx! {
301        div {
302            style: "display: flex; flex-direction: column; gap: 2px;",
303            
304            span {
305                style: "
306                    font-size: {font_size_val}; 
307                    font-weight: {weight_val}; 
308                    color: {label_color};
309                    white-space: nowrap;
310                ",
311                "{props.label}"
312            }
313            
314            if let Some(desc) = props.description.clone() {
315                span {
316                    style: "
317                        font-size: {desc_size_val}; 
318                        color: #94a3b8;
319                        white-space: nowrap;
320                    ",
321                    "{desc}"
322                }
323            }
324        }
325    }
326}