Skip to main content

blitz_dom_api/
style.rs

1//! `CSSStyleDeclaration` operations against the inline `style` attribute.
2//!
3//! Upstream: `blitz-script/src/dom/style.rs`. See MAPPING.md.
4//!
5//! These read and write the *inline* style attribute only. None of them
6//! consults computed style, which is upstream's limitation as well and is why
7//! `getPropertyValue` answers `""` for a property that a stylesheet set.
8//!
9//! A style write changes geometry. The caller marks layout dirty; see the
10//! crate root.
11
12use blitz_dom::{BaseDocument, NodeId};
13
14use crate::Result;
15use crate::element::attr_name;
16
17/// Split a style attribute into `(property, value)` pairs.
18///
19/// A simplification carried over from upstream: it does not handle `;` or `:`
20/// inside values, so `background: url(a;b)` parses wrongly.
21fn parse_declarations(style_attr: &str) -> Vec<(String, String)> {
22    style_attr
23        .split(';')
24        .filter_map(|decl| decl.split_once(':'))
25        .map(|(prop, value)| (prop.trim().to_string(), value.trim().to_string()))
26        .filter(|(prop, _)| !prop.is_empty())
27        .collect()
28}
29
30fn serialize_declarations(decls: &[(String, String)]) -> String {
31    decls
32        .iter()
33        .map(|(prop, value)| format!("{prop}: {value};"))
34        .collect::<Vec<_>>()
35        .join(" ")
36}
37
38fn style_attr(doc: &BaseDocument, node_id: NodeId) -> String {
39    doc.get_node(node_id)
40        .and_then(|node| node.attr(blitz_dom::local_name!("style")))
41        .unwrap_or_default()
42        .to_string()
43}
44
45fn update_style_attr(
46    doc: &mut BaseDocument,
47    node_id: NodeId,
48    f: impl FnOnce(&mut Vec<(String, String)>),
49) {
50    let mut decls = parse_declarations(&style_attr(doc, node_id));
51    f(&mut decls);
52    let new_style = serialize_declarations(&decls);
53    doc.mutate()
54        .set_attribute(node_id, attr_name("style"), &new_style);
55}
56
57/// `style.getPropertyValue(name)`.
58///
59/// Empty string when the property is not set inline, which is what the DOM
60/// specifies for an unset property.
61pub fn get_property_value(doc: &BaseDocument, node: NodeId, name: &str) -> Result<String> {
62    Ok(parse_declarations(&style_attr(doc, node))
63        .into_iter()
64        .find(|(prop, _)| prop.eq_ignore_ascii_case(name))
65        .map(|(_, value)| value)
66        .unwrap_or_default())
67}
68
69/// `style.setProperty(name, value)`.
70///
71/// Setting a property to the empty string removes it, matching upstream, and
72/// a re-set moves the declaration to the end of the attribute.
73pub fn set_property(doc: &mut BaseDocument, node: NodeId, name: &str, value: &str) -> Result<()> {
74    let value = value.to_owned();
75    update_style_attr(doc, node, |decls| {
76        decls.retain(|(prop, _)| !prop.eq_ignore_ascii_case(name));
77        if !value.is_empty() {
78            decls.push((name.to_ascii_lowercase(), value));
79        }
80    });
81    Ok(())
82}
83
84/// `style.removeProperty(name)`, returning the value that was removed.
85///
86/// Empty string when the property was not set, as the DOM specifies.
87pub fn remove_property(doc: &mut BaseDocument, node: NodeId, name: &str) -> Result<String> {
88    let mut removed = String::new();
89    update_style_attr(doc, node, |decls| {
90        if let Some((_, value)) = decls
91            .iter()
92            .find(|(prop, _)| prop.eq_ignore_ascii_case(name))
93        {
94            removed = value.clone();
95        }
96        decls.retain(|(prop, _)| !prop.eq_ignore_ascii_case(name));
97    });
98    Ok(removed)
99}
100
101/// `style.cssText`: the raw inline style attribute.
102pub fn css_text(doc: &BaseDocument, node: NodeId) -> Result<String> {
103    Ok(style_attr(doc, node))
104}
105
106/// `style.cssText = css`: replace the whole inline style attribute.
107///
108/// The setter half of the `cssText` accessor. Written verbatim, without a
109/// parse-and-reserialise round trip, which is what upstream does.
110pub fn set_css_text(doc: &mut BaseDocument, node: NodeId, css: &str) -> Result<()> {
111    doc.mutate().set_attribute(node, attr_name("style"), css);
112    Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::document;
119    use crate::element;
120    use crate::node;
121    use crate::test_support::skeleton;
122
123    fn styled(doc: &mut BaseDocument, parent: NodeId) -> NodeId {
124        let id = document::create_element(doc, "div").unwrap();
125        node::append_child(doc, parent, id).unwrap();
126        id
127    }
128
129    #[test]
130    fn set_property_then_get_property_value_round_trips() {
131        let (mut doc, _html, _head, body) = skeleton();
132        let id = styled(&mut doc, body);
133        set_property(&mut doc, id, "height", "70px").unwrap();
134        assert_eq!(get_property_value(&doc, id, "height").unwrap(), "70px");
135    }
136
137    #[test]
138    fn get_property_value_is_empty_for_an_unset_property() {
139        let (mut doc, _html, _head, body) = skeleton();
140        let id = styled(&mut doc, body);
141        assert_eq!(get_property_value(&doc, id, "height").unwrap(), "");
142    }
143
144    #[test]
145    fn set_property_replaces_rather_than_appends_a_duplicate() {
146        let (mut doc, _html, _head, body) = skeleton();
147        let id = styled(&mut doc, body);
148        set_property(&mut doc, id, "height", "10px").unwrap();
149        set_property(&mut doc, id, "height", "20px").unwrap();
150        assert_eq!(get_property_value(&doc, id, "height").unwrap(), "20px");
151        assert_eq!(css_text(&doc, id).unwrap().matches("height").count(), 1);
152    }
153
154    /// The copied idiom that autosizing depends on: an empty value removes.
155    #[test]
156    fn setting_a_property_to_the_empty_string_removes_it() {
157        let (mut doc, _html, _head, body) = skeleton();
158        let id = styled(&mut doc, body);
159        set_property(&mut doc, id, "height", "10px").unwrap();
160        set_property(&mut doc, id, "height", "").unwrap();
161        assert_eq!(get_property_value(&doc, id, "height").unwrap(), "");
162    }
163
164    #[test]
165    fn remove_property_returns_what_it_removed() {
166        let (mut doc, _html, _head, body) = skeleton();
167        let id = styled(&mut doc, body);
168        set_property(&mut doc, id, "height", "10px").unwrap();
169        assert_eq!(remove_property(&mut doc, id, "height").unwrap(), "10px");
170        assert_eq!(remove_property(&mut doc, id, "height").unwrap(), "");
171        assert_eq!(get_property_value(&doc, id, "height").unwrap(), "");
172    }
173
174    #[test]
175    fn css_text_reads_the_inline_style_attribute() {
176        let (mut doc, _html, _head, body) = skeleton();
177        let id = styled(&mut doc, body);
178        element::set_attribute(&mut doc, id, "style", "color: red;").unwrap();
179        assert_eq!(css_text(&doc, id).unwrap(), "color: red;");
180    }
181
182    #[test]
183    fn set_css_text_replaces_every_declaration() {
184        let (mut doc, _html, _head, body) = skeleton();
185        let id = styled(&mut doc, body);
186        set_property(&mut doc, id, "height", "10px").unwrap();
187        set_css_text(&mut doc, id, "width: 5px;").unwrap();
188        assert_eq!(css_text(&doc, id).unwrap(), "width: 5px;");
189        assert_eq!(get_property_value(&doc, id, "height").unwrap(), "");
190        assert_eq!(get_property_value(&doc, id, "width").unwrap(), "5px");
191    }
192
193    #[test]
194    fn property_names_match_case_insensitively() {
195        let (mut doc, _html, _head, body) = skeleton();
196        let id = styled(&mut doc, body);
197        set_css_text(&mut doc, id, "Max-Height: 4px;").unwrap();
198        assert_eq!(get_property_value(&doc, id, "max-height").unwrap(), "4px");
199        assert_eq!(remove_property(&mut doc, id, "MAX-HEIGHT").unwrap(), "4px");
200    }
201}