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>` - Per-field validator map. Each
139    ///   validator is a closure that takes the current value and returns
140    ///   `Some(error_message)` or `None`.
141    ///
142    /// # Returns
143    ///
144    /// - `bool` - A boolean.
145    pub fn validate(&self, validators: &HashMap<&'static str, Validator>) -> bool {
146        let values: HashMap<&'static str, String> = self.get_values().get();
147        let mut next_errors: HashMap<&'static str, String> = HashMap::new();
148        let mut all_valid: bool = true;
149        for (name, validator) in validators.iter() {
150            let current_value: &str = values.get(name).map(String::as_str).unwrap_or("");
151            if let Some(error_message) = validator(current_value) {
152                if !error_message.is_empty() {
153                    all_valid = false;
154                }
155                next_errors.insert(name, error_message);
156            }
157        }
158        self.get_errors().set(next_errors);
159        all_valid
160    }
161
162    /// Runs the user-supplied submit handler if all
163    /// validators pass.
164    ///
165    /// Sets `submitting` to `true` for the duration of the
166    /// call (so a `disabled={state.get_submitting().get()}`
167    /// button stays disabled until the handler returns),
168    /// then resets it to `false`. If validators were
169    /// supplied AND at least one field failed validation,
170    /// the submit handler is NOT invoked and `submitting`
171    /// is left `false`.
172    ///
173    /// Returns `true` if the handler was invoked,
174    /// `false` if validation failed and the handler was
175    /// skipped.
176    ///
177    /// # Arguments
178    ///
179    /// - `&HashMap<&'static str, Validator>` - Validators to run before
180    ///   invoking the handler. Pass an empty map to skip validation entirely
181    ///   (the handler always runs).
182    /// - `F` - The submit handler. Receives the current values map by
183    ///   reference — clone what you need to keep past the call.
184    ///
185    /// # Returns
186    ///
187    /// - `bool` - A boolean.
188    pub fn submit<F>(&self, validators: &HashMap<&'static str, Validator>, on_submit: F) -> bool
189    where
190        F: FnOnce(&HashMap<&'static str, String>),
191    {
192        let all_valid: bool = if validators.is_empty() {
193            true
194        } else {
195            self.validate(validators)
196        };
197        if !all_valid {
198            return false;
199        }
200        self.get_submitting().set(true);
201        let snapshot: HashMap<&'static str, String> = self.get_values().get();
202        on_submit(&snapshot);
203        self.get_submitting().set(false);
204        true
205    }
206
207    /// Clears values, errors, and touched state. Leaves
208    /// `submitting` untouched (it should already be
209    /// `false`).
210    ///
211    /// Useful for "form submitted successfully, reset for
212    /// the next entry" UX flows.
213    pub fn reset(&self) {
214        self.get_values().set(HashMap::new());
215        self.get_errors().set(HashMap::new());
216        self.get_touched().set(HashSet::new());
217    }
218
219    /// Returns the number of fields that currently have
220    /// a non-empty error. Useful for "submit button stays
221    /// disabled until form is valid" without re-running
222    /// validation.
223    ///
224    /// # Returns
225    ///
226    /// - `usize` - Count of currently-registered errors.
227    pub fn error_count(&self) -> usize {
228        self.get_errors()
229            .get()
230            .values()
231            .filter(|message: &&String| !message.is_empty())
232            .count()
233    }
234}