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