1use wasm_bindgen::{JsCast, JsValue, closure::Closure};
5use web_sys::{Element, EventTarget, Node};
6
7pub trait ElementExt {
13 fn set_class(&self, class: &str);
15 fn set_id(&self, id: &str);
17 fn set_text(&self, text: &str);
19 fn set_html(&self, html: &str) -> Result<(), JsValue>;
21 fn set_attr(&self, attr: &str, value: &str) -> Result<(), JsValue>;
23 fn get_attr(&self, attr: &str) -> Option<String>;
25 fn remove_attr(&self, attr: &str) -> Result<(), JsValue>;
27 fn add_class(&self, class: &str) -> Result<(), JsValue>;
29 fn remove_class(&self, class: &str) -> Result<(), JsValue>;
32 fn toggle_class(&self, class: &str) -> Result<(), JsValue>;
34 fn has_class(&self, class: &str) -> bool;
37 fn on<F>(&self, event: &str, handler: F) -> Result<(), JsValue>
42 where
43 F: FnMut(web_sys::Event) + 'static;
44 fn append(&self, child: &Element) -> Result<(), JsValue>;
46 fn prepend(&self, child: &Element) -> Result<(), JsValue>;
49 fn remove(&self) -> Result<(), JsValue>;
51 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 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}