Skip to main content

rdom_core/
token_list.rs

1//! `DomTokenList` + `DomTokenListMut` — DOM-faithful snapshot
2//! wrapper for ordered string-token attributes (today: `class`,
3//! tomorrow: `rel`, `sandbox`, …).
4//!
5//! ## Live vs snapshot
6//!
7//! Per the M4 scope lock (parity ledger §25 #2), wrapper **shape**
8//! ships but **liveness** does not. [`DomTokenList`] takes a
9//! snapshot of the tokens at construction; re-call the accessor on
10//! the element to refresh. Listeners that need to react to class
11//! changes use `MutationObserver`.
12//!
13//! ## API shape
14//!
15//! Read side ([`DomTokenList`]) — `len`, `is_empty`, `item(i)`,
16//! `value()`, `contains(token)`, `iter()`.
17//!
18//! Write side ([`DomTokenListMut`]) — `add`, `remove`,
19//! `toggle(token, force)`, `replace(old, new)`, `supports(token)`.
20//! All mutations route through the existing
21//! `add_class` / `remove_class` / `replace_class` paths on
22//! `Dom<Ext>` so cascade invalidation, mutation events, and the
23//! per-class index stay in lockstep.
24//!
25//! The author-facing constructors `NodeRef::class_list()` /
26//! `NodeMut::class_list_mut()` ship in M4b step 14 / 15; this
27//! module's types are public and accept a `NodeMut` in their
28//! constructor so tests can exercise the shape today.
29
30use crate::accessor::NodeMut;
31use crate::error::Result;
32
33/// Snapshot of an element's ordered class tokens (DOM
34/// `Element.classList` read-side view). Re-construct via the
35/// element accessor to refresh after a class mutation.
36///
37/// `tokens` preserves the iteration order of the underlying
38/// class set; rdom-core stores classes in a `BTreeSet`, so
39/// iteration is alphabetic. Browsers preserve insertion order;
40/// this is a documented divergence (the same one `class_list()`
41/// has carried since Phase 1).
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct DomTokenList {
44    tokens: Vec<String>,
45}
46
47impl DomTokenList {
48    /// Construct from an iterator of class tokens. Used by the
49    /// `NodeRef::class_list()` integration (M4b step 14); exposed
50    /// publicly so tests and other consumers can build snapshots
51    /// directly.
52    pub fn from_tokens(tokens: impl IntoIterator<Item = String>) -> Self {
53        Self {
54            tokens: tokens.into_iter().collect(),
55        }
56    }
57
58    /// Number of tokens in the snapshot. DOM `length`.
59    pub fn len(&self) -> usize {
60        self.tokens.len()
61    }
62
63    /// `true` iff the snapshot has no tokens.
64    pub fn is_empty(&self) -> bool {
65        self.tokens.is_empty()
66    }
67
68    /// Borrow the token at `index`, or `None` if out of bounds.
69    /// DOM `item(i)`.
70    pub fn item(&self, index: usize) -> Option<&str> {
71        self.tokens.get(index).map(String::as_str)
72    }
73
74    /// Re-serialize the snapshot as a space-joined string. DOM
75    /// `value` / `toString()`.
76    pub fn value(&self) -> String {
77        self.tokens.join(" ")
78    }
79
80    /// `true` iff the snapshot contains `token`. DOM `contains`.
81    pub fn contains(&self, token: &str) -> bool {
82        self.tokens.iter().any(|t| t == token)
83    }
84
85    /// Iterate token strings in snapshot order.
86    pub fn iter(&self) -> impl Iterator<Item = &str> + '_ {
87        self.tokens.iter().map(String::as_str)
88    }
89}
90
91/// Mutable handle to an element's class tokens. Holds a `NodeMut`
92/// so mutations route through the existing class-set machinery
93/// (`add_class` / `remove_class` / `replace_class`), keeping
94/// cascade dirty-tracking and the per-class index in sync.
95pub struct DomTokenListMut<'a, Ext: 'static> {
96    node: NodeMut<'a, Ext>,
97}
98
99impl<'a, Ext: 'static> DomTokenListMut<'a, Ext> {
100    /// Construct from a borrowed `NodeMut`. The mutable list shares
101    /// the node's lifetime — drop the list to release the borrow.
102    pub fn new(node: NodeMut<'a, Ext>) -> Self {
103        Self { node }
104    }
105
106    /// Add `token` to the class set. Idempotent — no-op if the
107    /// token is already present. DOM `add`.
108    pub fn add(&mut self, token: &str) -> Result<()> {
109        self.node.add_class(token)
110    }
111
112    /// Remove `token` from the class set. Returns `true` iff a
113    /// token was actually removed (`false` when the token wasn't
114    /// present). DOM `remove`.
115    pub fn remove(&mut self, token: &str) -> Result<bool> {
116        self.node.remove_class(token)
117    }
118
119    /// Toggle `token` with optional force.
120    ///
121    /// - `force = Some(true)` → ensure present; returns `true`.
122    /// - `force = Some(false)` → ensure absent; returns `false`.
123    /// - `force = None` → flip; returns the post-flip presence.
124    ///
125    /// DOM `toggle(token, force)`.
126    pub fn toggle(&mut self, token: &str, force: Option<bool>) -> Result<bool> {
127        let has = self.contains(token);
128        match force {
129            Some(true) => {
130                if !has {
131                    self.add(token)?;
132                }
133                Ok(true)
134            }
135            Some(false) => {
136                if has {
137                    self.remove(token)?;
138                }
139                Ok(false)
140            }
141            None => {
142                if has {
143                    self.remove(token)?;
144                    Ok(false)
145                } else {
146                    self.add(token)?;
147                    Ok(true)
148                }
149            }
150        }
151    }
152
153    /// Replace `old` with `new`. Returns `true` iff the
154    /// replacement happened (i.e., `old` was present). DOM
155    /// `replace(old, new)`.
156    pub fn replace(&mut self, old: &str, new: &str) -> Result<bool> {
157        self.node.replace_class(old, new)
158    }
159
160    /// `true` iff `token` is in the supported-token list for this
161    /// attribute. DOM spec only defines this for specific
162    /// element-attribute pairs (`<link>.relList`,
163    /// `<iframe>.sandbox`, …); for `classList` and everything else
164    /// rdom doesn't track, returns `false`. DOM `supports`.
165    pub fn supports(&self, _token: &str) -> bool {
166        false
167    }
168
169    /// Borrow the current token set without releasing the `NodeMut`.
170    pub fn contains(&self, token: &str) -> bool {
171        self.node.as_ref().has_class(token)
172    }
173
174    /// Number of tokens currently on the element. DOM `length`.
175    pub fn len(&self) -> usize {
176        self.node.dom.class_list(self.node.id).count()
177    }
178
179    /// `true` iff the element has no class tokens.
180    pub fn is_empty(&self) -> bool {
181        self.node.dom.class_list(self.node.id).next().is_none()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::Dom;
189
190    // ── DomTokenList (read-side snapshot) ─────────────────────────────
191
192    #[test]
193    fn snapshot_from_tokens_preserves_input_order() {
194        let list = DomTokenList::from_tokens(vec!["a".into(), "b".into(), "c".into()]);
195        assert_eq!(list.len(), 3);
196        assert!(!list.is_empty());
197        assert_eq!(list.item(0), Some("a"));
198        assert_eq!(list.item(1), Some("b"));
199        assert_eq!(list.item(2), Some("c"));
200        assert_eq!(list.item(3), None);
201    }
202
203    #[test]
204    fn snapshot_value_is_space_joined() {
205        let list = DomTokenList::from_tokens(vec!["foo".into(), "bar".into()]);
206        assert_eq!(list.value(), "foo bar");
207    }
208
209    #[test]
210    fn empty_snapshot_value_is_empty_string() {
211        let list = DomTokenList::from_tokens(Vec::<String>::new());
212        assert!(list.is_empty());
213        assert_eq!(list.value(), "");
214    }
215
216    #[test]
217    fn snapshot_contains_iter() {
218        let list = DomTokenList::from_tokens(vec!["x".into(), "y".into()]);
219        assert!(list.contains("x"));
220        assert!(list.contains("y"));
221        assert!(!list.contains("z"));
222        let collected: Vec<&str> = list.iter().collect();
223        assert_eq!(collected, ["x", "y"]);
224    }
225
226    // ── DomTokenListMut (mutating side) ──────────────────────────────
227
228    fn element_with_classes(classes: &[&str]) -> (Dom, crate::node_id::NodeId) {
229        let mut dom: Dom = Dom::new();
230        let el = dom.create_element("div");
231        for c in classes {
232            dom.add_class(el, c).unwrap();
233        }
234        (dom, el)
235    }
236
237    #[test]
238    fn add_is_idempotent() {
239        let (mut dom, el) = element_with_classes(&[]);
240        let mut list = DomTokenListMut::new(dom.node_mut(el));
241        list.add("foo").unwrap();
242        list.add("foo").unwrap();
243        assert!(list.contains("foo"));
244        assert_eq!(list.len(), 1);
245    }
246
247    #[test]
248    fn remove_returns_true_when_present_false_when_absent() {
249        let (mut dom, el) = element_with_classes(&["foo"]);
250        let mut list = DomTokenListMut::new(dom.node_mut(el));
251        assert!(list.remove("foo").unwrap());
252        assert!(!list.remove("foo").unwrap());
253        assert!(!list.contains("foo"));
254    }
255
256    #[test]
257    fn toggle_with_force_true_is_add_idempotent() {
258        // Canonical step-10 failing test: `toggle(name, Some(true))`
259        // must be force-add — present-or-not, the post-state is
260        // present, and the returned boolean is `true`.
261        let (mut dom, el) = element_with_classes(&["foo"]);
262        let mut list = DomTokenListMut::new(dom.node_mut(el));
263
264        // Already present: force-add returns true, no removal.
265        assert!(list.toggle("foo", Some(true)).unwrap());
266        assert!(list.contains("foo"));
267
268        // Not present: force-add adds it, returns true.
269        assert!(list.toggle("bar", Some(true)).unwrap());
270        assert!(list.contains("bar"));
271    }
272
273    #[test]
274    fn toggle_with_force_false_is_remove_idempotent() {
275        let (mut dom, el) = element_with_classes(&["foo"]);
276        let mut list = DomTokenListMut::new(dom.node_mut(el));
277
278        // Present: force-remove returns false, token gone.
279        assert!(!list.toggle("foo", Some(false)).unwrap());
280        assert!(!list.contains("foo"));
281
282        // Not present: force-remove returns false, still gone.
283        assert!(!list.toggle("foo", Some(false)).unwrap());
284    }
285
286    #[test]
287    fn toggle_with_no_force_flips_state() {
288        let (mut dom, el) = element_with_classes(&[]);
289        let mut list = DomTokenListMut::new(dom.node_mut(el));
290
291        // Not present → toggle → present, returns true.
292        assert!(list.toggle("foo", None).unwrap());
293        assert!(list.contains("foo"));
294
295        // Present → toggle → absent, returns false.
296        assert!(!list.toggle("foo", None).unwrap());
297        assert!(!list.contains("foo"));
298    }
299
300    #[test]
301    fn replace_swaps_existing_token() {
302        let (mut dom, el) = element_with_classes(&["foo", "bar"]);
303        let mut list = DomTokenListMut::new(dom.node_mut(el));
304        assert!(list.replace("foo", "baz").unwrap());
305        assert!(!list.contains("foo"));
306        assert!(list.contains("baz"));
307        assert!(list.contains("bar"));
308    }
309
310    #[test]
311    fn replace_returns_false_when_old_absent() {
312        let (mut dom, el) = element_with_classes(&["foo"]);
313        let mut list = DomTokenListMut::new(dom.node_mut(el));
314        assert!(!list.replace("missing", "new").unwrap());
315        assert!(!list.contains("new"));
316    }
317
318    #[test]
319    fn supports_always_false_for_classlist() {
320        let (mut dom, el) = element_with_classes(&[]);
321        let list = DomTokenListMut::new(dom.node_mut(el));
322        // classList has no supported-token list; spec requires false.
323        assert!(!list.supports("foo"));
324        assert!(!list.supports("anything"));
325    }
326
327    #[test]
328    fn len_and_is_empty_reflect_current_state() {
329        let (mut dom, el) = element_with_classes(&[]);
330        let mut list = DomTokenListMut::new(dom.node_mut(el));
331        assert!(list.is_empty());
332        assert_eq!(list.len(), 0);
333
334        list.add("a").unwrap();
335        list.add("b").unwrap();
336        assert!(!list.is_empty());
337        assert_eq!(list.len(), 2);
338    }
339}