Skip to main content

rdom_core/
dom_string_map.rs

1//! `DomStringMap` + `DomStringMapMut` — DOM-faithful read /
2//! write wrapper for `data-*` attributes (the `el.dataset` IDL
3//! property).
4//!
5//! ## Naming convention
6//!
7//! camelCase ↔ kebab-case is the load-bearing JS ↔ Rust mapping
8//! (HTML spec, §custom-data-attribute):
9//!
10//! | JS dataset key | Rust dataset key | Underlying attribute |
11//! |---|---|---|
12//! | `el.dataset.foo`        | `dataset().get("foo")`        | `data-foo`        |
13//! | `el.dataset.fooBar`     | `dataset().get("fooBar")`     | `data-foo-bar`    |
14//! | `el.dataset.fooBARbaz`  | `dataset().get("fooBARbaz")`  | `data-foo-b-a-rbaz` |
15//!
16//! ## Edge cases
17//!
18//! - `data-` alone (empty key) is excluded.
19//! - `data-Foo` is not a valid HTML data attribute name (uppercase
20//!   letters aren't allowed). [`DomStringMap::iter`] skips
21//!   entries whose attribute name doesn't round-trip through the
22//!   conversion.
23
24use crate::accessor::{NodeMut, NodeRef};
25use crate::error::Result;
26
27/// Read-side view of `el.dataset`. Borrows the element via
28/// `NodeRef`; lookup converts a camelCase key to a `data-*`
29/// attribute name on the fly.
30pub struct DomStringMap<'a, Ext: 'static> {
31    node: NodeRef<'a, Ext>,
32}
33
34impl<'a, Ext: 'static> DomStringMap<'a, Ext> {
35    /// Construct from a borrowed `NodeRef`. Used by the
36    /// `NodeRef::dataset()` integration (M4b step 19) and tests.
37    pub fn new(node: NodeRef<'a, Ext>) -> Self {
38        Self { node }
39    }
40
41    /// Read the `data-*` attribute corresponding to `key`. Returns
42    /// `None` when no such attribute is set. DOM
43    /// `el.dataset[key]`.
44    pub fn get(&self, key: &str) -> Option<&'a str> {
45        let attr = camel_to_data_attr(key)?;
46        self.node.get_attribute(&attr)
47    }
48
49    /// `true` iff a `data-*` attribute corresponding to `key`
50    /// exists on the element. DOM `key in el.dataset`.
51    pub fn contains_key(&self, key: &str) -> bool {
52        self.get(key).is_some()
53    }
54
55    /// Iterate `(key, value)` pairs over every `data-*` attribute
56    /// that round-trips through the camelCase ↔ kebab-case rule.
57    /// Attributes like `data-Foo` (uppercase) or `data-` (empty
58    /// key) are skipped because they aren't valid HTML data
59    /// attributes and don't have a JS-side key.
60    pub fn iter(&self) -> impl Iterator<Item = (String, &'a str)> + 'a {
61        // Go through the Dom borrow directly (not via `&self`) so
62        // the resulting iterator has lifetime `'a`, not `'self`.
63        self.node
64            .dom
65            .attributes(self.node.id)
66            .filter_map(|(name, value)| Some((data_attr_to_camel(name)?, value)))
67    }
68
69    /// Count of valid `data-*` attributes (entries that would
70    /// appear in [`Self::iter`]).
71    pub fn len(&self) -> usize {
72        self.iter().count()
73    }
74
75    /// `true` iff [`Self::len`] is zero.
76    pub fn is_empty(&self) -> bool {
77        self.iter().next().is_none()
78    }
79}
80
81/// Write-side view of `el.dataset`. Borrows the element via
82/// `NodeMut`; mutations route through `set_attribute` /
83/// `remove_attribute` so the existing attribute-change mutation
84/// observer fires.
85pub struct DomStringMapMut<'a, Ext: 'static> {
86    node: NodeMut<'a, Ext>,
87}
88
89impl<'a, Ext: 'static> DomStringMapMut<'a, Ext> {
90    /// Construct from a borrowed `NodeMut`. Used by
91    /// `NodeMut::dataset_mut()` (M4b step 19) and tests.
92    pub fn new(node: NodeMut<'a, Ext>) -> Self {
93        Self { node }
94    }
95
96    /// Set the `data-*` attribute corresponding to `key`. Errors
97    /// only when the underlying node isn't an element. DOM
98    /// `el.dataset[key] = value`.
99    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
100        let Some(attr) = camel_to_data_attr(key) else {
101            return Ok(()); // empty key — DOM ignores; we match.
102        };
103        self.node.set_attribute(&attr, value).map(|_| ())
104    }
105
106    /// Remove the `data-*` attribute corresponding to `key`.
107    /// Returns `true` iff the attribute was present (and is now
108    /// gone). DOM `delete el.dataset[key]`.
109    pub fn remove(&mut self, key: &str) -> Result<bool> {
110        let Some(attr) = camel_to_data_attr(key) else {
111            return Ok(false);
112        };
113        self.node.remove_attribute(&attr)
114    }
115}
116
117/// Convert a JS-side dataset key (e.g. `"fooBar"`) to the
118/// underlying HTML attribute name (e.g. `"data-foo-bar"`). Returns
119/// `None` for the empty string (DOM excludes the empty key).
120fn camel_to_data_attr(key: &str) -> Option<String> {
121    if key.is_empty() {
122        return None;
123    }
124    let mut out = String::with_capacity(key.len() + 6);
125    out.push_str("data-");
126    for c in key.chars() {
127        if c.is_ascii_uppercase() {
128            out.push('-');
129            out.push(c.to_ascii_lowercase());
130        } else {
131            out.push(c);
132        }
133    }
134    Some(out)
135}
136
137/// Convert an HTML attribute name (e.g. `"data-foo-bar"`) to the
138/// JS-side dataset key (e.g. `"fooBar"`). Returns `None` when:
139///
140/// - The name doesn't start with `"data-"`.
141/// - The name is just `"data-"` (no key part).
142/// - The name contains uppercase letters (not a valid HTML data
143///   attribute name).
144/// - The name has a trailing hyphen or a hyphen followed by a
145///   non-lowercase-letter (wouldn't round-trip back).
146fn data_attr_to_camel(name: &str) -> Option<String> {
147    let suffix = name.strip_prefix("data-")?;
148    if suffix.is_empty() {
149        return None;
150    }
151    // Reject any uppercase — HTML data attribute names are all
152    // lowercase, and a stray uppercase wouldn't round-trip.
153    if suffix.chars().any(|c| c.is_ascii_uppercase()) {
154        return None;
155    }
156    let mut out = String::with_capacity(suffix.len());
157    let mut chars = suffix.chars();
158    while let Some(c) = chars.next() {
159        if c == '-' {
160            // Hyphen — must be followed by lowercase ASCII letter
161            // to round-trip. Anything else (digit, hyphen, end)
162            // means the source attribute name isn't a valid
163            // round-tripable data attribute.
164            match chars.next() {
165                Some(next) if next.is_ascii_lowercase() => {
166                    out.push(next.to_ascii_uppercase());
167                }
168                _ => return None,
169            }
170        } else {
171            out.push(c);
172        }
173    }
174    Some(out)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::Dom;
181
182    // ── Conversion helpers ────────────────────────────────────────────
183
184    #[test]
185    fn camel_to_data_attr_simple_key() {
186        assert_eq!(camel_to_data_attr("foo"), Some("data-foo".into()));
187    }
188
189    #[test]
190    fn camel_to_data_attr_uppercase_becomes_hyphen_lowercase() {
191        // Canonical step-12 mapping: "fooBar" → "data-foo-bar".
192        assert_eq!(camel_to_data_attr("fooBar"), Some("data-foo-bar".into()));
193    }
194
195    #[test]
196    fn camel_to_data_attr_consecutive_uppercase_each_gets_hyphen() {
197        // "fooBARbaz" → "data-foo-b-a-rbaz" per the HTML spec.
198        assert_eq!(
199            camel_to_data_attr("fooBARbaz"),
200            Some("data-foo-b-a-rbaz".into())
201        );
202    }
203
204    #[test]
205    fn camel_to_data_attr_empty_key_rejected() {
206        assert_eq!(camel_to_data_attr(""), None);
207    }
208
209    #[test]
210    fn data_attr_to_camel_simple() {
211        assert_eq!(data_attr_to_camel("data-foo"), Some("foo".into()));
212    }
213
214    #[test]
215    fn data_attr_to_camel_hyphen_lowercase_becomes_uppercase() {
216        assert_eq!(data_attr_to_camel("data-foo-bar"), Some("fooBar".into()));
217    }
218
219    #[test]
220    fn data_attr_to_camel_round_trips_consecutive_uppercase_pattern() {
221        assert_eq!(
222            data_attr_to_camel("data-foo-b-a-rbaz"),
223            Some("fooBARbaz".into())
224        );
225    }
226
227    #[test]
228    fn data_attr_to_camel_rejects_non_data_prefix() {
229        assert_eq!(data_attr_to_camel("foo"), None);
230        assert_eq!(data_attr_to_camel("class"), None);
231    }
232
233    #[test]
234    fn data_attr_to_camel_rejects_empty_suffix() {
235        assert_eq!(data_attr_to_camel("data-"), None);
236    }
237
238    #[test]
239    fn data_attr_to_camel_rejects_uppercase_in_attr() {
240        // `data-Foo` is not a valid HTML data attribute name.
241        assert_eq!(data_attr_to_camel("data-Foo"), None);
242    }
243
244    #[test]
245    fn data_attr_to_camel_rejects_trailing_hyphen() {
246        // `data-foo-` would convert to `foo` then a stray hyphen
247        // that doesn't round-trip cleanly.
248        assert_eq!(data_attr_to_camel("data-foo-"), None);
249    }
250
251    #[test]
252    fn data_attr_to_camel_rejects_double_hyphen() {
253        // `data-foo--bar` — hyphen followed by hyphen has no
254        // letter to uppercase, so reject.
255        assert_eq!(data_attr_to_camel("data-foo--bar"), None);
256    }
257
258    // ── DomStringMap (read) ───────────────────────────────────────────
259
260    fn element_with_attrs(pairs: &[(&str, &str)]) -> (Dom, crate::node_id::NodeId) {
261        let mut dom: Dom = Dom::new();
262        let el = dom.create_element("div");
263        for (k, v) in pairs {
264            dom.set_attribute(el, k, v).unwrap();
265        }
266        (dom, el)
267    }
268
269    #[test]
270    fn get_foo_bar_reads_data_foo_bar() {
271        // Canonical step-12 failing test: dataset().get("fooBar")
272        // returns the value of the data-foo-bar attribute.
273        let (dom, el) = element_with_attrs(&[("data-foo-bar", "value!")]);
274        let ds = DomStringMap::new(dom.node(el));
275        assert_eq!(ds.get("fooBar"), Some("value!"));
276        assert!(ds.contains_key("fooBar"));
277    }
278
279    #[test]
280    fn get_missing_returns_none() {
281        let (dom, el) = element_with_attrs(&[]);
282        let ds = DomStringMap::new(dom.node(el));
283        assert_eq!(ds.get("foo"), None);
284        assert!(!ds.contains_key("foo"));
285    }
286
287    #[test]
288    fn iter_returns_round_tripable_attrs_with_camel_keys() {
289        let (dom, el) = element_with_attrs(&[
290            ("data-foo", "1"),
291            ("data-foo-bar", "2"),
292            ("class", "ignored"),
293            ("id", "ignored"),
294        ]);
295        let ds = DomStringMap::new(dom.node(el));
296        let mut entries: Vec<(String, &str)> = ds.iter().collect();
297        entries.sort_by(|a, b| a.0.cmp(&b.0));
298        assert_eq!(
299            entries,
300            vec![("foo".to_string(), "1"), ("fooBar".to_string(), "2"),]
301        );
302    }
303
304    #[test]
305    fn iter_skips_invalid_data_attrs() {
306        let (dom, el) = element_with_attrs(&[("data-foo", "ok"), ("data-", "skipped-empty")]);
307        let ds = DomStringMap::new(dom.node(el));
308        let entries: Vec<(String, &str)> = ds.iter().collect();
309        assert_eq!(entries, vec![("foo".to_string(), "ok")]);
310    }
311
312    #[test]
313    fn len_counts_only_valid_data_attrs() {
314        let (dom, el) = element_with_attrs(&[
315            ("data-foo", "1"),
316            ("data-bar-baz", "2"),
317            ("class", "ignored"),
318        ]);
319        let ds = DomStringMap::new(dom.node(el));
320        assert_eq!(ds.len(), 2);
321        assert!(!ds.is_empty());
322    }
323
324    #[test]
325    fn empty_dataset_is_empty() {
326        let (dom, el) = element_with_attrs(&[("class", "not-data")]);
327        let ds = DomStringMap::new(dom.node(el));
328        assert_eq!(ds.len(), 0);
329        assert!(ds.is_empty());
330    }
331
332    // ── DomStringMapMut (write) ──────────────────────────────────────
333
334    #[test]
335    fn set_writes_camel_key_as_kebab_data_attr() {
336        let (mut dom, el) = element_with_attrs(&[]);
337        let mut ds = DomStringMapMut::new(dom.node_mut(el));
338        ds.set("fooBar", "X").unwrap();
339        // Read back via the underlying attribute.
340        assert_eq!(dom.node(el).get_attribute("data-foo-bar"), Some("X"));
341    }
342
343    #[test]
344    fn set_then_get_round_trips() {
345        let (mut dom, el) = element_with_attrs(&[]);
346        {
347            let mut ds = DomStringMapMut::new(dom.node_mut(el));
348            ds.set("fooBARbaz", "weird").unwrap();
349        }
350        let ds_read = DomStringMap::new(dom.node(el));
351        assert_eq!(ds_read.get("fooBARbaz"), Some("weird"));
352    }
353
354    #[test]
355    fn remove_returns_true_when_present() {
356        let (mut dom, el) = element_with_attrs(&[("data-foo", "1")]);
357        let mut ds = DomStringMapMut::new(dom.node_mut(el));
358        assert!(ds.remove("foo").unwrap());
359        assert!(!ds.remove("foo").unwrap(), "second remove finds nothing");
360    }
361
362    #[test]
363    fn set_empty_key_is_ignored() {
364        let (mut dom, el) = element_with_attrs(&[]);
365        let mut ds = DomStringMapMut::new(dom.node_mut(el));
366        ds.set("", "value").unwrap();
367        // No `data-` attribute should have been written.
368        assert!(dom.node(el).get_attribute("data-").is_none());
369    }
370}