1use std::rc::Rc;
11use ferrox_front_core::*;
12use wasm_bindgen::JsCast;
13use web_sys::{HtmlInputElement, Event};
14
15pub type ValidationRule<T> = Rc<dyn Fn(&T) -> Result<(), String>>;
17
18pub struct RuleBuilder<T> {
20 rules: Vec<ValidationRule<T>>,
21}
22
23impl<T: 'static> RuleBuilder<T> {
24 pub fn new() -> Self {
26 Self { rules: Vec::new() }
27 }
28
29 pub fn custom<F>(mut self, f: F) -> Self
31 where
32 F: Fn(&T) -> Result<(), String> + 'static,
33 {
34 self.rules.push(Rc::new(f));
35 self
36 }
37
38 pub fn build(self) -> Vec<ValidationRule<T>> {
40 self.rules
41 }
42}
43
44impl Default for RuleBuilder<String> {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50impl RuleBuilder<String> {
51 pub fn required(mut self, err_msg: &str) -> Self {
53 let msg = err_msg.to_string();
54 self.rules.push(Rc::new(move |val: &String| {
55 if val.trim().is_empty() {
56 Err(msg.clone())
57 } else {
58 Ok(())
59 }
60 }));
61 self
62 }
63
64 pub fn email(mut self, err_msg: &str) -> Self {
66 let msg = err_msg.to_string();
67 self.rules.push(Rc::new(move |val: &String| {
68 let s = val.trim();
69 if s.is_empty() {
70 return Ok(()); }
72 let regex = js_sys::RegExp::new(
74 r#"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$"#,
75 "",
76 );
77 if regex.test(s) {
78 Ok(())
79 } else {
80 Err(msg.clone())
81 }
82 }));
83 self
84 }
85
86 pub fn min_length(mut self, min: usize, err_msg: &str) -> Self {
88 let msg = err_msg.to_string();
89 self.rules.push(Rc::new(move |val: &String| {
90 if val.chars().count() < min {
91 Err(msg.clone())
92 } else {
93 Ok(())
94 }
95 }));
96 self
97 }
98
99 pub fn max_length(mut self, max: usize, err_msg: &str) -> Self {
101 let msg = err_msg.to_string();
102 self.rules.push(Rc::new(move |val: &String| {
103 if val.chars().count() > max {
104 Err(msg.clone())
105 } else {
106 Ok(())
107 }
108 }));
109 self
110 }
111
112 pub fn numeric_range(mut self, min: f64, max: f64, err_msg: &str) -> Self {
114 let msg = err_msg.to_string();
115 self.rules.push(Rc::new(move |val: &String| {
116 match val.trim().parse::<f64>() {
117 Ok(n) if n >= min && n <= max => Ok(()),
118 _ => Err(msg.clone()),
119 }
120 }));
121 self
122 }
123
124 pub fn regex(mut self, pattern: &str, err_msg: &str) -> Self {
126 let msg = err_msg.to_string();
127 let pat = pattern.to_string();
128 self.rules.push(Rc::new(move |val: &String| {
129 let re = js_sys::RegExp::new(&pat, "");
130 if re.test(val) {
131 Ok(())
132 } else {
133 Err(msg.clone())
134 }
135 }));
136 self
137 }
138}
139
140#[derive(Clone)]
142pub struct FormField<T: Clone + PartialEq + 'static> {
143 pub id: String,
144 pub name: String,
145 pub label: String,
146 pub placeholder: String,
147 pub helper_text: String,
148 pub input_type: String,
149 pub value: Signal<T>,
150 pub error: Signal<Option<String>>,
151 pub touched: Signal<bool>,
152 pub dirty: Signal<bool>,
153 pub is_valid: Signal<bool>,
154 rules: Vec<ValidationRule<T>>,
155 initial_value: T,
156}
157
158impl<T: Clone + PartialEq + 'static> FormField<T> {
159 pub fn new(
161 id: impl Into<String>,
162 name: impl Into<String>,
163 label: impl Into<String>,
164 initial_value: T,
165 rules: Vec<ValidationRule<T>>,
166 ) -> Self {
167 let id_str = id.into();
168 Self {
169 id: id_str,
170 name: name.into(),
171 label: label.into(),
172 placeholder: String::new(),
173 helper_text: String::new(),
174 input_type: "text".to_string(),
175 value: Signal::new(initial_value.clone()),
176 error: Signal::new(None),
177 touched: Signal::new(false),
178 dirty: Signal::new(false),
179 is_valid: Signal::new(true),
180 rules,
181 initial_value,
182 }
183 }
184
185 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
187 self.placeholder = placeholder.into();
188 self
189 }
190
191 pub fn with_helper_text(mut self, helper: impl Into<String>) -> Self {
193 self.helper_text = helper.into();
194 self
195 }
196
197 pub fn with_input_type(mut self, input_type: impl Into<String>) -> Self {
199 self.input_type = input_type.into();
200 self
201 }
202
203 pub fn set_value(&self, val: T) {
205 let dirty = val != self.initial_value;
206 self.dirty.set(dirty);
207 self.value.set(val);
208 self.validate();
209 }
210
211 pub fn touch(&self) {
213 self.touched.set(true);
214 self.validate();
215 }
216
217 pub fn validate(&self) -> bool {
219 let current_val = self.value.get();
220 for rule in &self.rules {
221 if let Err(err_msg) = rule(¤t_val) {
222 self.error.set(Some(err_msg));
223 self.is_valid.set(false);
224 return false;
225 }
226 }
227 self.error.set(None);
228 self.is_valid.set(true);
229 true
230 }
231
232 pub fn reset(&self) {
234 self.value.set(self.initial_value.clone());
235 self.error.set(None);
236 self.touched.set(false);
237 self.dirty.set(false);
238 self.is_valid.set(true);
239 }
240}
241
242impl FormField<String> {
243 pub fn render(&self) -> DomBuilder {
245 let field_id = self.id.clone();
246 let error_id = format!("{}-error", field_id);
247 let helper_id = format!("{}-helper", field_id);
248
249 let current_val = self.value.get();
250 let current_err = self.error.get();
251 let is_touched = self.touched.get();
252 let has_error = current_err.is_some() && is_touched;
253 let is_success = is_touched && self.dirty.get() && current_err.is_none();
254
255 let input_classes = vec![
257 "ferrox-form-input",
258 "w-full",
259 "px-4",
260 "py-2.5",
261 "rounded-xl",
262 "text-sm",
263 "font-sans",
264 "transition-all",
265 "duration-200",
266 "outline-none",
267 ];
268
269 let inline_style = if has_error {
270 "background: rgba(239, 68, 68, 0.06); border: 1.5px solid #ef4444; color: #f8fafc; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);"
271 } else if is_success {
272 "background: rgba(16, 185, 129, 0.06); border: 1.5px solid #10b981; color: #f8fafc; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);"
273 } else {
274 "background: rgba(15, 23, 42, 0.65); border: 1.5px solid rgba(255, 255, 255, 0.12); color: #f8fafc;"
275 };
276
277 let described_by = if has_error {
278 error_id.clone()
279 } else if !self.helper_text.is_empty() {
280 helper_id.clone()
281 } else {
282 "".to_string()
283 };
284
285 let mut input_elem = input()
286 .id(&field_id)
287 .attr("name", &self.name)
288 .attr("type", &self.input_type)
289 .attr("value", ¤t_val)
290 .attr("placeholder", &self.placeholder)
291 .attr("aria-invalid", if has_error { "true" } else { "false" })
292 .attr("aria-describedby", &described_by)
293 .class(&input_classes.join(" "))
294 .style_attr(inline_style);
295
296 let self_clone = self.clone();
298 input_elem = input_elem.on_input(move |e: Event| {
299 if let Some(target) = e.target() {
300 if let Ok(input_el) = target.dyn_into::<HtmlInputElement>() {
301 self_clone.set_value(input_el.value());
302 }
303 }
304 });
305
306 let self_blur = self.clone();
308 input_elem = input_elem.on("blur", move |_: Event| {
309 self_blur.touch();
310 });
311
312 let mut status_icon = div().class("absolute right-3 top-3 pointer-events-none");
314 if has_error {
315 status_icon = status_icon.child(
317 svg()
318 .attr("width", "18")
319 .attr("height", "18")
320 .attr("viewBox", "0 0 24 24")
321 .attr("fill", "none")
322 .attr("stroke", "#ef4444")
323 .attr("stroke-width", "2")
324 .attr("stroke-linecap", "round")
325 .attr("stroke-linejoin", "round")
326 .children(vec![
327 circle().attr("cx", "12").attr("cy", "12").attr("r", "10"),
328 line().attr("x1", "12").attr("y1", "8").attr("x2", "12").attr("y2", "12"),
329 line().attr("x1", "12").attr("y1", "16").attr("x2", "12.01").attr("y2", "16"),
330 ])
331 );
332 } else if is_success {
333 status_icon = status_icon.child(
335 svg()
336 .attr("width", "18")
337 .attr("height", "18")
338 .attr("viewBox", "0 0 24 24")
339 .attr("fill", "none")
340 .attr("stroke", "#10b981")
341 .attr("stroke-width", "2")
342 .attr("stroke-linecap", "round")
343 .attr("stroke-linejoin", "round")
344 .child(polyline().attr("points", "20 6 9 17 4 12"))
345 );
346 }
347
348 let label_elem = label()
350 .attr("for", &field_id)
351 .class("block text-xs font-semibold uppercase tracking-wider text-slate-300 mb-1.5")
352 .text(&self.label);
353
354 let input_wrapper = div()
356 .class("relative flex items-center")
357 .child(input_elem)
358 .child(status_icon);
359
360 let mut group = div()
362 .class("ferrox-form-group flex flex-col mb-4")
363 .child(label_elem)
364 .child(input_wrapper);
365
366 if has_error {
368 if let Some(err) = current_err {
369 let error_elem = div()
370 .id(&error_id)
371 .attr("role", "alert")
372 .class("flex items-center gap-1.5 mt-1.5 text-xs text-rose-400 font-medium")
373 .children(vec![
374 svg()
375 .attr("width", "14")
376 .attr("height", "14")
377 .attr("viewBox", "0 0 24 24")
378 .attr("fill", "none")
379 .attr("stroke", "currentColor")
380 .attr("stroke-width", "2")
381 .children(vec![
382 circle().attr("cx", "12").attr("cy", "12").attr("r", "10"),
383 line().attr("x1", "12").attr("y1", "8").attr("x2", "12").attr("y2", "12"),
384 line().attr("x1", "12").attr("y1", "16").attr("x2", "12.01").attr("y2", "16"),
385 ]),
386 span().text(&err),
387 ]);
388 group = group.child(error_elem);
389 }
390 } else if !self.helper_text.is_empty() {
391 let helper_elem = div()
392 .id(&helper_id)
393 .class("mt-1 text-xs text-slate-400")
394 .text(&self.helper_text);
395 group = group.child(helper_elem);
396 }
397
398 group
399 }
400}
401
402#[derive(Clone)]
404pub struct WizardStep {
405 pub id: String,
406 pub title: String,
407 pub subtitle: String,
408 pub validate_step: Rc<dyn Fn() -> bool>,
409 pub render_content: Rc<dyn Fn() -> DomBuilder>,
410}
411
412impl WizardStep {
413 pub fn new(
414 id: impl Into<String>,
415 title: impl Into<String>,
416 subtitle: impl Into<String>,
417 validate_step: impl Fn() -> bool + 'static,
418 render_content: impl Fn() -> DomBuilder + 'static,
419 ) -> Self {
420 Self {
421 id: id.into(),
422 title: title.into(),
423 subtitle: subtitle.into(),
424 validate_step: Rc::new(validate_step),
425 render_content: Rc::new(render_content),
426 }
427 }
428}
429
430#[derive(Clone)]
432pub struct FormWizardState {
433 pub current_step: Signal<usize>,
434 pub total_steps: usize,
435 pub steps: Vec<WizardStep>,
436 pub is_completed: Signal<bool>,
437}
438
439impl FormWizardState {
440 pub fn new(steps: Vec<WizardStep>) -> Self {
441 let total = steps.len();
442 Self {
443 current_step: Signal::new(0),
444 total_steps: total,
445 steps,
446 is_completed: Signal::new(false),
447 }
448 }
449
450 pub fn next(&self) -> bool {
452 let curr = self.current_step.get();
453 if curr < self.total_steps {
454 let step = &self.steps[curr];
455 if (step.validate_step)() {
456 if curr + 1 < self.total_steps {
457 self.current_step.set(curr + 1);
458 return true;
459 } else {
460 self.is_completed.set(true);
461 return true;
462 }
463 }
464 }
465 false
466 }
467
468 pub fn prev(&self) {
470 let curr = self.current_step.get();
471 if curr > 0 {
472 self.current_step.set(curr - 1);
473 self.is_completed.set(false);
474 }
475 }
476
477 pub fn is_current(&self, step_idx: usize) -> bool {
479 self.current_step.get() == step_idx
480 }
481
482 pub fn is_finished(&self, step_idx: usize) -> bool {
484 self.current_step.get() > step_idx || self.is_completed.get()
485 }
486}
487
488pub fn form_wizard(
490 state: FormWizardState,
491 on_complete: impl Fn() + 'static,
492) -> DomBuilder {
493 let curr = state.current_step.get();
494 let total = state.total_steps;
495 let completed = state.is_completed.get();
496
497 let mut step_nav = div().class("flex items-center justify-between mb-8 relative");
499
500 for (idx, step) in state.steps.iter().enumerate() {
501 let is_active = idx == curr && !completed;
502 let is_past = idx < curr || completed;
503
504 let badge_style = if is_active {
505 "background: linear-gradient(135deg, #3b82f6, #6366f1); color: #ffffff; box-shadow: 0 0 15px rgba(99, 102, 241, 0.5); border: 2px solid #818cf8;"
506 } else if is_past {
507 "background: #10b981; color: #ffffff; border: 2px solid #059669;"
508 } else {
509 "background: rgba(30, 41, 59, 0.8); color: #64748b; border: 2px solid rgba(255, 255, 255, 0.1);"
510 };
511
512 let badge_content = if is_past {
513 span().child(
515 svg()
516 .attr("width", "14")
517 .attr("height", "14")
518 .attr("viewBox", "0 0 24 24")
519 .attr("fill", "none")
520 .attr("stroke", "currentColor")
521 .attr("stroke-width", "3")
522 .attr("stroke-linecap", "round")
523 .attr("stroke-linejoin", "round")
524 .child(polyline().attr("points", "20 6 9 17 4 12"))
525 )
526 } else {
527 span().text(&(idx + 1).to_string())
528 };
529
530 let step_item = div()
531 .class("flex flex-col items-center z-10")
532 .children(vec![
533 div()
534 .class("w-9 h-9 rounded-full flex items-center justify-center font-bold text-sm transition-all duration-300")
535 .style_attr(badge_style)
536 .child(badge_content),
537 span()
538 .class(&format!(
539 "text-xs font-semibold mt-2 {}",
540 if is_active { "text-indigo-400" } else if is_past { "text-emerald-400" } else { "text-slate-500" }
541 ))
542 .text(&step.title),
543 ]);
544
545 step_nav = step_nav.child(step_item);
546 }
547
548 let content_area = if completed {
550 div()
551 .class("p-8 text-center flex flex-col items-center justify-center min-h-[220px]")
552 .children(vec![
553 div()
554 .class("w-16 h-16 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center mb-4 ring-8 ring-emerald-500/10")
555 .child(
556 svg()
557 .attr("width", "32")
558 .attr("height", "32")
559 .attr("viewBox", "0 0 24 24")
560 .attr("fill", "none")
561 .attr("stroke", "currentColor")
562 .attr("stroke-width", "2.5")
563 .attr("stroke-linecap", "round")
564 .child(polyline().attr("points", "20 6 9 17 4 12"))
565 ),
566 h3().class("text-xl font-bold text-white mb-2").text("Procedura Completata con Successo"),
567 p().class("text-sm text-slate-400 max-w-md")
568 .text("Tutti i dati inseriti sono stati validati in memoria Wasm con zero dipendenze esterne."),
569 ])
570 } else if let Some(active_step) = state.steps.get(curr) {
571 div()
572 .class("wizard-step-body p-6 rounded-2xl bg-slate-900/40 border border-slate-800/80 mb-6")
573 .children(vec![
574 div()
575 .class("mb-5 border-b border-slate-800/60 pb-3")
576 .children(vec![
577 h4().class("text-base font-bold text-white").text(&active_step.title),
578 p().class("text-xs text-slate-400 mt-0.5").text(&active_step.subtitle),
579 ]),
580 (active_step.render_content)(),
581 ])
582 } else {
583 div()
584 };
585
586 let state_prev = state.clone();
588 let state_next = state.clone();
589 let on_complete_rc = Rc::new(on_complete);
590
591 let prev_btn = button()
592 .class(&format!(
593 "px-5 py-2.5 rounded-xl text-xs font-semibold transition-all {}",
594 if curr > 0 && !completed {
595 "bg-slate-800 hover:bg-slate-700 text-slate-200 cursor-pointer"
596 } else {
597 "bg-slate-900 text-slate-600 cursor-not-allowed opacity-50"
598 }
599 ))
600 .text("← Precedente")
601 .on_click(move || {
602 state_prev.prev();
603 });
604
605 let is_last_step = curr + 1 == total;
606 let next_btn_text = if is_last_step { "Conferma & Invia ✓" } else { "Successivo →" };
607
608 let next_btn = button()
609 .class(&format!(
610 "px-6 py-2.5 rounded-xl text-xs font-semibold transition-all shadow-lg {}",
611 if completed {
612 "bg-slate-800 text-slate-500 cursor-not-allowed opacity-50"
613 } else if is_last_step {
614 "bg-emerald-600 hover:bg-emerald-500 text-white cursor-pointer shadow-emerald-900/30"
615 } else {
616 "bg-indigo-600 hover:bg-indigo-500 text-white cursor-pointer shadow-indigo-900/30"
617 }
618 ))
619 .text(next_btn_text)
620 .on_click(move || {
621 if !completed {
622 if state_next.next() && is_last_step {
623 on_complete_rc();
624 }
625 }
626 });
627
628 let footer_actions = div()
629 .class("flex items-center justify-between pt-4 border-t border-slate-800/80")
630 .children(vec![prev_btn, next_btn]);
631
632 div()
633 .class("ferrox-form-wizard w-full max-w-2xl mx-auto p-6 rounded-3xl bg-slate-950/80 border border-slate-800/80 shadow-2xl backdrop-blur-xl")
634 .children(vec![step_nav, content_area, footer_actions])
635}