Skip to main content

rdom_core/
form_control.rs

1//! Form-control state the substrate can answer from names, attributes
2//! and tree shape alone — no rendering, no runtime.
3//!
4//! - [`Dom::is_actually_disabled`] — HTML §4.10.18.5 "disabled" plus the
5//!   `<fieldset>` rule (§4.10.15) and the `<option>` / `<optgroup>` rules
6//!   (§4.10.9 / §4.10.10): the one predicate behind the `:disabled` /
7//!   `:enabled` selectors, focus, activation and form submission.
8
9use crate::dom::Dom;
10use crate::node_id::NodeId;
11
12/// Elements HTML calls *form controls* for the purpose of the
13/// `disabled` attribute: the ones a `disabled` attribute (own or via a
14/// disabled `<fieldset>`) makes actually disabled.
15fn is_disableable_control(tag: &str) -> bool {
16    matches!(tag, "button" | "input" | "select" | "textarea" | "fieldset")
17}
18
19impl<Ext> Dom<Ext> {
20    /// Whether `id` is *actually disabled* (HTML §4.16.3, the state
21    /// `:disabled` matches):
22    ///
23    /// - a `<button>`, `<input>`, `<select>`, `<textarea>` or
24    ///   `<fieldset>` with a `disabled` attribute (any value);
25    /// - one of those that is a descendant of a `<fieldset disabled>`
26    ///   and not a descendant of that fieldset's first `<legend>` child
27    ///   (§4.10.18.5 form controls, §4.10.15 "disabled fieldset");
28    /// - an `<optgroup disabled>`;
29    /// - an `<option disabled>`, or an `<option>` whose parent is an
30    ///   `<optgroup disabled>` (§4.10.10).
31    ///
32    /// Every other element — including a `<div disabled>` — is never
33    /// disabled: the attribute means nothing there. A dead or
34    /// non-element id is not disabled.
35    pub fn is_actually_disabled(&self, id: NodeId) -> bool {
36        let Some(tag) = self.get_node(id).and_then(|n| n.tag_name()) else {
37            return false;
38        };
39        match tag {
40            "option" => {
41                self.has_attribute(id, "disabled")
42                    || self.parent_element_id(id).is_some_and(|p| {
43                        self.tag_is(p, "optgroup") && self.has_attribute(p, "disabled")
44                    })
45            }
46            "optgroup" => self.has_attribute(id, "disabled"),
47            t if is_disableable_control(t) => {
48                self.has_attribute(id, "disabled") || self.in_disabled_fieldset(id)
49            }
50            _ => false,
51        }
52    }
53
54    /// Whether `id` matches `:enabled` (HTML §4.16.3): one of the
55    /// elements that *can* be disabled (`<button>`, `<input>`,
56    /// `<select>`, `<textarea>`, `<fieldset>`, `<optgroup>`, `<option>`)
57    /// that is not [actually disabled](Self::is_actually_disabled).
58    pub fn is_enabled_control(&self, id: NodeId) -> bool {
59        let Some(tag) = self.get_node(id).and_then(|n| n.tag_name()) else {
60            return false;
61        };
62        (is_disableable_control(tag) || matches!(tag, "optgroup" | "option"))
63            && !self.is_actually_disabled(id)
64    }
65
66    /// Is `id` a descendant of a `<fieldset disabled>` without being
67    /// inside that fieldset's first `<legend>` child? Walks the ancestor
68    /// chain once, remembering the child through which each ancestor was
69    /// reached.
70    fn in_disabled_fieldset(&self, id: NodeId) -> bool {
71        let mut via = id;
72        let mut cur = self.parent_element_id(id);
73        while let Some(anc) = cur {
74            if self.tag_is(anc, "fieldset")
75                && self.has_attribute(anc, "disabled")
76                && self.first_legend_child(anc) != Some(via)
77            {
78                return true;
79            }
80            via = anc;
81            cur = self.parent_element_id(anc);
82        }
83        false
84    }
85
86    /// The fieldset's first `<legend>` element child, if any.
87    fn first_legend_child(&self, fieldset: NodeId) -> Option<NodeId> {
88        let mut c = self.get_node(fieldset)?.first_child;
89        while let Some(cid) = c {
90            let n = self.get_node(cid)?;
91            if n.tag_name() == Some("legend") {
92                return Some(cid);
93            }
94            c = n.next_sibling;
95        }
96        None
97    }
98
99    pub(crate) fn parent_element_id(&self, id: NodeId) -> Option<NodeId> {
100        self.get_node(id).and_then(|n| n.parent)
101    }
102
103    pub(crate) fn tag_is(&self, id: NodeId, tag: &str) -> bool {
104        self.get_node(id).and_then(|n| n.tag_name()) == Some(tag)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use crate::Dom;
111    use crate::node_id::NodeId;
112
113    fn el(dom: &mut Dom, parent: NodeId, tag: &str, attrs: &[(&str, &str)]) -> NodeId {
114        let e = dom.create_element(tag);
115        for (k, v) in attrs {
116            dom.set_attribute(e, k, v).unwrap();
117        }
118        dom.append_child(parent, e).unwrap();
119        e
120    }
121
122    #[test]
123    fn own_disabled_attribute_disables_a_control_but_not_a_div() {
124        let mut dom: Dom = Dom::new();
125        let root = dom.root();
126        let input = el(&mut dom, root, "input", &[("disabled", "")]);
127        let div = el(&mut dom, root, "div", &[("disabled", "")]);
128        let plain = el(&mut dom, root, "button", &[]);
129        assert!(dom.is_actually_disabled(input));
130        assert!(!dom.is_actually_disabled(div));
131        assert!(!dom.is_actually_disabled(plain));
132    }
133
134    /// HTML §4.10.18.5: a descendant of a disabled fieldset is disabled.
135    #[test]
136    fn control_inside_disabled_fieldset_is_disabled() {
137        let mut dom: Dom = Dom::new();
138        let root = dom.root();
139        let fs = el(&mut dom, root, "fieldset", &[("disabled", "")]);
140        let div = el(&mut dom, fs, "div", &[]);
141        let input = el(&mut dom, div, "input", &[]);
142        let button = el(&mut dom, fs, "button", &[]);
143        let select = el(&mut dom, fs, "select", &[]);
144        let textarea = el(&mut dom, fs, "textarea", &[]);
145        for c in [input, button, select, textarea] {
146            assert!(dom.is_actually_disabled(c));
147        }
148        assert!(!dom.is_actually_disabled(div), "a div is never disabled");
149    }
150
151    /// The first `<legend>` child is exempt; a later legend is not.
152    #[test]
153    fn control_in_first_legend_stays_enabled_but_not_in_a_later_one() {
154        let mut dom: Dom = Dom::new();
155        let root = dom.root();
156        let fs = el(&mut dom, root, "fieldset", &[("disabled", "")]);
157        let legend = el(&mut dom, fs, "legend", &[]);
158        let in_legend = el(&mut dom, legend, "input", &[("type", "checkbox")]);
159        let legend2 = el(&mut dom, fs, "legend", &[]);
160        let in_legend2 = el(&mut dom, legend2, "input", &[]);
161        assert!(!dom.is_actually_disabled(in_legend));
162        assert!(dom.is_actually_disabled(in_legend2));
163    }
164
165    /// A legend that is not a direct child of the fieldset is no exemption.
166    #[test]
167    fn nested_legend_that_is_not_a_child_is_no_exemption() {
168        let mut dom: Dom = Dom::new();
169        let root = dom.root();
170        let fs = el(&mut dom, root, "fieldset", &[("disabled", "")]);
171        let div = el(&mut dom, fs, "div", &[]);
172        let legend = el(&mut dom, div, "legend", &[]);
173        let input = el(&mut dom, legend, "input", &[]);
174        assert!(dom.is_actually_disabled(input));
175    }
176
177    /// §4.10.15: a fieldset inside a disabled fieldset is itself
178    /// disabled, and so is its content — even its own first legend,
179    /// which only exempts from *its* `disabled` attribute.
180    #[test]
181    fn nested_fieldset_in_a_disabled_fieldset_disables_its_content() {
182        let mut dom: Dom = Dom::new();
183        let root = dom.root();
184        let outer = el(&mut dom, root, "fieldset", &[("disabled", "")]);
185        let inner = el(&mut dom, outer, "fieldset", &[]);
186        let inner_legend = el(&mut dom, inner, "legend", &[]);
187        let in_inner_legend = el(&mut dom, inner_legend, "input", &[]);
188        let input = el(&mut dom, inner, "input", &[]);
189        assert!(dom.is_actually_disabled(outer));
190        assert!(dom.is_actually_disabled(inner));
191        assert!(dom.is_actually_disabled(input));
192        assert!(dom.is_actually_disabled(in_inner_legend));
193    }
194
195    /// An inner disabled fieldset disables its content even when the
196    /// outer one is enabled; a fieldset inside the outer's first legend
197    /// is exempt from the outer's attribute.
198    #[test]
199    fn inner_disabled_fieldset_and_legend_exempt_fieldset() {
200        let mut dom: Dom = Dom::new();
201        let root = dom.root();
202        let outer = el(&mut dom, root, "fieldset", &[]);
203        let inner = el(&mut dom, outer, "fieldset", &[("disabled", "")]);
204        let input = el(&mut dom, inner, "input", &[]);
205        assert!(!dom.is_actually_disabled(outer));
206        assert!(dom.is_actually_disabled(input));
207
208        let off = el(&mut dom, root, "fieldset", &[("disabled", "")]);
209        let legend = el(&mut dom, off, "legend", &[]);
210        let fs_in_legend = el(&mut dom, legend, "fieldset", &[]);
211        let deep = el(&mut dom, fs_in_legend, "input", &[]);
212        assert!(!dom.is_actually_disabled(fs_in_legend));
213        assert!(!dom.is_actually_disabled(deep));
214    }
215
216    /// §4.10.10: an option is disabled by its own attribute or a disabled
217    /// optgroup parent — not by a disabled fieldset (it is not a form
218    /// control; its `<select>` is).
219    #[test]
220    fn option_and_optgroup_rules() {
221        let mut dom: Dom = Dom::new();
222        let root = dom.root();
223        let fs = el(&mut dom, root, "fieldset", &[("disabled", "")]);
224        let select = el(&mut dom, fs, "select", &[]);
225        let og = el(&mut dom, select, "optgroup", &[("disabled", "")]);
226        let in_og = el(&mut dom, og, "option", &[]);
227        let own = el(&mut dom, select, "option", &[("disabled", "")]);
228        let plain = el(&mut dom, select, "option", &[]);
229        assert!(dom.is_actually_disabled(select));
230        assert!(dom.is_actually_disabled(og));
231        assert!(dom.is_actually_disabled(in_og));
232        assert!(dom.is_actually_disabled(own));
233        assert!(!dom.is_actually_disabled(plain));
234    }
235
236    #[test]
237    fn disabled_and_enabled_selectors_follow_the_predicate() {
238        let mut dom: Dom = Dom::new();
239        let root = dom.root();
240        let fs = el(&mut dom, root, "fieldset", &[("disabled", "")]);
241        let legend = el(&mut dom, fs, "legend", &[]);
242        let in_legend = el(&mut dom, legend, "input", &[]);
243        let input = el(&mut dom, fs, "input", &[]);
244        let div = el(&mut dom, root, "div", &[("disabled", "")]);
245
246        assert!(dom.matches(fs, ":disabled").unwrap());
247        assert!(dom.matches(input, ":disabled").unwrap());
248        assert!(dom.matches(input, "input:disabled").unwrap());
249        assert!(!dom.matches(input, ":enabled").unwrap());
250        assert!(!dom.matches(in_legend, ":disabled").unwrap());
251        assert!(dom.matches(in_legend, ":enabled").unwrap());
252        // `:disabled` / `:enabled` apply only to disableable elements.
253        assert!(!dom.matches(div, ":disabled").unwrap());
254        assert!(!dom.matches(div, ":enabled").unwrap());
255        assert!(!dom.matches(legend, ":enabled").unwrap());
256        assert_eq!(
257            dom.query_selector_all_in(root, ":disabled").unwrap(),
258            vec![fs, input]
259        );
260    }
261}