Skip to main content

euv_ui/component/theme/hook/
impl.rs

1use super::*;
2
3/// Implementation of theme state functionality.
4///
5/// Provides methods for creating theme state, detecting system theme changes,
6/// and managing theme-related reactive state.
7impl ThemeState {
8    /// Creates theme state for managing light/dark mode and root CSS classes.
9    ///
10    /// Detects the system color scheme preference at startup and initializes the
11    /// theme signal accordingly. Uses `watch!` to reactively update the root class
12    /// whenever the theme or mobile signal changes.
13    ///
14    /// # Arguments
15    ///
16    /// - `Signal<bool>` - The reactive signal indicating whether the viewport is mobile-sized.
17    ///
18    /// # Returns
19    ///
20    /// - `ThemeState` - The reactive theme state containing the theme signal and root class signal.
21    pub fn use_theme_state(mobile_signal: Signal<bool>) -> ThemeState {
22        let theme: Signal<String> = App::use_signal(Self::detect_system_theme);
23        Self::use_system_theme_change(theme);
24        let initial_theme: String = theme.get();
25        let initial_mobile: bool = mobile_signal.get();
26        let initial_root: &'static str = if initial_mobile {
27            c_mobile_app_root().get_name()
28        } else {
29            c_app_root().get_name()
30        };
31        let root_class: Signal<String> = App::use_signal(|| {
32            format!(
33                "{initial_root} {scheme_class} {theme_class}",
34                scheme_class = scheme_class_name(),
35                theme_class = Self::theme_class_name(&initial_theme)
36            )
37        });
38        watch!(mobile_signal, theme, |mobile: bool, theme_value: String| {
39            let root: &'static str = if mobile {
40                c_mobile_app_root().get_name()
41            } else {
42                c_app_root().get_name()
43            };
44            root_class.set(format!(
45                "{root} {scheme_class} {theme_class}",
46                scheme_class = scheme_class_name(),
47                theme_class = Self::theme_class_name(&theme_value)
48            ));
49        });
50        ThemeState { theme, root_class }
51    }
52
53    /// Subscribes to system color scheme changes and updates the theme signal.
54    ///
55    /// Creates a `MediaQueryList` for `prefers-color-scheme: dark` and listens
56    /// for `change` events. When the system theme changes, the theme signal is
57    /// updated accordingly. The listener is automatically cleaned up when the
58    /// hook context is cleared.
59    ///
60    /// # Arguments
61    ///
62    /// - `Signal<String>` - The theme signal to update when the system theme changes.
63    pub fn use_system_theme_change(theme_signal: Signal<String>) {
64        let Some(window) = window() else {
65            return;
66        };
67        let media_query: Option<MediaQueryList> = window
68            .match_media(THEME_DARK_SCHEME_MEDIA_QUERY)
69            .ok()
70            .flatten();
71        let Some(mql) = media_query else {
72            return;
73        };
74        let closure: Closure<dyn FnMut(Event)> = Closure::wrap(Box::new(move |_: Event| {
75            let detected: String = Self::detect_system_theme();
76            let current: String = theme_signal.get();
77            if current != detected {
78                theme_signal.set(detected);
79            }
80        }));
81        let _: Result<(), JsValue> = mql.add_event_listener_with_callback(
82            THEME_MEDIA_QUERY_CHANGE_EVENT,
83            closure.as_ref().unchecked_ref(),
84        );
85        closure.forget();
86    }
87
88    /// Detects the current system color scheme preference.
89    ///
90    /// Uses `window.matchMedia("(prefers-color-scheme: dark)")` to check whether
91    /// the operating system is in dark mode. Falls back to light theme if the
92    /// detection fails.
93    ///
94    /// # Returns
95    ///
96    /// - `String` - The detected system theme name ("light" or "dark").
97    pub fn detect_system_theme() -> String {
98        let Some(window) = window() else {
99            return THEME_LIGHT.to_string();
100        };
101        let is_dark: bool = window
102            .match_media(THEME_DARK_SCHEME_MEDIA_QUERY)
103            .ok()
104            .flatten()
105            .map(|mql: MediaQueryList| mql.matches())
106            .unwrap_or_default();
107        if is_dark {
108            THEME_DARK.to_string()
109        } else {
110            THEME_LIGHT.to_string()
111        }
112    }
113
114    /// Returns the CSS class name for the given theme value.
115    ///
116    /// # Arguments
117    ///
118    /// - `&str` - The theme name ("light" or "dark").
119    ///
120    /// # Returns
121    ///
122    /// - `&'static str` - The CSS class name for the theme.
123    pub(crate) fn theme_class_name(theme: &str) -> &'static str {
124        if theme == THEME_DARK {
125            c_theme_dark().get_name()
126        } else {
127            c_theme_light().get_name()
128        }
129    }
130
131    /// Creates a click event handler that toggles the theme between "light" and "dark".
132    ///
133    /// The switch stays smooth even on pages with many elements (e.g. the list
134    /// page) because individual components no longer carry their own colour
135    /// (`background` / `color` / `border-color`) transitions — only the app root
136    /// container animates its colour change, so the page fades as a whole while
137    /// each component snaps to the new theme instantly. This avoids hundreds of
138    /// elements running simultaneous colour transitions, which previously caused
139    /// dropped frames.
140    ///
141    /// # Arguments
142    ///
143    /// - `Signal<String>` - The theme signal to toggle.
144    ///
145    /// # Returns
146    ///
147    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler that flips the theme value.
148    pub fn toggle(theme_signal: Signal<String>) -> Option<Rc<dyn Fn(Event)>> {
149        Some(Rc::new(move |_: Event| {
150            let current: String = theme_signal.get();
151            if current == THEME_LIGHT {
152                theme_signal.set(THEME_DARK.to_string());
153            } else {
154                theme_signal.set(THEME_LIGHT.to_string());
155            }
156        }))
157    }
158}