yew_hooks/hooks/
use_theme.rs1use std::ops::Deref;
2use std::rc::Rc;
3
4use gloo::storage::{LocalStorage, Storage};
5use gloo::utils::{document, window};
6use wasm_bindgen::closure::Closure;
7use wasm_bindgen::JsCast;
8use wasm_bindgen::JsValue;
9use yew::prelude::*;
10
11use super::use_event_with_window;
12
13pub struct UseThemeHandle {
18 inner: UseStateHandle<String>,
19 is_system: UseStateHandle<bool>,
20 key: Rc<String>,
21}
22
23impl UseThemeHandle {
24 pub fn toggle(&self) {
26 if *self.is_system {
27 let current = computed_theme();
29 let next = if current == "dark" { "light" } else { "dark" }.to_string();
30 LocalStorage::set(&*self.key, next.clone()).ok();
31 self.inner.set(next);
32 self.is_system.set(false);
33 } else {
34 let next = if self.inner.as_str() == "dark" {
35 "light".to_string()
36 } else {
37 "dark".to_string()
38 };
39 LocalStorage::set(&*self.key, next.clone()).ok();
40 self.inner.set(next);
41 self.is_system.set(false);
42 }
43 }
44
45 pub fn set_dark(&self) {
47 LocalStorage::set(&*self.key, "dark".to_string()).ok();
48 self.inner.set("dark".to_string());
49 self.is_system.set(false);
50 }
51
52 pub fn set_light(&self) {
54 LocalStorage::set(&*self.key, "light".to_string()).ok();
55 self.inner.set("light".to_string());
56 self.is_system.set(false);
57 }
58
59 pub fn set_system(&self) {
61 LocalStorage::delete(&*self.key);
62 let sys = computed_theme();
63 self.inner.set(sys);
64 self.is_system.set(true);
65 }
66
67 pub fn is_dark(&self) -> bool {
69 self.inner.as_str() == "dark"
70 }
71}
72
73impl Deref for UseThemeHandle {
74 type Target = String;
75
76 fn deref(&self) -> &Self::Target {
77 &self.inner
78 }
79}
80
81impl Clone for UseThemeHandle {
82 fn clone(&self) -> Self {
83 Self {
84 inner: self.inner.clone(),
85 is_system: self.is_system.clone(),
86 key: self.key.clone(),
87 }
88 }
89}
90
91impl PartialEq for UseThemeHandle {
92 fn eq(&self, other: &Self) -> bool {
93 *self.inner == *other.inner && *self.is_system == *other.is_system
94 }
95}
96
97fn match_media_matches(query: &str) -> Option<bool> {
99 let w = window();
100 let mm = js_sys::Reflect::get(&w, &JsValue::from_str("matchMedia")).ok()?;
102 if mm.is_function() {
103 let func: js_sys::Function = mm.dyn_into().ok()?;
104 let mql = func.call1(&w.into(), &JsValue::from_str(query)).ok()?;
105 let matches = js_sys::Reflect::get(&mql, &JsValue::from_str("matches")).ok()?;
106 matches.as_bool()
107 } else {
108 None
109 }
110}
111
112fn computed_theme() -> String {
114 match match_media_matches("(prefers-color-scheme: dark)") {
115 Some(true) => "dark".to_string(),
116 _ => "light".to_string(),
117 }
118}
119
120fn apply_theme_to_document(theme: &str) {
123 if let Some(doc_el) = document().document_element() {
124 let current = doc_el.get_attribute("class").unwrap_or_default();
127 let mut parts: Vec<&str> = current
128 .split_whitespace()
129 .filter(|s| !s.is_empty())
130 .collect();
131 let has_dark = parts.contains(&"dark");
132 if theme == "dark" {
133 if !has_dark {
134 parts.push("dark");
135 }
136 } else {
137 parts.retain(|&p| p != "dark");
138 }
139 let new = parts.join(" ");
140 let _ = if new.is_empty() {
141 doc_el.remove_attribute("class")
142 } else {
143 doc_el.set_attribute("class", &new)
144 };
145 let _ = doc_el.set_attribute("data-theme", theme);
147 }
148}
149
150#[hook]
183pub fn use_theme(key: String) -> UseThemeHandle {
184 let stored = LocalStorage::get::<String>(&key).ok();
188 let (initial_theme, initial_is_system) = match stored {
189 Some(s) if s == "dark" || s == "light" => (s, false),
190 _ => (computed_theme(), true),
191 };
192
193 let inner = use_state(|| initial_theme.clone());
194 let is_system = use_state(|| initial_is_system);
195 let key_rc = use_memo((), |_| key);
196
197 {
199 let inner = inner.clone();
200 use_effect_with(inner.clone(), move |theme| {
201 apply_theme_to_document(theme.as_str());
202 || ()
203 });
204 }
205
206 {
208 let inner = inner.clone();
209 let is_system = is_system.clone();
210 let key = key_rc.clone();
211 use_event_with_window("storage", move |e: web_sys::Event| {
212 let js_e = JsValue::from(e);
214 let key_from_event = js_sys::Reflect::get(&js_e, &JsValue::from_str("key"))
215 .ok()
216 .and_then(|v| v.as_string());
217 if let Some(k) = key_from_event {
218 if k == *key {
219 if let Ok(v) = LocalStorage::get::<String>(&*key) {
221 if v == "dark" || v == "light" {
222 inner.set(v);
223 is_system.set(false);
224 } else {
225 inner.set(computed_theme());
227 is_system.set(true);
228 }
229 } else {
230 inner.set(computed_theme());
231 is_system.set(true);
232 }
233 }
234 }
235 });
236 }
237
238 {
241 let inner = inner.clone();
242 let is_system = is_system.clone();
243 use_effect_with((), move |_| {
244 let w = window();
246 let mm = js_sys::Reflect::get(&w, &JsValue::from_str("matchMedia")).ok();
247 let mql = mm
248 .and_then(|mm| mm.dyn_into::<js_sys::Function>().ok())
249 .and_then(|f| {
250 f.call1(
251 &w.into(),
252 &JsValue::from_str("(prefers-color-scheme: dark)"),
253 )
254 .ok()
255 });
256
257 if let Some(mql_val) = mql {
258 if *is_system {
260 let matches = js_sys::Reflect::get(&mql_val, &JsValue::from_str("matches"))
261 .ok()
262 .and_then(|v| v.as_bool())
263 .unwrap_or(false);
264 inner.set(if matches {
265 "dark".to_string()
266 } else {
267 "light".to_string()
268 });
269 }
270
271 if let Ok(target) = mql_val.clone().dyn_into::<web_sys::EventTarget>() {
274 let inner_cl = inner.clone();
275 let is_system_cl = is_system.clone();
276 let mql_for_closure = mql_val.clone();
278 let closure = Closure::wrap(Box::new(move |_ev: JsValue| {
279 if *is_system_cl {
280 let matches = js_sys::Reflect::get(
281 &mql_for_closure,
282 &JsValue::from_str("matches"),
283 )
284 .ok()
285 .and_then(|v| v.as_bool())
286 .unwrap_or(false);
287 inner_cl.set(if matches {
288 "dark".to_string()
289 } else {
290 "light".to_string()
291 });
292 }
293 }) as Box<dyn FnMut(_)>);
294
295 let _ = target.add_event_listener_with_callback(
296 "change",
297 closure.as_ref().unchecked_ref(),
298 );
299 closure.forget();
301 }
302 }
303
304 || ()
305 });
306 }
307
308 UseThemeHandle {
309 inner,
310 is_system,
311 key: key_rc,
312 }
313}