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