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}