1use dioxus::prelude::*;
6
7use crate::atoms::{Icon, IconSize, IconColor};
8
9#[derive(Clone, PartialEq, Default, Debug)]
11pub enum StepState {
12 #[default]
14 Pending,
15 Active,
17 Completed,
19 Error,
21}
22
23impl StepState {
24 pub fn color(&self) -> &'static str {
26 match self {
27 StepState::Pending => "#94a3b8",
28 StepState::Active => "#0f172a",
29 StepState::Completed => "#22c55e",
30 StepState::Error => "#ef4444",
31 }
32 }
33
34 pub fn bg_color(&self) -> &'static str {
36 match self {
37 StepState::Pending => "#f1f5f9",
38 StepState::Active => "#0f172a",
39 StepState::Completed => "#22c55e",
40 StepState::Error => "#fef2f2",
41 }
42 }
43
44 pub fn text_color(&self) -> &'static str {
46 match self {
47 StepState::Pending => "#64748b",
48 StepState::Active => "white",
49 StepState::Completed => "white",
50 StepState::Error => "#ef4444",
51 }
52 }
53}
54
55#[derive(Clone, PartialEq, Default, Debug)]
57pub enum StepSize {
58 Sm,
60 #[default]
62 Md,
63 Lg,
65}
66
67impl StepSize {
68 pub fn size_px(&self) -> u32 {
70 match self {
71 StepSize::Sm => 24,
72 StepSize::Md => 32,
73 StepSize::Lg => 40,
74 }
75 }
76
77 pub fn font_size(&self) -> &'static str {
79 match self {
80 StepSize::Sm => "12px",
81 StepSize::Md => "14px",
82 StepSize::Lg => "16px",
83 }
84 }
85}
86
87#[derive(Props, Clone, PartialEq)]
89pub struct StepIndicatorProps {
90 #[props(default = 1)]
92 pub step: u32,
93 #[props(default)]
95 pub state: StepState,
96 #[props(default)]
98 pub size: StepSize,
99 #[props(default)]
101 pub icon: Option<String>,
102 #[props(default)]
104 pub bg_color: Option<String>,
105 #[props(default)]
107 pub text_color: Option<String>,
108 #[props(default)]
110 pub on_click: Option<EventHandler<()>>,
111 #[props(default)]
113 pub aria_label: Option<String>,
114}
115
116#[component]
118pub fn StepIndicator(props: StepIndicatorProps) -> Element {
119 let size = props.size.size_px();
120 let bg = props.bg_color.clone().unwrap_or_else(|| props.state.bg_color().to_string());
121 let color = props.text_color.clone().unwrap_or_else(|| props.state.text_color().to_string());
122 let border = if props.state == StepState::Active {
123 "2px solid #3b82f6"
124 } else {
125 "none"
126 };
127
128 let cursor = if props.on_click.is_some() { "pointer" } else { "default" };
129 let clickable = props.on_click.clone();
130
131 let content: Element = if let Some(icon) = props.icon.clone() {
133 rsx! {
134 Icon {
135 name: icon,
136 size: match props.size {
137 StepSize::Sm => IconSize::Small,
138 StepSize::Md => IconSize::Medium,
139 StepSize::Lg => IconSize::Large,
140 },
141 color: if props.state == StepState::Completed {
142 IconColor::Success
143 } else if props.state == StepState::Error {
144 IconColor::Destructive
145 } else {
146 IconColor::Current
147 },
148 }
149 }
150 } else {
151 match props.state {
152 StepState::Completed => rsx! {
153 Icon {
154 name: "check".to_string(),
155 size: match props.size {
156 StepSize::Sm => IconSize::Small,
157 StepSize::Md => IconSize::Medium,
158 StepSize::Lg => IconSize::Large,
159 },
160 color: IconColor::Success,
161 }
162 },
163 StepState::Error => rsx! {
164 Icon {
165 name: "alert-triangle".to_string(),
166 size: match props.size {
167 StepSize::Sm => IconSize::Small,
168 StepSize::Md => IconSize::Medium,
169 StepSize::Lg => IconSize::Large,
170 },
171 color: IconColor::Destructive,
172 }
173 },
174 _ => rsx! { "{props.step}" },
175 }
176 };
177
178 let font_size_val = props.size.font_size();
179
180 rsx! {
181 div {
182 style: "
183 width: {size}px;
184 height: {size}px;
185 border-radius: 50%;
186 background: {bg};
187 color: {color};
188 border: {border};
189 display: flex;
190 align-items: center;
191 justify-content: center;
192 font-size: {font_size_val};
193 font-weight: 600;
194 flex-shrink: 0;
195 cursor: {cursor};
196 transition: all 200ms ease;
197 ",
198 aria_label: props.aria_label.clone().unwrap_or_else(|| format!("Step {}", props.step)),
199 aria_current: if props.state == StepState::Active { Some("step") } else { None },
200 onclick: move |_| {
201 if let Some(handler) = clickable.clone() {
202 handler.call(());
203 }
204 },
205
206 {content}
207 }
208 }
209}
210
211#[derive(Props, Clone, PartialEq)]
213pub struct StepConnectorProps {
214 #[props(default = true)]
216 pub horizontal: bool,
217 #[props(default)]
219 pub completed: bool,
220 #[props(default)]
222 pub color: Option<String>,
223 #[props(default = "2px".to_string())]
225 pub thickness: String,
226}
227
228#[component]
230pub fn StepConnector(props: StepConnectorProps) -> Element {
231 let color = props.color.clone().unwrap_or_else(|| {
232 if props.completed { "#22c55e".to_string() } else { "#e2e8f0".to_string() }
233 });
234 let thickness_val = props.thickness.clone();
235
236 if props.horizontal {
237 rsx! {
238 div {
239 style: "
240 flex: 1;
241 height: {thickness_val};
242 background: {color};
243 min-width: 24px;
244 transition: background 200ms ease;
245 ",
246 role: "separator",
247 aria_orientation: "horizontal",
248 }
249 }
250 } else {
251 rsx! {
252 div {
253 style: "
254 width: {thickness_val};
255 flex: 1;
256 background: {color};
257 min-height: 24px;
258 margin-left: 15px;
259 transition: background 200ms ease;
260 ",
261 role: "separator",
262 aria_orientation: "vertical",
263 }
264 }
265 }
266}
267
268#[derive(Props, Clone, PartialEq)]
270pub struct StepLabelProps {
271 pub label: String,
273 #[props(default)]
275 pub description: Option<String>,
276 #[props(default)]
278 pub state: StepState,
279 #[props(default)]
281 pub size: StepSize,
282}
283
284#[component]
286pub fn StepLabel(props: StepLabelProps) -> Element {
287 let label_color = if props.state == StepState::Active { "#0f172a" } else { "#64748b" };
288 let font_size_val = match props.size {
289 StepSize::Sm => "13px",
290 StepSize::Md => "14px",
291 StepSize::Lg => "16px",
292 };
293 let desc_size_val = match props.size {
294 StepSize::Sm => "11px",
295 StepSize::Md => "12px",
296 StepSize::Lg => "13px",
297 };
298 let weight_val = if props.state == StepState::Active { "600" } else { "500" };
299
300 rsx! {
301 div {
302 style: "display: flex; flex-direction: column; gap: 2px;",
303
304 span {
305 style: "
306 font-size: {font_size_val};
307 font-weight: {weight_val};
308 color: {label_color};
309 white-space: nowrap;
310 ",
311 "{props.label}"
312 }
313
314 if let Some(desc) = props.description.clone() {
315 span {
316 style: "
317 font-size: {desc_size_val};
318 color: #94a3b8;
319 white-space: nowrap;
320 ",
321 "{desc}"
322 }
323 }
324 }
325 }
326}