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}