dioxus-theme 0.1.0-alpha.1

Dioxus components and native metadata for no-reload theme switching.
Documentation
use dioxus::prelude::*;
pub use dioxus_theme_core::{
    DEFAULT_THEME_ANIMATION_SPEED, DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY,
    DEFAULT_THEME_ANIMATION_STORAGE_KEY, DEFAULT_THEME_ATTRIBUTE, DEFAULT_THEME_DURATION_MS,
    DEFAULT_THEME_EASING, DEFAULT_THEME_RUNTIME_BASE_PATH, DEFAULT_THEME_RUNTIME_PATH,
    DEFAULT_THEME_RUNTIME_VERSION, DEFAULT_THEME_STORAGE_KEY, DEFAULT_THEME_TARGET,
    MAX_THEME_ANIMATION_SPEED, MIN_THEME_ANIMATION_SPEED, THEME_TOKEN_ACCENT, THEME_TOKEN_BG,
    THEME_TOKEN_FG, THEME_TOKEN_MUTED, THEME_TOKEN_PANEL, THEME_TOKEN_PANEL_BORDER,
    ThemeAnimationMode, ThemeAnimationPreset, ThemeColorScheme, ThemeConfig, ThemeDefinition,
    ThemeReducedMotion, ThemeRegistry, ThemeValidationCode, ThemeValidationIssue,
    ThemeValidationReport, ThemeValidationSeverity, is_safe_css_token_value,
    is_valid_theme_attribute, is_valid_theme_target, normalize_animation_speed, theme_id,
    theme_tokens_css,
};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ThemeRuntimeMode {
    BrowserRuntime,
    StaticFallback,
}

pub fn theme_runtime_mode(config: &ThemeConfig) -> ThemeRuntimeMode {
    if cfg!(all(feature = "web", target_arch = "wasm32")) && config.animation.is_animated() {
        ThemeRuntimeMode::BrowserRuntime
    } else {
        ThemeRuntimeMode::StaticFallback
    }
}

pub fn theme_native_fallback_config() -> ThemeConfig {
    ThemeConfig::default().with_animation(ThemeAnimationMode::CssOnly)
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ThemeNativeAction {
    ToggleTheme,
    SetTheme,
    CycleTheme,
    SetAnimationPreset,
    SetAnimationSpeed,
}

impl ThemeNativeAction {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::ToggleTheme => "toggle-theme",
            Self::SetTheme => "set-theme",
            Self::CycleTheme => "cycle-theme",
            Self::SetAnimationPreset => "set-animation-preset",
            Self::SetAnimationSpeed => "set-animation-speed",
        }
    }

    pub const fn label(self) -> &'static str {
        match self {
            Self::ToggleTheme => "Toggle theme",
            Self::SetTheme => "Set theme",
            Self::CycleTheme => "Cycle theme",
            Self::SetAnimationPreset => "Set animation preset",
            Self::SetAnimationSpeed => "Set animation speed",
        }
    }
}

pub fn theme_native_package_actions(
    route: Option<&str>,
) -> Vec<dioxus_native_port::NativePackageAction> {
    let route = route.map(str::to_string);
    [
        ThemeNativeAction::ToggleTheme,
        ThemeNativeAction::SetTheme,
        ThemeNativeAction::CycleTheme,
        ThemeNativeAction::SetAnimationPreset,
        ThemeNativeAction::SetAnimationSpeed,
    ]
    .into_iter()
    .map(|action| {
        let mut package_action = dioxus_native_port::NativePackageAction::new(
            "dioxus-theme",
            action.as_str(),
            action.label(),
            dioxus_native_port::NativeActionKind::NativeAction,
        )
        .description("Applies a configured theme without reloading the page.");
        if let Some(route) = route.clone() {
            package_action = package_action.route(route);
        }
        package_action
    })
    .collect()
}

