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