Skip to main content

rdom_core/
attrs.rs

1//! Attribute + classList API on `Dom`.
2//!
3//! Attributes stored as `BTreeMap<String, String>` for deterministic
4//! iteration (markup round-trips, snapshot stability). Class list as
5//! `BTreeSet<String>` for set semantics. Both live inside the `Element`
6//! variant of `NodeData`.
7
8use crate::dom::Dom;
9use crate::error::{DomError, Result};
10use crate::node::NodeData;
11use crate::node_id::NodeId;
12use crate::observer::Mutation;
13
14impl<Ext: 'static> Dom<Ext> {
15    // ── Attributes ───────────────────────────────────────────────────
16
17    pub fn set_attribute(&mut self, id: NodeId, key: &str, value: &str) -> Result<()> {
18        let old_full = match &mut self.node_mut_or_err(id)?.data {
19            NodeData::Element { attrs, .. } => attrs.insert(key.to_string(), value.to_string()),
20            other => {
21                return Err(DomError::WrongNodeType {
22                    expected: "Element",
23                    got: node_type_of(other),
24                });
25            }
26        };
27        if key == "id" {
28            if let Some(prev) = &old_full {
29                self.indexes.unregister_id(id, prev);
30            }
31            self.indexes.register_id(id, value);
32        }
33        // Per WHATWG DOM: setting the "class" attribute MUST update
34        // `Element.classList`. The attribute string is just one of
35        // three sources that must agree (attrs["class"] / the
36        // `classes` BTreeSet / the per-class `indexes` map);
37        // `set_attribute` is the WHATWG-canonical entry point for
38        // setting `class`, so it owns the sync.
39        if key == "class" {
40            self.sync_class_list_from_attribute_value(id, value);
41        }
42        self.fire_mutation(Mutation::AttributeChanged {
43            id,
44            name: key.to_string(),
45            old: old_full,
46            new: Some(value.to_string()),
47        });
48        Ok(())
49    }
50
51    /// Rebuild the `classes` BTreeSet + selector indexes from the
52    /// whitespace-separated class attribute value. Called by
53    /// `set_attribute` whenever the "class" attribute is written so
54    /// classList stays in sync with the attribute string.
55    ///
56    /// Fires one `ClassChanged` record with the net diff (added /
57    /// removed) iff the set actually changed — observers see
58    /// classList changes whether they came via `add_class` or
59    /// `set_attribute("class", _)`.
60    fn sync_class_list_from_attribute_value(&mut self, id: NodeId, value: &str) {
61        let new_tokens: std::collections::BTreeSet<String> =
62            value.split_whitespace().map(String::from).collect();
63        let old_tokens: std::collections::BTreeSet<String> = match self.get_node(id) {
64            Some(node) => match &node.data {
65                NodeData::Element { classes, .. } => classes.clone(),
66                _ => return,
67            },
68            None => return,
69        };
70        if new_tokens == old_tokens {
71            return;
72        }
73        let added: Vec<String> = new_tokens.difference(&old_tokens).cloned().collect();
74        let removed: Vec<String> = old_tokens.difference(&new_tokens).cloned().collect();
75
76        for cls in &removed {
77            self.indexes.unregister_class(id, cls);
78        }
79        for cls in &added {
80            self.indexes.register_class(id, cls);
81        }
82        if let Some(node) = self.node_mut_or_err(id).ok()
83            && let NodeData::Element { classes, .. } = &mut node.data
84        {
85            *classes = new_tokens;
86        }
87        self.fire_mutation(Mutation::ClassChanged { id, added, removed });
88    }
89
90    /// Write the `class` attribute string from the current
91    /// `classes` BTreeSet. Called by `add_class`/`remove_class`/
92    /// `toggle_class`/`replace_class` to maintain the reverse
93    /// half of the round-trip with `set_attribute("class", _)`.
94    /// Joins tokens with single spaces — iteration is
95    /// alphabetic per the BTreeSet ordering, which is a
96    /// pre-existing iteration-order divergence from browsers
97    /// (documented in [`crate::token_list::DomTokenList`]).
98    fn sync_class_attribute_from_class_list(&mut self, id: NodeId) {
99        let new_attr: String = match self.get_node(id) {
100            Some(node) => match &node.data {
101                NodeData::Element { classes, .. } => {
102                    classes.iter().cloned().collect::<Vec<_>>().join(" ")
103                }
104                _ => return,
105            },
106            None => return,
107        };
108        // Write directly to attrs without going back through
109        // `set_attribute` — that would loop through
110        // sync_class_list_from_attribute_value. The attribute
111        // change fires no synthetic `AttributeChanged` record
112        // here: the `ClassChanged` record from the calling
113        // add/remove/toggle is the canonical signal.
114        if let Some(node) = self.node_mut_or_err(id).ok()
115            && let NodeData::Element { attrs, .. } = &mut node.data
116        {
117            if new_attr.is_empty() {
118                attrs.remove("class");
119            } else {
120                attrs.insert("class".to_string(), new_attr);
121            }
122        }
123    }
124
125    pub fn get_attribute(&self, id: NodeId, key: &str) -> Option<&str> {
126        match &self.get_node(id)?.data {
127            NodeData::Element { attrs, .. } => attrs.get(key).map(String::as_str),
128            _ => None,
129        }
130    }
131
132    pub fn remove_attribute(&mut self, id: NodeId, key: &str) -> Result<bool> {
133        let removed = match &mut self.node_mut_or_err(id)?.data {
134            NodeData::Element { attrs, .. } => attrs.remove(key),
135            other => {
136                return Err(DomError::WrongNodeType {
137                    expected: "Element",
138                    got: node_type_of(other),
139                });
140            }
141        };
142        if key == "id"
143            && let Some(prev) = &removed
144        {
145            self.indexes.unregister_id(id, prev);
146        }
147        if removed.is_some() {
148            self.fire_mutation(Mutation::AttributeChanged {
149                id,
150                name: key.to_string(),
151                old: removed.clone(),
152                new: None,
153            });
154        }
155        Ok(removed.is_some())
156    }
157
158    pub fn has_attribute(&self, id: NodeId, key: &str) -> bool {
159        matches!(
160            self.get_node(id).map(|n| &n.data),
161            Some(NodeData::Element { attrs, .. }) if attrs.contains_key(key)
162        )
163    }
164
165    /// Toggle: if absent, set to empty string; if present, remove.
166    /// Returns the new presence state.
167    pub fn toggle_attribute(&mut self, id: NodeId, key: &str) -> Result<bool> {
168        let (was_present, prev_value) = match &mut self.node_mut_or_err(id)?.data {
169            NodeData::Element { attrs, .. } => {
170                if let Some(prev) = attrs.remove(key) {
171                    (true, Some(prev))
172                } else {
173                    attrs.insert(key.to_string(), String::new());
174                    (false, None)
175                }
176            }
177            other => {
178                return Err(DomError::WrongNodeType {
179                    expected: "Element",
180                    got: node_type_of(other),
181                });
182            }
183        };
184        if key == "id"
185            && let Some(prev) = &prev_value
186        {
187            self.indexes.unregister_id(id, prev);
188        }
189        // else: added as empty string — empty ids are ignored by indexer.
190        let (old, new) = if was_present {
191            (prev_value.clone(), None)
192        } else {
193            (None, Some(String::new()))
194        };
195        self.fire_mutation(Mutation::AttributeChanged {
196            id,
197            name: key.to_string(),
198            old,
199            new,
200        });
201        Ok(!was_present)
202    }
203
204    /// Iterate `(name, value)` pairs in deterministic (alphabetic) order.
205    pub fn attributes(&self, id: NodeId) -> impl Iterator<Item = (&str, &str)> {
206        let slot = self.get_node(id);
207
208        match slot.map(|n| &n.data) {
209            Some(NodeData::Element { attrs, .. }) => {
210                Box::new(attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
211                    as Box<dyn Iterator<Item = (&str, &str)>>
212            }
213            _ => Box::new(std::iter::empty()) as Box<dyn Iterator<Item = (&str, &str)>>,
214        }
215    }
216
217    /// Convenience: `id` attribute.
218    pub fn set_id(&mut self, id: NodeId, value: &str) -> Result<()> {
219        self.set_attribute(id, "id", value)
220    }
221
222    pub fn id_attr(&self, id: NodeId) -> Option<&str> {
223        self.get_attribute(id, "id")
224    }
225
226    // ── classList ────────────────────────────────────────────────────
227
228    pub fn add_class(&mut self, id: NodeId, class: &str) -> Result<()> {
229        let inserted = match &mut self.node_mut_or_err(id)?.data {
230            NodeData::Element { classes, .. } => classes.insert(class.to_string()),
231            other => {
232                return Err(DomError::WrongNodeType {
233                    expected: "Element",
234                    got: node_type_of(other),
235                });
236            }
237        };
238        if inserted {
239            self.indexes.register_class(id, class);
240            self.sync_class_attribute_from_class_list(id);
241            self.fire_mutation(Mutation::ClassChanged {
242                id,
243                added: vec![class.to_string()],
244                removed: vec![],
245            });
246        }
247        Ok(())
248    }
249
250    pub fn remove_class(&mut self, id: NodeId, class: &str) -> Result<bool> {
251        let removed = match &mut self.node_mut_or_err(id)?.data {
252            NodeData::Element { classes, .. } => classes.remove(class),
253            other => {
254                return Err(DomError::WrongNodeType {
255                    expected: "Element",
256                    got: node_type_of(other),
257                });
258            }
259        };
260        if removed {
261            self.indexes.unregister_class(id, class);
262            self.sync_class_attribute_from_class_list(id);
263            self.fire_mutation(Mutation::ClassChanged {
264                id,
265                added: vec![],
266                removed: vec![class.to_string()],
267            });
268        }
269        Ok(removed)
270    }
271
272    pub fn toggle_class(&mut self, id: NodeId, class: &str) -> Result<bool> {
273        let (removed, added) = match &mut self.node_mut_or_err(id)?.data {
274            NodeData::Element { classes, .. } => {
275                if classes.remove(class) {
276                    (true, false)
277                } else {
278                    classes.insert(class.to_string());
279                    (false, true)
280                }
281            }
282            other => {
283                return Err(DomError::WrongNodeType {
284                    expected: "Element",
285                    got: node_type_of(other),
286                });
287            }
288        };
289        if removed {
290            self.indexes.unregister_class(id, class);
291            self.sync_class_attribute_from_class_list(id);
292            self.fire_mutation(Mutation::ClassChanged {
293                id,
294                added: vec![],
295                removed: vec![class.to_string()],
296            });
297        } else if added {
298            self.indexes.register_class(id, class);
299            self.sync_class_attribute_from_class_list(id);
300            self.fire_mutation(Mutation::ClassChanged {
301                id,
302                added: vec![class.to_string()],
303                removed: vec![],
304            });
305        }
306        Ok(added)
307    }
308
309    pub fn has_class(&self, id: NodeId, class: &str) -> bool {
310        matches!(
311            self.get_node(id).map(|n| &n.data),
312            Some(NodeData::Element { classes, .. }) if classes.contains(class)
313        )
314    }
315
316    pub fn replace_class(&mut self, id: NodeId, old: &str, new: &str) -> Result<bool> {
317        let swapped = match &mut self.node_mut_or_err(id)?.data {
318            NodeData::Element { classes, .. } => {
319                if classes.remove(old) {
320                    classes.insert(new.to_string());
321                    true
322                } else {
323                    false
324                }
325            }
326            other => {
327                return Err(DomError::WrongNodeType {
328                    expected: "Element",
329                    got: node_type_of(other),
330                });
331            }
332        };
333        if swapped {
334            self.indexes.unregister_class(id, old);
335            self.indexes.register_class(id, new);
336            self.sync_class_attribute_from_class_list(id);
337            self.fire_mutation(Mutation::ClassChanged {
338                id,
339                added: vec![new.to_string()],
340                removed: vec![old.to_string()],
341            });
342        }
343        Ok(swapped)
344    }
345
346    /// Iterate class tokens in alphabetic order.
347    pub fn class_list(&self, id: NodeId) -> impl Iterator<Item = &str> {
348        let slot = self.get_node(id);
349
350        match slot.map(|n| &n.data) {
351            Some(NodeData::Element { classes, .. }) => {
352                Box::new(classes.iter().map(String::as_str)) as Box<dyn Iterator<Item = &str>>
353            }
354            _ => Box::new(std::iter::empty()) as Box<dyn Iterator<Item = &str>>,
355        }
356    }
357}
358
359fn node_type_of<Ext>(data: &NodeData<Ext>) -> crate::node::NodeType {
360    use crate::node::NodeType;
361    match data {
362        NodeData::Element { .. } => NodeType::Element,
363        NodeData::Text { .. } => NodeType::Text,
364        NodeData::Comment { .. } => NodeType::Comment,
365        NodeData::Fragment => NodeType::Fragment,
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    // ── Attributes ───────────────────────────────────────────────────
374
375    #[test]
376    fn set_get_remove_attribute() {
377        let mut dom: Dom = Dom::new();
378        let el = dom.create_element("div");
379        assert!(!dom.has_attribute(el, "role"));
380
381        dom.set_attribute(el, "role", "banner").unwrap();
382        assert_eq!(dom.get_attribute(el, "role"), Some("banner"));
383        assert!(dom.has_attribute(el, "role"));
384
385        assert!(dom.remove_attribute(el, "role").unwrap());
386        assert!(!dom.has_attribute(el, "role"));
387        assert!(!dom.remove_attribute(el, "role").unwrap());
388    }
389
390    #[test]
391    fn set_attribute_overwrites() {
392        let mut dom: Dom = Dom::new();
393        let el = dom.create_element("div");
394        dom.set_attribute(el, "role", "banner").unwrap();
395        dom.set_attribute(el, "role", "navigation").unwrap();
396        assert_eq!(dom.get_attribute(el, "role"), Some("navigation"));
397    }
398
399    #[test]
400    fn toggle_attribute_flips_presence() {
401        let mut dom: Dom = Dom::new();
402        let el = dom.create_element("input");
403        assert!(dom.toggle_attribute(el, "disabled").unwrap()); // true (added)
404        assert!(dom.has_attribute(el, "disabled"));
405        assert_eq!(dom.get_attribute(el, "disabled"), Some(""));
406
407        assert!(!dom.toggle_attribute(el, "disabled").unwrap()); // false (removed)
408        assert!(!dom.has_attribute(el, "disabled"));
409    }
410
411    #[test]
412    fn attributes_iterate_in_alpha_order() {
413        let mut dom: Dom = Dom::new();
414        let el = dom.create_element("div");
415        dom.set_attribute(el, "z", "1").unwrap();
416        dom.set_attribute(el, "a", "2").unwrap();
417        dom.set_attribute(el, "m", "3").unwrap();
418        let names: Vec<&str> = dom.attributes(el).map(|(k, _)| k).collect();
419        assert_eq!(names, vec!["a", "m", "z"]);
420    }
421
422    #[test]
423    fn attribute_on_non_element_errors() {
424        let mut dom: Dom = Dom::new();
425        let t = dom.create_text_node("hi");
426        assert!(matches!(
427            dom.set_attribute(t, "role", "banner").unwrap_err(),
428            DomError::WrongNodeType { .. }
429        ));
430        // Getters gracefully return None.
431        assert!(dom.get_attribute(t, "anything").is_none());
432    }
433
434    #[test]
435    fn id_sugar() {
436        let mut dom: Dom = Dom::new();
437        let el = dom.create_element("div");
438        dom.set_id(el, "hero").unwrap();
439        assert_eq!(dom.id_attr(el), Some("hero"));
440    }
441
442    // ── Classes ──────────────────────────────────────────────────────
443
444    #[test]
445    fn add_remove_has_class() {
446        let mut dom: Dom = Dom::new();
447        let el = dom.create_element("div");
448        dom.add_class(el, "active").unwrap();
449        assert!(dom.has_class(el, "active"));
450        assert!(dom.remove_class(el, "active").unwrap());
451        assert!(!dom.has_class(el, "active"));
452        assert!(!dom.remove_class(el, "active").unwrap());
453    }
454
455    #[test]
456    fn add_class_is_idempotent() {
457        let mut dom: Dom = Dom::new();
458        let el = dom.create_element("div");
459        dom.add_class(el, "active").unwrap();
460        dom.add_class(el, "active").unwrap();
461        let list: Vec<&str> = dom.class_list(el).collect();
462        assert_eq!(list, vec!["active"]);
463    }
464
465    #[test]
466    fn toggle_class() {
467        let mut dom: Dom = Dom::new();
468        let el = dom.create_element("div");
469        assert!(dom.toggle_class(el, "on").unwrap()); // added
470        assert!(!dom.toggle_class(el, "on").unwrap()); // removed
471    }
472
473    #[test]
474    fn replace_class_swaps() {
475        let mut dom: Dom = Dom::new();
476        let el = dom.create_element("div");
477        dom.add_class(el, "old").unwrap();
478        assert!(dom.replace_class(el, "old", "new").unwrap());
479        assert!(!dom.has_class(el, "old"));
480        assert!(dom.has_class(el, "new"));
481    }
482
483    #[test]
484    fn replace_class_returns_false_when_old_missing() {
485        let mut dom: Dom = Dom::new();
486        let el = dom.create_element("div");
487        assert!(!dom.replace_class(el, "never-there", "new").unwrap());
488        assert!(!dom.has_class(el, "new")); // nothing added when old missing
489    }
490
491    #[test]
492    fn class_list_alpha_order() {
493        let mut dom: Dom = Dom::new();
494        let el = dom.create_element("div");
495        dom.add_class(el, "zeta").unwrap();
496        dom.add_class(el, "alpha").unwrap();
497        dom.add_class(el, "mu").unwrap();
498        let list: Vec<&str> = dom.class_list(el).collect();
499        assert_eq!(list, vec!["alpha", "mu", "zeta"]);
500    }
501
502    #[test]
503    fn class_on_non_element_errors() {
504        let mut dom: Dom = Dom::new();
505        let t = dom.create_text_node("hi");
506        assert!(matches!(
507            dom.add_class(t, "x").unwrap_err(),
508            DomError::WrongNodeType { .. }
509        ));
510        assert!(!dom.has_class(t, "anything"));
511    }
512
513    // ── class attribute / classList round-trip ────────────────────
514
515    #[test]
516    fn set_attribute_class_syncs_class_list() {
517        // WHATWG DOM: setting the "class" attribute MUST update
518        // `Element.classList`. rdom historically diverged — the
519        // attribute string was written but the indexed classList
520        // (and selector matching) didn't reflect it. Surfaced by
521        // M2's showcase shell: every `.foo` selector silently
522        // failed to match. Round-trip fixed in the same patch as
523        // this test.
524        let mut dom: Dom = Dom::new();
525        let el = dom.create_element("div");
526
527        dom.set_attribute(el, "class", "alpha beta").unwrap();
528
529        // class_list now contains the parsed tokens.
530        let tokens: Vec<&str> = dom.class_list(el).collect();
531        assert!(tokens.contains(&"alpha"));
532        assert!(tokens.contains(&"beta"));
533        assert_eq!(tokens.len(), 2);
534
535        // has_class reflects the tokens.
536        assert!(dom.has_class(el, "alpha"));
537        assert!(dom.has_class(el, "beta"));
538        assert!(!dom.has_class(el, "gamma"));
539    }
540
541    #[test]
542    fn set_attribute_class_replaces_existing_classes() {
543        // Setting "class" again replaces — the old tokens go away,
544        // the new tokens take over.
545        let mut dom: Dom = Dom::new();
546        let el = dom.create_element("div");
547        dom.add_class(el, "old").unwrap();
548        assert!(dom.has_class(el, "old"));
549
550        dom.set_attribute(el, "class", "fresh").unwrap();
551
552        assert!(!dom.has_class(el, "old"), "old token cleared");
553        assert!(dom.has_class(el, "fresh"), "new token present");
554    }
555
556    #[test]
557    fn set_attribute_class_empty_clears_class_list() {
558        let mut dom: Dom = Dom::new();
559        let el = dom.create_element("div");
560        dom.add_class(el, "x").unwrap();
561        dom.add_class(el, "y").unwrap();
562        assert_eq!(dom.class_list(el).count(), 2);
563
564        dom.set_attribute(el, "class", "").unwrap();
565
566        assert_eq!(dom.class_list(el).count(), 0);
567    }
568
569    #[test]
570    fn add_class_syncs_class_attribute() {
571        // The reverse direction: `add_class` writes through to
572        // `attrs["class"]` so `get_attribute("class")` round-trips
573        // with classList membership.
574        let mut dom: Dom = Dom::new();
575        let el = dom.create_element("div");
576
577        dom.add_class(el, "foo").unwrap();
578
579        let attr = dom.get_attribute(el, "class");
580        assert_eq!(attr, Some("foo"), "add_class wrote the attribute as well");
581    }
582
583    #[test]
584    fn remove_class_syncs_class_attribute() {
585        let mut dom: Dom = Dom::new();
586        let el = dom.create_element("div");
587        dom.add_class(el, "a").unwrap();
588        dom.add_class(el, "b").unwrap();
589        assert!(
590            dom.get_attribute(el, "class").unwrap().contains('a')
591                && dom.get_attribute(el, "class").unwrap().contains('b')
592        );
593
594        dom.remove_class(el, "a").unwrap();
595
596        let attr = dom.get_attribute(el, "class").unwrap_or("");
597        assert!(!attr.contains('a'), "removed token gone from attribute");
598        assert!(attr.contains('b'), "remaining token still in attribute");
599    }
600
601    #[test]
602    fn set_attribute_then_class_selector_via_index_round_trips() {
603        // The substrate's classList drives selector matching. After
604        // `set_attribute(_, "class", "hero")`, queries for class
605        // "hero" must return `el`. Without the round-trip sync,
606        // every CSS `.hero` selector silently misses — exactly the
607        // showcase shell bug surfaced in M2.
608        let mut dom: Dom = Dom::new();
609        let root = dom.root();
610        let el = dom.create_element("div");
611        dom.append_child(root, el).unwrap();
612
613        dom.set_attribute(el, "class", "hero").unwrap();
614
615        let matches = dom.get_elements_by_class_name(root, "hero");
616        assert!(
617            matches.contains(&el),
618            "el is in the indexed match set for .hero (got {matches:?})"
619        );
620    }
621}