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