1#![forbid(unsafe_code)]
8use std::cell::RefCell;
9use wasm_bindgen::prelude::*;
10use wasm_bindgen::{JsCast, UnwrapThrowExt};
11use web_sys::{Document, Storage, Window};
12
13const BOUND_ATTR: &str = "data-wasm-theme-bound";
18
19thread_local! {
20 static CACHED_THEME: RefCell<Option<String>> = const { RefCell::new(None) };
27}
28
29fn local_storage(window: &Window) -> Option<Storage> {
35 window.local_storage().ok().flatten()
36}
37
38fn store_theme(storage: Option<&Storage>, theme: &str) {
42 let Some(storage) = storage else { return };
43 if storage.set_item("theme", theme).is_err() {
44 web_sys::console::error_1(&JsValue::from_str(
45 "wasm-theme: failed to persist theme to local storage",
46 ));
47 }
48}
49
50fn set_data_theme(document: &Document, theme: &str) {
52 let Some(document_element) = document.document_element() else {
53 return;
54 };
55 if document_element.set_attribute("data-theme", theme).is_err() {
56 web_sys::console::error_1(&JsValue::from_str(
57 "wasm-theme: failed to set data-theme attribute",
58 ));
59 }
60}
61
62fn prefers_color_scheme(window: &Window, document: &Document) -> String {
68 if let Some(cached) = CACHED_THEME.with_borrow(Clone::clone) {
69 return cached;
70 }
71
72 let storage = local_storage(window);
73 let stored = storage
74 .as_ref()
75 .and_then(|storage| storage.get_item("theme").ok().flatten());
76
77 let data_theme = stored.unwrap_or_else(|| {
78 let prefers_dark = window
79 .match_media("(prefers-color-scheme: dark)")
80 .ok()
81 .flatten()
82 .is_some_and(|scheme| scheme.matches());
83 let theme = if prefers_dark { "dark" } else { "default" }.to_string();
84 store_theme(storage.as_ref(), &theme);
85 theme
86 });
87
88 set_data_theme(document, &data_theme);
89 CACHED_THEME.with_borrow_mut(|cached| *cached = Some(data_theme.clone()));
90 data_theme
91}
92
93fn checked_value(value: &str) -> &str {
97 value.rsplit(',').next().unwrap_or(value)
98}
99
100fn toggle_theme(value: &str, checked: bool) -> &str {
104 let mut itr = value.rsplitn(2, ',');
105 let checked_value = itr.next();
106 let unchecked_value = itr.next();
107
108 if checked {
109 checked_value.unwrap_or(value)
110 } else {
111 unchecked_value.unwrap_or("default")
112 }
113}
114
115fn toggle_callback(window: Window, document: Document) -> Closure<dyn FnMut(web_sys::Event)> {
116 Closure::wrap(Box::new(move |e: web_sys::Event| {
117 let Some(input) = e
118 .current_target()
119 .and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
120 else {
121 return;
122 };
123
124 let value = input.value();
125 let theme = toggle_theme(&value, input.checked());
126
127 set_data_theme(&document, theme);
128 store_theme(local_storage(&window).as_ref(), theme);
129 CACHED_THEME.with_borrow_mut(|cached| *cached = Some(theme.to_string()));
130 }) as Box<dyn FnMut(_)>)
131}
132
133#[wasm_bindgen]
149pub fn theme_toggle() {
150 let window = web_sys::window().expect_throw("Could not access window");
151 let document = window
152 .document()
153 .expect_throw("Could not access window document");
154 let prefered = prefers_color_scheme(&window, &document);
155
156 let Ok(check_boxes) = document.query_selector_all("[name=theme-toggle]") else {
157 return;
158 };
159
160 let mut callback: Option<Closure<dyn FnMut(web_sys::Event)>> = None;
161
162 for entry in check_boxes.values() {
163 let Ok(node) = entry else { continue };
164 let Ok(element) = node.dyn_into::<web_sys::HtmlInputElement>() else {
165 continue;
166 };
167
168 if element.has_attribute(BOUND_ATTR) {
169 continue;
170 }
171
172 let value = element.value();
173 if checked_value(&value) == prefered {
174 element.set_checked(true);
175 }
176
177 let callback =
178 callback.get_or_insert_with(|| toggle_callback(window.clone(), document.clone()));
179
180 if element
181 .add_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
182 .is_ok()
183 {
184 let _ = element.set_attribute(BOUND_ATTR, "");
185 }
186 }
187
188 if let Some(callback) = callback {
189 callback.forget();
190 }
191}
192
193fn radio_callback(window: Window, document: Document) -> Closure<dyn FnMut(web_sys::Event)> {
194 Closure::wrap(Box::new(move |e: web_sys::Event| {
195 let Some(input) = e
196 .current_target()
197 .and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
198 else {
199 return;
200 };
201
202 let theme = input.value();
203 set_data_theme(&document, &theme);
204 store_theme(local_storage(&window).as_ref(), &theme);
205 CACHED_THEME.with_borrow_mut(|cached| *cached = Some(theme));
206 }) as Box<dyn FnMut(_)>)
207}
208
209#[wasm_bindgen]
223pub fn theme_radio() {
224 let window = web_sys::window().expect_throw("Could not access window");
225 let document = window
226 .document()
227 .expect_throw("Could not access window document");
228 let prefered = prefers_color_scheme(&window, &document);
229
230 let Ok(radios) = document.query_selector_all("[name=theme-radios]") else {
231 return;
232 };
233
234 let mut callback: Option<Closure<dyn FnMut(web_sys::Event)>> = None;
235
236 for entry in radios.values() {
237 let Ok(node) = entry else { continue };
238 let Ok(element) = node.dyn_into::<web_sys::HtmlInputElement>() else {
239 continue;
240 };
241
242 if element.has_attribute(BOUND_ATTR) {
243 continue;
244 }
245
246 if element.value() == prefered {
247 element.set_checked(true);
248 }
249
250 let callback = callback.get_or_insert_with(|| radio_callback(window.clone(), document.clone()));
251
252 if element
253 .add_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
254 .is_ok()
255 {
256 let _ = element.set_attribute(BOUND_ATTR, "");
257 }
258 }
259
260 if let Some(callback) = callback {
261 callback.forget();
262 }
263}
264
265fn button_callback(window: Window, document: Document) -> Closure<dyn FnMut(web_sys::Event)> {
266 Closure::wrap(Box::new(move |e: web_sys::Event| {
267 let Some(button) = e
268 .current_target()
269 .and_then(|target| target.dyn_into::<web_sys::HtmlButtonElement>().ok())
270 else {
271 return;
272 };
273
274 let theme = button.value();
275 set_data_theme(&document, &theme);
276 store_theme(local_storage(&window).as_ref(), &theme);
277 CACHED_THEME.with_borrow_mut(|cached| *cached = Some(theme));
278 }) as Box<dyn FnMut(_)>)
279}
280
281#[wasm_bindgen]
294pub fn theme_buttons() {
295 let window = web_sys::window().expect_throw("Could not access window");
296 let document = window
297 .document()
298 .expect_throw("Could not access window document");
299 let _prefered = prefers_color_scheme(&window, &document);
303
304 let Ok(buttons) = document.query_selector_all("[name=theme-button]") else {
305 return;
306 };
307
308 let mut callback: Option<Closure<dyn FnMut(web_sys::Event)>> = None;
309
310 for entry in buttons.values() {
311 let Ok(node) = entry else { continue };
312 let Ok(element) = node.dyn_into::<web_sys::HtmlButtonElement>() else {
313 continue;
314 };
315
316 if element.has_attribute(BOUND_ATTR) {
317 continue;
318 }
319
320 let callback =
321 callback.get_or_insert_with(|| button_callback(window.clone(), document.clone()));
322
323 if element
324 .add_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
325 .is_ok()
326 {
327 let _ = element.set_attribute(BOUND_ATTR, "");
328 }
329 }
330
331 if let Some(callback) = callback {
332 callback.forget();
333 }
334}
335
336fn select_callback(window: Window, document: Document) -> Closure<dyn FnMut(web_sys::Event)> {
337 Closure::wrap(Box::new(move |e: web_sys::Event| {
338 let Some(select) = e
339 .current_target()
340 .and_then(|target| target.dyn_into::<web_sys::HtmlSelectElement>().ok())
341 else {
342 return;
343 };
344
345 let theme = select.value();
346 set_data_theme(&document, &theme);
347 store_theme(local_storage(&window).as_ref(), &theme);
348 CACHED_THEME.with_borrow_mut(|cached| *cached = Some(theme));
349 }) as Box<dyn FnMut(_)>)
350}
351
352#[wasm_bindgen]
366pub fn theme_select() {
367 let window = web_sys::window().expect_throw("Could not access window");
368 let document = window
369 .document()
370 .expect_throw("Could not access window document");
371 let prefered = prefers_color_scheme(&window, &document);
372
373 let Ok(selects) = document.query_selector_all("[name=theme-select]") else {
374 return;
375 };
376
377 let mut callback: Option<Closure<dyn FnMut(web_sys::Event)>> = None;
378
379 for entry in selects.values() {
380 let Ok(node) = entry else { continue };
381 let Ok(element) = node.dyn_into::<web_sys::HtmlSelectElement>() else {
382 continue;
383 };
384
385 if element.has_attribute(BOUND_ATTR) {
386 continue;
387 }
388
389 element.set_value(&prefered);
390
391 let callback =
392 callback.get_or_insert_with(|| select_callback(window.clone(), document.clone()));
393
394 if element
395 .add_event_listener_with_callback("change", callback.as_ref().unchecked_ref())
396 .is_ok()
397 {
398 let _ = element.set_attribute(BOUND_ATTR, "");
399 }
400 }
401
402 if let Some(callback) = callback {
403 callback.forget();
404 }
405}
406
407#[cfg(all(test, not(target_arch = "wasm32")))]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn toggle_theme_uses_checked_side_when_checked() {
413 assert_eq!(toggle_theme("light,dark", true), "dark");
414 }
415
416 #[test]
417 fn toggle_theme_uses_unchecked_side_when_unchecked() {
418 assert_eq!(toggle_theme("light,dark", false), "light");
419 }
420
421 #[test]
422 fn toggle_theme_falls_back_to_default_when_unchecked_with_no_comma() {
423 assert_eq!(toggle_theme("dark", false), "default");
424 }
425
426 #[test]
427 fn toggle_theme_uses_whole_value_when_checked_with_no_comma() {
428 assert_eq!(toggle_theme("dark", true), "dark");
429 }
430
431 #[test]
432 fn checked_value_takes_last_comma_segment() {
433 assert_eq!(checked_value("light,dark"), "dark");
434 }
435
436 #[test]
437 fn checked_value_is_whole_value_with_no_comma() {
438 assert_eq!(checked_value("dark"), "dark");
439 }
440}
441
442#[cfg(all(test, target_arch = "wasm32"))]
443mod browser_tests {
444 use super::*;
445 use wasm_bindgen_test::*;
446
447 wasm_bindgen_test_configure!(run_in_browser);
448
449 fn window_and_document() -> (Window, Document) {
450 let window = web_sys::window().unwrap_throw();
451 let document = window.document().unwrap_throw();
452 (window, document)
453 }
454
455 fn fixture_container(document: &Document) -> web_sys::Element {
463 if let Some(el) = document.get_element_by_id("wasm-theme-test-fixtures") {
464 return el;
465 }
466 let el = document.create_element("div").unwrap_throw();
467 el.set_attribute("id", "wasm-theme-test-fixtures")
468 .unwrap_throw();
469 document
470 .body()
471 .unwrap_throw()
472 .append_child(&el)
473 .unwrap_throw();
474 el
475 }
476
477 fn reset() {
478 let (window, document) = window_and_document();
479 if let Some(storage) = local_storage(&window) {
480 let _ = storage.clear();
481 }
482 fixture_container(&document).set_inner_html("");
483 CACHED_THEME.with_borrow_mut(|cached| *cached = None);
484 }
485
486 fn append_toggle(document: &Document, value: &str) -> web_sys::HtmlInputElement {
487 let element = document
488 .create_element("input")
489 .unwrap_throw()
490 .dyn_into::<web_sys::HtmlInputElement>()
491 .unwrap_throw();
492 element.set_type("checkbox");
493 element.set_attribute("name", "theme-toggle").unwrap_throw();
494 element.set_value(value);
495 fixture_container(document)
496 .append_child(&element)
497 .unwrap_throw();
498 element
499 }
500
501 #[wasm_bindgen_test]
502 fn applies_stored_theme_as_initial_checked_state() {
503 reset();
504 let (window, document) = window_and_document();
505 local_storage(&window)
506 .unwrap_throw()
507 .set_item("theme", "dark")
508 .unwrap_throw();
509 let toggle = append_toggle(&document, "light,dark");
510
511 theme_toggle();
512
513 assert!(toggle.checked());
514 assert_eq!(
515 document
516 .document_element()
517 .unwrap_throw()
518 .get_attribute("data-theme")
519 .as_deref(),
520 Some("dark")
521 );
522 }
523
524 #[wasm_bindgen_test]
525 fn click_flips_data_theme_and_persists_it() {
526 reset();
527 let (window, document) = window_and_document();
528 let toggle = append_toggle(&document, "light,dark");
529
530 theme_toggle();
531 toggle.set_checked(true);
532 toggle
533 .dispatch_event(&web_sys::Event::new("click").unwrap_throw())
534 .unwrap_throw();
535
536 assert_eq!(
537 document
538 .document_element()
539 .unwrap_throw()
540 .get_attribute("data-theme")
541 .as_deref(),
542 Some("dark")
543 );
544 assert_eq!(
545 local_storage(&window)
546 .unwrap_throw()
547 .get_item("theme")
548 .unwrap_throw()
549 .as_deref(),
550 Some("dark")
551 );
552 }
553
554 #[wasm_bindgen_test]
555 fn second_call_skips_already_bound_elements() {
556 reset();
557 let (window, document) = window_and_document();
558 local_storage(&window)
559 .unwrap_throw()
560 .set_item("theme", "dark")
561 .unwrap_throw();
562 let toggle = append_toggle(&document, "light,dark");
563
564 toggle.set_attribute(BOUND_ATTR, "").unwrap_throw();
569 toggle.set_checked(false);
570
571 theme_toggle();
572
573 assert!(!toggle.checked());
574 }
575}