1use dioxus::prelude::*;
2pub use dioxus_theme_core::{
3 DEFAULT_THEME_ANIMATION_SPEED, DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY,
4 DEFAULT_THEME_ANIMATION_STORAGE_KEY, DEFAULT_THEME_ATTRIBUTE, DEFAULT_THEME_DURATION_MS,
5 DEFAULT_THEME_EASING, DEFAULT_THEME_RUNTIME_BASE_PATH, DEFAULT_THEME_RUNTIME_PATH,
6 DEFAULT_THEME_RUNTIME_VERSION, DEFAULT_THEME_STORAGE_KEY, DEFAULT_THEME_TARGET,
7 MAX_THEME_ANIMATION_SPEED, MIN_THEME_ANIMATION_SPEED, THEME_TOKEN_ACCENT, THEME_TOKEN_BG,
8 THEME_TOKEN_FG, THEME_TOKEN_MUTED, THEME_TOKEN_PANEL, THEME_TOKEN_PANEL_BORDER,
9 ThemeAnimationMode, ThemeAnimationPreset, ThemeColorScheme, ThemeConfig, ThemeDefinition,
10 ThemeReducedMotion, ThemeRegistry, ThemeValidationCode, ThemeValidationIssue,
11 ThemeValidationReport, ThemeValidationSeverity, is_safe_css_token_value,
12 is_valid_theme_attribute, is_valid_theme_target, normalize_animation_speed, theme_id,
13 theme_tokens_css,
14};
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum ThemeRuntimeMode {
18 BrowserRuntime,
19 StaticFallback,
20}
21
22pub fn theme_runtime_mode(config: &ThemeConfig) -> ThemeRuntimeMode {
23 if cfg!(all(feature = "web", target_arch = "wasm32")) && config.animation.is_animated() {
24 ThemeRuntimeMode::BrowserRuntime
25 } else {
26 ThemeRuntimeMode::StaticFallback
27 }
28}
29
30pub fn theme_native_fallback_config() -> ThemeConfig {
31 ThemeConfig::default().with_animation(ThemeAnimationMode::CssOnly)
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum ThemeNativeAction {
36 ToggleTheme,
37 SetTheme,
38 CycleTheme,
39 SetAnimationPreset,
40 SetAnimationSpeed,
41}
42
43impl ThemeNativeAction {
44 pub const fn as_str(self) -> &'static str {
45 match self {
46 Self::ToggleTheme => "toggle-theme",
47 Self::SetTheme => "set-theme",
48 Self::CycleTheme => "cycle-theme",
49 Self::SetAnimationPreset => "set-animation-preset",
50 Self::SetAnimationSpeed => "set-animation-speed",
51 }
52 }
53
54 pub const fn label(self) -> &'static str {
55 match self {
56 Self::ToggleTheme => "Toggle theme",
57 Self::SetTheme => "Set theme",
58 Self::CycleTheme => "Cycle theme",
59 Self::SetAnimationPreset => "Set animation preset",
60 Self::SetAnimationSpeed => "Set animation speed",
61 }
62 }
63}
64
65pub fn theme_native_package_actions(
66 route: Option<&str>,
67) -> Vec<dioxus_native_port::NativePackageAction> {
68 let route = route.map(str::to_string);
69 [
70 ThemeNativeAction::ToggleTheme,
71 ThemeNativeAction::SetTheme,
72 ThemeNativeAction::CycleTheme,
73 ThemeNativeAction::SetAnimationPreset,
74 ThemeNativeAction::SetAnimationSpeed,
75 ]
76 .into_iter()
77 .map(|action| {
78 let mut package_action = dioxus_native_port::NativePackageAction::new(
79 "dioxus-theme",
80 action.as_str(),
81 action.label(),
82 dioxus_native_port::NativeActionKind::NativeAction,
83 )
84 .description("Applies a configured theme without reloading the page.");
85 if let Some(route) = route.clone() {
86 package_action = package_action.route(route);
87 }
88 package_action
89 })
90 .collect()
91}
92
93pub fn theme_native_action(
94 config: &ThemeConfig,
95 action: ThemeNativeAction,
96 current_theme: impl Into<String>,
97) -> dioxus_native_port::NativeActionResult {
98 let current_theme = theme_id(current_theme.into());
99 let next_theme = match action {
100 ThemeNativeAction::ToggleTheme | ThemeNativeAction::CycleTheme => {
101 config.toggle_theme_id(¤t_theme)
102 }
103 ThemeNativeAction::SetTheme => current_theme.clone(),
104 ThemeNativeAction::SetAnimationPreset | ThemeNativeAction::SetAnimationSpeed => {
105 current_theme.clone()
106 }
107 };
108 let mode = theme_runtime_mode(config);
109 let backend = match mode {
110 ThemeRuntimeMode::BrowserRuntime => "browser-runtime",
111 ThemeRuntimeMode::StaticFallback => "static-fallback",
112 };
113
114 dioxus_native_port::NativeActionResult::succeeded(
115 "dioxus-theme",
116 action.as_str(),
117 dioxus_native_port::NativeActionKind::NativeAction,
118 format!("{} prepared `{next_theme}`", action.label()),
119 )
120 .with_backend(backend)
121 .with_output("currentTheme", current_theme)
122 .with_output("nextTheme", next_theme)
123 .with_output("storageKey", config.storage_key.clone())
124 .with_output("animation", config.animation.as_attr())
125 .with_output("animationPreset", config.animation_preset.as_attr())
126 .with_output("animationStorageKey", config.animation_storage_key.clone())
127 .with_output("animationSpeed", config.animation_speed.to_string())
128 .with_output(
129 "animationSpeedStorageKey",
130 config.animation_speed_storage_key.clone(),
131 )
132 .with_output("themeCount", config.registry.themes.len().to_string())
133}
134
135pub fn use_theme(config: ThemeConfig) -> dioxus_native_port::PortableStorage {
136 let key = config.storage_key.clone();
137 let default_theme = config.default_theme.clone();
138 dioxus_native_port::use_portable_storage(key, move || default_theme)
139}
140
141fn theme_control_id(prefix: &str, handler: &str, label: &str) -> String {
142 format!("{prefix}-{}", theme_id(format!("{handler}-{label}")))
143}
144
145#[derive(Props, Clone, PartialEq)]
146pub struct ThemeProviderProps {
147 #[props(default)]
148 pub config: ThemeConfig,
149 #[props(default)]
150 pub class: String,
151 pub children: Element,
152}
153
154#[component]
155pub fn ThemeProvider(props: ThemeProviderProps) -> Element {
156 let default_theme = props.config.default_theme.clone();
157 let storage_key = props.config.storage_key.clone();
158 rsx! {
159 div {
160 class: "{props.class}",
161 "data-dxt-provider": "true",
162 "data-dxt-default-theme": "{default_theme}",
163 "data-dxt-storage-key": "{storage_key}",
164 {props.children}
165 }
166 }
167}
168
169#[derive(Props, Clone, PartialEq)]
170pub struct ThemeToggleProps {
171 #[props(default = "theme.toggle".to_string())]
172 pub handler: String,
173 #[props(default = "Toggle theme".to_string())]
174 pub label: String,
175 #[props(default)]
176 pub class: String,
177 #[props(default)]
178 pub next_theme: String,
179}
180
181#[component]
182pub fn ThemeToggle(props: ThemeToggleProps) -> Element {
183 let next_theme = theme_id(&props.next_theme);
184 rsx! {
185 button {
186 r#type: "button",
187 class: "{props.class}",
188 "aria-label": "{props.label}",
189 "data-dxr-on-click": "{props.handler}",
190 "data-dxt-theme-next": "{next_theme}",
191 "data-dxt-theme-control": "toggle",
192 span {
193 "data-dxt-theme-toggle-label": "true",
194 "aria-live": "polite",
195 "{props.label}"
196 }
197 }
198 }
199}
200
201#[derive(Props, Clone, PartialEq)]
202pub struct ThemeSelectProps {
203 #[props(default)]
204 pub config: ThemeConfig,
205 #[props(default = "theme.set".to_string())]
206 pub handler: String,
207 #[props(default)]
208 pub class: String,
209 #[props(default = "Theme".to_string())]
210 pub label: String,
211}
212
213#[component]
214pub fn ThemeSelect(props: ThemeSelectProps) -> Element {
215 let select_id = theme_control_id("dxt-theme-select", &props.handler, &props.label);
216 let label_id = format!("{select_id}-label");
217 let current_id = format!("{select_id}-current");
218 let current_label = props
219 .config
220 .resolve_theme(&props.config.default_theme)
221 .map(|theme| theme.label.clone())
222 .unwrap_or_else(|| props.config.default_theme.clone());
223 rsx! {
224 label {
225 class: "{props.class}",
226 "data-dxt-theme-control": "select",
227 "for": "{select_id}",
228 span {
229 id: "{label_id}",
230 "{props.label}"
231 }
232 select {
233 id: "{select_id}",
234 "aria-labelledby": "{label_id} {current_id}",
235 "data-dxr-on-change": "{props.handler}",
236 "data-dxt-theme-select": "true",
237 for theme in props.config.registry.themes.iter() {
238 option {
239 value: "{theme.id}",
240 "{theme.label}"
241 }
242 }
243 }
244 span {
245 id: "{current_id}",
246 "aria-live": "polite",
247 "data-dxt-theme-current": "true",
248 "{current_label}"
249 }
250 }
251 }
252}
253
254#[derive(Props, Clone, PartialEq)]
255pub struct ThemeAnimationSelectProps {
256 #[props(default)]
257 pub config: ThemeConfig,
258 #[props(default = "theme.animation".to_string())]
259 pub handler: String,
260 #[props(default)]
261 pub class: String,
262 #[props(default = "Animation".to_string())]
263 pub label: String,
264}
265
266#[component]
267pub fn ThemeAnimationSelect(props: ThemeAnimationSelectProps) -> Element {
268 let current = props.config.animation_preset.as_attr();
269 let select_id = theme_control_id("dxt-theme-animation", &props.handler, &props.label);
270 let label_id = format!("{select_id}-label");
271 let current_id = format!("{select_id}-current");
272 let current_label = props.config.animation_preset.label();
273 rsx! {
274 label {
275 class: "{props.class}",
276 "data-dxt-theme-control": "animation-select",
277 "for": "{select_id}",
278 span {
279 id: "{label_id}",
280 "{props.label}"
281 }
282 select {
283 id: "{select_id}",
284 value: "{current}",
285 "aria-labelledby": "{label_id} {current_id}",
286 "data-dxr-on-change": "{props.handler}",
287 "data-dxt-theme-animation-select": "true",
288 for preset in ThemeAnimationPreset::all().iter().copied() {
289 option {
290 value: "{preset.as_attr()}",
291 selected: preset == props.config.animation_preset,
292 "{preset.label()}"
293 }
294 }
295 }
296 span {
297 id: "{current_id}",
298 "aria-live": "polite",
299 "data-dxt-theme-animation-current": "true",
300 "{current_label}"
301 }
302 }
303 }
304}
305
306#[derive(Props, Clone, PartialEq)]
307pub struct ThemeAnimationSpeedSliderProps {
308 #[props(default)]
309 pub config: ThemeConfig,
310 #[props(default = "theme.animation-speed".to_string())]
311 pub handler: String,
312 #[props(default)]
313 pub class: String,
314 #[props(default = "Animation speed".to_string())]
315 pub label: String,
316 #[props(default = MIN_THEME_ANIMATION_SPEED)]
317 pub min: u16,
318 #[props(default = MAX_THEME_ANIMATION_SPEED)]
319 pub max: u16,
320 #[props(default = 25)]
321 pub step: u16,
322}
323
324#[component]
325pub fn ThemeAnimationSpeedSlider(props: ThemeAnimationSpeedSliderProps) -> Element {
326 let value = normalize_animation_speed(props.config.animation_speed);
327 let min = normalize_animation_speed(props.min);
328 let max = normalize_animation_speed(props.max);
329 let step = props.step.max(1);
330 let input_id = theme_control_id("dxt-theme-animation-speed", &props.handler, &props.label);
331 let output_id = format!("{input_id}-output");
332 rsx! {
333 label {
334 class: "{props.class}",
335 "data-dxt-theme-control": "animation-speed",
336 "for": "{input_id}",
337 span {
338 "{props.label}: "
339 output {
340 id: "{output_id}",
341 "for": "{input_id}",
342 "aria-live": "polite",
343 "data-dxt-theme-animation-speed-current": "true",
344 "{value}%"
345 }
346 }
347 input {
348 id: "{input_id}",
349 r#type: "range",
350 min: "{min}",
351 max: "{max}",
352 step: "{step}",
353 value: "{value}",
354 "aria-describedby": "{output_id}",
355 "data-dxr-on-input": "{props.handler}",
356 "data-dxt-theme-animation-speed": "true"
357 }
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn native_action_reports_next_theme() {
368 let config = ThemeConfig::default().with_default_theme("dark");
369 let result = theme_native_action(&config, ThemeNativeAction::ToggleTheme, "dark");
370 assert_eq!(
371 result.outputs.get("currentTheme"),
372 Some(&"dark".to_string())
373 );
374 assert!(result.outputs.contains_key("nextTheme"));
375 assert_eq!(
376 result.outputs.get("animationPreset"),
377 Some(&"cross-fade".to_string())
378 );
379 assert_eq!(
380 result.outputs.get("animationSpeed"),
381 Some(&"100".to_string())
382 );
383
384 let actions = theme_native_package_actions(Some("/browser"));
385 assert!(
386 actions
387 .iter()
388 .any(|action| action.action == "set-animation-preset")
389 );
390 assert!(
391 actions
392 .iter()
393 .any(|action| action.action == "set-animation-speed")
394 );
395 }
396}