Skip to main content

telegram_webapp_sdk/dom/
element.rs

1// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use wasm_bindgen::{JsCast, JsValue, closure::Closure};
5use web_sys::{Element, EventTarget, Node};
6
7/// Ergonomic extension methods for [`web_sys::Element`].
8///
9/// Provides concise helpers for common DOM manipulations (attributes, classes,
10/// text/HTML content, event listeners, and child insertion/removal) on top of
11/// the lower-level `web-sys` API.
12pub trait ElementExt {
13    /// Sets the `class` attribute, replacing any existing class list.
14    fn set_class(&self, class: &str);
15    /// Sets the `id` attribute.
16    fn set_id(&self, id: &str);
17    /// Replaces the element's text content with `text`.
18    fn set_text(&self, text: &str);
19    /// Replaces the element's inner HTML with `html`.
20    fn set_html(&self, html: &str) -> Result<(), JsValue>;
21    /// Sets attribute `attr` to `value`.
22    fn set_attr(&self, attr: &str, value: &str) -> Result<(), JsValue>;
23    /// Returns the value of attribute `attr`, or `None` if it is absent.
24    fn get_attr(&self, attr: &str) -> Option<String>;
25    /// Removes attribute `attr`.
26    fn remove_attr(&self, attr: &str) -> Result<(), JsValue>;
27    /// Adds `class` to the `class` attribute if it is not already present.
28    fn add_class(&self, class: &str) -> Result<(), JsValue>;
29    /// Removes `class` from the `class` attribute, dropping the attribute
30    /// entirely when no classes remain.
31    fn remove_class(&self, class: &str) -> Result<(), JsValue>;
32    /// Adds `class` if absent, or removes it if present.
33    fn toggle_class(&self, class: &str) -> Result<(), JsValue>;
34    /// Returns `true` if the `class` attribute contains `class` as a whole
35    /// token.
36    fn has_class(&self, class: &str) -> bool;
37    /// Attaches `handler` as a listener for `event`.
38    ///
39    /// The underlying closure is leaked so the listener remains valid for the
40    /// lifetime of the element.
41    fn on<F>(&self, event: &str, handler: F) -> Result<(), JsValue>
42    where
43        F: FnMut(web_sys::Event) + 'static;
44    /// Appends `child` as the last child of this element.
45    fn append(&self, child: &Element) -> Result<(), JsValue>;
46    /// Inserts `child` as the first child of this element, or appends it when
47    /// the element has no children.
48    fn prepend(&self, child: &Element) -> Result<(), JsValue>;
49    /// Detaches this element from its parent; a no-op when it has no parent.
50    fn remove(&self) -> Result<(), JsValue>;
51    /// Removes all child nodes of this element.
52    fn clear(&self);
53}
54
55impl ElementExt for Element {
56    fn set_class(&self, class: &str) {
57        self.set_attribute("class", class).ok();
58    }
59
60    fn set_id(&self, id: &str) {
61        self.set_attribute("id", id).ok();
62    }
63
64    fn set_text(&self, text: &str) {
65        self.set_text_content(Some(text));
66    }
67
68    fn set_html(&self, html: &str) -> Result<(), JsValue> {
69        self.set_inner_html(html);
70        Ok(())
71    }
72
73    fn set_attr(&self, attr: &str, value: &str) -> Result<(), JsValue> {
74        self.set_attribute(attr, value)
75    }
76
77    fn get_attr(&self, attr: &str) -> Option<String> {
78        self.get_attribute(attr)
79    }
80
81    fn remove_attr(&self, attr: &str) -> Result<(), JsValue> {
82        self.remove_attribute(attr)
83    }
84
85    fn add_class(&self, class: &str) -> Result<(), JsValue> {
86        let current = self.get_attribute("class").unwrap_or_default();
87        if !current.split_whitespace().any(|c| c == class) {
88            let new_class = if current.is_empty() {
89                class.to_string()
90            } else {
91                format!("{} {}", current, class)
92            };
93            self.set_attribute("class", &new_class)?;
94        }
95        Ok(())
96    }
97
98    fn remove_class(&self, class: &str) -> Result<(), JsValue> {
99        let current = self.get_attribute("class").unwrap_or_default();
100        let new_class: String = current
101            .split_whitespace()
102            .filter(|c| *c != class)
103            .collect::<Vec<_>>()
104            .join(" ");
105        if new_class.is_empty() {
106            self.remove_attribute("class")?;
107        } else {
108            self.set_attribute("class", &new_class)?;
109        }
110        Ok(())
111    }
112
113    fn toggle_class(&self, class: &str) -> Result<(), JsValue> {
114        if self.has_class(class) {
115            self.remove_class(class)?;
116        } else {
117            self.add_class(class)?;
118        }
119        Ok(())
120    }
121
122    fn has_class(&self, class: &str) -> bool {
123        self.get_attribute("class")
124            .map(|c| c.split_whitespace().any(|x| x == class))
125            .unwrap_or(false)
126    }
127
128    fn on<F>(&self, event: &str, mut handler: F) -> Result<(), JsValue>
129    where
130        F: FnMut(web_sys::Event) + 'static
131    {
132        let target: EventTarget = self.clone().unchecked_into();
133        let closure = Closure::wrap(Box::new(move |e: web_sys::Event| {
134            handler(e);
135        }) as Box<dyn FnMut(_)>);
136
137        target.add_event_listener_with_callback(event, closure.as_ref().unchecked_ref())?;
138        closure.forget();
139
140        Ok(())
141    }
142
143    fn append(&self, child: &Element) -> Result<(), JsValue> {
144        Node::append_child(self, child)
145            .map(|_| ())
146            .map_err(|e| e.unchecked_into())
147    }
148
149    fn prepend(&self, child: &Element) -> Result<(), JsValue> {
150        if let Some(first) = self.first_child() {
151            Node::insert_before(self, child, Some(&first))
152                .map(|_| ())
153                .map_err(|e| e.unchecked_into())
154        } else {
155            self.append(child)
156        }
157    }
158
159    fn remove(&self) -> Result<(), JsValue> {
160        if let Some(parent) = self.parent_element() {
161            Node::remove_child(&parent, self)
162                .map(|_| ())
163                .map_err(|e| e.unchecked_into())
164        } else {
165            Ok(())
166        }
167    }
168
169    fn clear(&self) {
170        while let Some(child) = self.first_child() {
171            let _ = Node::remove_child(self, &child);
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use std::{cell::Cell, rc::Rc};
179
180    use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
181    use web_sys::Element;
182
183    use super::*;
184    use crate::dom::Document;
185
186    wasm_bindgen_test_configure!(run_in_browser);
187
188    fn make(tag: &str) -> Element {
189        Document.create_element(tag).expect("create")
190    }
191
192    #[wasm_bindgen_test]
193    #[allow(dead_code, clippy::unused_unit)]
194    fn set_class_sets_attribute() {
195        let el = make("div");
196        el.set_class("a b");
197        assert_eq!(el.get_attribute("class").as_deref(), Some("a b"));
198    }
199
200    #[wasm_bindgen_test]
201    #[allow(dead_code, clippy::unused_unit)]
202    fn set_id_sets_attribute() {
203        let el = make("div");
204        el.set_id("x");
205        assert_eq!(el.get_attribute("id").as_deref(), Some("x"));
206    }
207
208    #[wasm_bindgen_test]
209    #[allow(dead_code, clippy::unused_unit)]
210    fn set_text_sets_text_content() {
211        let el = make("p");
212        el.set_text("hello");
213        assert_eq!(el.text_content().as_deref(), Some("hello"));
214    }
215
216    #[wasm_bindgen_test]
217    #[allow(dead_code, clippy::unused_unit)]
218    fn set_html_sets_inner_html() {
219        let el = make("div");
220        el.set_html("<b>bold</b>").expect("ok");
221        assert!(el.inner_html().contains("<b>"));
222    }
223
224    #[wasm_bindgen_test]
225    #[allow(dead_code, clippy::unused_unit)]
226    fn set_and_get_attr() {
227        let el = make("div");
228        el.set_attr("data-x", "42").expect("ok");
229        assert_eq!(el.get_attr("data-x").as_deref(), Some("42"));
230    }
231
232    #[wasm_bindgen_test]
233    #[allow(dead_code, clippy::unused_unit)]
234    fn remove_attr_clears_value() {
235        let el = make("div");
236        el.set_attr("data-x", "42").expect("ok");
237        el.remove_attr("data-x").expect("ok");
238        assert!(el.get_attr("data-x").is_none());
239    }
240
241    #[wasm_bindgen_test]
242    #[allow(dead_code, clippy::unused_unit)]
243    fn add_class_appends_and_dedupes() {
244        let el = make("div");
245        el.add_class("a").expect("ok");
246        el.add_class("b").expect("ok");
247        el.add_class("a").expect("ok");
248        assert_eq!(el.get_attribute("class").as_deref(), Some("a b"));
249    }
250
251    #[wasm_bindgen_test]
252    #[allow(dead_code, clippy::unused_unit)]
253    fn remove_class_filters_and_clears_when_empty() {
254        let el = make("div");
255        el.set_class("a b");
256        el.remove_class("a").expect("ok");
257        assert_eq!(el.get_attribute("class").as_deref(), Some("b"));
258        el.remove_class("b").expect("ok");
259        assert!(el.get_attribute("class").is_none());
260    }
261
262    #[wasm_bindgen_test]
263    #[allow(dead_code, clippy::unused_unit)]
264    fn toggle_class_round_trip() {
265        let el = make("div");
266        el.toggle_class("on").expect("ok");
267        assert!(el.has_class("on"));
268        el.toggle_class("on").expect("ok");
269        assert!(!el.has_class("on"));
270    }
271
272    #[wasm_bindgen_test]
273    #[allow(dead_code, clippy::unused_unit)]
274    fn has_class_finds_only_whole_tokens() {
275        let el = make("div");
276        el.set_class("alpha beta");
277        assert!(el.has_class("alpha"));
278        assert!(el.has_class("beta"));
279        assert!(!el.has_class("alph"));
280        assert!(!el.has_class("gamma"));
281    }
282
283    #[wasm_bindgen_test]
284    #[allow(dead_code, clippy::unused_unit)]
285    fn on_event_fires_handler() {
286        let body = Document.body().expect("body");
287        let el = make("button");
288        body.append_child(&el).expect("attach");
289
290        let hits = Rc::new(Cell::new(0u32));
291        let hits_cb = hits.clone();
292        el.on("click", move |_| hits_cb.set(hits_cb.get() + 1))
293            .expect("ok");
294
295        let evt = web_sys::Event::new("click").expect("event");
296        el.dispatch_event(&evt).expect("dispatch");
297
298        assert_eq!(hits.get(), 1);
299        el.remove();
300    }
301
302    #[wasm_bindgen_test]
303    #[allow(dead_code, clippy::unused_unit)]
304    fn append_places_child_at_end() {
305        let parent = make("ul");
306        let a = make("li");
307        a.set_id("a");
308        let b = make("li");
309        b.set_id("b");
310        parent.append(&a).expect("ok");
311        parent.append(&b).expect("ok");
312
313        let last = parent.last_element_child().expect("last");
314        assert_eq!(last.id(), "b");
315    }
316
317    #[wasm_bindgen_test]
318    #[allow(dead_code, clippy::unused_unit)]
319    fn prepend_inserts_at_start_or_appends_when_empty() {
320        let parent = make("ul");
321        let a = make("li");
322        a.set_id("a");
323        parent.prepend(&a).expect("ok");
324        assert_eq!(parent.first_element_child().expect("first").id(), "a");
325
326        let b = make("li");
327        b.set_id("b");
328        parent.prepend(&b).expect("ok");
329        assert_eq!(parent.first_element_child().expect("first").id(), "b");
330    }
331
332    #[wasm_bindgen_test]
333    #[allow(dead_code, clippy::unused_unit)]
334    fn remove_detaches_and_is_noop_without_parent() {
335        let parent = make("div");
336        let child = make("span");
337        parent.append(&child).expect("ok");
338        assert!(parent.first_element_child().is_some());
339        // UFCS because `web_sys::Element::remove` also exists and shadows the trait
340        // method.
341        ElementExt::remove(&child).expect("detach");
342        assert!(parent.first_element_child().is_none());
343
344        let orphan = make("div");
345        ElementExt::remove(&orphan).expect("noop");
346    }
347
348    fn element_child_count(parent: &Element) -> usize {
349        let mut count = 0usize;
350        let mut next = parent.first_element_child();
351        while let Some(el) = next {
352            count += 1;
353            next = el.next_element_sibling();
354        }
355        count
356    }
357
358    #[wasm_bindgen_test]
359    #[allow(dead_code, clippy::unused_unit)]
360    fn clear_removes_all_children() {
361        let parent = make("div");
362        for _ in 0..3 {
363            parent.append(&make("span")).expect("ok");
364        }
365        assert_eq!(element_child_count(&parent), 3);
366        parent.clear();
367        assert_eq!(element_child_count(&parent), 0);
368    }
369}