dioxus_ui_system/organisms/
stepper.rs1use 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#[derive(Clone, PartialEq, Default, Debug)]
15pub enum StepperVariant {
16 #[default]
18 Horizontal,
19 Vertical,
21 Compact,
23}
24
25#[derive(Props, Clone, PartialEq)]
27pub struct StepperProps {
28 pub steps: Vec<StepItem>,
30 pub active_step: usize,
32 #[props(default)]
34 pub variant: StepperVariant,
35 #[props(default)]
37 pub size: StepSize,
38 #[props(default)]
40 pub title: Option<String>,
41 #[props(default)]
43 pub description: Option<String>,
44 #[props(default)]
46 pub on_step_change: Option<EventHandler<usize>>,
47 #[props(default = true)]
49 pub show_numbers: bool,
50 #[props(default = true)]
52 pub allow_back_navigation: bool,
53 #[props(default = true)]
55 pub show_content: bool,
56 #[props(default)]
58 pub children: Element,
59}
60
61#[component]
63pub fn Stepper(props: StepperProps) -> Element {
64 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 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 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 if props.show_content {
147 div {
148 style: "min-height: 100px;",
149
150 {props.children}
151 }
152 }
153 }
154 }
155 }
156}
157
158#[derive(Props, Clone, PartialEq)]
160pub struct CompactStepperProps {
161 pub steps: Vec<StepItem>,
163 pub active_step: usize,
165 #[props(default = StepSize::Sm)]
167 pub size: StepSize,
168 #[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#[derive(Props, Clone, PartialEq)]
207pub struct WizardProps {
208 pub steps: Vec<WizardStep>,
210 pub active_step: usize,
212 pub on_step_change: EventHandler<usize>,
214 pub on_finish: EventHandler<()>,
216 #[props(default)]
218 pub on_cancel: Option<EventHandler<()>>,
219 #[props(default)]
221 pub title: Option<String>,
222 #[props(default = true)]
224 pub show_progress: bool,
225 #[props(default = true)]
227 pub validate: bool,
228 pub children: Element,
230}
231
232#[derive(Clone, PartialEq)]
234pub struct WizardStep {
235 pub label: String,
237 pub description: Option<String>,
239 pub is_valid: bool,
241 pub content: Option<Element>,
243}
244
245impl WizardStep {
246 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 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
258 self.description = Some(desc.into());
259 self
260 }
261
262 pub fn valid(mut self, valid: bool) -> Self {
264 self.is_valid = valid;
265 self
266 }
267}
268
269#[component]
271pub fn Wizard(props: WizardProps) -> Element {
272 let total_steps = props.steps.len();
273 let current = props.active_step;
274
275 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 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 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 HorizontalStepper {
353 steps: step_items,
354 active_step: current,
355 size: StepSize::Md,
356 on_step_click: Some(EventHandler::new(move |step: usize| {
357 if step <= current {
359 props.on_step_change.call(step);
360 }
361 })),
362 }
363
364 div {
366 style: "min-height: 150px; padding: 16px 0;",
367
368 {props.children}
369 }
370
371 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#[derive(Props, Clone, PartialEq)]
411pub struct StepSummaryProps {
412 pub steps: Vec<StepSummaryItem>,
414 #[props(default)]
416 pub editable: bool,
417 #[props(default)]
419 pub on_edit: Option<EventHandler<usize>>,
420}
421
422#[derive(Clone, PartialEq)]
424pub struct StepSummaryItem {
425 pub label: String,
427 pub value: String,
429 pub completed: bool,
431}
432
433impl StepSummaryItem {
434 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#[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}