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