adui_dioxus/components/
steps.rs

1use crate::components::config_provider::ComponentSize;
2use dioxus::prelude::*;
3
4/// Visual status of an individual step.
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum StepStatus {
7    Wait,
8    Process,
9    Finish,
10    Error,
11}
12
13impl StepStatus {
14    fn as_class(&self) -> &'static str {
15        match self {
16            StepStatus::Wait => "adui-steps-status-wait",
17            StepStatus::Process => "adui-steps-status-process",
18            StepStatus::Finish => "adui-steps-status-finish",
19            StepStatus::Error => "adui-steps-status-error",
20        }
21    }
22}
23
24/// Direction of the steps bar.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum StepsDirection {
27    Horizontal,
28    Vertical,
29}
30
31impl StepsDirection {
32    fn as_class(&self) -> &'static str {
33        match self {
34            StepsDirection::Horizontal => "adui-steps-horizontal",
35            StepsDirection::Vertical => "adui-steps-vertical",
36        }
37    }
38}
39
40/// Data model for a single step item.
41#[derive(Clone, PartialEq)]
42pub struct StepItem {
43    pub key: String,
44    pub title: Element,
45    pub description: Option<Element>,
46    pub status: Option<StepStatus>,
47    pub disabled: bool,
48}
49
50impl StepItem {
51    pub fn new(key: impl Into<String>, title: Element) -> Self {
52        Self {
53            key: key.into(),
54            title,
55            description: None,
56            status: None,
57            disabled: false,
58        }
59    }
60}
61
62/// Props for the Steps component (MVP subset).
63#[derive(Props, Clone, PartialEq)]
64pub struct StepsProps {
65    pub items: Vec<StepItem>,
66    #[props(optional)]
67    pub current: Option<usize>,
68    #[props(optional)]
69    pub default_current: Option<usize>,
70    #[props(optional)]
71    pub on_change: Option<EventHandler<usize>>,
72    #[props(optional)]
73    pub direction: Option<StepsDirection>,
74    #[props(optional)]
75    pub size: Option<ComponentSize>,
76    #[props(optional)]
77    pub class: Option<String>,
78    #[props(optional)]
79    pub style: Option<String>,
80}
81
82fn effective_status(index: usize, current: usize, explicit: Option<StepStatus>) -> StepStatus {
83    if let Some(st) = explicit {
84        return st;
85    }
86    if index < current {
87        StepStatus::Finish
88    } else if index == current {
89        StepStatus::Process
90    } else {
91        StepStatus::Wait
92    }
93}
94
95/// Ant Design flavored Steps (MVP: horizontal line steps with basic status).
96#[component]
97pub fn Steps(props: StepsProps) -> Element {
98    let StepsProps {
99        items,
100        current,
101        default_current,
102        on_change,
103        direction,
104        size,
105        class,
106        style,
107    } = props;
108
109    let initial_current = default_current.unwrap_or(0);
110    let current_internal: Signal<usize> = use_signal(|| initial_current);
111    let is_controlled = current.is_some();
112    let current_index = current.unwrap_or_else(|| *current_internal.read());
113
114    let dir = direction.unwrap_or(StepsDirection::Horizontal);
115
116    let mut class_list = vec!["adui-steps".to_string(), dir.as_class().to_string()];
117    if let Some(sz) = size {
118        match sz {
119            ComponentSize::Small => class_list.push("adui-steps-sm".into()),
120            ComponentSize::Middle => {}
121            ComponentSize::Large => class_list.push("adui-steps-lg".into()),
122        }
123    }
124    if let Some(extra) = class {
125        class_list.push(extra);
126    }
127    let class_attr = class_list.join(" ");
128    let style_attr = style.unwrap_or_default();
129
130    let on_change_cb = on_change;
131
132    rsx! {
133        ol { class: "{class_attr}", style: "{style_attr}",
134            {items.iter().enumerate().map(|(idx, item)| {
135                let status = effective_status(idx, current_index, item.status);
136                let mut item_class = vec!["adui-steps-item".to_string(), status.as_class().to_string()];
137                if item.disabled {
138                    item_class.push("adui-steps-item-disabled".into());
139                }
140                if idx == current_index {
141                    item_class.push("adui-steps-item-current".into());
142                }
143                let item_class_attr = item_class.join(" ");
144
145                let current_internal_for_click = current_internal;
146                let on_change_for_click = on_change_cb;
147                let disabled = item.disabled;
148
149                let title = item.title.clone();
150                let description = item.description.clone();
151                let display_index = idx + 1;
152
153                rsx! {
154                    li {
155                        key: "step-{idx}",
156                        class: "{item_class_attr}",
157                        onclick: move |_| {
158                            if disabled {
159                                return;
160                            }
161                            if !is_controlled {
162                                let mut sig = current_internal_for_click;
163                                sig.set(idx);
164                            }
165                            if let Some(cb) = on_change_for_click {
166                                cb.call(idx);
167                            }
168                        },
169                        div { class: "adui-steps-item-icon",
170                            span { class: "adui-steps-item-index", "{display_index}" }
171                        }
172                        div { class: "adui-steps-item-content",
173                            div { class: "adui-steps-item-title", {title} }
174                            if let Some(desc) = description {
175                                div { class: "adui-steps-item-description", {desc} }
176                            }
177                        }
178                    }
179                }
180            })}
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn effective_status_defaults_by_index() {
191        assert_eq!(effective_status(0, 1, None), StepStatus::Finish);
192        assert_eq!(effective_status(1, 1, None), StepStatus::Process);
193        assert_eq!(effective_status(2, 1, None), StepStatus::Wait);
194    }
195
196    #[test]
197    fn effective_status_respects_explicit_status() {
198        assert_eq!(
199            effective_status(0, 1, Some(StepStatus::Error)),
200            StepStatus::Error
201        );
202        assert_eq!(
203            effective_status(1, 1, Some(StepStatus::Wait)),
204            StepStatus::Wait
205        );
206        assert_eq!(
207            effective_status(2, 1, Some(StepStatus::Finish)),
208            StepStatus::Finish
209        );
210    }
211
212    #[test]
213    fn effective_status_before_current() {
214        assert_eq!(effective_status(0, 2, None), StepStatus::Finish);
215        assert_eq!(effective_status(1, 2, None), StepStatus::Finish);
216    }
217
218    #[test]
219    fn effective_status_at_current() {
220        assert_eq!(effective_status(0, 0, None), StepStatus::Process);
221        assert_eq!(effective_status(5, 5, None), StepStatus::Process);
222    }
223
224    #[test]
225    fn effective_status_after_current() {
226        assert_eq!(effective_status(3, 1, None), StepStatus::Wait);
227        assert_eq!(effective_status(10, 5, None), StepStatus::Wait);
228    }
229
230    #[test]
231    fn step_status_class_mapping() {
232        assert_eq!(StepStatus::Wait.as_class(), "adui-steps-status-wait");
233        assert_eq!(StepStatus::Process.as_class(), "adui-steps-status-process");
234        assert_eq!(StepStatus::Finish.as_class(), "adui-steps-status-finish");
235        assert_eq!(StepStatus::Error.as_class(), "adui-steps-status-error");
236    }
237
238    #[test]
239    fn step_status_all_variants() {
240        let variants = [
241            StepStatus::Wait,
242            StepStatus::Process,
243            StepStatus::Finish,
244            StepStatus::Error,
245        ];
246        for variant in variants.iter() {
247            let class = variant.as_class();
248            assert!(!class.is_empty());
249            assert!(class.starts_with("adui-steps-status-"));
250        }
251    }
252
253    #[test]
254    fn steps_direction_class_mapping() {
255        assert_eq!(
256            StepsDirection::Horizontal.as_class(),
257            "adui-steps-horizontal"
258        );
259        assert_eq!(StepsDirection::Vertical.as_class(), "adui-steps-vertical");
260    }
261
262    #[test]
263    fn steps_direction_equality() {
264        assert_eq!(StepsDirection::Horizontal, StepsDirection::Horizontal);
265        assert_eq!(StepsDirection::Vertical, StepsDirection::Vertical);
266        assert_ne!(StepsDirection::Horizontal, StepsDirection::Vertical);
267    }
268
269    #[test]
270    fn step_item_new() {
271        let item = StepItem::new("key1", rsx!(div { "Title" }));
272        assert_eq!(item.key, "key1");
273        assert_eq!(item.description, None);
274        assert_eq!(item.status, None);
275        assert_eq!(item.disabled, false);
276    }
277
278    #[test]
279    fn step_item_clone() {
280        let item = StepItem::new("key1", rsx!(div { "Title" }));
281        let cloned = item.clone();
282        assert_eq!(item.key, cloned.key);
283        assert_eq!(item.disabled, cloned.disabled);
284    }
285
286    #[test]
287    fn steps_props_defaults() {
288        // StepsProps requires items, so we can't create a fully default instance
289        // But we can verify:
290        // current is optional
291        // default_current is optional
292        // direction is optional (defaults to Horizontal)
293        // size is optional
294    }
295
296    #[test]
297    fn effective_status_all_status_variants() {
298        // Test all status variants with explicit status
299        assert_eq!(
300            effective_status(0, 0, Some(StepStatus::Wait)),
301            StepStatus::Wait
302        );
303        assert_eq!(
304            effective_status(0, 0, Some(StepStatus::Process)),
305            StepStatus::Process
306        );
307        assert_eq!(
308            effective_status(0, 0, Some(StepStatus::Finish)),
309            StepStatus::Finish
310        );
311        assert_eq!(
312            effective_status(0, 0, Some(StepStatus::Error)),
313            StepStatus::Error
314        );
315    }
316
317    #[test]
318    fn effective_status_explicit_overrides_index() {
319        // Explicit status should override index-based logic
320        assert_eq!(
321            effective_status(10, 5, Some(StepStatus::Finish)),
322            StepStatus::Finish
323        );
324        assert_eq!(
325            effective_status(0, 10, Some(StepStatus::Wait)),
326            StepStatus::Wait
327        );
328    }
329
330    #[test]
331    fn effective_status_boundary_index_zero() {
332        assert_eq!(effective_status(0, 0, None), StepStatus::Process);
333        assert_eq!(effective_status(0, 1, None), StepStatus::Finish);
334    }
335
336    #[test]
337    fn effective_status_large_indices() {
338        assert_eq!(effective_status(100, 50, None), StepStatus::Wait);
339        assert_eq!(effective_status(50, 100, None), StepStatus::Finish);
340        assert_eq!(effective_status(100, 100, None), StepStatus::Process);
341    }
342
343    #[test]
344    fn effective_status_index_equals_current() {
345        // When index equals current, should be Process
346        assert_eq!(effective_status(0, 0, None), StepStatus::Process);
347        assert_eq!(effective_status(1, 1, None), StepStatus::Process);
348        assert_eq!(effective_status(99, 99, None), StepStatus::Process);
349    }
350
351    #[test]
352    fn effective_status_index_less_than_current() {
353        // When index < current, should be Finish
354        assert_eq!(effective_status(0, 1, None), StepStatus::Finish);
355        assert_eq!(effective_status(5, 10, None), StepStatus::Finish);
356        assert_eq!(effective_status(98, 99, None), StepStatus::Finish);
357    }
358
359    #[test]
360    fn effective_status_index_greater_than_current() {
361        // When index > current, should be Wait
362        assert_eq!(effective_status(1, 0, None), StepStatus::Wait);
363        assert_eq!(effective_status(10, 5, None), StepStatus::Wait);
364        assert_eq!(effective_status(99, 98, None), StepStatus::Wait);
365    }
366
367    #[test]
368    fn step_status_equality() {
369        assert_eq!(StepStatus::Wait, StepStatus::Wait);
370        assert_eq!(StepStatus::Process, StepStatus::Process);
371        assert_eq!(StepStatus::Finish, StepStatus::Finish);
372        assert_eq!(StepStatus::Error, StepStatus::Error);
373        assert_ne!(StepStatus::Wait, StepStatus::Process);
374        assert_ne!(StepStatus::Finish, StepStatus::Error);
375    }
376
377    #[test]
378    fn step_status_clone() {
379        let original = StepStatus::Error;
380        let cloned = original;
381        assert_eq!(original, cloned);
382        assert_eq!(original.as_class(), cloned.as_class());
383    }
384
385    #[test]
386    fn step_status_debug() {
387        let wait = StepStatus::Wait;
388        let process = StepStatus::Process;
389        let finish = StepStatus::Finish;
390        let error = StepStatus::Error;
391
392        let wait_str = format!("{:?}", wait);
393        let process_str = format!("{:?}", process);
394        let finish_str = format!("{:?}", finish);
395        let error_str = format!("{:?}", error);
396
397        assert!(wait_str.contains("Wait"));
398        assert!(process_str.contains("Process"));
399        assert!(finish_str.contains("Finish"));
400        assert!(error_str.contains("Error"));
401    }
402
403    #[test]
404    fn steps_direction_clone() {
405        let original = StepsDirection::Vertical;
406        let cloned = original;
407        assert_eq!(original, cloned);
408        assert_eq!(original.as_class(), cloned.as_class());
409    }
410
411    #[test]
412    fn steps_direction_debug() {
413        let horizontal = StepsDirection::Horizontal;
414        let vertical = StepsDirection::Vertical;
415
416        let h_str = format!("{:?}", horizontal);
417        let v_str = format!("{:?}", vertical);
418
419        assert!(h_str.contains("Horizontal"));
420        assert!(v_str.contains("Vertical"));
421    }
422
423    #[test]
424    fn step_item_equality() {
425        let item1 = StepItem::new("key1", rsx!(div { "Title" }));
426        let item2 = StepItem::new("key1", rsx!(div { "Title" }));
427        let item3 = StepItem::new("key2", rsx!(div { "Title" }));
428
429        // Note: Element comparison might not work as expected in tests
430        // But we can test other fields
431        assert_eq!(item1.key, item2.key);
432        assert_ne!(item1.key, item3.key);
433    }
434
435    #[test]
436    fn step_item_with_all_fields() {
437        let mut item = StepItem::new("key1", rsx!(div { "Title" }));
438        item.description = Some(rsx!(div { "Description" }));
439        item.status = Some(StepStatus::Error);
440        item.disabled = true;
441
442        assert_eq!(item.key, "key1");
443        assert!(item.description.is_some());
444        assert_eq!(item.status, Some(StepStatus::Error));
445        assert_eq!(item.disabled, true);
446    }
447}