Skip to main content

dioxus_ui_system/organisms/
stepper.rs

1//! Stepper organism component
2//!
3//! Complete stepper with header, content areas, and navigation.
4
5use dioxus::prelude::*;
6
7use crate::atoms::{StepState, StepSize, Heading, HeadingLevel};
8use crate::molecules::{
9    StepItem, HorizontalStepper, VerticalStepper, StepperActions,
10    StepperActionsAlign, StepItemComponent,
11    Card, CardVariant,
12};
13
14/// Stepper variant
15#[derive(Clone, PartialEq, Default, Debug)]
16pub enum StepperVariant {
17    /// Horizontal stepper (default)
18    #[default]
19    Horizontal,
20    /// Vertical stepper
21    Vertical,
22    /// Compact stepper (smaller, no labels)
23    Compact,
24}
25
26/// Stepper organism props
27#[derive(Props, Clone, PartialEq)]
28pub struct StepperProps {
29    /// List of steps
30    pub steps: Vec<StepItem>,
31    /// Current active step index
32    pub active_step: usize,
33    /// Stepper variant
34    #[props(default)]
35    pub variant: StepperVariant,
36    /// Step size
37    #[props(default)]
38    pub size: StepSize,
39    /// Optional title
40    #[props(default)]
41    pub title: Option<String>,
42    /// Optional description
43    #[props(default)]
44    pub description: Option<String>,
45    /// Step change callback
46    #[props(default)]
47    pub on_step_change: Option<EventHandler<usize>>,
48    /// Show step numbers
49    #[props(default = true)]
50    pub show_numbers: bool,
51    /// Allow clicking on completed steps
52    #[props(default = true)]
53    pub allow_back_navigation: bool,
54    /// Show content area
55    #[props(default = true)]
56    pub show_content: bool,
57    /// Content elements for each step
58    #[props(default)]
59    pub children: Element,
60}
61
62/// Stepper organism - complete stepper component
63#[component]
64pub fn Stepper(props: StepperProps) -> Element {
65    // Update step states based on active step
66    let steps: Vec<StepItem> = props.steps.iter().enumerate().map(|(i, step)| {
67        let mut updated = step.clone();
68        updated.state = if i < props.active_step {
69            StepState::Completed
70        } else if i == props.active_step {
71            StepState::Active
72        } else {
73            StepState::Pending
74        };
75        updated
76    }).collect();
77    
78    rsx! {
79        Card {
80            variant: CardVariant::Default,
81            full_width: true,
82            
83            div {
84                style: "display: flex; flex-direction: column; gap: 24px;",
85                
86                // Header
87                if props.title.is_some() || props.description.is_some() {
88                    div {
89                        if let Some(title) = props.title.clone() {
90                            Heading {
91                                level: HeadingLevel::H3,
92                                "{title}"
93                            }
94                        }
95                        
96                        if let Some(desc) = props.description.clone() {
97                            p {
98                                style: "margin: 4px 0 0 0; color: #64748b; font-size: 14px;",
99                                "{desc}"
100                            }
101                        }
102                    }
103                }
104                
105                // Stepper header
106                match props.variant {
107                    StepperVariant::Horizontal => rsx! {
108                        HorizontalStepper {
109                            steps: steps.clone(),
110                            active_step: props.active_step,
111                            size: props.size.clone(),
112                            on_step_click: if props.allow_back_navigation {
113                                props.on_step_change.clone()
114                            } else {
115                                None
116                            },
117                        }
118                    },
119                    StepperVariant::Vertical => rsx! {
120                        VerticalStepper {
121                            steps: steps.clone(),
122                            active_step: props.active_step,
123                            size: props.size.clone(),
124                            on_step_click: if props.allow_back_navigation {
125                                props.on_step_change.clone()
126                            } else {
127                                None
128                            },
129                        }
130                    },
131                    StepperVariant::Compact => rsx! {
132                        CompactStepper {
133                            steps: steps.clone(),
134                            active_step: props.active_step,
135                            size: StepSize::Sm,
136                            on_step_click: props.on_step_change.clone(),
137                        }
138                    },
139                }
140                
141                // Content area
142                if props.show_content {
143                    div {
144                        style: "min-height: 100px;",
145                        
146                        {props.children}
147                    }
148                }
149            }
150        }
151    }
152}
153
154/// Compact stepper - minimal horizontal stepper
155#[derive(Props, Clone, PartialEq)]
156pub struct CompactStepperProps {
157    /// List of steps
158    pub steps: Vec<StepItem>,
159    /// Current active step
160    pub active_step: usize,
161    /// Step size
162    #[props(default = StepSize::Sm)]
163    pub size: StepSize,
164    /// Click handler
165    #[props(default)]
166    pub on_step_click: Option<EventHandler<usize>>,
167}
168
169#[component]
170pub fn CompactStepper(props: CompactStepperProps) -> Element {
171    rsx! {
172        div {
173            style: "display: flex; align-items: center; justify-content: center; gap: 8px;",
174            
175            for (index, step) in props.steps.iter().enumerate() {
176                div {
177                    key: "{index}",
178                    style: "display: flex; align-items: center; gap: 8px;",
179                    
180                    StepItemComponent {
181                        index: index,
182                        step: step.clone(),
183                        size: props.size.clone(),
184                        show_connector: false,
185                        connector_completed: false,
186                        horizontal: true,
187                        on_click: props.on_step_click.clone(),
188                    }
189                    
190                    if index < props.steps.len() - 1 {
191                        div {
192                            style: if index < props.active_step { "width: 16px; height: 2px; background: #22c55e;" } else { "width: 16px; height: 2px; background: #e2e8f0;" },
193                        }
194                    }
195                }
196            }
197        }
198    }
199}
200
201/// Wizard stepper - full-featured wizard with validation
202#[derive(Props, Clone, PartialEq)]
203pub struct WizardProps {
204    /// List of steps
205    pub steps: Vec<WizardStep>,
206    /// Current active step
207    pub active_step: usize,
208    /// Step change callback
209    pub on_step_change: EventHandler<usize>,
210    /// Finish callback
211    pub on_finish: EventHandler<()>,
212    /// Cancel callback
213    #[props(default)]
214    pub on_cancel: Option<EventHandler<()>>,
215    /// Wizard title
216    #[props(default)]
217    pub title: Option<String>,
218    /// Show progress summary
219    #[props(default = true)]
220    pub show_progress: bool,
221    /// Enable validation
222    #[props(default = true)]
223    pub validate: bool,
224    /// Step content
225    pub children: Element,
226}
227
228/// Wizard step with validation
229#[derive(Clone, PartialEq)]
230pub struct WizardStep {
231    /// Step label
232    pub label: String,
233    /// Step description
234    pub description: Option<String>,
235    /// Validation function - returns true if step is valid
236    pub is_valid: bool,
237    /// Step content
238    pub content: Option<Element>,
239}
240
241impl WizardStep {
242    /// Create new wizard step
243    pub fn new(label: impl Into<String>) -> Self {
244        Self {
245            label: label.into(),
246            description: None,
247            is_valid: true,
248            content: None,
249        }
250    }
251
252    /// Add description
253    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
254        self.description = Some(desc.into());
255        self
256    }
257
258    /// Set validation state
259    pub fn valid(mut self, valid: bool) -> Self {
260        self.is_valid = valid;
261        self
262    }
263}
264
265/// Wizard organism - full-featured multi-step wizard
266#[component]
267pub fn Wizard(props: WizardProps) -> Element {
268    let total_steps = props.steps.len();
269    let current = props.active_step;
270    
271    // Convert wizard steps to step items
272    let step_items: Vec<StepItem> = props.steps.iter().enumerate().map(|(i, step)| {
273        let state = if i < current {
274            StepState::Completed
275        } else if i == current {
276            if step.is_valid {
277                StepState::Active
278            } else {
279                StepState::Error
280            }
281        } else {
282            StepState::Pending
283        };
284        
285        StepItem {
286            label: step.label.clone(),
287            description: step.description.clone(),
288            icon: None,
289            state,
290            disabled: false,
291            error: if !step.is_valid && i == current {
292                Some("Please complete required fields".to_string())
293            } else {
294                None
295            },
296        }
297    }).collect();
298    
299    let can_go_next = props.steps.get(current).map(|s| s.is_valid).unwrap_or(true);
300    let can_finish = current == total_steps - 1 && can_go_next;
301    
302    let progress = ((current + 1) as f32 / total_steps as f32 * 100.0) as u32;
303    
304    rsx! {
305        Card {
306            variant: CardVariant::Elevated,
307            full_width: true,
308            
309            div {
310                style: "display: flex; flex-direction: column; gap: 24px;",
311                
312                // Header with progress
313                if let Some(title) = props.title.clone() {
314                    div {
315                        style: "display: flex; justify-content: space-between; align-items: center;",
316                        
317                        Heading {
318                            level: HeadingLevel::H3,
319                            "{title}"
320                        }
321                        
322                        if props.show_progress {
323                            span {
324                                style: "font-size: 14px; color: #64748b;",
325                                "Step {current + 1} of {total_steps}"
326                            }
327                        }
328                    }
329                }
330                
331                // Progress bar
332                if props.show_progress {
333                    div {
334                        style: "width: 100%; height: 4px; background: #e2e8f0; border-radius: 2px; overflow: hidden;",
335                        
336                        div {
337                            style: "height: 100%; width: {progress}%; background: #22c55e; transition: width 300ms ease;",
338                        }
339                    }
340                }
341                
342                // Stepper
343                HorizontalStepper {
344                    steps: step_items,
345                    active_step: current,
346                    size: StepSize::Md,
347                    on_step_click: Some(EventHandler::new(move |step: usize| {
348                        // Only allow going back or to completed steps
349                        if step <= current {
350                            props.on_step_change.call(step);
351                        }
352                    })),
353                }
354                
355                // Content
356                div {
357                    style: "min-height: 150px; padding: 16px 0;",
358                    
359                    {props.children}
360                }
361                
362                // Actions
363                StepperActions {
364                    current_step: current,
365                    total_steps: total_steps,
366                    on_back: if current > 0 {
367                        Some(EventHandler::new(move |_| {
368                            if current > 0 {
369                                props.on_step_change.call(current - 1);
370                            }
371                        }))
372                    } else {
373                        None
374                    },
375                    on_next: if !can_finish {
376                        Some(EventHandler::new(move |_| {
377                            if current < total_steps - 1 {
378                                props.on_step_change.call(current + 1);
379                            }
380                        }))
381                    } else {
382                        None
383                    },
384                    on_finish: if can_finish {
385                        Some(EventHandler::new(move |_| {
386                            props.on_finish.call(());
387                        }))
388                    } else {
389                        None
390                    },
391                    on_skip: None,
392                    disable_next: !can_go_next,
393                    align: StepperActionsAlign::SpaceBetween,
394                }
395            }
396        }
397    }
398}
399
400/// Step summary component - shows overview of all steps
401#[derive(Props, Clone, PartialEq)]
402pub struct StepSummaryProps {
403    /// Steps data
404    pub steps: Vec<StepSummaryItem>,
405    /// Show edit buttons
406    #[props(default)]
407    pub editable: bool,
408    /// Edit callback
409    #[props(default)]
410    pub on_edit: Option<EventHandler<usize>>,
411}
412
413/// Step summary item
414#[derive(Clone, PartialEq)]
415pub struct StepSummaryItem {
416    /// Step label
417    pub label: String,
418    /// Step value/content
419    pub value: String,
420    /// Is step complete
421    pub completed: bool,
422}
423
424impl StepSummaryItem {
425    /// Create new summary item
426    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
427        Self {
428            label: label.into(),
429            value: value.into(),
430            completed: true,
431        }
432    }
433}
434
435/// Step summary - review all steps before final submission
436#[component]
437pub fn StepSummary(props: StepSummaryProps) -> Element {
438    rsx! {
439        div {
440            style: "display: flex; flex-direction: column; gap: 16px;",
441            
442            for (index, item) in props.steps.iter().enumerate() {
443                div {
444                    key: "{index}",
445                    style: "
446                        display: flex; 
447                        justify-content: space-between; 
448                        align-items: flex-start;
449                        padding: 16px;
450                        background: #f8fafc;
451                        border-radius: 8px;
452                        border: 1px solid #e2e8f0;
453                    ",
454                    
455                    div {
456                        style: "display: flex; flex-direction: column; gap: 4px;",
457                        
458                        span {
459                            style: "font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase;",
460                            "{item.label}"
461                        }
462                        
463                        span {
464                            style: "font-size: 14px; color: #0f172a;",
465                            "{item.value}"
466                        }
467                    }
468                    
469                    if props.editable {
470                        if let Some(on_edit) = props.on_edit.clone() {
471                            crate::atoms::Button {
472                                variant: crate::atoms::ButtonVariant::Ghost,
473                                size: crate::atoms::ButtonSize::Sm,
474                                onclick: move |_| on_edit.call(index),
475                                "Edit"
476                            }
477                        }
478                    }
479                }
480            }
481        }
482    }
483}