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