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