theme 0.0.3

🎨 A highly customizable theming system for WASM frameworks like Yew, Dioxus, and Leptos.
Documentation
#![doc = include_str!("../DIOXUS.md")]

use dioxus::prelude::*;
use std::collections::HashMap;
use std::rc::Rc;
use std::str::FromStr;

#[cfg(target_arch = "wasm32")]
use web_sys::{
    HtmlElement, MediaQueryList, Storage, Window,
    js_sys::Date,
    wasm_bindgen::{JsCast, prelude::*},
};

#[cfg(target_arch = "wasm32")]
use crate::common::SYSTEM_THEME_QUERY;

use crate::common::{CustomTheme, DEFAULT_STORAGE_KEY, StorageType, Theme};

#[derive(Clone, PartialEq)]
pub struct ThemeContext {
    pub theme: Signal<Theme>,
    pub resolved_theme: Signal<Theme>,
    pub system_theme: Signal<Theme>,
    pub set_theme: Callback<Theme>,
    pub forced_theme: Option<Theme>,
    pub custom_themes: Signal<HashMap<String, Rc<CustomTheme>>>,
    pub set_custom_theme: Callback<Rc<CustomTheme>>,
    pub reset_to_system: Callback<()>,
    pub preview_theme: Signal<Option<Theme>>,
    pub apply_preview: Callback<Theme>,
}

#[derive(Props, PartialEq, Clone)]
pub struct ThemeProviderProps {
    #[props(default)]
    pub children: Element,
    #[props(default)]
    pub default_theme: Theme,
    #[props(default)]
    pub storage_type: StorageType,
    #[props(default = DEFAULT_STORAGE_KEY)]
    pub storage_name: &'static str,
    #[props(default)]
    pub forced_theme: Option<Theme>,
    #[props(default)]
    pub custom_themes: HashMap<String, Rc<CustomTheme>>,
}

