Skip to main content

euv_ui/hook/form/
impl.rs

1use super::*;
2
3/// Implements [`HookContextFormExt`] for [`HookContext`].
4impl HookContextFormExt for HookContext {
5    /// Returns a fresh [`FormState`] bound to the current component scope.
6    ///
7    /// # Returns
8    ///
9    /// - `FormState` - A `FormState` value.
10    fn form() -> FormState {
11        HookContext::use_hook(|| {
12            FormState::new(
13                Signal::create(HashMap::new()),
14                Signal::create(HashMap::new()),
15                Signal::create(HashSet::new()),
16                Signal::create(false),
17            )
18        })
19    }
20}
21
22/// Inherent implementation of [`FormState`].
23impl FormState {
24    /// Returns the current value of the named field, or
25    /// `""` if the field has never been set.
26    ///
27    /// This is a snapshot read, not a subscription —
28    /// callers inside a render closure that want to
29    /// re-render on value changes should use
30    /// `state.get_values().get().get(name).cloned().unwrap_or_default()`
31    /// instead, so the closure actually subscribes.
32    ///
33    /// # Arguments
34    ///
35    /// - `&'static str` - Shared reference to a `'static str`.
36    ///
37    /// # Returns
38    ///
39    /// - `String` - A `String` value.
40    pub fn field(&self, name: &'static str) -> String {
41        self.get_values()
42            .get()
43            .get(name)
44            .cloned()
45            .unwrap_or_default()
46    }
47
48    /// Returns the current error for the named field, or
49    /// `""` if the field has no error.
50    ///
51    /// Snapshot read — see `field` for the subscription
52    /// caveat.
53    ///
54    /// # Arguments
55    ///
56    /// - `&'static str` - Shared reference to a `'static str`.
57    ///
58    /// # Returns
59    ///
60    /// - `String` - A `String` value.
61    pub fn error(&self, name: &'static str) -> String {
62        self.get_errors()
63            .get()
64            .get(name)
65            .cloned()
66            .unwrap_or_default()
67    }
68
69    /// Returns `true` if the user has interacted with the
70    /// named field.
71    ///
72    /// # Arguments
73    ///
74    /// - `&'static str` - Field name.
75    ///
76    /// # Returns
77    ///
78    /// - `bool` - `true` when the field has been touched.
79    pub fn is_touched(&self, name: &'static str) -> bool {
80        self.get_touched().get().contains(name)
81    }
82
83    /// Sets the value of the named field.
84    ///
85    /// Marks the field as touched (mirroring the
86    /// `oninput` event that triggered the call) and
87    /// clears any prior error for the field. The error
88    /// clear is a UX choice — the next `validate` call
89    /// will repopulate it if the new value is still
90    /// invalid.
91    ///
92    /// # Arguments
93    ///
94    /// - `&'static str` - Shared reference to a `'static str`.
95    /// - `&str` - Shared reference to a `str`.
96    pub fn set_field(&self, name: &'static str, value: &str) {
97        let mut current: HashMap<&'static str, String> = self.get_values().get();
98        current.insert(name, value.to_string());
99        self.get_values().set(current);
100
101        let mut touched: HashSet<&'static str> = self.get_touched().get();
102        touched.insert(name);
103        self.get_touched().set(touched);
104
105        let mut errors: HashMap<&'static str, String> = self.get_errors().get();
106        errors.remove(name);
107        self.get_errors().set(errors);
108    }
109
110    /// Marks the named field as touched without changing
111    /// its value. Used by `onblur` handlers — "the user
112    /// left this field, so it counts as interacted".
113    ///
114    /// # Arguments
115    ///
116    /// - `&'static str` - Shared reference to a `'static str`.
117    pub fn touch(&self, name: &'static str) {
118        let mut touched: HashSet<&'static str> = self.get_touched().get();
119        touched.insert(name);
120        self.get_touched().set(touched);
121    }
122
123    /// Runs every validator in `validators` and updates the
124    /// `errors` signal.
125    ///
126    /// Returns `true` if every field validated
127    /// successfully (i.e. every validator returned
128    /// `None`), `false` otherwise. The errors signal is
129    /// always updated, regardless of return value —
130    /// callers should call `validate` and then branch on
131    /// the boolean.
132    ///
133    /// Fields with no validator are silently skipped —
134    /// they cannot produce an error.
135    ///
136    /// # Arguments
137    ///
138    /// - `&HashMap<&'static str, Validator>` -
139    ///   Per-field validator map. Each validator is a
140    ///   closure that takes the current value and
141    ///   returns `Some(error_message)` or `None`.
142    ///
143    /// # Returns
144    ///
145    /// - `bool` - A boolean.
146    pub fn validate(&self, validators: &HashMap<&'static str, Validator>) -> bool {
147        let values: HashMap<&'static str, String> = self.get_values().get();
148        let mut next_errors: HashMap<&'static str, String> = HashMap::new();
149        let mut all_valid: bool = true;
150        for (name, validator) in validators.iter() {
151            let current_value: &str = values.get(name).map(String::as_str).unwrap_or("");
152            if let Some(error_message) = validator(current_value) {
153                if !error_message.is_empty() {
154                    all_valid = false;
155                }
156                next_errors.insert(name, error_message);
157            }
158        }
159        self.get_errors().set(next_errors);
160        all_valid
161    }
162
163    /// Runs the user-supplied submit handler if all
164    /// validators pass.
165    ///
166    /// Sets `submitting` to `true` for the duration of the
167    /// call (so a `disabled={state.get_submitting().get()}`
168    /// button stays disabled until the handler returns),
169    /// then resets it to `false`. If validators were
170    /// supplied AND at least one field failed validation,
171    /// the submit handler is NOT invoked and `submitting`
172    /// is left `false`.
173    ///
174    /// Returns `true` if the handler was invoked,
175    /// `false` if validation failed and the handler was
176    /// skipped.
177    ///
178    /// # Arguments
179    ///
180    /// - `&HashMap<&'static str, Validator>` -
181    ///   Validators to run before invoking the handler.
182    ///   Pass an empty map to skip validation entirely
183    ///   (the handler always runs).
184    /// - `impl FnOnce(&HashMap<&'static str, String>)` -
185    ///   The submit handler. Receives the current values
186    ///   map by reference — clone what you need to keep
187    ///   past the call.
188    ///
189    /// # Returns
190    ///
191    /// - `bool` - A boolean.
192    pub fn submit<F>(&self, validators: &HashMap<&'static str, Validator>, on_submit: F) -> bool
193    where
194        F: FnOnce(&HashMap<&'static str, String>),
195    {
196        let all_valid: bool = if validators.is_empty() {
197            true
198        } else {
199            self.validate(validators)
200        };
201        if !all_valid {
202            return false;
203        }
204        self.get_submitting().set(true);
205        let snapshot: HashMap<&'static str, String> = self.get_values().get();
206        on_submit(&snapshot);
207        self.get_submitting().set(false);
208        true
209    }
210
211    /// Clears values, errors, and touched state. Leaves
212    /// `submitting` untouched (it should already be
213    /// `false`).
214    ///
215    /// Useful for "form submitted successfully, reset for
216    /// the next entry" UX flows.
217    pub fn reset(&self) {
218        self.get_values().set(HashMap::new());
219        self.get_errors().set(HashMap::new());
220        self.get_touched().set(HashSet::new());
221    }
222
223    /// Returns the number of fields that currently have
224    /// a non-empty error. Useful for "submit button stays
225    /// disabled until form is valid" without re-running
226    /// validation.
227    ///
228    /// # Returns
229    ///
230    /// - `usize` - Count of currently-registered errors.
231    pub fn error_count(&self) -> usize {
232        self.get_errors()
233            .get()
234            .values()
235            .filter(|message: &&String| !message.is_empty())
236            .count()
237    }
238}