1use dioxus::prelude::*;
6
7use crate::atoms::{Icon, IconColor, IconSize};
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
121 .bg_color
122 .clone()
123 .unwrap_or_else(|| props.state.bg_color().to_string());
124 let color = props
125 .text_color
126 .clone()
127 .unwrap_or_else(|| props.state.text_color().to_string());
128 let border = if props.state == StepState::Active {
129 "2px solid #3b82f6"
130 } else {
131 "none"
132 };
133
134 let cursor = if props.on_click.is_some() {
135 "pointer"
136 } else {
137 "default"
138 };
139 let clickable = props.on_click.clone();
140
141 let content: Element = if let Some(icon) = props.icon.clone() {
143 rsx! {
144 Icon {
145 name: icon,
146 size: match props.size {
147 StepSize::Sm => IconSize::Small,
148 StepSize::Md => IconSize::Medium,
149 StepSize::Lg => IconSize::Large,
150 },
151 color: if props.state == StepState::Completed {
152 IconColor::Success
153 } else if props.state == StepState::Error {
154 IconColor::Destructive
155 } else {
156 IconColor::Current
157 },
158 }
159 }
160 } else {
161 match props.state {
162 StepState::Completed => rsx! {
163 Icon {
164 name: "check".to_string(),
165 size: match props.size {
166 StepSize::Sm => IconSize::Small,
167 StepSize::Md => IconSize::Medium,
168 StepSize::Lg => IconSize::Large,
169 },
170 color: IconColor::Success,
171 }
172 },
173 StepState::Error => rsx! {
174 Icon {
175 name: "alert-triangle".to_string(),
176 size: match props.size {
177 StepSize::Sm => IconSize::Small,
178 StepSize::Md => IconSize::Medium,
179 StepSize::Lg => IconSize::Large,
180 },
181 color: IconColor::Destructive,
182 }
183 },
184 _ => rsx! { "{props.step}" },
185 }
186 };
187
188 let font_size_val = props.size.font_size();
189
190 rsx! {
191 div {
192 style: "
193 width: {size}px;
194 height: {size}px;
195 border-radius: 50%;
196 background: {bg};
197 color: {color};
198 border: {border};
199 display: flex;
200 align-items: center;
201 justify-content: center;
202 font-size: {font_size_val};
203 font-weight: 600;
204 flex-shrink: 0;
205 cursor: {cursor};
206 transition: all 200ms ease;
207 ",
208 aria_label: props.aria_label.clone().unwrap_or_else(|| format!("Step {}", props.step)),
209 aria_current: if props.state == StepState::Active { Some("step") } else { None },
210 onclick: move |_| {
211 if let Some(handler) = clickable.clone() {
212 handler.call(());
213 }
214 },
215
216 {content}
217 }
218 }
219}
220
221#[derive(Props, Clone, PartialEq)]
223pub struct StepConnectorProps {
224 #[props(default = true)]
226 pub horizontal: bool,
227 #[props(default)]
229 pub completed: bool,
230 #[props(default)]
232 pub color: Option<String>,
233 #[props(default = "2px".to_string())]
235 pub thickness: String,
236}
237
238#[component]
240pub fn StepConnector(props: StepConnectorProps) -> Element {
241 let color = props.color.clone().unwrap_or_else(|| {
242 if props.completed {
243 "#22c55e".to_string()
244 } else {
245 "#e2e8f0".to_string()
246 }
247 });
248 let thickness_val = props.thickness.clone();
249
250 if props.horizontal {
251 rsx! {
252 div {
253 style: "
254 flex: 1;
255 height: {thickness_val};
256 background: {color};
257 min-width: 24px;
258 transition: background 200ms ease;
259 ",
260 role: "separator",
261 aria_orientation: "horizontal",
262 }
263 }
264 } else {
265 rsx! {
266 div {
267 style: "
268 width: {thickness_val};
269 flex: 1;
270 background: {color};
271 min-height: 24px;
272 margin-left: 15px;
273 transition: background 200ms ease;
274 ",
275 role: "separator",
276 aria_orientation: "vertical",
277 }
278 }
279 }
280}
281
282#[derive(Props, Clone, PartialEq)]
284pub struct StepLabelProps {
285 pub label: String,
287 #[props(default)]
289 pub description: Option<String>,
290 #[props(default)]
292 pub state: StepState,
293 #[props(default)]
295 pub size: StepSize,
296}
297
298#[component]
300pub fn StepLabel(props: StepLabelProps) -> Element {
301 let label_color = if props.state == StepState::Active {
302 "#0f172a"
303 } else {
304 "#64748b"
305 };
306 let font_size_val = match props.size {
307 StepSize::Sm => "13px",
308 StepSize::Md => "14px",
309 StepSize::Lg => "16px",
310 };
311 let desc_size_val = match props.size {
312 StepSize::Sm => "11px",
313 StepSize::Md => "12px",
314 StepSize::Lg => "13px",
315 };
316 let weight_val = if props.state == StepState::Active {
317 "600"
318 } else {
319 "500"
320 };
321
322 rsx! {
323 div {
324 style: "display: flex; flex-direction: column; gap: 2px;",
325
326 span {
327 style: "
328 font-size: {font_size_val};
329 font-weight: {weight_val};
330 color: {label_color};
331 white-space: nowrap;
332 ",
333 "{props.label}"
334 }
335
336 if let Some(desc) = props.description.clone() {
337 span {
338 style: "
339 font-size: {desc_size_val};
340 color: #94a3b8;
341 white-space: nowrap;
342 ",
343 "{desc}"
344 }
345 }
346 }
347 }
348}