#[component]
pub fn ThemeProvider(props: ThemeProviderProps) -> Element {
    let val: Option<String> = {
        #[cfg(target_arch = "wasm32")]
        {
            let window = web_sys::window().unwrap();
            let storage: Option<Storage> = match props.storage_type {
                StorageType::LocalStorage => window.local_storage().unwrap(),
                StorageType::SessionStorage => window.session_storage().unwrap(),
            };
            storage
                .and_then(|s| s.get_item(props.storage_name).ok())
                .expect("stored theme not found")
        }

        #[cfg(not(target_arch = "wasm32"))]
        {
            None
        }
    };

    let stored_theme = val
        .as_deref()
        .and_then(|s| Theme::from_str(s).ok())
        .unwrap_or(props.default_theme);

    let mut theme = use_signal(|| stored_theme.clone());
    #[cfg(target_arch = "wasm32")]
    let mut system_theme = use_signal(|| Theme::Light);
    #[cfg(not(target_arch = "wasm32"))]
    let system_theme = use_signal(|| Theme::Light);
    let mut resolved_theme = use_signal(|| Theme::Light);
    let mut preview_theme = use_signal(|| None::<Theme>);
    let mut custom_themes_state = use_signal(|| props.custom_themes.clone());

    #[cfg(target_arch = "wasm32")]
    let window = use_signal(|| web_sys::window().expect("window not found"));

    #[cfg(target_arch = "wasm32")]
    let document = window().document().expect("document not found");

    #[cfg(target_arch = "wasm32")]
    let html_element = use_signal(|| {
        document
            .document_element()
            .expect("html element not found")
            .dyn_into::<HtmlElement>()
            .expect("failed to cast to HtmlElement")
    });

    #[cfg(target_arch = "wasm32")]
    let forced_theme = props.forced_theme.clone();

    let update_resolved = {
        Callback::new(move |new_theme: Theme| {
            #[cfg(target_arch = "wasm32")]
            {
                let mq = window()
                    .match_media(SYSTEM_THEME_QUERY)
                    .unwrap()
                    .unwrap()
                    .unchecked_into::<MediaQueryList>();
                let sys_theme = if mq.matches() {
                    Theme::Dark
                } else {
                    Theme::Light
                };
                system_theme.set(sys_theme.clone());

                let binding = preview_theme();
                let final_theme = if let Some(forced) = &forced_theme {
                    forced.clone()
                } else if let Some(preview) = &binding {
                    preview.clone()
                } else {
                    match new_theme {
                        Theme::System => sys_theme.clone(),
                        other => other,
                    }
                };

                resolved_theme.set(final_theme.clone());
                let _ = html_element().set_attribute("data-theme", &final_theme.as_str());
                let _ = html_element().set_attribute("class", &final_theme.as_str());
                let _ = html_element()
                    .set_attribute("style", &format!("color-scheme: {};", final_theme.as_str()));
            }

            #[cfg(not(target_arch = "wasm32"))]
            {
                resolved_theme.set(match new_theme {
                    Theme::System => Theme::Light,
                    other => other,
                });
            }
        })
    };

    #[cfg(target_arch = "wasm32")]
    let storage_name = props.storage_name;
    #[cfg(target_arch = "wasm32")]
    let storage_type = props.storage_type;

    use_effect(move || {
        update_resolved.call(theme());

        #[cfg(target_arch = "wasm32")]
        {
            let mq = window()
                .match_media(SYSTEM_THEME_QUERY)
                .unwrap()
                .unwrap()
                .unchecked_into::<MediaQueryList>();

            let closure: Closure<dyn FnMut(web_sys::Event)> = Closure::wrap(Box::new(move |_| {
                update_resolved.call(theme());
            }));
            mq.add_event_listener_with_callback("change", closure.as_ref().unchecked_ref())
                .unwrap();
            closure.forget();

            let on_storage: Closure<dyn FnMut(web_sys::StorageEvent)> =
                Closure::wrap(Box::new(move |_event| {
                    let val: Option<String> = match storage_type {
                        StorageType::LocalStorage => web_sys::window()
                            .unwrap()
                            .local_storage()
                            .unwrap()
                            .and_then(|s| s.get_item(storage_name).ok())
                            .expect("stored theme not found"),
                        StorageType::SessionStorage => web_sys::window()
                            .unwrap()
                            .session_storage()
                            .unwrap()
                            .and_then(|s| s.get_item(storage_name).ok())
                            .expect("stored theme not found"),
                    };

                    if let Some(s) = val {
                        if let Ok(t) = Theme::from_str(&s) {
                            theme.set(t.clone());
                            update_resolved.call(t);
                        }
                    }
                }));
            window()
                .add_event_listener_with_callback("storage", on_storage.as_ref().unchecked_ref())
                .unwrap();
            on_storage.forget();

            let interval_closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
                let hour = Date::new_0().get_hours();
                let next = if (7..19).contains(&hour) {
                    Theme::Light
                } else {
                    Theme::Dark
                };
                theme.set(next.clone());
                update_resolved.call(next);
            }));

            let interval_id = window()
                .set_interval_with_callback_and_timeout_and_arguments_0(
                    interval_closure.as_ref().unchecked_ref(),
                    60_000,
                )
                .unwrap();
            interval_closure.forget();

            window().clear_interval_with_handle(interval_id);
        }
    });

    let set_theme = {
        #[cfg(target_arch = "wasm32")]
        let storage_name = props.storage_name;
        #[cfg(target_arch = "wasm32")]
        let storage_type = props.storage_type;
        Callback::new(move |new_theme: Theme| {
            #[cfg(target_arch = "wasm32")]
            {
                let _ = match storage_type {
                    StorageType::LocalStorage => web_sys::window()
                        .unwrap()
                        .local_storage()
                        .unwrap()
                        .and_then(|ls| ls.set_item(storage_name, &new_theme.as_str()).ok()),
                    StorageType::SessionStorage => web_sys::window()
                        .unwrap()
                        .session_storage()
                        .unwrap()
                        .and_then(|ss| ss.set_item(storage_name, &new_theme.as_str()).ok()),
                };
            }
            theme.set(new_theme.clone());
            update_resolved.call(new_theme);
        })
    };

    let set_custom_theme = {
        Callback::new(move |new_theme: Rc<CustomTheme>| {
            #[cfg(target_arch = "wasm32")]
            if let Err(e) = new_theme.validate() {
                web_sys::console::error_1(&format!("Theme validation error: {}", e).into());
                return;
            }
            let mut themes = custom_themes_state();
            themes.insert(new_theme.name.clone(), new_theme);
            custom_themes_state.set(themes);
        })
    };

    let reset_to_system = {
        Callback::new(move |_| {
            set_theme.call(Theme::System);
        })
    };

    let apply_preview = {
        Callback::new(move |theme: Theme| {
            preview_theme.set(Some(theme.clone()));
            #[cfg(target_arch = "wasm32")]
            {
                let _ = html_element().set_attribute("data-theme", &theme.as_str());
                let _ = html_element().set_attribute("class", &theme.as_str());
                let _ = html_element()
                    .set_attribute("style", &format!("color-scheme: {};", theme.as_str()));
            }
        })
    };

    let context = Rc::new(ThemeContext {
        theme,
        resolved_theme,
        system_theme,
        set_theme,
        forced_theme: props.forced_theme,
        custom_themes: custom_themes_state,
        set_custom_theme,
        reset_to_system,
        preview_theme,
        apply_preview,
    });

    provide_context(context);

    props.children
}

pub fn use_theme() -> Rc<ThemeContext> {
    consume_context::<Rc<ThemeContext>>()
}