Skip to main content

dioxus_theme/
lib.rs

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(&current_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}