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: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}