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