1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
use dioxus::prelude::*;
#[derive(Clone, PartialEq, Props)]
pub struct StepperProps {
#[props(extends = div, extends = GlobalAttributes)]
attributes: Vec<Attribute>,
/// Current step index (0-based)
pub current_step: usize,
/// Total number of steps
pub total_steps: usize,
/// Title for each step
pub step_titles: Vec<String>,
/// Which steps are completed
#[props(default)]
pub completed_steps: Vec<bool>,
/// Called when "Next" is clicked
pub on_next: EventHandler<()>,
/// Called when "Back" is clicked
pub on_back: EventHandler<()>,
/// Called when "Complete" is clicked (last step)
#[props(optional)]
pub on_complete: Option<EventHandler<()>>,
/// Called when a step indicator is clicked (passes step index)
#[props(optional)]
pub on_step_click: Option<EventHandler<usize>>,
/// Whether to show "Draft saved" indicator
#[props(default)]
pub show_saved: bool,
children: Element,
}
#[component]
pub fn Stepper(mut props: StepperProps) -> Element {
let default_classes = "stepper";
crate::setup_class_attribute(&mut props.attributes, default_classes);
let is_first = props.current_step == 0;
let is_last = props.current_step >= props.total_steps.saturating_sub(1);
let step_title = props
.step_titles
.get(props.current_step)
.cloned()
.unwrap_or_default();
rsx! {
div {..props.attributes,
// Progress bar
div { class: "stepper-progress",
for i in 0..props.total_steps {
{
let state = if props.completed_steps.get(i).copied().unwrap_or(false) {
"completed"
} else if i == props.current_step {
"current"
} else {
"pending"
};
let clickable = props.on_step_click.is_some();
rsx! {
div {
class: "stepper-progress-segment",
class: if clickable { "cursor-pointer" },
"data-state": state,
onclick: move |_| {
if let Some(ref on_step_click) = props.on_step_click {
on_step_click.call(i);
}
},
}
}
}
}
}
// Header
div { class: "stepper-header",
p { class: "stepper-step-label",
"Step {props.current_step + 1} of {props.total_steps}"
}
h2 { class: "stepper-step-title",
"{step_title}"
}
}
// Content
div { class: "stepper-content",
{props.children}
}
// Navigation
div { class: "stepper-nav",
if !is_first {
button {
class: "button",
"data-variant": "outline",
onclick: move |_| props.on_back.call(()),
"Back"
}
} else {
div {}
}
div { class: "flex items-center gap-3",
if props.show_saved {
span { class: "stepper-save-indicator",
"Draft saved"
}
}
if is_last {
if let Some(on_complete) = &props.on_complete {
button {
class: "button",
"data-variant": "default",
onclick: {
let on_complete = *on_complete;
move |_| on_complete.call(())
},
"Complete"
}
}
} else {
button {
class: "button",
"data-variant": "default",
onclick: move |_| props.on_next.call(()),
"Next"
}
}
}
}
}
}
}