dioxus_ui_system/molecules/
stepper.rs1use dioxus::prelude::*;
6
7use crate::atoms::{StepIndicator, StepConnector, StepLabel, StepState, StepSize};
8
9#[derive(Clone, PartialEq, Debug)]
11pub struct StepItem {
12 pub label: String,
14 pub description: Option<String>,
16 pub icon: Option<String>,
18 pub state: StepState,
20 pub disabled: bool,
22 pub error: Option<String>,
24}
25
26impl StepItem {
27 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 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
41 self.description = Some(desc.into());
42 self
43 }
44
45 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
47 self.icon = Some(icon.into());
48 self
49 }
50
51 pub fn with_state(mut self, state: StepState) -> Self {
53 self.state = state;
54 self
55 }
56
57 pub fn disabled(mut self) -> Self {
59 self.disabled = true;
60 self
61 }
62
63 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#[derive(Props, Clone, PartialEq)]
73pub struct StepItemProps {
74 pub index: usize,
76 pub step: StepItem,
78 #[props(default)]
80 pub size: StepSize,
81 #[props(default = true)]
83 pub show_connector: bool,
84 #[props(default)]
86 pub connector_completed: bool,
87 #[props(default = true)]
89 pub horizontal: bool,
90 #[props(default)]
92 pub on_click: Option<EventHandler<usize>>,
93}
94
95#[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 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}
159 }
160 }
161 } else {
162 rsx! {
163 div {
164 style: "display: flex; flex-direction: column;",
165
166 div {
168 style: "display: flex; align-items: flex-start; gap: 12px; cursor: {cursor_val}; opacity: {opacity_val};",
169
170 div {
172 style: "display: flex; flex-direction: column; align-items: center;",
173
174 {indicator}
175 {connector}
176 }
177
178 div {
180 style: "padding-top: 6px;",
181 {label}
182 }
183 }
184 }
185 }
186 }
187}
188
189#[derive(Props, Clone, PartialEq)]
191pub struct HorizontalStepperProps {
192 pub steps: Vec<StepItem>,
194 pub active_step: usize,
196 #[props(default)]
198 pub size: StepSize,
199 #[props(default)]
201 pub on_step_click: Option<EventHandler<usize>>,
202 #[props(default = true)]
204 pub allow_click_completed: bool,
205}
206
207#[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#[derive(Props, Clone, PartialEq)]
234pub struct VerticalStepperProps {
235 pub steps: Vec<StepItem>,
237 pub active_step: usize,
239 #[props(default)]
241 pub size: StepSize,
242 #[props(default)]
244 pub on_step_click: Option<EventHandler<usize>>,
245}
246
247#[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#[derive(Props, Clone, PartialEq)]
275pub struct StepContentProps {
276 pub active_step: usize,
278 pub step_index: usize,
280 pub children: Element,
282}
283
284#[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#[derive(Props, Clone, PartialEq)]
305pub struct StepperActionsProps {
306 pub current_step: usize,
308 pub total_steps: usize,
310 #[props(default)]
312 pub on_back: Option<EventHandler<()>>,
313 #[props(default)]
315 pub on_next: Option<EventHandler<()>>,
316 #[props(default)]
318 pub on_finish: Option<EventHandler<()>>,
319 #[props(default)]
321 pub on_skip: Option<EventHandler<()>>,
322 #[props(default = "Finish".to_string())]
324 pub finish_label: String,
325 #[props(default = "Next".to_string())]
327 pub next_label: String,
328 #[props(default = "Back".to_string())]
330 pub back_label: String,
331 #[props(default)]
333 pub disable_back: bool,
334 #[props(default)]
336 pub disable_next: bool,
337 #[props(default)]
339 pub show_skip: bool,
340 #[props(default = StepperActionsAlign::End)]
342 pub align: StepperActionsAlign,
343}
344
345#[derive(Clone, PartialEq, Default)]
347pub enum StepperActionsAlign {
348 #[default]
349 End,
350 Center,
351 SpaceBetween,
352}
353
354#[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 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 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 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}