Skip to main content

dioxus_ui_system/molecules/
stepper.rs

1//! Stepper molecule components
2//!
3//! Provides step items and horizontal/vertical stepper layouts.
4
5use dioxus::prelude::*;
6
7use crate::atoms::{StepIndicator, StepConnector, StepLabel, StepState, StepSize};
8
9/// Step item data structure
10#[derive(Clone, PartialEq, Debug)]
11pub struct StepItem {
12    /// Step label
13    pub label: String,
14    /// Optional step description
15    pub description: Option<String>,
16    /// Optional icon name
17    pub icon: Option<String>,
18    /// Step state
19    pub state: StepState,
20    /// Whether step is disabled
21    pub disabled: bool,
22    /// Optional error message
23    pub error: Option<String>,
24}
25
26impl StepItem {
27    /// Create a new step item
28    pub fn new(label: impl Into<String>) -> Self {
29        Self {
30            label: label.into(),
31            description: None,
32            icon: None,
33            state: StepState::Pending,
34            disabled: false,
35            error: None,
36        }
37    }
38
39    /// Add description to step
40    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
41        self.description = Some(desc.into());
42        self
43    }
44
45    /// Add icon to step
46    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
47        self.icon = Some(icon.into());
48        self
49    }
50
51    /// Set step state
52    pub fn with_state(mut self, state: StepState) -> Self {
53        self.state = state;
54        self
55    }
56
57    /// Mark step as disabled
58    pub fn disabled(mut self) -> Self {
59        self.disabled = true;
60        self
61    }
62
63    /// Add error to step
64    pub fn with_error(mut self, error: impl Into<String>) -> Self {
65        self.error = Some(error.into());
66        self.state = StepState::Error;
67        self
68    }
69}
70
71/// Step item molecule - combines indicator and label
72#[derive(Props, Clone, PartialEq)]
73pub struct StepItemProps {
74    /// Step index (0-based)
75    pub index: usize,
76    /// Step data
77    pub step: StepItem,
78    /// Step size
79    #[props(default)]
80    pub size: StepSize,
81    /// Whether to show connector (not for last step)
82    #[props(default = true)]
83    pub show_connector: bool,
84    /// Connector is completed
85    #[props(default)]
86    pub connector_completed: bool,
87    /// Horizontal layout
88    #[props(default = true)]
89    pub horizontal: bool,
90    /// Click handler
91    #[props(default)]
92    pub on_click: Option<EventHandler<usize>>,
93}
94
95/// Step item molecule - displays a single step with indicator, label, and optional connector
96#[component]
97pub fn StepItemComponent(props: StepItemProps) -> Element {
98    let step = &props.step;
99    let clickable = !step.disabled && props.on_click.is_some();
100    let on_click = props.on_click.clone();
101    let index = props.index;
102    
103    let indicator = rsx! {
104        StepIndicator {
105            step: (props.index + 1) as u32,
106            state: step.state.clone(),
107            size: props.size.clone(),
108            icon: step.icon.clone(),
109            on_click: if clickable {
110                Some(EventHandler::new(move |_| {
111                    if let Some(handler) = on_click.clone() {
112                        handler.call(index);
113                    }
114                }))
115            } else {
116                None
117            },
118        }
119    };
120    
121    let label = rsx! {
122        StepLabel {
123            label: step.label.clone(),
124            description: step.description.clone(),
125            state: step.state.clone(),
126            size: props.size.clone(),
127        }
128    };
129    
130    let connector = if props.show_connector {
131        Some(rsx! {
132            StepConnector {
133                horizontal: props.horizontal,
134                completed: props.connector_completed,
135            }
136        })
137    } else {
138        None
139    };
140    
141    let cursor_val = if clickable { "pointer" } else { "default" };
142    let opacity_val = if step.disabled { "0.5" } else { "1" };
143    
144    if props.horizontal {
145        rsx! {
146            div {
147                style: "display: flex; align-items: center; flex: 1;",
148                
149                // Step content (indicator + label stacked)
150                div {
151                    style: "display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: {cursor_val}; opacity: {opacity_val};",
152                    
153                    {indicator}
154                    {label}
155                }
156                
157                // Connector
158                {connector}
159            }
160        }
161    } else {
162        rsx! {
163            div {
164                style: "display: flex; flex-direction: column;",
165                
166                // Step content row (indicator + label side by side)
167                div {
168                    style: "display: flex; align-items: flex-start; gap: 12px; cursor: {cursor_val}; opacity: {opacity_val};",
169                    
170                    // Indicator column
171                    div {
172                        style: "display: flex; flex-direction: column; align-items: center;",
173                        
174                        {indicator}
175                        {connector}
176                    }
177                    
178                    // Label column
179                    div {
180                        style: "padding-top: 6px;",
181                        {label}
182                    }
183                }
184            }
185        }
186    }
187}
188
189/// Horizontal stepper molecule
190#[derive(Props, Clone, PartialEq)]
191pub struct HorizontalStepperProps {
192    /// List of steps
193    pub steps: Vec<StepItem>,
194    /// Current active step index (0-based)
195    pub active_step: usize,
196    /// Step size
197    #[props(default)]
198    pub size: StepSize,
199    /// Optional click handler for steps
200    #[props(default)]
201    pub on_step_click: Option<EventHandler<usize>>,
202    /// Allow clicking completed steps
203    #[props(default = true)]
204    pub allow_click_completed: bool,
205}
206
207/// Horizontal stepper - steps arranged horizontally
208#[component]
209pub fn HorizontalStepper(props: HorizontalStepperProps) -> Element {
210    rsx! {
211        div {
212            style: "display: flex; align-items: flex-start; width: 100%;",
213            role: "tablist",
214            aria_label: Some("Progress steps"),
215            
216            for (index, step) in props.steps.iter().enumerate() {
217                StepItemComponent {
218                    key: "{index}",
219                    index: index,
220                    step: step.clone(),
221                    size: props.size.clone(),
222                    show_connector: index < props.steps.len() - 1,
223                    connector_completed: index < props.active_step,
224                    horizontal: true,
225                    on_click: props.on_step_click.clone(),
226                }
227            }
228        }
229    }
230}
231
232/// Vertical stepper molecule
233#[derive(Props, Clone, PartialEq)]
234pub struct VerticalStepperProps {
235    /// List of steps
236    pub steps: Vec<StepItem>,
237    /// Current active step index (0-based)
238    pub active_step: usize,
239    /// Step size
240    #[props(default)]
241    pub size: StepSize,
242    /// Optional click handler for steps
243    #[props(default)]
244    pub on_step_click: Option<EventHandler<usize>>,
245}
246
247/// Vertical stepper - steps arranged vertically
248#[component]
249pub fn VerticalStepper(props: VerticalStepperProps) -> Element {
250    rsx! {
251        div {
252            style: "display: flex; flex-direction: column; gap: 0;",
253            role: "tablist",
254            aria_label: Some("Progress steps"),
255            aria_orientation: "vertical",
256            
257            for (index, step) in props.steps.iter().enumerate() {
258                StepItemComponent {
259                    key: "{index}",
260                    index: index,
261                    step: step.clone(),
262                    size: props.size.clone(),
263                    show_connector: index < props.steps.len() - 1,
264                    connector_completed: index < props.active_step,
265                    horizontal: false,
266                    on_click: props.on_step_click.clone(),
267                }
268            }
269        }
270    }
271}
272
273/// Step content panel for showing step content
274#[derive(Props, Clone, PartialEq)]
275pub struct StepContentProps {
276    /// Current active step
277    pub active_step: usize,
278    /// Step index this content belongs to
279    pub step_index: usize,
280    /// Content to display
281    pub children: Element,
282}
283
284/// Step content panel - shows content only when step is active
285#[component]
286pub fn StepContent(props: StepContentProps) -> Element {
287    let is_active = props.active_step == props.step_index;
288    
289    rsx! {
290        if is_active {
291            div {
292                style: "animation: fadeIn 200ms ease;",
293                role: "tabpanel",
294                id: "step-content-{props.step_index}",
295                aria_labelledby: "step-{props.step_index}",
296                
297                {props.children}
298            }
299        }
300    }
301}
302
303/// Stepper actions (navigation buttons)
304#[derive(Props, Clone, PartialEq)]
305pub struct StepperActionsProps {
306    /// Current step
307    pub current_step: usize,
308    /// Total steps
309    pub total_steps: usize,
310    /// Back button handler
311    #[props(default)]
312    pub on_back: Option<EventHandler<()>>,
313    /// Next button handler
314    #[props(default)]
315    pub on_next: Option<EventHandler<()>>,
316    /// Finish button handler
317    #[props(default)]
318    pub on_finish: Option<EventHandler<()>>,
319    /// Skip button handler
320    #[props(default)]
321    pub on_skip: Option<EventHandler<()>>,
322    /// Custom finish button label
323    #[props(default = "Finish".to_string())]
324    pub finish_label: String,
325    /// Custom next button label
326    #[props(default = "Next".to_string())]
327    pub next_label: String,
328    /// Custom back button label
329    #[props(default = "Back".to_string())]
330    pub back_label: String,
331    /// Disable back button
332    #[props(default)]
333    pub disable_back: bool,
334    /// Disable next button
335    #[props(default)]
336    pub disable_next: bool,
337    /// Show skip button
338    #[props(default)]
339    pub show_skip: bool,
340    /// Alignment
341    #[props(default = StepperActionsAlign::End)]
342    pub align: StepperActionsAlign,
343}
344
345/// Stepper actions alignment
346#[derive(Clone, PartialEq, Default)]
347pub enum StepperActionsAlign {
348    #[default]
349    End,
350    Center,
351    SpaceBetween,
352}
353
354/// Stepper actions - navigation buttons for stepper
355#[component]
356pub fn StepperActions(props: StepperActionsProps) -> Element {
357    let is_first = props.current_step == 0;
358    let is_last = props.current_step >= props.total_steps - 1;
359    
360    let justify_content = match props.align {
361        StepperActionsAlign::End => "flex-end",
362        StepperActionsAlign::Center => "center",
363        StepperActionsAlign::SpaceBetween => "space-between",
364    };
365    
366    rsx! {
367        div {
368            style: "
369                display: flex; 
370                justify-content: {justify_content}; 
371                align-items: center; 
372                gap: 12px; 
373                margin-top: 24px;
374                padding-top: 24px;
375                border-top: 1px solid #e2e8f0;
376            ",
377            justify_content: justify_content,
378            
379            // Back button (or spacer for alignment)
380            if !is_first {
381                if let Some(on_back) = props.on_back.clone() {
382                    crate::atoms::Button {
383                        variant: crate::atoms::ButtonVariant::Secondary,
384                        disabled: props.disable_back,
385                        onclick: move |_| on_back.call(()),
386                        "{props.back_label}"
387                    }
388                }
389            } else if props.align == StepperActionsAlign::SpaceBetween {
390                div {}
391            }
392            
393            // Skip button (optional)
394            if props.show_skip && !is_last {
395                if let Some(on_skip) = props.on_skip.clone() {
396                    crate::atoms::Button {
397                        variant: crate::atoms::ButtonVariant::Ghost,
398                        onclick: move |_| on_skip.call(()),
399                        "Skip"
400                    }
401                }
402            }
403            
404            // Next/Finish button
405            if is_last {
406                if let Some(on_finish) = props.on_finish.clone() {
407                    crate::atoms::Button {
408                        variant: crate::atoms::ButtonVariant::Primary,
409                        disabled: props.disable_next,
410                        onclick: move |_| on_finish.call(()),
411                        "{props.finish_label}"
412                    }
413                }
414            } else {
415                if let Some(on_next) = props.on_next.clone() {
416                    crate::atoms::Button {
417                        variant: crate::atoms::ButtonVariant::Primary,
418                        disabled: props.disable_next,
419                        onclick: move |_| on_next.call(()),
420                        "{props.next_label}"
421                    }
422                }
423            }
424        }
425    }
426}