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(¤t_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}