rdom_core/constraint.rs
1//! Constraint-validation candidacy (HTML §4.10.20.1) — which elements
2//! take part in constraint validation at all. Names, attributes and tree
3//! shape only; the validity *states* need values and patterns and live
4//! in the backend (rdom-tui's `validation` builtin).
5//!
6//! - [`Dom::will_validate`] — a submittable element that is not *barred
7//! from constraint validation*: the `willValidate` IDL attribute.
8//! - [`Dom::constraint_validity`] — what `:valid` / `:invalid` match
9//! (HTML §4.16.3), with the per-candidate verdict delegated to the
10//! backend's [`ValidityHook`].
11//! - [`Dom::is_required_control`] / [`Dom::is_optional_control`] — what
12//! `:required` / `:optional` match.
13
14use crate::dom::Dom;
15use crate::input_type::InputTypeState;
16use crate::node_id::NodeId;
17
18/// A backend's constraint check: whether candidate `id` satisfies its
19/// constraints. The validity states need values, patterns and state the
20/// substrate does not model (rdom-tui's `validation` builtin computes
21/// them), so the substrate asks the backend through this hook — a plain
22/// `fn`, so matching (`&self`) can call it. Installed with
23/// [`Dom::set_validity_hook`].
24pub type ValidityHook<Ext> = fn(&Dom<Ext>, NodeId) -> bool;
25
26/// Storage for the hook with a `Debug` impl.
27pub(crate) struct ValiditySlot<Ext: 'static>(pub(crate) Option<ValidityHook<Ext>>);
28
29impl<Ext: 'static> std::fmt::Debug for ValiditySlot<Ext> {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.write_str(if self.0.is_some() {
32 "ValiditySlot(Some(hook))"
33 } else {
34 "ValiditySlot(None)"
35 })
36 }
37}
38
39impl<Ext: 'static> Dom<Ext> {
40 /// Install (or remove, with `None`) the backend's constraint check
41 /// behind `:valid` / `:invalid` and [`constraint_validity`](Self::constraint_validity).
42 ///
43 /// **A `Dom` has no hook until one is installed, and without one
44 /// every candidate is valid.** rdom-core cannot compute validity
45 /// states itself (they need values, patterns and a custom validity),
46 /// so an unhooked `Dom` answers "valid" for every candidate — an
47 /// `<input>`, `<select>`, `<textarea>` or submit button, and so a
48 /// `<form>` / `<fieldset>` holding one: `:valid` matches, `:invalid`
49 /// never does, whatever `required` / `pattern` say. The answer is
50 /// the same in debug and release builds, and nothing is logged
51 /// (rdom-core has no warning channel). rdom-tui's `App` installs its
52 /// hook at construction; a bare `TuiDom` cascaded without an `App`
53 /// must call `rdom_tui::runtime::builtins::validation::install`, and
54 /// a backend-less `Dom` that matches these pseudo-classes installs
55 /// its own.
56 pub fn set_validity_hook(&mut self, hook: Option<ValidityHook<Ext>>) {
57 self.validity_hook = ValiditySlot(hook);
58 }
59
60 /// What `:valid` / `:invalid` match (HTML §4.16.3):
61 ///
62 /// - a [candidate](Self::will_validate): `Some(verdict)` of the
63 /// validity hook ([`set_validity_hook`](Self::set_validity_hook));
64 /// `Some(true)` without one, in every build;
65 /// - a `<form>`: `Some(false)` when a candidate it owns
66 /// ([`form_listed_elements`](Self::form_listed_elements)) is
67 /// invalid, else `Some(true)`;
68 /// - a `<fieldset>`: the same over its descendant candidates;
69 /// - anything else, barred controls included: `None` (neither).
70 pub fn constraint_validity(&self, id: NodeId) -> Option<bool> {
71 let tag = self.get_node(id)?.tag_name()?;
72 match tag {
73 "form" => Some(
74 !self
75 .form_listed_elements(id)
76 .into_iter()
77 .any(|c| self.suffers(c)),
78 ),
79 "fieldset" => Some(!self.any_descendant_suffers(id)),
80 _ if self.will_validate(id) => Some(self.satisfies(id)),
81 _ => None,
82 }
83 }
84
85 /// Whether `id` matches `:required` (HTML §4.16.3): an `<input>` in
86 /// a state the `required` attribute applies to (not hidden, range,
87 /// color or the button types), a `<select>` or a `<textarea>`, with
88 /// `required`.
89 pub fn is_required_control(&self, id: NodeId) -> bool {
90 self.required_applies(id) && self.has_attribute(id, "required")
91 }
92
93 /// Whether the `required` attribute applies to `id` (HTML §4.10.5.3.4
94 /// and the per-state lists of §4.10.5.1): a `<select>`, a
95 /// `<textarea>`, or an `<input>` in any state but hidden, range,
96 /// color and the button types — the one list behind `:required` /
97 /// `:optional` and a backend's `valueMissing` check.
98 pub fn required_applies(&self, id: NodeId) -> bool {
99 use InputTypeState as T;
100 match self.get_node(id).and_then(|n| n.tag_name()) {
101 Some("select" | "textarea") => true,
102 Some("input") => !matches!(
103 self.input_type_state(id),
104 Some(T::Hidden | T::Range | T::Color | T::Submit | T::Image | T::Reset | T::Button)
105 ),
106 _ => false,
107 }
108 }
109
110 /// Whether `id` matches `:optional`: an `<input>`, `<select>` or
111 /// `<textarea>` that is not [required](Self::is_required_control).
112 pub fn is_optional_control(&self, id: NodeId) -> bool {
113 matches!(
114 self.get_node(id).and_then(|n| n.tag_name()),
115 Some("input" | "select" | "textarea")
116 ) && !self.is_required_control(id)
117 }
118
119 /// The hook's verdict for candidate `id`; "valid" without a hook
120 /// (`set_validity_hook`), in every build (`P7G-PUBLIC-SURFACE-2`).
121 fn satisfies(&self, id: NodeId) -> bool {
122 self.validity_hook.0.is_none_or(|hook| hook(self, id))
123 }
124
125 /// A candidate that does not satisfy its constraints.
126 fn suffers(&self, id: NodeId) -> bool {
127 self.will_validate(id) && !self.satisfies(id)
128 }
129
130 fn any_descendant_suffers(&self, id: NodeId) -> bool {
131 let mut stack: Vec<NodeId> = Vec::new();
132 let push_children = |stack: &mut Vec<NodeId>, of: NodeId| {
133 let mut c = self.get_node(of).and_then(|n| n.first_child);
134 while let Some(cid) = c {
135 stack.push(cid);
136 c = self.get_node(cid).and_then(|n| n.next_sibling);
137 }
138 };
139 push_children(&mut stack, id);
140 while let Some(n) = stack.pop() {
141 if self.suffers(n) {
142 return true;
143 }
144 push_children(&mut stack, n);
145 }
146 false
147 }
148}
149
150impl<Ext> Dom<Ext> {
151 /// Whether `id` is a *candidate for constraint validation* (HTML
152 /// §4.10.20.1, the `willValidate` IDL attribute): a submittable
153 /// element (`<button>`, `<input>`, `<select>`, `<textarea>`) that is
154 /// not barred. Barred are:
155 ///
156 /// - an [actually disabled](Self::is_actually_disabled) control
157 /// (own `disabled`, or a disabled `<fieldset>`);
158 /// - a control with a `<datalist>` ancestor;
159 /// - a `<button>` that is not a submit button;
160 /// - an `<input>` in the Hidden, Reset or Button state;
161 /// - an `<input>` with `readonly` in a state `readonly` applies to
162 /// (the text, date / time and number states — not a checkbox), and
163 /// a `<textarea readonly>`.
164 ///
165 /// `<object>`, `<output>` and `<fieldset>` are never candidates.
166 pub fn will_validate(&self, id: NodeId) -> bool {
167 let Some(tag) = self.get_node(id).and_then(|n| n.tag_name()) else {
168 return false;
169 };
170 let submittable_and_not_self_barred = match tag {
171 "button" => self.is_submit_button(id),
172 "input" => {
173 use InputTypeState as T;
174 match self.input_type_state(id) {
175 Some(T::Hidden | T::Reset | T::Button) | None => false,
176 Some(t) => !(readonly_applies(t) && self.has_attribute(id, "readonly")),
177 }
178 }
179 "select" => true,
180 "textarea" => !self.has_attribute(id, "readonly"),
181 _ => false,
182 };
183 submittable_and_not_self_barred
184 && !self.is_actually_disabled(id)
185 && !self.has_datalist_ancestor(id)
186 }
187
188 fn has_datalist_ancestor(&self, id: NodeId) -> bool {
189 let mut cur = self.parent_element_id(id);
190 while let Some(a) = cur {
191 if self.tag_is(a, "datalist") {
192 return true;
193 }
194 cur = self.parent_element_id(a);
195 }
196 false
197 }
198}
199
200/// The `<input>` states the `readonly` attribute applies to (HTML
201/// §4.10.5, the "readonly" row of the attribute table).
202fn readonly_applies(t: InputTypeState) -> bool {
203 use InputTypeState as T;
204 matches!(
205 t,
206 T::Text
207 | T::Search
208 | T::Url
209 | T::Tel
210 | T::Email
211 | T::Password
212 | T::Date
213 | T::Month
214 | T::Week
215 | T::Time
216 | T::DateTimeLocal
217 | T::Number
218 )
219}
220
221#[cfg(test)]
222mod tests {
223 use crate::Dom;
224 use crate::node_id::NodeId;
225
226 fn el(dom: &mut Dom, parent: NodeId, tag: &str, attrs: &[(&str, &str)]) -> NodeId {
227 let e = dom.create_element(tag);
228 for (k, v) in attrs {
229 dom.set_attribute(e, k, v).unwrap();
230 }
231 dom.append_child(parent, e).unwrap();
232 e
233 }
234
235 #[test]
236 fn candidates_are_the_submittable_controls_that_are_not_barred() {
237 let mut dom: Dom = Dom::new();
238 let root = dom.root();
239 for (tag, attrs, candidate) in [
240 ("input", vec![], true),
241 ("input", vec![("type", "checkbox")], true),
242 ("input", vec![("type", "Submit")], true),
243 ("input", vec![("type", "hidden")], false),
244 ("input", vec![("type", "reset")], false),
245 ("input", vec![("type", "button")], false),
246 ("button", vec![], true),
247 ("button", vec![("type", "button")], false),
248 ("button", vec![("type", "reset")], false),
249 ("select", vec![], true),
250 ("textarea", vec![], true),
251 ("fieldset", vec![], false),
252 ("output", vec![], false),
253 ("object", vec![], false),
254 ("form", vec![], false),
255 ("div", vec![], false),
256 ] {
257 let id = el(&mut dom, root, tag, &attrs);
258 assert_eq!(dom.will_validate(id), candidate, "{tag} {attrs:?}");
259 }
260 }
261
262 #[test]
263 fn disabled_readonly_and_datalist_controls_are_barred() {
264 let mut dom: Dom = Dom::new();
265 let root = dom.root();
266 let disabled = el(&mut dom, root, "input", &[("disabled", "")]);
267 let fs = el(&mut dom, root, "fieldset", &[("disabled", "")]);
268 let in_fs = el(&mut dom, fs, "select", &[]);
269 let ro_text = el(&mut dom, root, "input", &[("readonly", "")]);
270 let ro_area = el(&mut dom, root, "textarea", &[("readonly", "")]);
271 let ro_box = el(
272 &mut dom,
273 root,
274 "input",
275 &[("type", "checkbox"), ("readonly", "")],
276 );
277 let list = el(&mut dom, root, "datalist", &[]);
278 let in_list = el(&mut dom, list, "input", &[]);
279 for id in [disabled, in_fs, ro_text, ro_area, in_list] {
280 assert!(!dom.will_validate(id), "{id:?}");
281 }
282 assert!(
283 dom.will_validate(ro_box),
284 "readonly does not apply to a checkbox"
285 );
286 }
287}