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_CHANGE_EVENT, THEME_TOKEN_ACCENT,
THEME_TOKEN_BACKGROUND, THEME_TOKEN_BG, THEME_TOKEN_FG, THEME_TOKEN_MUTED, THEME_TOKEN_PANEL,
THEME_TOKEN_PANEL_BORDER, THEME_TOKEN_SURFACE, THEME_TOKEN_SURFACE_BORDER, THEME_TOKEN_TEXT,
THEME_VISUAL_TOKEN_MANIFEST_VERSION, THEME_VISUAL_TOKENS, ThemeAnimationMode,
ThemeAnimationPreset, ThemeColorScheme, ThemeConfig, ThemeDefinition, ThemeReducedMotion,
ThemeRegistry, ThemeValidationCode, ThemeValidationIssue, ThemeValidationReport,
ThemeValidationSeverity, ThemeVisualTokenDefinition, ThemeVisualTokenManifest,
ThemeVisualTokenRole, is_safe_css_token_value, is_valid_theme_attribute, is_valid_theme_target,
normalize_animation_speed, theme_id, theme_tokens_css, theme_visual_token_manifest,
theme_visual_token_manifest_json,
};
#[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)
}
pub fn theme_native_compatibility_manifest() -> dioxus_native_port::VisualCompatibilityManifest {
dioxus_native_port::native_port_visual_compatibility_manifest("dioxus-theme")
.expect("dioxus-theme visual compatibility manifest is registered")
}
#[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(¤t_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")
);
}
#[test]
fn visual_token_contract_is_reexported() {
let manifest = theme_visual_token_manifest();
let native_manifest = theme_native_compatibility_manifest();
assert_eq!(THEME_CHANGE_EVENT, "dioxus-theme:change");
assert_eq!(manifest.tokens.len(), THEME_VISUAL_TOKENS.len());
assert_eq!(native_manifest.package, "dioxus-theme");
assert_eq!(ThemeVisualTokenRole::Accent.css_var(), THEME_TOKEN_ACCENT);
assert_eq!(THEME_TOKEN_SURFACE_BORDER, THEME_TOKEN_PANEL_BORDER);
}
}