Skip to main content

wasm_theme/
lib.rs

1//! Set the `data-theme` attribute on the document's root `<html>` element
2//! from checkbox/toggle, radio-button, button, and/or `<select>` controls,
3//! persisting the choice to `localStorage` and falling back to the
4//! browser's `prefers-color-scheme` on first load. See the crate
5//! [README](https://github.com/justins-engineering/wasm-theme) for usage
6//! with each control type, and for wiring into tailwindcss/daisyUI.
7#![forbid(unsafe_code)]
8use std::cell::RefCell;
9use wasm_bindgen::prelude::*;
10use wasm_bindgen::{JsCast, UnwrapThrowExt};
11use web_sys::{Document, Storage, Window};
12
13/// Marks an element whose listener has already been attached, so a
14/// repeated call to one of the `theme_*` functions below (e.g. a
15/// `use_effect` that reruns, or a component that remounts) doesn't attach
16/// a second listener to the same element.
17const BOUND_ATTR: &str = "data-wasm-theme-bound";
18
19thread_local! {
20  // Memoizes the theme resolved by `prefers_color_scheme` for the page's
21  // lifetime. `theme_toggle`/`theme_radio`/`theme_buttons`/`theme_select`
22  // are commonly all called together from a single `use_effect` (see the
23  // README) — without this, each call would redundantly re-read local
24  // storage, re-evaluate `matchMedia`, and re-write the same `data-theme`
25  // attribute and storage entry.
26  static CACHED_THEME: RefCell<Option<String>> = const { RefCell::new(None) };
27}
28
29/// `local_storage()` returns `Err` when the browser denies access — e.g. a
30/// sandboxed iframe without `allow-same-origin`, some private browsing
31/// modes, or a "block all cookies/storage" setting. Treat that the same as
32/// "nothing stored yet" rather than panicking: the theme still applies for
33/// the current page load, it just won't survive a reload.
34fn local_storage(window: &Window) -> Option<Storage> {
35  window.local_storage().ok().flatten()
36}
37
38/// Best-effort persist `theme` to local storage; logs and gives up rather
39/// than panicking if storage is unavailable or the write fails (e.g. quota
40/// exceeded).
41fn 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
50/// Best-effort set `data-theme` on the document's root element.
51fn 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
62/// Resolves the theme to apply on load — whatever is already in local
63/// storage, else `"dark"` if the browser reports
64/// `prefers-color-scheme: dark`, else `"default"` — applies it to the
65/// `<html>` element, persists it, and caches the result for the rest of
66/// the page's lifetime.
67fn 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
93/// The value on the "checked" side of a toggle's `value` attribute, i.e.
94/// everything after the last comma (or the whole value, if there's no
95/// comma).
96fn checked_value(value: &str) -> &str {
97  value.rsplit(',').next().unwrap_or(value)
98}
99
100/// Resolves which theme a toggle's `click` should apply, given its
101/// `value` attribute (`"unchecked,checked"`, or a single value paired
102/// implicitly with `"default"`) and its new checked state.
103fn 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/// Wires up every `[name=theme-toggle]` checkbox on the page: applies the
134/// resolved theme (see [`prefers_color_scheme`]) as each control's initial
135/// `checked` state, and attaches a `click` listener that flips
136/// `data-theme` between the control's declared checked/unchecked values
137/// (`value="light,dark"`) — or between its single `value` and `"default"`
138/// if no comma is present.
139///
140/// Calling this more than once (e.g. a rerun `use_effect`) is safe: a
141/// control that's already wired up is left alone rather than gaining a
142/// duplicate listener.
143///
144/// # Panics
145///
146/// Panics if called outside a browser main-thread context (no `window`, or
147/// no `document` on it).
148#[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/// Wires up every `[name=theme-radios]` radio button on the page: checks
210/// the one whose `value` matches the resolved theme (see
211/// [`prefers_color_scheme`]), and attaches a `click` listener that sets
212/// `data-theme` to the checked control's `value`.
213///
214/// Calling this more than once (e.g. a rerun `use_effect`) is safe: a
215/// control that's already wired up is left alone rather than gaining a
216/// duplicate listener.
217///
218/// # Panics
219///
220/// Panics if called outside a browser main-thread context (no `window`, or
221/// no `document` on it).
222#[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/// Wires up every `[name=theme-button]` button on the page: attaches a
282/// `click` listener that sets `data-theme` to the clicked button's
283/// `value`.
284///
285/// Calling this more than once (e.g. a rerun `use_effect`) is safe: a
286/// button that's already wired up is left alone rather than gaining a
287/// duplicate listener.
288///
289/// # Panics
290///
291/// Panics if called outside a browser main-thread context (no `window`, or
292/// no `document` on it).
293#[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  // Buttons have no "current" state to compare against the resolved
300  // theme, but resolving it still applies+persists the initial theme as a
301  // side effect (see `prefers_color_scheme`).
302  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/// Wires up every `[name=theme-select]` `<select>` on the page: sets its
353/// value to the resolved theme (see [`prefers_color_scheme`]), and
354/// attaches a `change` listener that sets `data-theme` to the newly
355/// selected `value`.
356///
357/// Calling this more than once (e.g. a rerun `use_effect`) is safe: a
358/// control that's already wired up is left alone rather than gaining a
359/// duplicate listener.
360///
361/// # Panics
362///
363/// Panics if called outside a browser main-thread context (no `window`, or
364/// no `document` on it).
365#[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  /// Gets (creating if needed) a `<div>` reserved for test fixtures. Tests
456  /// must append their elements here rather than to `<body>` directly and
457  /// must never touch `<body>`'s own contents: the wasm-bindgen-test
458  /// harness keeps its own tracking elements there too, and clearing all
459  /// of `<body>` out from under it silently breaks test-completion
460  /// detection (the runner just hangs and reports a timeout, with no
461  /// indication the harness's own DOM got wiped).
462  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    // Pre-mark the element as already bound, and pre-set a checked state
565    // that does *not* match the stored theme. A first-time call would
566    // flip it to match "dark" (see the test above) — this asserts that an
567    // element already marked bound is left alone instead.
568    toggle.set_attribute(BOUND_ATTR, "").unwrap_throw();
569    toggle.set_checked(false);
570
571    theme_toggle();
572
573    assert!(!toggle.checked());
574  }
575}