Skip to main content

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}