Skip to main content

rdom_core/
html_collection.rs

1//! `HtmlCollection` — snapshot wrapper for named-access
2//! collections (`form.elements`, `document.getElementsByTagName`,
3//! etc).
4//!
5//! Differs from [`NodeList`](crate::NodeList) by adding
6//! [`HtmlCollection::named_item`] — DOM's "named property getter"
7//! that looks up by `name` or `id` attribute. Same snapshot
8//! semantics: ids frozen at construction, resolution skips slots
9//! whose node has been removed.
10
11use crate::accessor::NodeRef;
12use crate::dom::Dom;
13use crate::node_id::NodeId;
14
15/// Snapshot of element ids captured at construction with
16/// name-based lookup. Used by `form.elements`,
17/// `getElementsByTagName`, etc.
18pub struct HtmlCollection<'a, Ext: 'static> {
19    nodes: Vec<NodeId>,
20    dom: &'a Dom<Ext>,
21}
22
23/// Alias for `form.elements`'s return type. The web platform
24/// distinguishes these at the IDL level but uses the same shape;
25/// rdom collapses them to a single struct + a type alias.
26pub type FormControlsCollection<'a, Ext> = HtmlCollection<'a, Ext>;
27
28impl<'a, Ext: 'static> HtmlCollection<'a, Ext> {
29    /// Construct from an iterator of element ids and a borrowed
30    /// Dom. Used by `form.elements()` (M4b step 30) and tests.
31    pub fn from_ids(dom: &'a Dom<Ext>, nodes: impl IntoIterator<Item = NodeId>) -> Self {
32        Self {
33            nodes: nodes.into_iter().collect(),
34            dom,
35        }
36    }
37
38    /// Number of element ids in the snapshot. DOM `length`.
39    pub fn len(&self) -> usize {
40        self.nodes.len()
41    }
42
43    /// `true` iff the snapshot has no element ids.
44    pub fn is_empty(&self) -> bool {
45        self.nodes.is_empty()
46    }
47
48    /// Borrow the `i`-th element if (a) the index is in range and
49    /// (b) the underlying id still resolves to a live element.
50    /// DOM `item(i)`.
51    pub fn item(&self, index: usize) -> Option<NodeRef<'a, Ext>> {
52        let id = *self.nodes.get(index)?;
53        if self.dom.contains(id) {
54            Some(self.dom.node(id))
55        } else {
56            None
57        }
58    }
59
60    /// Look up the first element whose `name` attribute matches
61    /// `name`, falling back to `id` attribute. Matches DOM
62    /// `namedItem` (the "named property getter" on
63    /// HTMLCollection). Snapshot-resolved — removed nodes are
64    /// skipped even if their id was in the original snapshot.
65    pub fn named_item(&self, name: &str) -> Option<NodeRef<'a, Ext>> {
66        for &id in &self.nodes {
67            if !self.dom.contains(id) {
68                continue;
69            }
70            let node = self.dom.node(id);
71            if node.get_attribute("name") == Some(name) || node.get_attribute("id") == Some(name) {
72                return Some(node);
73            }
74        }
75        None
76    }
77
78    /// Iterate over the live members of the snapshot in
79    /// snapshot order. Nodes removed since the snapshot was taken
80    /// are skipped.
81    pub fn iter(&self) -> impl Iterator<Item = NodeRef<'a, Ext>> + '_ {
82        self.nodes
83            .iter()
84            .copied()
85            .filter(|id| self.dom.contains(*id))
86            .map(move |id| self.dom.node(id))
87    }
88
89    /// Raw access to the snapshot's id slice. Escape hatch.
90    pub fn ids(&self) -> &[NodeId] {
91        &self.nodes
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn snapshot_len_item() {
101        let mut dom: Dom = Dom::new();
102        let a = dom.create_element("a");
103        let b = dom.create_element("b");
104        let col = HtmlCollection::from_ids(&dom, [a, b]);
105        assert_eq!(col.len(), 2);
106        assert!(!col.is_empty());
107        assert_eq!(col.item(0).map(|n| n.id()), Some(a));
108        assert_eq!(col.item(1).map(|n| n.id()), Some(b));
109        assert_eq!(col.item(2).map(|n| n.id()), None);
110    }
111
112    #[test]
113    fn named_item_matches_name_attribute_first() {
114        let mut dom: Dom = Dom::new();
115        let input = dom.create_element("input");
116        dom.set_attribute(input, "name", "user").unwrap();
117        let col = HtmlCollection::from_ids(&dom, [input]);
118        assert_eq!(col.named_item("user").map(|n| n.id()), Some(input));
119        assert!(col.named_item("missing").is_none());
120    }
121
122    #[test]
123    fn named_item_falls_back_to_id_attribute() {
124        let mut dom: Dom = Dom::new();
125        let el = dom.create_element("div");
126        dom.set_id(el, "main").unwrap();
127        let col = HtmlCollection::from_ids(&dom, [el]);
128        assert_eq!(col.named_item("main").map(|n| n.id()), Some(el));
129    }
130
131    #[test]
132    fn named_item_name_wins_over_id_on_other_elements() {
133        // If element A has name="foo" and element B has id="foo",
134        // a lookup of "foo" returns A (first match in snapshot
135        // order, then attribute preference within each candidate).
136        let mut dom: Dom = Dom::new();
137        let a = dom.create_element("input");
138        dom.set_attribute(a, "name", "foo").unwrap();
139        let b = dom.create_element("div");
140        dom.set_id(b, "foo").unwrap();
141        let col = HtmlCollection::from_ids(&dom, [a, b]);
142        assert_eq!(col.named_item("foo").map(|n| n.id()), Some(a));
143    }
144
145    #[test]
146    fn named_item_skips_removed_elements() {
147        let mut dom: Dom = Dom::new();
148        let root = dom.root();
149        let a = dom.create_element("input");
150        dom.set_attribute(a, "name", "x").unwrap();
151        dom.append_child(root, a).unwrap();
152        let b = dom.create_element("input");
153        dom.set_attribute(b, "name", "y").unwrap();
154        dom.append_child(root, b).unwrap();
155
156        let snapshot = vec![a, b];
157        dom.remove_child(root, a).unwrap();
158        dom.drop_subtree(a).unwrap();
159
160        let col = HtmlCollection::from_ids(&dom, snapshot);
161        // a is dropped → look-up of "x" returns None even though
162        // the id is still in the snapshot.
163        assert!(col.named_item("x").is_none());
164        assert_eq!(col.named_item("y").map(|n| n.id()), Some(b));
165    }
166
167    #[test]
168    fn iter_yields_live_elements_in_snapshot_order() {
169        let mut dom: Dom = Dom::new();
170        let a = dom.create_element("a");
171        let b = dom.create_element("b");
172        let col = HtmlCollection::from_ids(&dom, [a, b]);
173        let ids: Vec<NodeId> = col.iter().map(|n| n.id()).collect();
174        assert_eq!(ids, vec![a, b]);
175    }
176
177    #[test]
178    fn ids_returns_raw_snapshot_slice() {
179        let mut dom: Dom = Dom::new();
180        let a = dom.create_element("a");
181        let b = dom.create_element("b");
182        let col = HtmlCollection::from_ids(&dom, [a, b]);
183        assert_eq!(col.ids(), &[a, b][..]);
184    }
185
186    #[test]
187    fn form_controls_collection_is_alias() {
188        // Compile-time check that FormControlsCollection is the
189        // same shape as HtmlCollection.
190        let mut dom: Dom = Dom::new();
191        let input = dom.create_element("input");
192        let coll: FormControlsCollection<'_, ()> = HtmlCollection::from_ids(&dom, [input]);
193        assert_eq!(coll.len(), 1);
194    }
195}