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, ThemeAnim, ThemeAnimationMode,
11 ThemeAnimationPreset, ThemeCfg, ThemeColorScheme, ThemeConfig, ThemeDef, ThemeDefinition,
12 ThemeMotion, ThemePreset, ThemeReducedMotion, ThemeReg, ThemeRegistry, ThemeValidationCode,
13 ThemeValidationIssue, ThemeValidationReport, ThemeValidationSeverity,
14 ThemeVisualTokenDefinition, ThemeVisualTokenManifest, ThemeVisualTokenRole, default_themes,
15 is_safe_css_token_value, is_valid_theme_attribute, is_valid_theme_target,
16 normalize_animation_speed, theme, theme_def, theme_id, theme_tokens_css,
17 theme_visual_token_css_var, theme_visual_token_manifest, theme_visual_token_manifest_json,
18 themes,
19};
20use std::sync::OnceLock;
21
22pub use ThemeAnimationSelect as AnimSelect;
23pub use ThemeAnimationSpeedSlider as SpeedSlider;
24pub use ThemeProvider as Theme;
25pub use ThemeSelect as Select;
26pub use ThemeToggle as Toggle;
27
28pub mod prelude {
29 pub use dioxus_theme_core::prelude::*;
30
31 pub use crate::{
32 AnimSelect, Select, SpeedSlider, Theme, ThemeAnim, ThemeAnimationMode,
33 ThemeAnimationPreset, ThemeCfg, ThemeColorScheme, ThemeConfig, ThemeDef, ThemeDefinition,
34 ThemePreset, ThemeProvider, ThemeReducedMotion, ThemeReg, ThemeRegistry, ThemeSelect,
35 ThemeToggle, Toggle, default_themes, theme, theme_component_explain,
36 theme_component_manifest, theme_def, theme_id, theme_native_integration_hints, themes,
37 };
38}
39
40pub mod dx {
41 pub use crate::prelude::*;
42 pub use dioxus_motion_core::dx::DurationDx;
43 pub use dioxus_theme_core::ThemeMotion;
44
45 pub type MotionPreset = dioxus_theme_core::ThemeAnimationPreset;
46
47 pub fn theme_cfg() -> dioxus_theme_core::ThemeConfig {
48 dioxus_theme_core::ThemeConfig::new()
49 }
50
51 pub fn theme(id: impl AsRef<str>) -> dioxus_theme_core::ThemeDefinition {
52 let id = id.as_ref();
53 dioxus_theme_core::ThemeDefinition::new(id, id)
54 }
55
56 pub fn motion() -> dioxus_theme_core::ThemeMotion {
57 dioxus_theme_core::ThemeMotion::new()
58 }
59
60 pub trait ThemeDefinitionDx {
61 fn dark(self) -> Self;
62 fn light(self) -> Self;
63 }
64
65 impl ThemeDefinitionDx for dioxus_theme_core::ThemeDefinition {
66 fn dark(self) -> Self {
67 self.with_color_scheme(dioxus_theme_core::ThemeColorScheme::Dark)
68 }
69
70 fn light(self) -> Self {
71 self.with_color_scheme(dioxus_theme_core::ThemeColorScheme::Light)
72 }
73 }
74
75 pub trait ThemeConfigDx {
76 fn default(self, theme: impl AsRef<str>) -> Self;
77 }
78
79 impl ThemeConfigDx for dioxus_theme_core::ThemeConfig {
80 fn default(self, theme: impl AsRef<str>) -> Self {
81 self.with_default_theme(theme)
82 }
83 }
84}
85
86pub fn theme_component_manifest(
87 config: &ThemeConfig,
88 policy: &dioxus_theme_core::ThemeRoutePolicy,
89) -> dioxus_theme_core::ThemeManifestFragment {
90 config.manifest_fragment(policy)
91}
92
93pub fn theme_component_explain(
94 config: &ThemeConfig,
95 policy: &dioxus_theme_core::ThemeRoutePolicy,
96) -> dioxus_theme_core::ThemeExplainReport {
97 config.explain(policy)
98}
99
100pub fn theme_native_integration_hints(
101 config: &ThemeConfig,
102 policy: &dioxus_theme_core::ThemeRoutePolicy,
103) -> std::collections::BTreeMap<String, String> {
104 let mut hints = dioxus_theme_core::theme_native_port_hints(config, policy);
105 hints.insert(
106 "nativeActions".to_string(),
107 theme_native_package_actions(policy.route.as_deref())
108 .len()
109 .to_string(),
110 );
111 hints.insert(
112 "nativePackage".to_string(),
113 theme_native_compatibility_manifest().package,
114 );
115 hints.insert(
116 "visualTokenCount".to_string(),
117 theme_visual_token_manifest().tokens.len().to_string(),
118 );
119 hints
120}
121
122#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123pub enum ThemeRuntimeMode {
124 BrowserRuntime,
125 StaticFallback,
126}
127
128pub fn theme_runtime_mode(config: &ThemeConfig) -> ThemeRuntimeMode {
129 if cfg!(all(feature = "web", target_arch = "wasm32")) && config.animation.is_animated() {
130 ThemeRuntimeMode::BrowserRuntime
131 } else {
132 ThemeRuntimeMode::StaticFallback
133 }
134}
135
136pub fn theme_native_fallback_config() -> ThemeConfig {
137 ThemeConfig::default().with_animation(ThemeAnimationMode::CssOnly)
138}
139
140pub fn theme_native_compatibility_manifest() -> dioxus_native_port::VisualCompatibilityManifest {
141 static MANIFEST: OnceLock<dioxus_native_port::VisualCompatibilityManifest> = OnceLock::new();
142 MANIFEST
143 .get_or_init(|| {
144 dioxus_native_port::native_port_visual_compatibility_manifest("dioxus-theme")
145 .expect("dioxus-theme visual compatibility manifest is registered")
146 })
147 .clone()
148}
149
150#[derive(Clone, Copy, Debug, Eq, PartialEq)]
151pub enum ThemeNativeAction {
152 ToggleTheme,
153 SetTheme,
154 CycleTheme,
155 SetAnimationPreset,
156 SetAnimationSpeed,
157}
158
159impl ThemeNativeAction {
160 pub const fn as_str(self) -> &'static str {
161 match self {
162 Self::ToggleTheme => "toggle-theme",
163 Self::SetTheme => "set-theme",
164 Self::CycleTheme => "cycle-theme",
165 Self::SetAnimationPreset => "set-animation-preset",
166 Self::SetAnimationSpeed => "set-animation-speed",
167 }
168 }
169
170 pub const fn label(self) -> &'static str {
171 match self {
172 Self::ToggleTheme => "Toggle theme",
173 Self::SetTheme => "Set theme",
174 Self::CycleTheme => "Cycle theme",
175 Self::SetAnimationPreset => "Set animation preset",
176 Self::SetAnimationSpeed => "Set animation speed",
177 }
178 }
179}
180
181pub fn theme_native_package_actions(
182 route: Option<&str>,
183) -> Vec<dioxus_native_port::NativePackageAction> {
184 let route = route.map(str::to_string);
185 [
186 ThemeNativeAction::ToggleTheme,
187 ThemeNativeAction::SetTheme,
188 ThemeNativeAction::CycleTheme,
189 ThemeNativeAction::SetAnimationPreset,
190 ThemeNativeAction::SetAnimationSpeed,
191 ]
192 .into_iter()
193 .map(|action| {
194 let mut package_action = dioxus_native_port::NativePackageAction::new(
195 "dioxus-theme",
196 action.as_str(),
197 action.label(),
198 dioxus_native_port::NativeActionKind::NativeAction,
199 )
200 .description("Applies a configured theme without reloading the page.");
201 if let Some(route) = route.clone() {
202 package_action = package_action.route(route);
203 }
204 package_action
205 })
206 .collect()
207}
208
209pub fn theme_native_action(
210 config: &ThemeConfig,
211 action: ThemeNativeAction,
212 current_theme: impl Into<String>,
213) -> dioxus_native_port::NativeActionResult {
214 let current_theme = theme_id(current_theme.into());
215 let next_theme = match action {
216 ThemeNativeAction::ToggleTheme | ThemeNativeAction::CycleTheme => {
217 config.toggle_theme_id(¤t_theme)
218 }
219 ThemeNativeAction::SetTheme => current_theme.clone(),
220 ThemeNativeAction::SetAnimationPreset | ThemeNativeAction::SetAnimationSpeed => {
221 current_theme.clone()
222 }
223 };
224 let mode = theme_runtime_mode(config);
225 let backend = match mode {
226 ThemeRuntimeMode::BrowserRuntime => "browser-runtime",
227 ThemeRuntimeMode::StaticFallback => "static-fallback",
228 };
229
230 dioxus_native_port::NativeActionResult::succeeded(
231 "dioxus-theme",
232 action.as_str(),
233 dioxus_native_port::NativeActionKind::NativeAction,
234 format!("{} prepared `{next_theme}`", action.label()),
235 )
236 .with_backend(backend)
237 .with_output("currentTheme", current_theme)
238 .with_output("nextTheme", next_theme)
239 .with_output("storageKey", config.storage_key.clone())
240 .with_output("animation", config.animation.as_attr())
241 .with_output("animationPreset", config.animation_preset.as_attr())
242 .with_output("animationStorageKey", config.animation_storage_key.clone())
243 .with_output("animationSpeed", config.animation_speed.to_string())
244 .with_output(
245 "animationSpeedStorageKey",
246 config.animation_speed_storage_key.clone(),
247 )
248 .with_output("themeCount", config.registry.themes.len().to_string())
249}
250
251pub fn use_theme(config: ThemeConfig) -> dioxus_native_port::PortableStorage {
252 let key = config.storage_key.clone();
253 let default_theme = config.default_theme.clone();
254 dioxus_native_port::use_portable_storage(key, move || default_theme)
255}
256
257fn theme_control_id(prefix: &str, handler: &str, label: &str) -> String {
258 format!("{prefix}-{}", theme_id(format!("{handler}-{label}")))
259}
260
261#[derive(Props, Clone, PartialEq)]
262pub struct ThemeProviderProps {
263 #[props(default)]
264 pub config: ThemeConfig,
265 #[props(default)]
266 pub class: String,
267 pub children: Element,
268}
269
270#[component]
271pub fn ThemeProvider(props: ThemeProviderProps) -> Element {
272 let default_theme = props.config.default_theme.clone();
273 let storage_key = props.config.storage_key.clone();
274 rsx! {
275 div {
276 class: "{props.class}",
277 "data-dxt-provider": "true",
278 "data-dxt-default-theme": "{default_theme}",
279 "data-dxt-storage-key": "{storage_key}",
280 {props.children}
281 }
282 }
283}
284
285#[derive(Props, Clone, PartialEq)]
286pub struct ThemeToggleProps {
287 #[props(default = "theme.toggle".to_string())]
288 pub handler: String,
289 #[props(default = "Toggle theme".to_string())]
290 pub label: String,
291 #[props(default)]
292 pub class: String,
293 #[props(default)]
294 pub next_theme: String,
295}
296
297#[component]
298pub fn ThemeToggle(props: ThemeToggleProps) -> Element {
299 let next_theme = theme_id(&props.next_theme);
300 rsx! {
301 button {
302 r#type: "button",
303 class: "{props.class}",
304 "aria-label": "{props.label}",
305 "data-dxr-on-click": "{props.handler}",
306 "data-dxt-theme-next": "{next_theme}",
307 "data-dxt-theme-control": "toggle",
308 span {
309 "data-dxt-theme-toggle-label": "true",
310 "aria-live": "polite",
311 "{props.label}"
312 }
313 }
314 }
315}
316
317#[derive(Props, Clone, PartialEq)]
318pub struct ThemeSelectProps {
319 #[props(default)]
320 pub config: ThemeConfig,
321 #[props(default = "theme.set".to_string())]
322 pub handler: String,
323 #[props(default)]
324 pub class: String,
325 #[props(default = "Theme".to_string())]
326 pub label: String,
327}
328
329#[component]
330pub fn ThemeSelect(props: ThemeSelectProps) -> Element {
331 let select_id = theme_control_id("dxt-theme-select", &props.handler, &props.label);
332 let label_id = format!("{select_id}-label");
333 let current_id = format!("{select_id}-current");
334 let current_label = props
335 .config
336 .resolve_theme(&props.config.default_theme)
337 .map(|theme| theme.label.clone())
338 .unwrap_or_else(|| props.config.default_theme.clone());
339 rsx! {
340 label {
341 class: "{props.class}",
342 "data-dxt-theme-control": "select",
343 "for": "{select_id}",
344 span {
345 id: "{label_id}",
346 "{props.label}"
347 }
348 select {
349 id: "{select_id}",
350 "aria-labelledby": "{label_id} {current_id}",
351 "data-dxr-on-change": "{props.handler}",
352 "data-dxt-theme-select": "true",
353 for theme in props.config.registry.themes.iter() {
354 option {
355 value: "{theme.id}",
356 "{theme.label}"
357 }
358 }
359 }
360 span {
361 id: "{current_id}",
362 "aria-live": "polite",
363 "data-dxt-theme-current": "true",
364 "{current_label}"
365 }
366 }
367 }
368}
369
370#[derive(Props, Clone, PartialEq)]
371pub struct ThemeAnimationSelectProps {
372 #[props(default)]
373 pub config: ThemeConfig,
374 #[props(default = "theme.animation".to_string())]
375 pub handler: String,
376 #[props(default)]
377 pub class: String,
378 #[props(default = "Animation".to_string())]
379 pub label: String,
380}
381
382#[component]
383pub fn ThemeAnimationSelect(props: ThemeAnimationSelectProps) -> Element {
384 let current = props.config.animation_preset.as_attr();
385 let select_id = theme_control_id("dxt-theme-animation", &props.handler, &props.label);
386 let label_id = format!("{select_id}-label");
387 let current_id = format!("{select_id}-current");
388 let current_label = props.config.animation_preset.label();
389 rsx! {
390 label {
391 class: "{props.class}",
392 "data-dxt-theme-control": "animation-select",
393 "for": "{select_id}",
394 span {
395 id: "{label_id}",
396 "{props.label}"
397 }
398 select {
399 id: "{select_id}",
400 value: "{current}",
401 "aria-labelledby": "{label_id} {current_id}",
402 "data-dxr-on-change": "{props.handler}",
403 "data-dxt-theme-animation-select": "true",
404 for preset in ThemeAnimationPreset::all().iter().copied() {
405 option {
406 value: "{preset.as_attr()}",
407 selected: preset == props.config.animation_preset,
408 "{preset.label()}"
409 }
410 }
411 }
412 span {
413 id: "{current_id}",
414 "aria-live": "polite",
415 "data-dxt-theme-animation-current": "true",
416 "{current_label}"
417 }
418 }
419 }
420}
421
422#[derive(Props, Clone, PartialEq)]
423pub struct ThemeAnimationSpeedSliderProps {
424 #[props(default)]
425 pub config: ThemeConfig,
426 #[props(default = "theme.animation-speed".to_string())]
427 pub handler: String,
428 #[props(default)]
429 pub class: String,
430 #[props(default = "Animation speed".to_string())]
431 pub label: String,
432 #[props(default = MIN_THEME_ANIMATION_SPEED)]
433 pub min: u16,
434 #[props(default = MAX_THEME_ANIMATION_SPEED)]
435 pub max: u16,
436 #[props(default = 25)]
437 pub step: u16,
438}
439
440#[component]
441pub fn ThemeAnimationSpeedSlider(props: ThemeAnimationSpeedSliderProps) -> Element {
442 let value = normalize_animation_speed(props.config.animation_speed);
443 let min = normalize_animation_speed(props.min);
444 let max = normalize_animation_speed(props.max);
445 let step = props.step.max(1);
446 let input_id = theme_control_id("dxt-theme-animation-speed", &props.handler, &props.label);
447 let output_id = format!("{input_id}-output");
448 rsx! {
449 label {
450 class: "{props.class}",
451 "data-dxt-theme-control": "animation-speed",
452 "for": "{input_id}",
453 span {
454 "{props.label}: "
455 output {
456 id: "{output_id}",
457 "for": "{input_id}",
458 "aria-live": "polite",
459 "data-dxt-theme-animation-speed-current": "true",
460 "{value}%"
461 }
462 }
463 input {
464 id: "{input_id}",
465 r#type: "range",
466 min: "{min}",
467 max: "{max}",
468 step: "{step}",
469 value: "{value}",
470 "aria-describedby": "{output_id}",
471 "data-dxr-on-input": "{props.handler}",
472 "data-dxt-theme-animation-speed": "true"
473 }
474 }
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn native_action_reports_next_theme() {
484 let config = ThemeConfig::default().with_default_theme("dark");
485 let result = theme_native_action(&config, ThemeNativeAction::ToggleTheme, "dark");
486 assert_eq!(
487 result.outputs.get("currentTheme"),
488 Some(&"dark".to_string())
489 );
490 assert!(result.outputs.contains_key("nextTheme"));
491 assert_eq!(
492 result.outputs.get("animationPreset"),
493 Some(&"cross-fade".to_string())
494 );
495 assert_eq!(
496 result.outputs.get("animationSpeed"),
497 Some(&"100".to_string())
498 );
499
500 let actions = theme_native_package_actions(Some("/browser"));
501 assert!(
502 actions
503 .iter()
504 .any(|action| action.action == "set-animation-preset")
505 );
506 assert!(
507 actions
508 .iter()
509 .any(|action| action.action == "set-animation-speed")
510 );
511 }
512
513 #[test]
514 fn visual_token_contract_is_reexported() {
515 let manifest = theme_visual_token_manifest();
516 let native_manifest = theme_native_compatibility_manifest();
517 let cached_native_manifest = theme_native_compatibility_manifest();
518 assert_eq!(THEME_CHANGE_EVENT, "dioxus-theme:change");
519 assert_eq!(manifest.tokens.len(), THEME_VISUAL_TOKENS.len());
520 assert_eq!(native_manifest.package, "dioxus-theme");
521 assert_eq!(native_manifest, cached_native_manifest);
522 assert_eq!(ThemeVisualTokenRole::Accent.css_var(), THEME_TOKEN_ACCENT);
523 assert_eq!(THEME_TOKEN_SURFACE_BORDER, THEME_TOKEN_PANEL_BORDER);
524 }
525
526 #[test]
527 fn component_manifest_wraps_core_policy_and_native_hints() {
528 let config = ThemeConfig::default();
529 let policy = dioxus_theme_core::theme_route_policy()
530 .route("/theme")
531 .tag("native");
532 let manifest = theme_component_manifest(&config, &policy);
533 let explain = theme_component_explain(&config, &policy);
534 let hints = theme_native_integration_hints(&config, &policy);
535
536 assert_eq!(manifest.route.as_deref(), Some("/theme"));
537 assert_eq!(explain.manifest.cache_key, manifest.cache_key);
538 assert_eq!(hints["nativePackage"], "dioxus-theme");
539 assert_eq!(
540 hints["visualTokenCount"],
541 theme_visual_token_manifest().tokens.len().to_string()
542 );
543 assert!(hints["nativeActions"].parse::<usize>().unwrap() >= 1);
544 }
545
546 #[test]
547 fn dx_theme_syntax_builds_namespaced_config() {
548 use crate::dx::{DurationDx, ThemeConfigDx, ThemeDefinitionDx};
549
550 let config = crate::dx::theme_cfg()
551 .theme(crate::dx::theme("brand").label("Brand").dark())
552 .default("brand")
553 .motion(
554 crate::dx::motion()
555 .dur(140.ms())
556 .preset(crate::dx::MotionPreset::RadialWipe),
557 );
558
559 let brand = config
560 .registry
561 .themes
562 .iter()
563 .find(|theme| theme.id == "brand")
564 .expect("brand theme is registered");
565
566 assert_eq!(config.default_theme, "brand");
567 assert_eq!(config.duration_ms, 140);
568 assert_eq!(config.animation_preset, ThemeAnimationPreset::RadialWipe);
569 assert_eq!(brand.label, "Brand");
570 assert_eq!(brand.color_scheme, ThemeColorScheme::Dark);
571 }
572}