Skip to main content

ferrox_front_ui/
form_engine.rs

1//! # Reactive Form State & Validation Engine (`form_engine`)
2//!
3//! Provides React Hook Form + Zod / Superforms parity for Rust WebAssembly:
4//! - Reactive `FormField<T>` tracking `value`, `error`, `touched`, `dirty`, `is_valid` signals.
5//! - Chainable `RuleBuilder<T>` supporting `required`, `email`, `min_length`, `max_length`, `numeric_range`, `regex`, and `custom` rules.
6//! - Automatic WCAG 2.1 AA compliant input rendering with accessible ARIA attributes (`aria-invalid`, `aria-describedby`),
7//!   `:focus-visible` ring indicators, and SVG alert icons.
8//! - Multi-step `FormWizard` component with step-gated validation preventing premature advancement.
9
10use std::rc::Rc;
11use ferrox_front_core::*;
12use wasm_bindgen::JsCast;
13use web_sys::{HtmlInputElement, Event};
14
15/// Type alias for validation rule functions.
16pub type ValidationRule<T> = Rc<dyn Fn(&T) -> Result<(), String>>;
17
18/// Chainable rule builder for form fields.
19pub struct RuleBuilder<T> {
20    rules: Vec<ValidationRule<T>>,
21}
22
23impl<T: 'static> RuleBuilder<T> {
24    /// Creates a new empty RuleBuilder.
25    pub fn new() -> Self {
26        Self { rules: Vec::new() }
27    }
28
29    /// Appends a custom rule callback returning `Ok(())` or `Err(String)`.
30    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    /// Finalizes and returns the vector of validation rules.
39    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    /// Asserts that a string field is not empty or whitespace only.
52    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    /// Validates an email address via RFC-compliant RegExp or pattern match.
65    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(()); // Use required() for empty check
71            }
72            // Use js_sys::RegExp in WebAssembly for zero-dependency high-accuracy regex
73            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    /// Enforces a minimum character length.
87    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    /// Enforces a maximum character length.
100    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    /// Validates a numeric range parsed from string.
113    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    /// Validates against a custom Regular Expression pattern string.
125    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/// A reactive form field containing independent state signals.
141#[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    /// Creates a new FormField with initial value and validation rules.
160    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    /// Sets the placeholder text for this field.
186    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
187        self.placeholder = placeholder.into();
188        self
189    }
190
191    /// Sets the helper text displayed below the field.
192    pub fn with_helper_text(mut self, helper: impl Into<String>) -> Self {
193        self.helper_text = helper.into();
194        self
195    }
196
197    /// Sets the HTML input type (e.g. "text", "email", "password", "number").
198    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    /// Sets the current field value, marks dirty flag, and triggers validation.
204    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    /// Marks the field as touched (focused and blurred).
212    pub fn touch(&self) {
213        self.touched.set(true);
214        self.validate();
215    }
216
217    /// Validates the current value against all configured rules.
218    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(&current_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    /// Resets the field to its initial value, clearing error and dirty states.
233    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    /// Renders an enterprise WCAG 2.1 AA compliant text/email/password/number input group.
244    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        // Accessible class computation
256        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", &current_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        // Bind reactive input event
297        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        // Bind blur event for touched state
307        let self_blur = self.clone();
308        input_elem = input_elem.on("blur", move |_: Event| {
309            self_blur.touch();
310        });
311
312        // Status badge / Icon
313        let mut status_icon = div().class("absolute right-3 top-3 pointer-events-none");
314        if has_error {
315            // Warning SVG exclamation
316            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            // Success checkmark SVG
334            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        // Label
349        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        // Input wrapper
355        let input_wrapper = div()
356            .class("relative flex items-center")
357            .child(input_elem)
358            .child(status_icon);
359
360        // Group container
361        let mut group = div()
362            .class("ferrox-form-group flex flex-col mb-4")
363            .child(label_elem)
364            .child(input_wrapper);
365
366        // Render Error or Helper message
367        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/// Step definition for `FormWizard`.
403#[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/// Reactive State coordinator for multi-step form wizards.
431#[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    /// Attempts to advance to the next step, executing current step's validator.
451    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    /// Moves back to the previous step without requiring validation.
469    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    /// Checks if a step index is currently active.
478    pub fn is_current(&self, step_idx: usize) -> bool {
479        self.current_step.get() == step_idx
480    }
481
482    /// Checks if a step index is completed.
483    pub fn is_finished(&self, step_idx: usize) -> bool {
484        self.current_step.get() > step_idx || self.is_completed.get()
485    }
486}
487
488/// Renders a multi-step FormWizard with step indicators, reactive validation gating, and navigation controls.
489pub 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    // 1. Wizard Header / Stepper Progress
498    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            // Checkmark
514            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    // 2. Active Step Content Container
549    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    // 3. Navigation Controls / Action Footer
587    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}