dioxus_ui_system/molecules/
stepper.rs1use dioxus::prelude::*;
6
7use crate::atoms::{
8 AlignItems, Box, HStack, JustifyContent, SpacingSize, StepConnector, StepIndicator, StepLabel,
9 StepSize, StepState, VStack,
10};
11
12#[derive(Clone, PartialEq, Debug)]
14pub struct StepItem {
15 pub label: String,
17 pub description: Option<String>,
19 pub icon: Option<String>,
21 pub state: StepState,
23 pub disabled: bool,
25 pub error: Option<String>,
27}
28
29impl StepItem {
30 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 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
44 self.description = Some(desc.into());
45 self
46 }
47
48 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
50 self.icon = Some(icon.into());
51 self
52 }
53
54 pub fn with_state(mut self, state: StepState) -> Self {
56 self.state = state;
57 self
58 }
59
60 pub fn disabled(mut self) -> Self {
62 self.disabled = true;
63 self
64 }
65
66 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#[derive(Props, Clone, PartialEq)]
76pub struct StepItemProps {
77 pub index: usize,
79 pub step: StepItem,
81 #[props(default)]
83 pub size: StepSize,
84 #[props(default = true)]
86 pub show_connector: bool,
87 #[props(default)]
89 pub connector_completed: bool,
90 #[props(default = true)]
92 pub horizontal: bool,
93 #[props(default)]
95 pub on_click: Option<EventHandler<usize>>,
96}
97
98#[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 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}
162 }
163 }
164 } else {
165 rsx! {
166 div {
167 style: "display: flex; flex-direction: column;",
168
169 div {
171 style: "display: flex; align-items: flex-start; gap: 12px; cursor: {cursor_val}; opacity: {opacity_val};",
172
173 div {
175 style: "display: flex; flex-direction: column; align-items: center;",
176
177 {indicator}
178 {connector}
179 }
180
181 Box {
183 style: "padding-top: 6px;",
184 {label}
185 }
186 }
187 }
188 }
189 }
190}
191
192#[derive(Props, Clone, PartialEq)]
194pub struct HorizontalStepperProps {
195 pub steps: Vec<StepItem>,
197 pub active_step: usize,
199 #[props(default)]
201 pub size: StepSize,
202 #[props(default)]
204 pub on_step_click: Option<EventHandler<usize>>,
205 #[props(default = true)]
207 pub allow_click_completed: bool,
208}
209
210#[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#[derive(Props, Clone, PartialEq)]
237pub struct VerticalStepperProps {
238 pub steps: Vec<StepItem>,
240 pub active_step: usize,
242 #[props(default)]
244 pub size: StepSize,
245 #[props(default)]
247 pub on_step_click: Option<EventHandler<usize>>,
248}
249
250#[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#[derive(Props, Clone, PartialEq)]
283pub struct StepContentProps {
284 pub active_step: usize,
286 pub step_index: usize,
288 pub children: Element,
290}
291
292#[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#[derive(Props, Clone, PartialEq)]
313pub struct StepperActionsProps {
314 pub current_step: usize,
316 pub total_steps: usize,
318 #[props(default)]
320 pub on_back: Option<EventHandler<()>>,
321 #[props(default)]
323 pub on_next: Option<EventHandler<()>>,
324 #[props(default)]
326 pub on_finish: Option<EventHandler<()>>,
327 #[props(default)]
329 pub on_skip: Option<EventHandler<()>>,
330 #[props(default = "Finish".to_string())]
332 pub finish_label: String,
333 #[props(default = "Next".to_string())]
335 pub next_label: String,
336 #[props(default = "Back".to_string())]
338 pub back_label: String,
339 #[props(default)]
341 pub disable_back: bool,
342 #[props(default)]
344 pub disable_next: bool,
345 #[props(default)]
347 pub show_skip: bool,
348 #[props(default = StepperActionsAlign::End)]
350 pub align: StepperActionsAlign,
351}
352
353#[derive(Clone, PartialEq, Default)]
355pub enum StepperActionsAlign {
356 #[default]
357 End,
358 Center,
359 SpaceBetween,
360}
361
362#[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 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 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 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}