1use dioxus::prelude::*;
2pub use dioxus_hoverfx_core::*;
3
4pub const HOVERFX_INIT_HANDLER: &str = "hoverfx.init";
5pub const HOVERFX_REFRESH_HANDLER: &str = "hoverfx.refresh";
6pub const HOVERFX_RADIUS_HANDLER: &str = "hoverfx.radius";
7pub const HOVERFX_SHAPE_HANDLER: &str = "hoverfx.shape";
8pub const HOVERFX_FALLOFF_HANDLER: &str = "hoverfx.falloff";
9pub const HOVERFX_STRENGTH_HANDLER: &str = "hoverfx.strength";
10pub const HOVERFX_THEME_CHANGE_EVENT: &str = dioxus_theme_core::THEME_CHANGE_EVENT;
11
12const DEFAULT_EFFECT: &str = "spotlight";
13const DEFAULT_RADIUS_MIN: u16 = 80;
14const DEFAULT_RADIUS_MAX: u16 = 640;
15const DEFAULT_RADIUS_STEP: u16 = 10;
16const DEFAULT_STRENGTH_PERCENT: u16 = 100;
17const DEFAULT_STRENGTH_MIN: u16 = 0;
18const DEFAULT_STRENGTH_MAX: u16 = 200;
19const DEFAULT_STRENGTH_STEP: u16 = 5;
20const HOVERFX_WALLPAPER_LAYER_STYLE: &str = concat!(
21 "position:absolute!important;",
22 "inset:0!important;",
23 "z-index:0!important;",
24 "display:block!important;",
25 "inline-size:100%!important;",
26 "block-size:100%!important;",
27 "width:100%!important;",
28 "height:100%!important;",
29 "min-inline-size:0!important;",
30 "min-block-size:0!important;",
31 "min-width:0!important;",
32 "min-height:0!important;",
33 "max-inline-size:100%!important;",
34 "max-block-size:100%!important;",
35 "margin:0!important;",
36 "padding:0!important;",
37 "border:0!important;",
38 "box-sizing:border-box!important;",
39 "border-radius:inherit!important;",
40 "overflow:hidden!important;",
41 "pointer-events:none!important;",
42 "contain:layout paint style!important;",
43 "flex:none!important;",
44 "align-self:stretch!important;",
45 "grid-area:1 / 1!important;"
46);
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub struct HoverFxThemeTokenInterop {
50 pub change_event: &'static str,
51 pub accent_token: &'static str,
52 pub muted_token: &'static str,
53 pub surface_token: &'static str,
54 pub text_token: &'static str,
55 pub sand_color_token: &'static str,
56 pub sand_highlight_token: &'static str,
57}
58
59pub const fn hoverfx_theme_token_interop() -> HoverFxThemeTokenInterop {
60 HoverFxThemeTokenInterop {
61 change_event: dioxus_theme_core::THEME_CHANGE_EVENT,
62 accent_token: dioxus_theme_core::THEME_TOKEN_ACCENT,
63 muted_token: dioxus_theme_core::THEME_TOKEN_MUTED,
64 surface_token: dioxus_theme_core::THEME_TOKEN_SURFACE,
65 text_token: dioxus_theme_core::THEME_TOKEN_TEXT,
66 sand_color_token: dioxus_theme_core::THEME_TOKEN_SURFACE,
67 sand_highlight_token: dioxus_theme_core::THEME_TOKEN_ACCENT,
68 }
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum HoverFxRuntimeMode {
73 BrowserRuntime,
74 StaticFallback,
75}
76
77pub fn hoverfx_runtime_mode(_config: &HoverFxConfig) -> HoverFxRuntimeMode {
78 if cfg!(all(feature = "web", target_arch = "wasm32")) {
79 HoverFxRuntimeMode::BrowserRuntime
80 } else {
81 HoverFxRuntimeMode::StaticFallback
82 }
83}
84
85pub fn hoverfx_native_fallback_config() -> HoverFxConfig {
86 HoverFxConfig::default()
87}
88
89pub fn hoverfx_native_compatibility_manifest() -> dioxus_native_port::VisualCompatibilityManifest {
90 dioxus_native_port::native_port_visual_compatibility_manifest("dioxus-hoverfx")
91 .expect("dioxus-hoverfx visual compatibility manifest is registered")
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub enum HoverFxNativeAction {
96 Init,
97 Refresh,
98 SetRadius,
99 SetShape,
100 SetFalloff,
101 SetStrength,
102}
103
104impl HoverFxNativeAction {
105 pub const fn as_str(self) -> &'static str {
106 match self {
107 Self::Init => "init",
108 Self::Refresh => "refresh",
109 Self::SetRadius => "set-radius",
110 Self::SetShape => "set-shape",
111 Self::SetFalloff => "set-falloff",
112 Self::SetStrength => "set-strength",
113 }
114 }
115
116 pub const fn handler(self) -> &'static str {
117 match self {
118 Self::Init => HOVERFX_INIT_HANDLER,
119 Self::Refresh => HOVERFX_REFRESH_HANDLER,
120 Self::SetRadius => HOVERFX_RADIUS_HANDLER,
121 Self::SetShape => HOVERFX_SHAPE_HANDLER,
122 Self::SetFalloff => HOVERFX_FALLOFF_HANDLER,
123 Self::SetStrength => HOVERFX_STRENGTH_HANDLER,
124 }
125 }
126
127 pub const fn label(self) -> &'static str {
128 match self {
129 Self::Init => "Initialize HoverFX",
130 Self::Refresh => "Refresh HoverFX targets",
131 Self::SetRadius => "Set hover radius",
132 Self::SetShape => "Set hover shape",
133 Self::SetFalloff => "Set hover falloff",
134 Self::SetStrength => "Set hover strength",
135 }
136 }
137}
138
139pub fn hoverfx_native_package_actions(
140 route: Option<&str>,
141) -> Vec<dioxus_native_port::NativePackageAction> {
142 let route = route.map(str::to_string);
143 [
144 HoverFxNativeAction::Init,
145 HoverFxNativeAction::Refresh,
146 HoverFxNativeAction::SetRadius,
147 HoverFxNativeAction::SetShape,
148 HoverFxNativeAction::SetFalloff,
149 HoverFxNativeAction::SetStrength,
150 ]
151 .into_iter()
152 .map(|action| {
153 let mut package_action = dioxus_native_port::NativePackageAction::new(
154 "dioxus-hoverfx",
155 action.as_str(),
156 action.label(),
157 dioxus_native_port::NativeActionKind::NativeAction,
158 )
159 .description(format!(
160 "Controls worker-first cursor hover effects without page hydration. Handler: {}.",
161 action.handler()
162 ));
163 if let Some(route) = route.clone() {
164 package_action = package_action.route(route);
165 }
166 package_action
167 })
168 .collect()
169}
170
171pub fn hoverfx_native_action(
172 config: &HoverFxConfig,
173 action: HoverFxNativeAction,
174) -> dioxus_native_port::NativeActionResult {
175 let mode = hoverfx_runtime_mode(config);
176 let backend = match mode {
177 HoverFxRuntimeMode::BrowserRuntime => "browser-runtime",
178 HoverFxRuntimeMode::StaticFallback => "static-fallback",
179 };
180
181 dioxus_native_port::NativeActionResult::succeeded(
182 "dioxus-hoverfx",
183 action.as_str(),
184 dioxus_native_port::NativeActionKind::NativeAction,
185 format!("{} prepared", action.label()),
186 )
187 .with_backend(backend)
188 .with_output("handler", action.handler())
189}
190
191pub fn hoverfx_preset_attr(preset: HoverFxPreset) -> &'static str {
192 preset.as_attr()
193}
194
195pub fn hoverfx_preset_label(preset: HoverFxPreset) -> &'static str {
196 preset.label()
197}
198
199pub fn hoverfx_shape_attr(shape: HoverFxShape) -> &'static str {
200 shape.as_attr()
201}
202
203pub fn hoverfx_shape_label(shape: HoverFxShape) -> &'static str {
204 shape.label()
205}
206
207pub fn hoverfx_falloff_attr(falloff: HoverFxFalloff) -> &'static str {
208 falloff.as_attr()
209}
210
211pub fn hoverfx_falloff_label(falloff: HoverFxFalloff) -> &'static str {
212 falloff.label()
213}
214
215pub fn hoverfx_text_contrast_attr(text_contrast: HoverFxTextContrastMode) -> &'static str {
216 text_contrast.as_attr()
217}
218
219fn hoverfx_control_id(prefix: &str, handler: &str, label: &str) -> String {
220 format!("{prefix}-{}", hoverfx_id(format!("{handler}-{label}")))
221}
222
223fn sanitized_effect(effect: &str, preset: Option<HoverFxPreset>) -> String {
224 preset
225 .map(|preset| hoverfx_preset_attr(preset).to_string())
226 .unwrap_or_else(|| hoverfx_id(effect.to_string()))
227}
228
229fn optional_u16_attr(value: Option<u16>) -> String {
230 value.map(|value| value.to_string()).unwrap_or_default()
231}
232
233fn optional_f32_attr(value: Option<f32>) -> String {
234 value.map(format_float).unwrap_or_default()
235}
236
237fn bool_attr(value: bool) -> &'static str {
238 if value { "true" } else { "false" }
239}
240
241fn format_float(value: f32) -> String {
242 let mut formatted = format!("{value:.3}");
243 while formatted.contains('.') && formatted.ends_with('0') {
244 formatted.pop();
245 }
246 if formatted.ends_with('.') {
247 formatted.pop();
248 }
249 formatted
250}
251
252fn optional_shape_attr(value: Option<HoverFxShape>) -> String {
253 value
254 .map(hoverfx_shape_attr)
255 .unwrap_or_default()
256 .to_string()
257}
258
259fn optional_falloff_attr(value: Option<HoverFxFalloff>) -> String {
260 value
261 .map(hoverfx_falloff_attr)
262 .unwrap_or_default()
263 .to_string()
264}
265
266fn optional_text_reveal_attr(value: Option<&HoverFxTextRevealConfig>) -> String {
267 value
268 .and_then(|value| value.to_json().ok())
269 .unwrap_or_default()
270}
271
272fn optional_texture_reveal_attr(value: Option<&HoverFxTextureRevealConfig>) -> String {
273 value
274 .and_then(|value| value.to_json().ok())
275 .unwrap_or_default()
276}
277
278fn optional_sand_attr(value: Option<&HoverFxSandConfig>) -> String {
279 value
280 .and_then(|value| value.to_json().ok())
281 .unwrap_or_default()
282}
283
284fn optional_text_contrast_attr(value: Option<HoverFxTextContrastMode>) -> String {
285 value
286 .map(hoverfx_text_contrast_attr)
287 .unwrap_or_default()
288 .to_string()
289}
290
291fn optional_textfx_config_attr(
292 value: Option<&HoverFxTextRevealConfig>,
293 interop: bool,
294 id: &str,
295) -> String {
296 if !interop {
297 return String::new();
298 }
299 let Some(value) = value else {
300 return String::new();
301 };
302 if value.animation_source == HoverFxTextAnimationSource::HoverFx {
303 return String::new();
304 }
305 value
306 .to_textfx_config_json(
307 if id.trim().is_empty() {
308 "hoverfx-binary-reveal"
309 } else {
310 id
311 },
312 "010101001101",
313 )
314 .unwrap_or_default()
315}
316
317#[derive(Props, Clone, PartialEq)]
318pub struct HoverFxProviderProps {
319 #[props(default)]
320 pub config: HoverFxConfig,
321 #[props(default)]
322 pub class: String,
323 #[props(default = "document".to_string())]
324 pub scope: String,
325 #[props(default = true)]
326 pub auto_init: bool,
327 pub children: Element,
328}
329
330#[component]
331pub fn HoverFxProvider(props: HoverFxProviderProps) -> Element {
332 let mode = hoverfx_runtime_mode(&props.config);
333 let runtime = match mode {
334 HoverFxRuntimeMode::BrowserRuntime => "browser-runtime",
335 HoverFxRuntimeMode::StaticFallback => "static-fallback",
336 };
337 let auto_init = if props.auto_init { "true" } else { "false" };
338 let default_effect = hoverfx_id(&props.config.default_effect);
339 let radius = props.config.radius_px.to_string();
340 let shape = props.config.shape.as_attr();
341 let falloff = props.config.falloff.as_attr();
342 let strength = format_float(props.config.strength);
343 let smoothing = format_float(props.config.smoothing);
344 let max_active = props.config.max_active_elements.to_string();
345 let renderer = props.config.renderer.as_attr();
346 let runtime_path = props.config.runtime_path.clone();
347 let worker_path = props.config.worker_path.clone();
348 let theme_tokens = hoverfx_theme_token_interop();
349 rsx! {
350 div {
351 class: "{props.class}",
352 "data-dxh-provider": "true",
353 "data-dxh-scope": "{props.scope}",
354 "data-dxh-theme-event": "{theme_tokens.change_event}",
355 "data-dxh-theme-accent-token": "{theme_tokens.accent_token}",
356 "data-dxh-theme-muted-token": "{theme_tokens.muted_token}",
357 "data-dxh-theme-surface-token": "{theme_tokens.surface_token}",
358 "data-dxh-theme-text-token": "{theme_tokens.text_token}",
359 "data-dxh-theme-sand-color-token": "{theme_tokens.sand_color_token}",
360 "data-dxh-theme-sand-highlight-token": "{theme_tokens.sand_highlight_token}",
361 "data-dxh-runtime": "{runtime}",
362 "data-dxh-renderer": "{renderer}",
363 "data-dxh-auto-init": "{auto_init}",
364 "data-dxh-default-effect": "{default_effect}",
365 "data-dxh-radius": "{radius}",
366 "data-dxh-shape": "{shape}",
367 "data-dxh-falloff": "{falloff}",
368 "data-dxh-strength": "{strength}",
369 "data-dxh-smoothing": "{smoothing}",
370 "data-dxh-max-active-elements": "{max_active}",
371 "data-dxh-runtime-path": "{runtime_path}",
372 "data-dxh-worker-path": "{worker_path}",
373 {props.children}
374 }
375 }
376}
377
378#[derive(Props, Clone, PartialEq)]
379pub struct HoverFxTargetProps {
380 #[props(default = DEFAULT_EFFECT.to_string())]
381 pub effect: String,
382 #[props(default)]
383 pub preset: Option<HoverFxPreset>,
384 #[props(default)]
385 pub radius: Option<u16>,
386 #[props(default)]
387 pub shape: Option<HoverFxShape>,
388 #[props(default)]
389 pub falloff: Option<HoverFxFalloff>,
390 #[props(default)]
391 pub strength: Option<f32>,
392 #[props(default)]
393 pub contained: bool,
394 #[props(default)]
395 pub controlled: bool,
396 #[props(default)]
397 pub text_reveal: Option<HoverFxTextRevealConfig>,
398 #[props(default)]
399 pub texture_reveal: Option<HoverFxTextureRevealConfig>,
400 #[props(default)]
401 pub sand: Option<HoverFxSandConfig>,
402 #[props(default)]
403 pub text_contrast: Option<HoverFxTextContrastMode>,
404 #[props(default)]
405 pub textfx_interop: bool,
406 #[props(default)]
407 pub class: String,
408 #[props(default)]
409 pub id: String,
410 pub children: Element,
411}
412
413#[component]
414pub fn HoverFxTarget(props: HoverFxTargetProps) -> Element {
415 let effect = sanitized_effect(&props.effect, props.preset);
416 let radius = optional_u16_attr(props.radius);
417 let shape = optional_shape_attr(props.shape);
418 let falloff = optional_falloff_attr(props.falloff);
419 let strength = optional_f32_attr(props.strength);
420 let contained = bool_attr(props.contained);
421 let controlled = bool_attr(props.controlled);
422 let text_reveal = optional_text_reveal_attr(props.text_reveal.as_ref());
423 let texture_reveal = optional_texture_reveal_attr(props.texture_reveal.as_ref());
424 let sand = optional_sand_attr(props.sand.as_ref());
425 let text_contrast = optional_text_contrast_attr(props.text_contrast);
426 let textfx_config =
427 optional_textfx_config_attr(props.text_reveal.as_ref(), props.textfx_interop, &props.id);
428 let wallpaper_layer_style = HOVERFX_WALLPAPER_LAYER_STYLE;
429
430 if textfx_config.is_empty() {
431 rsx! {
432 div {
433 id: "{props.id}",
434 class: "{props.class}",
435 "data-dxh-target": "true",
436 "data-dxh-effect": "{effect}",
437 "data-dxh-radius": "{radius}",
438 "data-dxh-shape": "{shape}",
439 "data-dxh-falloff": "{falloff}",
440 "data-dxh-strength": "{strength}",
441 "data-dxh-contain": "{contained}",
442 "data-dxh-controlled": "{controlled}",
443 "data-dxh-text-reveal": "{text_reveal}",
444 "data-dxh-texture-reveal": "{texture_reveal}",
445 "data-dxh-sand": "{sand}",
446 "data-dxh-text-contrast": "{text_contrast}",
447 {props.children}
448 }
449 }
450 } else {
451 rsx! {
452 div {
453 id: "{props.id}",
454 class: "{props.class}",
455 "data-dxh-target": "true",
456 "data-dxh-effect": "{effect}",
457 "data-dxh-radius": "{radius}",
458 "data-dxh-shape": "{shape}",
459 "data-dxh-falloff": "{falloff}",
460 "data-dxh-strength": "{strength}",
461 "data-dxh-contain": "{contained}",
462 "data-dxh-controlled": "{controlled}",
463 "data-dxh-text-reveal": "{text_reveal}",
464 "data-dxh-texture-reveal": "{texture_reveal}",
465 "data-dxh-sand": "{sand}",
466 "data-dxh-text-contrast": "{text_contrast}",
467 "data-dxr-on-pointerover": "textfx.run",
468 span {
469 class: "dxh-text-reveal-layer",
470 style: "{wallpaper_layer_style}",
471 "aria-hidden": "true",
472 "data-dxh-text-layer": "true",
473 "data-dxt-textfx": "{textfx_config}"
474 }
475 {props.children}
476 }
477 }
478 }
479}
480
481#[derive(Props, Clone, PartialEq)]
482pub struct HoverFxCardProps {
483 #[props(default = DEFAULT_EFFECT.to_string())]
484 pub effect: String,
485 #[props(default)]
486 pub preset: Option<HoverFxPreset>,
487 #[props(default)]
488 pub radius: Option<u16>,
489 #[props(default)]
490 pub shape: Option<HoverFxShape>,
491 #[props(default)]
492 pub falloff: Option<HoverFxFalloff>,
493 #[props(default)]
494 pub strength: Option<f32>,
495 #[props(default)]
496 pub contained: bool,
497 #[props(default)]
498 pub controlled: bool,
499 #[props(default)]
500 pub text_reveal: Option<HoverFxTextRevealConfig>,
501 #[props(default)]
502 pub texture_reveal: Option<HoverFxTextureRevealConfig>,
503 #[props(default)]
504 pub sand: Option<HoverFxSandConfig>,
505 #[props(default)]
506 pub text_contrast: Option<HoverFxTextContrastMode>,
507 #[props(default)]
508 pub textfx_interop: bool,
509 #[props(default)]
510 pub class: String,
511 #[props(default)]
512 pub id: String,
513 pub children: Element,
514}
515
516#[component]
517pub fn HoverFxCard(props: HoverFxCardProps) -> Element {
518 rsx! {
519 HoverFxTarget {
520 id: props.id,
521 class: props.class,
522 effect: props.effect,
523 preset: props.preset,
524 radius: props.radius,
525 shape: props.shape,
526 falloff: props.falloff,
527 strength: props.strength,
528 contained: props.contained,
529 controlled: props.controlled,
530 text_reveal: props.text_reveal,
531 texture_reveal: props.texture_reveal,
532 sand: props.sand,
533 text_contrast: props.text_contrast,
534 textfx_interop: props.textfx_interop,
535 div {
536 "data-dxh-card": "true",
537 {props.children}
538 }
539 }
540 }
541}
542
543#[derive(Props, Clone, PartialEq)]
544pub struct HoverFxRadiusSliderProps {
545 #[props(default = HOVERFX_RADIUS_HANDLER.to_string())]
546 pub handler: String,
547 #[props(default)]
548 pub class: String,
549 #[props(default = "Hover radius".to_string())]
550 pub label: String,
551 #[props(default = DEFAULT_HOVERFX_RADIUS_PX)]
552 pub value: u16,
553 #[props(default = DEFAULT_RADIUS_MIN)]
554 pub min: u16,
555 #[props(default = DEFAULT_RADIUS_MAX)]
556 pub max: u16,
557 #[props(default = DEFAULT_RADIUS_STEP)]
558 pub step: u16,
559 #[props(default)]
560 pub apply_to_all: bool,
561}
562
563#[component]
564pub fn HoverFxRadiusSlider(props: HoverFxRadiusSliderProps) -> Element {
565 let input_id = hoverfx_control_id("dxh-radius", &props.handler, &props.label);
566 let output_id = format!("{input_id}-output");
567 let min = props.min.min(props.max);
568 let max = props.max.max(props.min);
569 let value = props.value.clamp(min, max);
570 let step = props.step.max(1);
571 let apply_to = if props.apply_to_all { "all" } else { "target" };
572 rsx! {
573 label {
574 class: "{props.class}",
575 "data-dxh-control": "radius",
576 "data-dxh-apply-to": "{apply_to}",
577 "for": "{input_id}",
578 span {
579 "{props.label}: "
580 output {
581 id: "{output_id}",
582 "for": "{input_id}",
583 "aria-live": "polite",
584 "data-dxh-radius-current": "true",
585 "{value}px"
586 }
587 }
588 input {
589 id: "{input_id}",
590 r#type: "range",
591 min: "{min}",
592 max: "{max}",
593 step: "{step}",
594 value: "{value}",
595 "aria-describedby": "{output_id}",
596 "data-dxr-on-input": "{props.handler}",
597 "data-dxh-radius-control": "true"
598 }
599 }
600 }
601}
602
603#[derive(Props, Clone, PartialEq)]
604pub struct HoverFxShapeSelectProps {
605 #[props(default = HOVERFX_SHAPE_HANDLER.to_string())]
606 pub handler: String,
607 #[props(default)]
608 pub class: String,
609 #[props(default = "Hover shape".to_string())]
610 pub label: String,
611 #[props(default = HoverFxShape::Circle)]
612 pub value: HoverFxShape,
613 #[props(default)]
614 pub apply_to_all: bool,
615}
616
617#[component]
618pub fn HoverFxShapeSelect(props: HoverFxShapeSelectProps) -> Element {
619 let select_id = hoverfx_control_id("dxh-shape", &props.handler, &props.label);
620 let label_id = format!("{select_id}-label");
621 let current_id = format!("{select_id}-current");
622 let current = hoverfx_shape_attr(props.value);
623 let current_label = hoverfx_shape_label(props.value);
624 let apply_to = if props.apply_to_all { "all" } else { "target" };
625 rsx! {
626 label {
627 class: "{props.class}",
628 "data-dxh-control": "shape",
629 "data-dxh-apply-to": "{apply_to}",
630 "for": "{select_id}",
631 span {
632 id: "{label_id}",
633 "{props.label}"
634 }
635 select {
636 id: "{select_id}",
637 value: "{current}",
638 "aria-labelledby": "{label_id} {current_id}",
639 "data-dxr-on-change": "{props.handler}",
640 "data-dxh-shape-control": "true",
641 for shape in [
642 HoverFxShape::Circle,
643 HoverFxShape::Square,
644 HoverFxShape::RoundedRect,
645 HoverFxShape::Polygon,
646 ] {
647 option {
648 value: "{hoverfx_shape_attr(shape)}",
649 selected: shape == props.value,
650 "{hoverfx_shape_label(shape)}"
651 }
652 }
653 }
654 span {
655 id: "{current_id}",
656 "aria-live": "polite",
657 "data-dxh-shape-current": "true",
658 "{current_label}"
659 }
660 }
661 }
662}
663
664#[derive(Props, Clone, PartialEq)]
665pub struct HoverFxFalloffSelectProps {
666 #[props(default = HOVERFX_FALLOFF_HANDLER.to_string())]
667 pub handler: String,
668 #[props(default)]
669 pub class: String,
670 #[props(default = "Hover falloff".to_string())]
671 pub label: String,
672 #[props(default = HoverFxFalloff::Smooth)]
673 pub value: HoverFxFalloff,
674 #[props(default)]
675 pub apply_to_all: bool,
676}
677
678#[component]
679pub fn HoverFxFalloffSelect(props: HoverFxFalloffSelectProps) -> Element {
680 let select_id = hoverfx_control_id("dxh-falloff", &props.handler, &props.label);
681 let label_id = format!("{select_id}-label");
682 let current_id = format!("{select_id}-current");
683 let current = hoverfx_falloff_attr(props.value);
684 let current_label = hoverfx_falloff_label(props.value);
685 let apply_to = if props.apply_to_all { "all" } else { "target" };
686 rsx! {
687 label {
688 class: "{props.class}",
689 "data-dxh-control": "falloff",
690 "data-dxh-apply-to": "{apply_to}",
691 "for": "{select_id}",
692 span {
693 id: "{label_id}",
694 "{props.label}"
695 }
696 select {
697 id: "{select_id}",
698 value: "{current}",
699 "aria-labelledby": "{label_id} {current_id}",
700 "data-dxr-on-change": "{props.handler}",
701 "data-dxh-falloff-control": "true",
702 for falloff in [
703 HoverFxFalloff::Hard,
704 HoverFxFalloff::Linear,
705 HoverFxFalloff::Smooth,
706 HoverFxFalloff::Exponential,
707 ] {
708 option {
709 value: "{hoverfx_falloff_attr(falloff)}",
710 selected: falloff == props.value,
711 "{hoverfx_falloff_label(falloff)}"
712 }
713 }
714 }
715 span {
716 id: "{current_id}",
717 "aria-live": "polite",
718 "data-dxh-falloff-current": "true",
719 "{current_label}"
720 }
721 }
722 }
723}
724
725#[derive(Props, Clone, PartialEq)]
726pub struct HoverFxStrengthSliderProps {
727 #[props(default = HOVERFX_STRENGTH_HANDLER.to_string())]
728 pub handler: String,
729 #[props(default)]
730 pub class: String,
731 #[props(default = "Hover strength".to_string())]
732 pub label: String,
733 #[props(default = DEFAULT_STRENGTH_PERCENT)]
734 pub value: u16,
735 #[props(default = DEFAULT_STRENGTH_MIN)]
736 pub min: u16,
737 #[props(default = DEFAULT_STRENGTH_MAX)]
738 pub max: u16,
739 #[props(default = DEFAULT_STRENGTH_STEP)]
740 pub step: u16,
741 #[props(default)]
742 pub apply_to_all: bool,
743}
744
745#[component]
746pub fn HoverFxStrengthSlider(props: HoverFxStrengthSliderProps) -> Element {
747 let input_id = hoverfx_control_id("dxh-strength", &props.handler, &props.label);
748 let output_id = format!("{input_id}-output");
749 let min = props.min.min(props.max);
750 let max = props.max.max(props.min);
751 let value = props.value.clamp(min, max);
752 let step = props.step.max(1);
753 let apply_to = if props.apply_to_all { "all" } else { "target" };
754 rsx! {
755 label {
756 class: "{props.class}",
757 "data-dxh-control": "strength",
758 "data-dxh-apply-to": "{apply_to}",
759 "for": "{input_id}",
760 span {
761 "{props.label}: "
762 output {
763 id: "{output_id}",
764 "for": "{input_id}",
765 "aria-live": "polite",
766 "data-dxh-strength-current": "true",
767 "{value}%"
768 }
769 }
770 input {
771 id: "{input_id}",
772 r#type: "range",
773 min: "{min}",
774 max: "{max}",
775 step: "{step}",
776 value: "{value}",
777 "aria-describedby": "{output_id}",
778 "data-dxr-on-input": "{props.handler}",
779 "data-dxh-strength-control": "true"
780 }
781 }
782 }
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788
789 #[test]
790 fn native_actions_include_expected_handlers() {
791 let actions = hoverfx_native_package_actions(Some("/hoverfx"));
792 let manifest = hoverfx_native_compatibility_manifest();
793 assert_eq!(manifest.package, "dioxus-hoverfx");
794 assert!(actions.iter().any(|action| action.action == "init"));
795 assert!(actions.iter().any(|action| action.action == "refresh"));
796 assert!(actions.iter().any(|action| action.action == "set-radius"));
797 assert!(actions.iter().any(|action| action.action == "set-shape"));
798 assert!(actions.iter().any(|action| action.action == "set-falloff"));
799 assert!(actions.iter().any(|action| action.action == "set-strength"));
800 assert!(
801 actions
802 .iter()
803 .any(|action| action.description.contains(HOVERFX_RADIUS_HANDLER))
804 );
805 }
806
807 #[test]
808 fn helpers_emit_runtime_attribute_values() {
809 assert_eq!(hoverfx_preset_attr(HoverFxPreset::SoftGlow), "soft-glow");
810 assert_eq!(
811 hoverfx_preset_attr(HoverFxPreset::BinaryReveal),
812 "binary-reveal"
813 );
814 assert_eq!(
815 hoverfx_preset_attr(HoverFxPreset::TextureReveal),
816 "texture-reveal"
817 );
818 assert_eq!(hoverfx_preset_attr(HoverFxPreset::Sand), "sand");
819 assert_eq!(
820 hoverfx_shape_attr(HoverFxShape::RoundedRect),
821 "rounded-rect"
822 );
823 assert_eq!(
824 hoverfx_falloff_attr(HoverFxFalloff::Exponential),
825 "exponential"
826 );
827 assert_eq!(
828 hoverfx_text_contrast_attr(HoverFxTextContrastMode::Invert),
829 "invert"
830 );
831 }
832
833 #[test]
834 fn text_reveal_attrs_emit_hoverfx_and_optional_textfx_config() {
835 let config = HoverFxTextRevealConfig::default();
836 let hoverfx = optional_text_reveal_attr(Some(&config));
837 assert!(hoverfx.contains(r#""charset":"01""#));
838 assert!(hoverfx.contains(r#""animationSource":"auto""#));
839 assert!(hoverfx.contains(r#""renderer":"glyph-atlas""#));
840
841 let textfx = optional_textfx_config_attr(Some(&config), true, "binary-card");
842 assert!(textfx.contains(r#""effect":"scramble""#));
843 assert!(textfx.contains(r#""charset":"01""#));
844 assert!(textfx.contains(r#""speedMs":220"#));
845 assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("position:absolute!important"));
846 assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("inset:0!important"));
847 assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("flex:none!important"));
848 assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("grid-area:1 / 1!important"));
849 assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("pointer-events:none!important"));
850 assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("contain:layout paint style!important"));
851 assert!(optional_textfx_config_attr(Some(&config), false, "binary-card").is_empty());
852 assert!(
853 optional_textfx_config_attr(
854 Some(
855 &config
856 .clone()
857 .with_animation_source(HoverFxTextAnimationSource::HoverFx)
858 ),
859 true,
860 "binary-card"
861 )
862 .is_empty()
863 );
864 }
865
866 #[test]
867 fn texture_reveal_attr_emits_hoverfx_config() {
868 let config =
869 HoverFxTextureRevealConfig::default().with_mode(HoverFxTextureRevealMode::Halftone);
870 let hoverfx = optional_texture_reveal_attr(Some(&config));
871 assert!(hoverfx.contains(r#""mode":"halftone""#));
872 assert!(optional_texture_reveal_attr(None).is_empty());
873 }
874
875 #[test]
876 fn sand_attr_emits_hoverfx_config() {
877 let config = HoverFxSandConfig::default()
878 .with_grain_size_px(1.4)
879 .with_shimmer_strength(0.9)
880 .with_shimmer_radius_px(280.0)
881 .with_specular_strength(1.2)
882 .with_color_source(HoverFxSandColorSource::Element)
883 .with_animation_speed_ms(720);
884 let hoverfx = optional_sand_attr(Some(&config));
885 assert!(hoverfx.contains(r#""grainSizePx":"#));
886 assert!(hoverfx.contains(r#""shimmerStrength":"#));
887 assert!(hoverfx.contains(r#""shimmerRadiusPx":"#));
888 assert!(hoverfx.contains(r#""specularStrength":"#));
889 assert!(hoverfx.contains(r#""colorSource":"element""#));
890 assert!(hoverfx.contains(r#""animationSpeedMs":720"#));
891 assert!(optional_sand_attr(None).is_empty());
892 }
893
894 #[test]
895 fn text_contrast_attr_emits_mode() {
896 assert_eq!(
897 optional_text_contrast_attr(Some(HoverFxTextContrastMode::Auto)),
898 "auto"
899 );
900 assert_eq!(
901 optional_text_contrast_attr(Some(HoverFxTextContrastMode::Darken)),
902 "darken"
903 );
904 assert_eq!(
905 optional_text_contrast_attr(Some(HoverFxTextContrastMode::Invert)),
906 "invert"
907 );
908 assert!(optional_text_contrast_attr(None).is_empty());
909 }
910
911 #[test]
912 fn theme_token_interop_metadata_uses_shared_contract() {
913 let interop = hoverfx_theme_token_interop();
914 assert_eq!(interop.change_event, "dioxus-theme:change");
915 assert_eq!(interop.accent_token, dioxus_theme_core::THEME_TOKEN_ACCENT);
916 assert_eq!(
917 interop.surface_token,
918 dioxus_theme_core::THEME_TOKEN_SURFACE
919 );
920 assert_eq!(
921 interop.sand_color_token,
922 dioxus_theme_core::THEME_TOKEN_SURFACE
923 );
924 assert_eq!(
925 interop.sand_highlight_token,
926 dioxus_theme_core::THEME_TOKEN_ACCENT
927 );
928 }
929
930 #[test]
931 fn native_action_reports_handler() {
932 let result =
933 hoverfx_native_action(&HoverFxConfig::default(), HoverFxNativeAction::SetRadius);
934 assert_eq!(
935 result.outputs.get("handler"),
936 Some(&HOVERFX_RADIUS_HANDLER.to_string())
937 );
938 }
939}