Skip to main content

dioxus_hoverfx/
lib.rs

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}