pub fn theme_native_action(
    config: &ThemeConfig,
    action: ThemeNativeAction,
    current_theme: impl Into<String>,
) -> dioxus_native_port::NativeActionResult {
    let current_theme = theme_id(current_theme.into());
    let next_theme = match action {
        ThemeNativeAction::ToggleTheme | ThemeNativeAction::CycleTheme => {
            config.toggle_theme_id(&current_theme)
        }
        ThemeNativeAction::SetTheme => current_theme.clone(),
        ThemeNativeAction::SetAnimationPreset | ThemeNativeAction::SetAnimationSpeed => {
            current_theme.clone()
        }
    };
    let mode = theme_runtime_mode(config);
    let backend = match mode {
        ThemeRuntimeMode::BrowserRuntime => "browser-runtime",
        ThemeRuntimeMode::StaticFallback => "static-fallback",
    };

    dioxus_native_port::NativeActionResult::succeeded(
        "dioxus-theme",
        action.as_str(),
        dioxus_native_port::NativeActionKind::NativeAction,
        format!("{} prepared `{next_theme}`", action.label()),
    )
    .with_backend(backend)
    .with_output("currentTheme", current_theme)
    .with_output("nextTheme", next_theme)
    .with_output("storageKey", config.storage_key.clone())
    .with_output("animation", config.animation.as_attr())
    .with_output("animationPreset", config.animation_preset.as_attr())
    .with_output("animationStorageKey", config.animation_storage_key.clone())
    .with_output("animationSpeed", config.animation_speed.to_string())
    .with_output(
        "animationSpeedStorageKey",
        config.animation_speed_storage_key.clone(),
    )
    .with_output("themeCount", config.registry.themes.len().to_string())
}

pub fn use_theme(config: ThemeConfig) -> dioxus_native_port::PortableStorage {
    let key = config.storage_key.clone();
    let default_theme = config.default_theme.clone();
    dioxus_native_port::use_portable_storage(key, move || default_theme)
}

fn theme_control_id(prefix: &str, handler: &str, label: &str) -> String {
    format!("{prefix}-{}", theme_id(format!("{handler}-{label}")))
}

#[derive(Props, Clone, PartialEq)]
pub struct ThemeProviderProps {
    #[props(default)]
    pub config: ThemeConfig,
    #[props(default)]
    pub class: String,
    pub children: Element,
}

#[component]
pub fn ThemeProvider(props: ThemeProviderProps) -> Element {
    let default_theme = props.config.default_theme.clone();
    let storage_key = props.config.storage_key.clone();
    rsx! {
        div {
            class: "{props.class}",
            "data-dxt-provider": "true",
            "data-dxt-default-theme": "{default_theme}",
            "data-dxt-storage-key": "{storage_key}",
            {props.children}
        }
    }
}

#[derive(Props, Clone, PartialEq)]
pub struct ThemeToggleProps {
    #[props(default = "theme.toggle".to_string())]
    pub handler: String,
    #[props(default = "Toggle theme".to_string())]
    pub label: String,
    #[props(default)]
    pub class: String,
    #[props(default)]
    pub next_theme: String,
}

#[component]
pub fn ThemeToggle(props: ThemeToggleProps) -> Element {
    let next_theme = theme_id(&props.next_theme);
    rsx! {
        button {
            r#type: "button",
            class: "{props.class}",
            "aria-label": "{props.label}",
            "data-dxr-on-click": "{props.handler}",
            "data-dxt-theme-next": "{next_theme}",
            "data-dxt-theme-control": "toggle",
            span {
                "data-dxt-theme-toggle-label": "true",
                "aria-live": "polite",
                "{props.label}"
            }
        }
    }
}

#[derive(Props, Clone, PartialEq)]
pub struct ThemeSelectProps {
    #[props(default)]
    pub config: ThemeConfig,
    #[props(default = "theme.set".to_string())]
    pub handler: String,
    #[props(default)]
    pub class: String,
    #[props(default = "Theme".to_string())]
    pub label: String,
}

#[component]
pub fn ThemeSelect(props: ThemeSelectProps) -> Element {
    let select_id = theme_control_id("dxt-theme-select", &props.handler, &props.label);
    let label_id = format!("{select_id}-label");
    let current_id = format!("{select_id}-current");
    let current_label = props
        .config
        .resolve_theme(&props.config.default_theme)
        .map(|theme| theme.label.clone())
        .unwrap_or_else(|| props.config.default_theme.clone());
    rsx! {
        label {
            class: "{props.class}",
            "data-dxt-theme-control": "select",
            "for": "{select_id}",
            span {
                id: "{label_id}",
                "{props.label}"
            }
            select {
                id: "{select_id}",
                "aria-labelledby": "{label_id} {current_id}",
                "data-dxr-on-change": "{props.handler}",
                "data-dxt-theme-select": "true",
                for theme in props.config.registry.themes.iter() {
                    option {
                        value: "{theme.id}",
                        "{theme.label}"
                    }
                }
            }
            span {
                id: "{current_id}",
                "aria-live": "polite",
                "data-dxt-theme-current": "true",
                "{current_label}"
            }
        }
    }
}

