Skip to main content

blitz_dom_api/
element.rs

1//! Element operations: attributes, `classList`, `innerHTML`, scoped matching.
2//!
3//! Upstream: `blitz-script/src/dom/element.rs`. See MAPPING.md.
4//!
5//! Tolerance is copied from upstream: an operation naming a node that is not
6//! an element behaves as though the attribute were simply absent, rather than
7//! raising. `blitz-script` has no `NotAnElement` case and neither does this.
8
9use blitz_dom::{BaseDocument, LocalName, NodeId, QualName};
10
11use crate::Result;
12use crate::error::DomError;
13
14/// A namespace-less qualified name, which is what an attribute takes.
15pub(crate) fn attr_name(local: &str) -> QualName {
16    QualName::new(None, markup5ever::ns!(), LocalName::from(local))
17}
18
19pub(crate) fn read_attr(doc: &BaseDocument, node_id: NodeId, name: &str) -> Option<String> {
20    let element = doc.get_node(node_id)?.element_data()?;
21    element
22        .attrs()
23        .iter()
24        .find(|attr| &*attr.name.local == name)
25        .map(|attr| attr.value.to_string())
26}
27
28/// `local == name.to_ascii_lowercase()`, without allocating the lowercased
29/// copy.
30///
31/// `to_ascii_lowercase` maps each ASCII byte to one ASCII byte and leaves every
32/// other byte alone, so byte length is preserved and this is exactly the
33/// comparison [`read_attr`]'s callers make — one byte at a time instead of one
34/// `String` up front.
35fn local_matches(local: &str, name: &str) -> bool {
36    local.len() == name.len()
37        && local
38            .bytes()
39            .zip(name.bytes())
40            .all(|(stored, queried)| stored == queried.to_ascii_lowercase())
41}
42
43/// Borrow an attribute's value out of the document, without copying it.
44///
45/// # Why this is allowed to return a borrow
46///
47/// The crate's borrow discipline — every reader returns an owned value, so no
48/// borrow survives a call and a runtime re-entering between operations cannot
49/// find a live one — is a rule about the **public** surface. This is
50/// `pub(crate)`, its borrow never escapes the function that took it, and every
51/// public caller either clones it or writes it into a buffer the caller
52/// supplied. The discipline is intact; what changes is that the copy is no
53/// longer mandatory *inside* the crate.
54///
55/// This is the whole mechanism behind the buffer-writing readers. See
56/// MAPPING.md, "Readers allocate a `String`".
57pub(crate) fn find_attr<'doc>(
58    doc: &'doc BaseDocument,
59    node_id: NodeId,
60    name: &str,
61) -> Option<&'doc str> {
62    let element = doc.get_node(node_id)?.element_data()?;
63    element
64        .attrs()
65        .iter()
66        .find(|attr| local_matches(&attr.name.local, name))
67        .map(|attr| &*attr.value)
68}
69
70pub(crate) fn write_attr(doc: &mut BaseDocument, node_id: NodeId, name: &str, value: &str) {
71    doc.mutate().set_attribute(node_id, attr_name(name), value);
72}
73
74pub(crate) fn clear_attr(doc: &mut BaseDocument, node_id: NodeId, name: &str) {
75    doc.mutate().clear_attribute(node_id, attr_name(name));
76}
77
78// === Basic element info ===
79
80/// `element.tagName`, upper-cased as the DOM specifies.
81///
82/// Empty string for a node that is not an element, matching upstream.
83pub fn tag_name(doc: &BaseDocument, node: NodeId) -> Result<String> {
84    Ok(doc
85        .get_node(node)
86        .and_then(|node| node.element_data())
87        .map(|element| element.name.local.to_uppercase())
88        .unwrap_or_default())
89}
90
91// === Attributes ===
92
93/// `element.getAttribute(name)`.
94///
95/// The name is ASCII-lowercased before the lookup, as upstream does.
96/// `None` is the DOM's `null`, meaning the attribute is absent, which is
97/// distinct from present-and-empty.
98pub fn get_attribute(doc: &BaseDocument, node: NodeId, name: &str) -> Result<Option<String>> {
99    Ok(read_attr(doc, node, &name.to_ascii_lowercase()))
100}
101
102/// `element.setAttribute(name, value)`.
103///
104/// The caller is responsible for marking layout dirty; see the crate root.
105pub fn set_attribute(doc: &mut BaseDocument, node: NodeId, name: &str, value: &str) -> Result<()> {
106    write_attr(doc, node, &name.to_ascii_lowercase(), value);
107    Ok(())
108}
109
110/// `element.removeAttribute(name)`.
111pub fn remove_attribute(doc: &mut BaseDocument, node: NodeId, name: &str) -> Result<()> {
112    clear_attr(doc, node, &name.to_ascii_lowercase());
113    Ok(())
114}
115
116/// `element.hasAttribute(name)`.
117///
118/// Allocates nothing. It used to allocate twice — the lowercased name, and a
119/// clone of the attribute's whole value, thrown away to answer a boolean —
120/// which is a cost with no boundary traffic at all to justify it. See
121/// MAPPING.md.
122pub fn has_attribute(doc: &BaseDocument, node: NodeId, name: &str) -> Result<bool> {
123    Ok(find_attr(doc, node, name).is_some())
124}
125
126/// `element.getAttribute(name)`, written into a caller-supplied buffer.
127///
128/// **The buffer-writing variant of [`get_attribute`], and the reason it
129/// exists**: the owning reader allocates a `String` that a binding then copies
130/// again into wherever it actually wanted the bytes. This writes into that
131/// destination directly, so the intermediate never exists.
132///
133/// - `Ok(None)` — the attribute is absent, which is the DOM's `null` and is not
134///   the same as present-and-empty. `out` is untouched.
135/// - `Ok(Some(len))` — `len` is the value's **full** byte length, always. The
136///   bytes were written to `out[..len]` if and only if `len <= out.len()`; a
137///   value that does not fit leaves `out` untouched rather than truncated,
138///   because half a UTF-8 string is not a string.
139///
140/// That is `snprintf`'s convention, and it is what lets a caller size a buffer
141/// from one call and get the bytes from a second, without the reader ever
142/// having to own them.
143///
144/// [`get_attribute`] is kept and is not deprecated: a caller that wants a
145/// `String` should not have to supply a buffer and then build one.
146pub fn get_attribute_into(
147    doc: &BaseDocument,
148    node: NodeId,
149    name: &str,
150    out: &mut [u8],
151) -> Result<Option<usize>> {
152    let Some(value) = find_attr(doc, node, name) else {
153        return Ok(None);
154    };
155    let len = value.len();
156    if len <= out.len() {
157        out[..len].copy_from_slice(value.as_bytes());
158    }
159    Ok(Some(len))
160}
161
162// === classList ===
163
164fn class_tokens(doc: &BaseDocument, node_id: NodeId) -> Vec<String> {
165    read_attr(doc, node_id, "class")
166        .unwrap_or_default()
167        .split_ascii_whitespace()
168        .map(str::to_owned)
169        .collect()
170}
171
172fn write_class_tokens(doc: &mut BaseDocument, node_id: NodeId, tokens: &[String]) {
173    write_attr(doc, node_id, "class", &tokens.join(" "));
174}
175
176/// Validate a `DOMTokenList` token. Upstream raises a `SyntaxError` here.
177fn class_token(token: &str) -> Result<&str> {
178    if token.is_empty() || token.chars().any(|ch| ch.is_ascii_whitespace()) {
179        return Err(DomError::InvalidClassToken(token.to_owned()));
180    }
181    Ok(token)
182}
183
184/// `element.classList.add(...tokens)`.
185///
186/// Every token is validated before anything is written, so a bad token in the
187/// list leaves the attribute untouched. That is upstream's ordering too: it
188/// collects all tokens through `class_token` before reading the attribute.
189pub fn class_list_add(doc: &mut BaseDocument, node: NodeId, tokens: &[&str]) -> Result<()> {
190    let to_add = tokens
191        .iter()
192        .map(|token| class_token(token).map(str::to_owned))
193        .collect::<Result<Vec<_>>>()?;
194    let mut current = class_tokens(doc, node);
195    for token in to_add {
196        if !current.contains(&token) {
197            current.push(token);
198        }
199    }
200    write_class_tokens(doc, node, &current);
201    Ok(())
202}
203
204/// `element.classList.remove(...tokens)`.
205pub fn class_list_remove(doc: &mut BaseDocument, node: NodeId, tokens: &[&str]) -> Result<()> {
206    let to_remove = tokens
207        .iter()
208        .map(|token| class_token(token).map(str::to_owned))
209        .collect::<Result<Vec<_>>>()?;
210    let mut current = class_tokens(doc, node);
211    current.retain(|token| !to_remove.contains(token));
212    write_class_tokens(doc, node, &current);
213    Ok(())
214}
215
216/// `element.classList.toggle(token, force)`, returning whether the token is
217/// present afterwards.
218pub fn class_list_toggle(
219    doc: &mut BaseDocument,
220    node: NodeId,
221    token: &str,
222    force: Option<bool>,
223) -> Result<bool> {
224    let token = class_token(token)?.to_owned();
225    let mut tokens = class_tokens(doc, node);
226    let present = tokens.contains(&token);
227    let retain = force.unwrap_or(!present);
228    if retain && !present {
229        tokens.push(token);
230    } else if !retain && present {
231        tokens.retain(|item| item != &token);
232    }
233    write_class_tokens(doc, node, &tokens);
234    Ok(retain)
235}
236
237/// `element.classList.contains(token)`.
238pub fn class_list_contains(doc: &BaseDocument, node: NodeId, token: &str) -> Result<bool> {
239    let token = class_token(token)?;
240    Ok(class_tokens(doc, node).iter().any(|item| item == token))
241}
242
243// === innerHTML ===
244
245/// `element.innerHTML`, serialised from the children.
246pub fn inner_html(doc: &BaseDocument, node: NodeId) -> Result<String> {
247    let mut html = String::new();
248    if let Some(node) = doc.get_node(node) {
249        for child_id in &node.children {
250            if let Some(child) = doc.get_node(*child_id) {
251                child.write_outer_html(&mut html);
252            }
253        }
254    }
255    Ok(html)
256}
257
258/// `element.innerHTML = html`.
259///
260/// Existing children are detached rather than dropped, so wrappers a runtime
261/// still holds for them stay valid. That detach is the only part of this
262/// operation the facade performs.
263///
264/// **Parsing is the document's job, not this crate's.** The new children come
265/// from `BaseDocument::html_parser_provider`, and the default provider,
266/// [`blitz_dom::DummyHtmlParserProvider`], parses nothing at all: against a
267/// document built with `DocumentConfig::default()` this call empties the
268/// element and stops. An embedder that wants `innerHTML` to work configures
269/// `blitz-html`'s provider. This crate does not depend on `blitz-html`, so it
270/// cannot check for you.
271pub fn set_inner_html(doc: &mut BaseDocument, node: NodeId, html: &str) -> Result<()> {
272    let mut mutr = doc.mutate();
273    for child_id in mutr.child_ids(node) {
274        mutr.remove_node(child_id);
275    }
276    mutr.set_inner_html(node, html);
277    Ok(())
278}
279
280// === Selector matching ===
281
282/// `element.matches(selector)`.
283///
284/// Deliberate difference: an unparseable selector is an error. Upstream's
285/// `is_ok_and` maps it to `false`.
286pub fn matches(doc: &BaseDocument, node: NodeId, selector: &str) -> Result<bool> {
287    doc.query_selector_all(selector)
288        .map(|matches| matches.contains(&node))
289        .map_err(|_| DomError::InvalidSelector(selector.to_owned()))
290}
291
292/// `element.closest(selector)`: this node or the nearest ancestor that matches.
293///
294/// Deliberate difference: an unparseable selector is an error. Upstream's
295/// `unwrap_or_default` searches an empty match set and returns `null`.
296pub fn closest(doc: &BaseDocument, node: NodeId, selector: &str) -> Result<Option<NodeId>> {
297    let matches = doc
298        .query_selector_all(selector)
299        .map_err(|_| DomError::InvalidSelector(selector.to_owned()))?;
300    let mut current = Some(node);
301    while let Some(id) = current {
302        if matches.contains(&id) {
303            return Ok(Some(id));
304        }
305        current = doc.get_node(id).and_then(|node| node.parent);
306    }
307    Ok(None)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::document;
314    use crate::node;
315    use crate::test_support::skeleton;
316
317    fn attached_div(doc: &mut BaseDocument, parent: NodeId) -> NodeId {
318        let id = document::create_element(doc, "div").unwrap();
319        node::append_child(doc, parent, id).unwrap();
320        id
321    }
322
323    #[test]
324    fn tag_name_is_upper_case() {
325        let (mut doc, _html, _head, body) = skeleton();
326        let id = attached_div(&mut doc, body);
327        assert_eq!(tag_name(&doc, id).unwrap(), "DIV");
328    }
329
330    #[test]
331    fn tag_name_of_a_text_node_is_empty() {
332        let (mut doc, _html, _head, _body) = skeleton();
333        let text = document::create_text_node(&mut doc, "x").unwrap();
334        assert_eq!(tag_name(&doc, text).unwrap(), "");
335    }
336
337    #[test]
338    fn set_and_get_attribute_round_trip() {
339        let (mut doc, _html, _head, body) = skeleton();
340        let id = attached_div(&mut doc, body);
341        set_attribute(&mut doc, id, "data-role", "panel").unwrap();
342        assert_eq!(
343            get_attribute(&doc, id, "data-role").unwrap(),
344            Some("panel".to_string())
345        );
346    }
347
348    #[test]
349    fn attribute_names_are_lowercased_on_both_sides() {
350        let (mut doc, _html, _head, body) = skeleton();
351        let id = attached_div(&mut doc, body);
352        set_attribute(&mut doc, id, "DATA-Role", "panel").unwrap();
353        assert_eq!(
354            get_attribute(&doc, id, "data-role").unwrap(),
355            Some("panel".to_string())
356        );
357        assert!(has_attribute(&doc, id, "Data-ROLE").unwrap());
358    }
359
360    #[test]
361    fn get_attribute_is_none_when_absent_and_some_when_empty() {
362        let (mut doc, _html, _head, body) = skeleton();
363        let id = attached_div(&mut doc, body);
364        assert_eq!(get_attribute(&doc, id, "title").unwrap(), None);
365        set_attribute(&mut doc, id, "title", "").unwrap();
366        assert_eq!(
367            get_attribute(&doc, id, "title").unwrap(),
368            Some(String::new())
369        );
370    }
371
372    /// The buffer variant agrees with the owning one, value for value.
373    ///
374    /// Asserted as an equivalence rather than against literals: the two are a
375    /// pair, and the failure worth catching is them drifting apart.
376    #[test]
377    fn get_attribute_into_agrees_with_get_attribute() {
378        let (mut doc, _html, _head, body) = skeleton();
379        let id = attached_div(&mut doc, body);
380        set_attribute(&mut doc, id, "data-role", "panel").unwrap();
381        set_attribute(&mut doc, id, "title", "").unwrap();
382
383        for name in ["data-role", "title", "absent"] {
384            let owned = get_attribute(&doc, id, name).unwrap();
385            let mut buf = [0u8; 64];
386            let written = get_attribute_into(&doc, id, name, &mut buf).unwrap();
387            match owned {
388                None => assert_eq!(written, None, "{name} should be absent both ways"),
389                Some(value) => {
390                    assert_eq!(written, Some(value.len()), "{name} length");
391                    assert_eq!(&buf[..value.len()], value.as_bytes(), "{name} bytes");
392                }
393            }
394        }
395    }
396
397    /// The `snprintf` contract: the full length is always reported, and a value
398    /// that does not fit leaves the buffer untouched rather than truncated.
399    #[test]
400    fn get_attribute_into_reports_the_length_it_could_not_write() {
401        let (mut doc, _html, _head, body) = skeleton();
402        let id = attached_div(&mut doc, body);
403        set_attribute(&mut doc, id, "data-role", "panel").unwrap();
404
405        // Too small by one. Nothing is written — not four bytes of `pane`.
406        let mut buf = [b'.'; 4];
407        assert_eq!(
408            get_attribute_into(&doc, id, "data-role", &mut buf).unwrap(),
409            Some(5)
410        );
411        assert_eq!(
412            &buf, b"....",
413            "a value that does not fit must not be written"
414        );
415
416        // Zero capacity is legal, and is how a caller asks for a length alone.
417        assert_eq!(
418            get_attribute_into(&doc, id, "data-role", &mut []).unwrap(),
419            Some(5)
420        );
421
422        // Sized from that answer, it fits exactly.
423        let mut buf = vec![0u8; 5];
424        assert_eq!(
425            get_attribute_into(&doc, id, "data-role", &mut buf).unwrap(),
426            Some(5)
427        );
428        assert_eq!(&buf, b"panel");
429    }
430
431    /// The name is matched case-insensitively without the lowercased copy the
432    /// owning reader allocates, so the two must still agree on a mixed-case
433    /// query.
434    #[test]
435    fn get_attribute_into_lowercases_the_queried_name() {
436        let (mut doc, _html, _head, body) = skeleton();
437        let id = attached_div(&mut doc, body);
438        set_attribute(&mut doc, id, "data-role", "panel").unwrap();
439
440        let mut buf = [0u8; 16];
441        assert_eq!(
442            get_attribute_into(&doc, id, "DATA-Role", &mut buf).unwrap(),
443            Some(5)
444        );
445        assert_eq!(&buf[..5], b"panel");
446        assert!(has_attribute(&doc, id, "Data-ROLE").unwrap());
447
448        // And a name of a different length is not a match, which is what the
449        // byte-length check in `local_matches` is for.
450        assert_eq!(
451            get_attribute_into(&doc, id, "data-rol", &mut buf).unwrap(),
452            None
453        );
454    }
455
456    #[test]
457    fn remove_attribute_clears_it() {
458        let (mut doc, _html, _head, body) = skeleton();
459        let id = attached_div(&mut doc, body);
460        set_attribute(&mut doc, id, "title", "x").unwrap();
461        remove_attribute(&mut doc, id, "title").unwrap();
462        assert_eq!(get_attribute(&doc, id, "title").unwrap(), None);
463    }
464
465    #[test]
466    fn has_attribute_tracks_presence() {
467        let (mut doc, _html, _head, body) = skeleton();
468        let id = attached_div(&mut doc, body);
469        assert!(!has_attribute(&doc, id, "hidden").unwrap());
470        set_attribute(&mut doc, id, "hidden", "").unwrap();
471        assert!(has_attribute(&doc, id, "hidden").unwrap());
472    }
473
474    #[test]
475    fn class_list_add_is_idempotent_and_ordered() {
476        let (mut doc, _html, _head, body) = skeleton();
477        let id = attached_div(&mut doc, body);
478        class_list_add(&mut doc, id, &["a", "b"]).unwrap();
479        class_list_add(&mut doc, id, &["a", "c"]).unwrap();
480        assert_eq!(
481            get_attribute(&doc, id, "class").unwrap(),
482            Some("a b c".to_string())
483        );
484    }
485
486    #[test]
487    fn class_list_remove_drops_every_named_token() {
488        let (mut doc, _html, _head, body) = skeleton();
489        let id = attached_div(&mut doc, body);
490        class_list_add(&mut doc, id, &["a", "b", "c"]).unwrap();
491        class_list_remove(&mut doc, id, &["a", "c"]).unwrap();
492        assert_eq!(
493            get_attribute(&doc, id, "class").unwrap(),
494            Some("b".to_string())
495        );
496    }
497
498    #[test]
499    fn class_list_toggle_flips_and_honours_force() {
500        let (mut doc, _html, _head, body) = skeleton();
501        let id = attached_div(&mut doc, body);
502        assert!(class_list_toggle(&mut doc, id, "on", None).unwrap());
503        assert!(class_list_contains(&doc, id, "on").unwrap());
504        assert!(!class_list_toggle(&mut doc, id, "on", None).unwrap());
505        assert!(!class_list_contains(&doc, id, "on").unwrap());
506
507        assert!(class_list_toggle(&mut doc, id, "on", Some(true)).unwrap());
508        assert!(class_list_toggle(&mut doc, id, "on", Some(true)).unwrap());
509        assert!(class_list_contains(&doc, id, "on").unwrap());
510        assert!(!class_list_toggle(&mut doc, id, "on", Some(false)).unwrap());
511        assert!(!class_list_contains(&doc, id, "on").unwrap());
512    }
513
514    #[test]
515    fn class_list_contains_reads_the_class_attribute() {
516        let (mut doc, _html, _head, body) = skeleton();
517        let id = attached_div(&mut doc, body);
518        set_attribute(&mut doc, id, "class", "  a   b ").unwrap();
519        assert!(class_list_contains(&doc, id, "a").unwrap());
520        assert!(class_list_contains(&doc, id, "b").unwrap());
521        assert!(!class_list_contains(&doc, id, "c").unwrap());
522    }
523
524    #[test]
525    fn an_invalid_class_token_is_rejected_before_anything_is_written() {
526        let (mut doc, _html, _head, body) = skeleton();
527        let id = attached_div(&mut doc, body);
528        class_list_add(&mut doc, id, &["keep"]).unwrap();
529
530        assert!(matches!(
531            class_list_add(&mut doc, id, &["ok", "two words"]),
532            Err(DomError::InvalidClassToken(_))
533        ));
534        assert!(matches!(
535            class_list_toggle(&mut doc, id, "", None),
536            Err(DomError::InvalidClassToken(_))
537        ));
538        assert!(matches!(
539            class_list_contains(&doc, id, "a b"),
540            Err(DomError::InvalidClassToken(_))
541        ));
542        assert_eq!(
543            get_attribute(&doc, id, "class").unwrap(),
544            Some("keep".to_string())
545        );
546    }
547
548    /// Built through the facade rather than through `set_inner_html`, because
549    /// parsing needs a provider this crate does not depend on. See the test
550    /// below.
551    #[test]
552    fn inner_html_serialises_the_children() {
553        let (mut doc, _html, _head, body) = skeleton();
554        let outer = attached_div(&mut doc, body);
555        let child = document::create_element(&mut doc, "span").unwrap();
556        set_attribute(&mut doc, child, "class", "x").unwrap();
557        let text = document::create_text_node(&mut doc, "hi").unwrap();
558        node::append_child(&mut doc, child, text).unwrap();
559        node::append_child(&mut doc, outer, child).unwrap();
560
561        assert_eq!(
562            inner_html(&doc, outer).unwrap(),
563            "<span class=\"x\">hi</span>"
564        );
565        assert_eq!(inner_html(&doc, child).unwrap(), "hi");
566    }
567
568    #[test]
569    fn inner_html_of_a_childless_element_is_empty() {
570        let (mut doc, _html, _head, body) = skeleton();
571        let outer = attached_div(&mut doc, body);
572        assert_eq!(inner_html(&doc, outer).unwrap(), "");
573    }
574
575    /// The half of `set_inner_html` that belongs to this crate: the old
576    /// children come off, and they are detached rather than dropped so a
577    /// runtime's wrappers for them stay valid.
578    #[test]
579    fn set_inner_html_detaches_the_previous_children() {
580        let (mut doc, _html, _head, body) = skeleton();
581        let outer = attached_div(&mut doc, body);
582        let old = attached_div(&mut doc, outer);
583        set_inner_html(&mut doc, outer, "<span></span>").unwrap();
584        assert_eq!(node::parent_node(&doc, old).unwrap(), None);
585        assert!(
586            doc.get_node(old).is_some(),
587            "the old child should be detached, not dropped"
588        );
589    }
590
591    /// The other half is the document's, and against the default provider
592    /// there is no other half. Asserted rather than assumed: a binding that
593    /// forgets to configure `blitz-html` gets a silently empty element, and
594    /// this is the test that says so out loud.
595    #[test]
596    fn set_inner_html_parses_nothing_without_a_parser_provider() {
597        let (mut doc, _html, _head, body) = skeleton();
598        let outer = attached_div(&mut doc, body);
599        set_inner_html(&mut doc, outer, "<span>hi</span>").unwrap();
600        assert!(
601            node::child_nodes(&doc, outer).unwrap().is_empty(),
602            "DummyHtmlParserProvider should have parsed nothing"
603        );
604    }
605
606    #[test]
607    fn matches_tests_this_element_against_the_selector() {
608        let (mut doc, _html, _head, body) = skeleton();
609        let id = attached_div(&mut doc, body);
610        class_list_add(&mut doc, id, &["panel"]).unwrap();
611        assert!(matches(&doc, id, ".panel").unwrap());
612        assert!(!matches(&doc, id, ".other").unwrap());
613    }
614
615    #[test]
616    fn closest_checks_this_node_first_then_walks_up() {
617        let (mut doc, _html, _head, body) = skeleton();
618        let outer = attached_div(&mut doc, body);
619        let inner = attached_div(&mut doc, outer);
620        class_list_add(&mut doc, outer, &["panel"]).unwrap();
621
622        assert_eq!(closest(&doc, inner, ".panel").unwrap(), Some(outer));
623        class_list_add(&mut doc, inner, &["panel"]).unwrap();
624        assert_eq!(closest(&doc, inner, ".panel").unwrap(), Some(inner));
625        assert_eq!(closest(&doc, inner, ".absent").unwrap(), None);
626    }
627
628    #[test]
629    fn an_unparseable_selector_is_an_error_in_matches_and_closest() {
630        let (mut doc, _html, _head, body) = skeleton();
631        let id = attached_div(&mut doc, body);
632        assert!(matches!(
633            matches(&doc, id, "!!!"),
634            Err(DomError::InvalidSelector(_))
635        ));
636        assert!(matches!(
637            closest(&doc, id, "!!!"),
638            Err(DomError::InvalidSelector(_))
639        ));
640    }
641}