skill_web/components/run/wizard_stepper.rs
1//! Wizard stepper component for step-by-step navigation
2//!
3//! Displays a horizontal progress indicator showing:
4//! - Current step (active/highlighted)
5//! - Completed steps (with checkmark)
6//! - Pending steps (grayed out)
7//!
8//! Users can click on accessible steps to navigate directly.
9
10use yew::prelude::*;
11use crate::hooks::WizardStep;
12
13#[derive(Properties, PartialEq)]
14pub struct WizardStepperProps {
15 pub current_step: WizardStep,
16 pub steps_completed: std::collections::HashMap<WizardStep, bool>,
17 pub on_step_click: Callback<WizardStep>,
18}
19
20#[function_component(WizardStepper)]
21pub fn wizard_stepper(props: &WizardStepperProps) -> Html {
22 let steps = vec![
23 WizardStep::SelectSkill,
24 WizardStep::SelectTool,
25 WizardStep::ConfigureParameters,
26 WizardStep::Execute,
27 ];
28
29 html! {
30 <div class="wizard-stepper">
31 // Desktop: Horizontal stepper
32 <div class="hidden md:flex items-center justify-between max-w-3xl mx-auto">
33 { for steps.iter().enumerate().map(|(idx, step)| {
34 let is_current = props.current_step == *step;
35 let is_completed = props.steps_completed.get(step).copied().unwrap_or(false);
36 let is_pending = !is_current && !is_completed;
37
38 let step_clone = *step;
39 let on_click = {
40 let on_step_click = props.on_step_click.clone();
41 Callback::from(move |_| {
42 on_step_click.emit(step_clone);
43 })
44 };
45
46 html! {
47 <>
48 // Step circle with number/checkmark
49 <div class="flex flex-col items-center">
50 <button
51 onclick={on_click}
52 class={classes!(
53 "wizard-step-circle",
54 is_current.then(|| "active"),
55 is_completed.then(|| "completed"),
56 is_pending.then(|| "pending"),
57 "transition-all", "duration-200"
58 )}
59 >
60 if is_completed {
61 // Checkmark icon
62 <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
63 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
64 </svg>
65 } else {
66 // Step number
67 { step.number() }
68 }
69 </button>
70
71 // Step label
72 <span class={classes!(
73 "text-xs", "font-medium", "mt-2", "text-center", "max-w-[100px]",
74 if is_current {
75 "text-primary-600 dark:text-primary-400"
76 } else if is_completed {
77 "text-green-600 dark:text-green-400"
78 } else {
79 "text-gray-400 dark:text-gray-500"
80 }
81 )}>
82 { step.label() }
83 </span>
84 </div>
85
86 // Connecting line (except after last step)
87 if idx < steps.len() - 1 {
88 <div class={classes!(
89 "flex-1", "h-0.5", "mx-4", "transition-colors",
90 if is_completed {
91 "bg-green-500"
92 } else {
93 "bg-gray-300 dark:bg-gray-700"
94 }
95 )}></div>
96 }
97 </>
98 }
99 }) }
100 </div>
101
102 // Mobile: Vertical stepper
103 <div class="md:hidden space-y-4">
104 { for steps.iter().map(|step| {
105 let is_current = props.current_step == *step;
106 let is_completed = props.steps_completed.get(step).copied().unwrap_or(false);
107 let is_pending = !is_current && !is_completed;
108
109 let step_clone = *step;
110 let on_click = {
111 let on_step_click = props.on_step_click.clone();
112 Callback::from(move |_| {
113 on_step_click.emit(step_clone);
114 })
115 };
116
117 html! {
118 <button
119 onclick={on_click}
120 class={classes!(
121 "flex", "items-center", "gap-3", "w-full", "text-left",
122 "p-3", "rounded-lg", "transition-all",
123 if is_current {
124 "bg-primary-50 dark:bg-primary-900/20 border-2 border-primary-500"
125 } else if is_completed {
126 "bg-green-50 dark:bg-green-900/20 border-2 border-green-500"
127 } else {
128 "bg-gray-50 dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-700"
129 }
130 )}
131 >
132 <div class={classes!(
133 "wizard-step-circle",
134 is_current.then(|| "active"),
135 is_completed.then(|| "completed"),
136 is_pending.then(|| "pending"),
137 "flex-shrink-0"
138 )}>
139 if is_completed {
140 <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
141 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
142 </svg>
143 } else {
144 { step.number() }
145 }
146 </div>
147
148 <div class="flex-1">
149 <div class={classes!(
150 "text-sm", "font-semibold",
151 if is_current {
152 "text-primary-600 dark:text-primary-400"
153 } else if is_completed {
154 "text-green-600 dark:text-green-400"
155 } else {
156 "text-gray-400 dark:text-gray-500"
157 }
158 )}>
159 { step.label() }
160 </div>
161
162 if is_completed {
163 <div class="text-xs text-green-600 dark:text-green-400 mt-0.5">
164 { "Completed" }
165 </div>
166 } else if is_current {
167 <div class="text-xs text-primary-600 dark:text-primary-400 mt-0.5">
168 { "In Progress" }
169 </div>
170 }
171 </div>
172
173 // Arrow indicator for current step
174 if is_current {
175 <svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
176 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
177 </svg>
178 }
179 </button>
180 }
181 }) }
182 </div>
183 </div>
184 }
185}