#[derive(Props, Clone, PartialEq)]
pub struct ThemeAnimationSelectProps {
    #[props(default)]
    pub config: ThemeConfig,
    #[props(default = "theme.animation".to_string())]
    pub handler: String,
    #[props(default)]
    pub class: String,
    #[props(default = "Animation".to_string())]
    pub label: String,
}

#[component]
pub fn ThemeAnimationSelect(props: ThemeAnimationSelectProps) -> Element {
    let current = props.config.animation_preset.as_attr();
    let select_id = theme_control_id("dxt-theme-animation", &props.handler, &props.label);
    let label_id = format!("{select_id}-label");
    let current_id = format!("{select_id}-current");
    let current_label = props.config.animation_preset.label();
    rsx! {
        label {
            class: "{props.class}",
            "data-dxt-theme-control": "animation-select",
            "for": "{select_id}",
            span {
                id: "{label_id}",
                "{props.label}"
            }
            select {
                id: "{select_id}",
                value: "{current}",
                "aria-labelledby": "{label_id} {current_id}",
                "data-dxr-on-change": "{props.handler}",
                "data-dxt-theme-animation-select": "true",
                for preset in ThemeAnimationPreset::all().iter().copied() {
                    option {
                        value: "{preset.as_attr()}",
                        selected: preset == props.config.animation_preset,
                        "{preset.label()}"
                    }
                }
            }
            span {
                id: "{current_id}",
                "aria-live": "polite",
                "data-dxt-theme-animation-current": "true",
                "{current_label}"
            }
        }
    }
}

#[derive(Props, Clone, PartialEq)]
pub struct ThemeAnimationSpeedSliderProps {
    #[props(default)]
    pub config: ThemeConfig,
    #[props(default = "theme.animation-speed".to_string())]
    pub handler: String,
    #[props(default)]
    pub class: String,
    #[props(default = "Animation speed".to_string())]
    pub label: String,
    #[props(default = MIN_THEME_ANIMATION_SPEED)]
    pub min: u16,
    #[props(default = MAX_THEME_ANIMATION_SPEED)]
    pub max: u16,
    #[props(default = 25)]
    pub step: u16,
}

#[component]
pub fn ThemeAnimationSpeedSlider(props: ThemeAnimationSpeedSliderProps) -> Element {
    let value = normalize_animation_speed(props.config.animation_speed);
    let min = normalize_animation_speed(props.min);
    let max = normalize_animation_speed(props.max);
    let step = props.step.max(1);
    let input_id = theme_control_id("dxt-theme-animation-speed", &props.handler, &props.label);
    let output_id = format!("{input_id}-output");
    rsx! {
        label {
            class: "{props.class}",
            "data-dxt-theme-control": "animation-speed",
            "for": "{input_id}",
            span {
                "{props.label}: "
                output {
                    id: "{output_id}",
                    "for": "{input_id}",
                    "aria-live": "polite",
                    "data-dxt-theme-animation-speed-current": "true",
                    "{value}%"
                }
            }
            input {
                id: "{input_id}",
                r#type: "range",
                min: "{min}",
                max: "{max}",
                step: "{step}",
                value: "{value}",
                "aria-describedby": "{output_id}",
                "data-dxr-on-input": "{props.handler}",
                "data-dxt-theme-animation-speed": "true"
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn native_action_reports_next_theme() {
        let config = ThemeConfig::default().with_default_theme("dark");
        let result = theme_native_action(&config, ThemeNativeAction::ToggleTheme, "dark");
        assert_eq!(
            result.outputs.get("currentTheme"),
            Some(&"dark".to_string())
        );
        assert!(result.outputs.contains_key("nextTheme"));
        assert_eq!(
            result.outputs.get("animationPreset"),
            Some(&"cross-fade".to_string())
        );
        assert_eq!(
            result.outputs.get("animationSpeed"),
            Some(&"100".to_string())
        );

        let actions = theme_native_package_actions(Some("/browser"));
        assert!(
            actions
                .iter()
                .any(|action| action.action == "set-animation-preset")
        );
        assert!(
            actions
                .iter()
                .any(|action| action.action == "set-animation-speed")
        );
    }
}