1use crate::accessor::{NodeMut, NodeRef};
25use crate::error::Result;
26
27pub struct DomStringMap<'a, Ext: 'static> {
31 node: NodeRef<'a, Ext>,
32}
33
34impl<'a, Ext: 'static> DomStringMap<'a, Ext> {
35 pub fn new(node: NodeRef<'a, Ext>) -> Self {
38 Self { node }
39 }
40
41 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 pub fn contains_key(&self, key: &str) -> bool {
52 self.get(key).is_some()
53 }
54
55 pub fn iter(&self) -> impl Iterator<Item = (String, &'a str)> + 'a {
61 self.node
64 .dom
65 .attributes(self.node.id)
66 .filter_map(|(name, value)| Some((data_attr_to_camel(name)?, value)))
67 }
68
69 pub fn len(&self) -> usize {
72 self.iter().count()
73 }
74
75 pub fn is_empty(&self) -> bool {
77 self.iter().next().is_none()
78 }
79}
80
81pub struct DomStringMapMut<'a, Ext: 'static> {
86 node: NodeMut<'a, Ext>,
87}
88
89impl<'a, Ext: 'static> DomStringMapMut<'a, Ext> {
90 pub fn new(node: NodeMut<'a, Ext>) -> Self {
93 Self { node }
94 }
95
96 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
100 let Some(attr) = camel_to_data_attr(key) else {
101 return Ok(()); };
103 self.node.set_attribute(&attr, value).map(|_| ())
104 }
105
106 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
117fn 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
137fn 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 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 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 #[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 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 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 assert_eq!(data_attr_to_camel("data-Foo"), None);
242 }
243
244 #[test]
245 fn data_attr_to_camel_rejects_trailing_hyphen() {
246 assert_eq!(data_attr_to_camel("data-foo-"), None);
249 }
250
251 #[test]
252 fn data_attr_to_camel_rejects_double_hyphen() {
253 assert_eq!(data_attr_to_camel("data-foo--bar"), None);
256 }
257
258 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 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 #[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 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 assert!(dom.node(el).get_attribute("data-").is_none());
369 }
370}