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