Skip to main content

dioxus_hoverfx/
lib.rs

1use dioxus::prelude::*;
2pub use dioxus_hoverfx_core::*;
3
4pub use HoverFxCard as Card;
5pub use HoverFxFalloffSelect as FalloffSelect;
6pub use HoverFxProvider as HoverProvider;
7pub use HoverFxRadiusSlider as RadiusSlider;
8pub use HoverFxRangeSlider as RangeSlider;
9pub use HoverFxShapeSelect as ShapeSelect;
10pub use HoverFxStrengthSlider as StrengthSlider;
11pub use HoverFxTarget as Target;
12
13pub mod prelude {
14    pub use dioxus_hoverfx_core::prelude::*;
15
16    pub use crate::{
17        Card, FalloffSelect, HoverCfg, HoverDef, HoverFxCard, HoverFxConfig, HoverFxFalloff,
18        HoverFxPreset, HoverFxProvider, HoverFxRangeSlider, HoverFxRenderer, HoverFxShape,
19        HoverFxTarget, HoverFxTooltipConfig, HoverFxTooltipPlacement, HoverProvider, RadiusSlider,
20        RangeSlider, ShapeSelect, StrengthSlider, Target, hover_def, hover_fx, hoverfx,
21        hoverfx_component_explain, hoverfx_component_manifest, hoverfx_native_integration_hints,
22    };
23}
24
25pub mod dx {
26    pub use crate::prelude::*;
27
28    pub fn hoverfx() -> dioxus_hoverfx_core::HoverFxConfig {
29        dioxus_hoverfx_core::HoverFxConfig::new()
30    }
31
32    pub fn tooltip(text: impl Into<String>) -> dioxus_hoverfx_core::HoverFxTooltipConfig {
33        dioxus_hoverfx_core::HoverFxTooltipConfig::new(text)
34    }
35
36    pub fn tooltip_key(
37        key: impl AsRef<str>,
38        fallback: impl Into<String>,
39    ) -> dioxus_hoverfx_core::HoverFxTooltipConfig {
40        dioxus_hoverfx_core::HoverFxTooltipConfig::new(fallback).key(key)
41    }
42
43    pub fn localized_tooltip(
44        key: impl AsRef<str>,
45        fallback: impl Into<String>,
46    ) -> dioxus_hoverfx_core::HoverFxTooltipConfig {
47        tooltip_key(key, fallback)
48    }
49
50    pub fn effect(
51        id: impl AsRef<str>,
52        label: impl Into<String>,
53    ) -> dioxus_hoverfx_core::HoverFxDefinition {
54        dioxus_hoverfx_core::HoverFxDefinition::new(id, label)
55    }
56
57    pub trait HoverFxConfigDx {
58        fn default(self, default_effect: impl AsRef<str>) -> Self;
59        fn default_effect(self, default_effect: impl AsRef<str>) -> Self;
60        fn tooltip(self) -> Self;
61        fn tooltips(self) -> Self;
62    }
63
64    impl HoverFxConfigDx for dioxus_hoverfx_core::HoverFxConfig {
65        fn default(self, default_effect: impl AsRef<str>) -> Self {
66            self.with_default_effect(default_effect)
67        }
68
69        fn default_effect(self, default_effect: impl AsRef<str>) -> Self {
70            self.with_default_effect(default_effect)
71        }
72
73        fn tooltip(self) -> Self {
74            self.with_default_effect(dioxus_hoverfx_core::HoverFxPreset::Tooltip.as_attr())
75        }
76
77        fn tooltips(self) -> Self {
78            self.with_effect(dioxus_hoverfx_core::HoverFxDefinition::from_preset(
79                dioxus_hoverfx_core::HoverFxPreset::Tooltip,
80            ))
81        }
82    }
83}
84
85pub const HOVERFX_INIT_HANDLER: &str = "hoverfx.init";
86pub const HOVERFX_REFRESH_HANDLER: &str = "hoverfx.refresh";
87pub const HOVERFX_RADIUS_HANDLER: &str = "hoverfx.radius";
88pub const HOVERFX_RANGE_HANDLER: &str = "hoverfx.range";
89pub const HOVERFX_SHAPE_HANDLER: &str = "hoverfx.shape";
90pub const HOVERFX_FALLOFF_HANDLER: &str = "hoverfx.falloff";
91pub const HOVERFX_STRENGTH_HANDLER: &str = "hoverfx.strength";
92pub const HOVERFX_THEME_CHANGE_EVENT: &str = dioxus_theme_core::THEME_CHANGE_EVENT;
93
94pub fn hoverfx_component_manifest(
95    config: &HoverFxConfig,
96    policy: &HoverFxRoutePolicy,
97) -> HoverFxManifestFragment {
98    config.manifest_fragment(policy)
99}
100
101pub fn hoverfx_component_explain(
102    config: &HoverFxConfig,
103    policy: &HoverFxRoutePolicy,
104) -> HoverFxExplainReport {
105    config.explain(policy)
106}
107
108pub fn hoverfx_native_integration_hints(
109    config: &HoverFxConfig,
110    policy: &HoverFxRoutePolicy,
111) -> std::collections::BTreeMap<String, String> {
112    let mut hints = dioxus_hoverfx_core::hoverfx_native_port_hints(config, policy);
113    hints.insert(
114        "nativeActions".to_string(),
115        hoverfx_native_package_actions(policy.route.as_deref())
116            .len()
117            .to_string(),
118    );
119    hints.insert(
120        "nativePackage".to_string(),
121        hoverfx_native_compatibility_manifest().package,
122    );
123    hints
124}
125
126const DEFAULT_EFFECT: &str = "spotlight";
127const DEFAULT_RADIUS_MIN: u16 = 80;
128const DEFAULT_RADIUS_MAX: u16 = 640;
129const DEFAULT_RADIUS_STEP: u16 = 10;
130const DEFAULT_RANGE_MIN: u16 = 0;
131const DEFAULT_RANGE_MAX: u16 = 1_200;
132const DEFAULT_RANGE_STEP: u16 = 10;
133const DEFAULT_STRENGTH_PERCENT: u16 = 100;
134const DEFAULT_STRENGTH_MIN: u16 = 0;
135const DEFAULT_STRENGTH_MAX: u16 = 200;
136const DEFAULT_STRENGTH_STEP: u16 = 5;
137const HOVERFX_WALLPAPER_LAYER_STYLE: &str = concat!(
138    "position:absolute!important;",
139    "inset:0!important;",
140    "z-index:0!important;",
141    "display:block!important;",
142    "inline-size:100%!important;",
143    "block-size:100%!important;",
144    "width:100%!important;",
145    "height:100%!important;",
146    "min-inline-size:0!important;",
147    "min-block-size:0!important;",
148    "min-width:0!important;",
149    "min-height:0!important;",
150    "max-inline-size:none!important;",
151    "max-block-size:none!important;",
152    "max-width:none!important;",
153    "max-height:none!important;",
154    "margin:0!important;",
155    "padding:0!important;",
156    "border:0!important;",
157    "box-sizing:border-box!important;",
158    "border-radius:inherit!important;",
159    "overflow:hidden!important;",
160    "pointer-events:none!important;",
161    "contain:layout paint style!important;",
162    "flex:none!important;",
163    "align-self:stretch!important;",
164    "grid-area:1 / 1!important;",
165    "mix-blend-mode:var(--dxh-text-layer-blend-mode,var(--dxh-layer-blend-mode,var(--dxh-blend-mode,normal)))!important;"
166);
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub struct HoverFxThemeTokenInterop {
170    pub change_event: &'static str,
171    pub accent_token: &'static str,
172    pub muted_token: &'static str,
173    pub surface_token: &'static str,
174    pub text_token: &'static str,
175    pub sand_color_token: &'static str,
176    pub sand_highlight_token: &'static str,
177}
178
179pub const fn hoverfx_theme_token_interop() -> HoverFxThemeTokenInterop {
180    HoverFxThemeTokenInterop {
181        change_event: dioxus_theme_core::THEME_CHANGE_EVENT,
182        accent_token: dioxus_theme_core::THEME_TOKEN_ACCENT,
183        muted_token: dioxus_theme_core::THEME_TOKEN_MUTED,
184        surface_token: dioxus_theme_core::THEME_TOKEN_SURFACE,
185        text_token: dioxus_theme_core::THEME_TOKEN_TEXT,
186        sand_color_token: dioxus_theme_core::THEME_TOKEN_SURFACE,
187        sand_highlight_token: dioxus_theme_core::THEME_TOKEN_ACCENT,
188    }
189}
190
191#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192pub enum HoverFxRuntimeMode {
193    BrowserRuntime,
194    StaticFallback,
195}
196
197pub fn hoverfx_runtime_mode(_config: &HoverFxConfig) -> HoverFxRuntimeMode {
198    if cfg!(all(feature = "web", target_arch = "wasm32")) {
199        HoverFxRuntimeMode::BrowserRuntime
200    } else {
201        HoverFxRuntimeMode::StaticFallback
202    }
203}
204
205pub fn hoverfx_native_fallback_config() -> HoverFxConfig {
206    HoverFxConfig::default()
207}
208
209pub fn hoverfx_native_compatibility_manifest() -> dioxus_native_port::VisualCompatibilityManifest {
210    dioxus_native_port::native_port_visual_compatibility_manifest("dioxus-hoverfx")
211        .expect("dioxus-hoverfx visual compatibility manifest is registered")
212}
213
214#[derive(Clone, Copy, Debug, Eq, PartialEq)]
215pub enum HoverFxNativeAction {
216    Init,
217    Refresh,
218    SetRadius,
219    SetRange,
220    SetShape,
221    SetFalloff,
222    SetStrength,
223}
224
225impl HoverFxNativeAction {
226    pub const fn as_str(self) -> &'static str {
227        match self {
228            Self::Init => "init",
229            Self::Refresh => "refresh",
230            Self::SetRadius => "set-radius",
231            Self::SetRange => "set-range",
232            Self::SetShape => "set-shape",
233            Self::SetFalloff => "set-falloff",
234            Self::SetStrength => "set-strength",
235        }
236    }
237
238    pub const fn handler(self) -> &'static str {
239        match self {
240            Self::Init => HOVERFX_INIT_HANDLER,
241            Self::Refresh => HOVERFX_REFRESH_HANDLER,
242            Self::SetRadius => HOVERFX_RADIUS_HANDLER,
243            Self::SetRange => HOVERFX_RANGE_HANDLER,
244            Self::SetShape => HOVERFX_SHAPE_HANDLER,
245            Self::SetFalloff => HOVERFX_FALLOFF_HANDLER,
246            Self::SetStrength => HOVERFX_STRENGTH_HANDLER,
247        }
248    }
249
250    pub const fn label(self) -> &'static str {
251        match self {
252            Self::Init => "Initialize HoverFX",
253            Self::Refresh => "Refresh HoverFX targets",
254            Self::SetRadius => "Set hover radius",
255            Self::SetRange => "Set hover activation range",
256            Self::SetShape => "Set hover shape",
257            Self::SetFalloff => "Set hover falloff",
258            Self::SetStrength => "Set hover strength",
259        }
260    }
261}
262
263pub fn hoverfx_native_package_actions(
264    route: Option<&str>,
265) -> Vec<dioxus_native_port::NativePackageAction> {
266    let route = route.map(str::to_string);
267    [
268        HoverFxNativeAction::Init,
269        HoverFxNativeAction::Refresh,
270        HoverFxNativeAction::SetRadius,
271        HoverFxNativeAction::SetRange,
272        HoverFxNativeAction::SetShape,
273        HoverFxNativeAction::SetFalloff,
274        HoverFxNativeAction::SetStrength,
275    ]
276    .into_iter()
277    .map(|action| {
278        let mut package_action = dioxus_native_port::NativePackageAction::new(
279            "dioxus-hoverfx",
280            action.as_str(),
281            action.label(),
282            dioxus_native_port::NativeActionKind::NativeAction,
283        )
284        .description(format!(
285            "Controls worker-first cursor hover effects without page hydration. Handler: {}.",
286            action.handler()
287        ));
288        if let Some(route) = route.clone() {
289            package_action = package_action.route(route);
290        }
291        package_action
292    })
293    .collect()
294}
295
296pub fn hoverfx_native_action(
297    config: &HoverFxConfig,
298    action: HoverFxNativeAction,
299) -> dioxus_native_port::NativeActionResult {
300    let mode = hoverfx_runtime_mode(config);
301    let backend = match mode {
302        HoverFxRuntimeMode::BrowserRuntime => "browser-runtime",
303        HoverFxRuntimeMode::StaticFallback => "static-fallback",
304    };
305
306    dioxus_native_port::NativeActionResult::succeeded(
307        "dioxus-hoverfx",
308        action.as_str(),
309        dioxus_native_port::NativeActionKind::NativeAction,
310        format!("{} prepared", action.label()),
311    )
312    .with_backend(backend)
313    .with_output("handler", action.handler())
314}
315
316pub fn hoverfx_preset_attr(preset: HoverFxPreset) -> &'static str {
317    preset.as_attr()
318}
319
320pub fn hoverfx_preset_label(preset: HoverFxPreset) -> &'static str {
321    preset.label()
322}
323
324pub fn hoverfx_shape_attr(shape: HoverFxShape) -> &'static str {
325    shape.as_attr()
326}
327
328pub fn hoverfx_shape_label(shape: HoverFxShape) -> &'static str {
329    shape.label()
330}
331
332pub fn hoverfx_falloff_attr(falloff: HoverFxFalloff) -> &'static str {
333    falloff.as_attr()
334}
335
336pub fn hoverfx_falloff_label(falloff: HoverFxFalloff) -> &'static str {
337    falloff.label()
338}
339
340pub fn hoverfx_text_contrast_attr(text_contrast: HoverFxTextContrastMode) -> &'static str {
341    text_contrast.as_attr()
342}
343
344fn hoverfx_control_id(prefix: &str, handler: &str, label: &str) -> String {
345    format!("{prefix}-{}", hoverfx_id(format!("{handler}-{label}")))
346}
347
348fn sanitized_effect(effect: &str, preset: Option<HoverFxPreset>) -> String {
349    preset
350        .map(|preset| hoverfx_preset_attr(preset).to_string())
351        .unwrap_or_else(|| hoverfx_id(effect))
352}
353
354fn optional_u16_attr(value: Option<u16>) -> String {
355    value.map(|value| value.to_string()).unwrap_or_default()
356}
357
358fn optional_f32_attr(value: Option<f32>) -> String {
359    value.map(format_float).unwrap_or_default()
360}
361
362fn bool_attr(value: bool) -> &'static str {
363    if value { "true" } else { "false" }
364}
365
366fn format_float(value: f32) -> String {
367    let mut formatted = format!("{value:.3}");
368    while formatted.contains('.') && formatted.ends_with('0') {
369        formatted.pop();
370    }
371    if formatted.ends_with('.') {
372        formatted.pop();
373    }
374    formatted
375}
376
377fn optional_shape_attr(value: Option<HoverFxShape>) -> String {
378    value
379        .map(hoverfx_shape_attr)
380        .unwrap_or_default()
381        .to_string()
382}
383
384fn optional_falloff_attr(value: Option<HoverFxFalloff>) -> String {
385    value
386        .map(hoverfx_falloff_attr)
387        .unwrap_or_default()
388        .to_string()
389}
390
391fn optional_text_reveal_attr(value: Option<&HoverFxTextRevealConfig>) -> String {
392    value
393        .and_then(|value| value.to_json().ok())
394        .unwrap_or_default()
395}
396
397fn optional_texture_reveal_attr(value: Option<&HoverFxTextureRevealConfig>) -> String {
398    value
399        .and_then(|value| value.to_json().ok())
400        .unwrap_or_default()
401}
402
403fn optional_sand_attr(value: Option<&HoverFxSandConfig>) -> String {
404    value
405        .and_then(|value| value.to_json().ok())
406        .unwrap_or_default()
407}
408
409fn optional_tooltip_attr(value: Option<&HoverFxTooltipConfig>) -> String {
410    value
411        .and_then(|value| value.to_json().ok())
412        .unwrap_or_default()
413}
414
415fn optional_tooltip_text_attr(value: Option<&HoverFxTooltipConfig>, fallback: &str) -> String {
416    value
417        .map(|value| value.text.as_str())
418        .filter(|text| !text.trim().is_empty())
419        .unwrap_or(fallback)
420        .to_string()
421}
422
423fn optional_tooltip_i18n_key_attr(value: Option<&HoverFxTooltipConfig>, fallback: &str) -> String {
424    value
425        .and_then(|value| value.i18n_key.as_deref())
426        .filter(|key| !key.trim().is_empty())
427        .unwrap_or(fallback)
428        .to_string()
429}
430
431fn optional_text_contrast_attr(value: Option<HoverFxTextContrastMode>) -> String {
432    value
433        .map(hoverfx_text_contrast_attr)
434        .unwrap_or_default()
435        .to_string()
436}
437
438fn optional_textfx_config_attr(
439    value: Option<&HoverFxTextRevealConfig>,
440    interop: bool,
441    id: &str,
442) -> String {
443    if !interop {
444        return String::new();
445    }
446    let Some(value) = value else {
447        return String::new();
448    };
449    if value.animation_source == HoverFxTextAnimationSource::HoverFx {
450        return String::new();
451    }
452    value
453        .to_textfx_config_json(
454            if id.trim().is_empty() {
455                "hoverfx-binary-reveal"
456            } else {
457                id
458            },
459            "010101001101",
460        )
461        .unwrap_or_default()
462}
463
464#[derive(Props, Clone, PartialEq)]
465pub struct HoverFxProviderProps {
466    #[props(default)]
467    pub config: HoverFxConfig,
468    #[props(default)]
469    pub class: String,
470    #[props(default = "document".to_string())]
471    pub scope: String,
472    #[props(default = true)]
473    pub auto_init: bool,
474    pub children: Element,
475}
476
477#[component]
478pub fn HoverFxProvider(props: HoverFxProviderProps) -> Element {
479    let mode = hoverfx_runtime_mode(&props.config);
480    let runtime = match mode {
481        HoverFxRuntimeMode::BrowserRuntime => "browser-runtime",
482        HoverFxRuntimeMode::StaticFallback => "static-fallback",
483    };
484    let auto_init = if props.auto_init { "true" } else { "false" };
485    let default_effect = hoverfx_id(&props.config.default_effect);
486    let radius = props.config.radius_px.to_string();
487    let range = props.config.range_px.to_string();
488    let shape = props.config.shape.as_attr();
489    let falloff = props.config.falloff.as_attr();
490    let strength = format_float(props.config.strength);
491    let smoothing = format_float(props.config.smoothing);
492    let max_active = props.config.max_active_elements.to_string();
493    let renderer = props.config.renderer.as_attr();
494    let runtime_path = props.config.runtime_path.clone();
495    let worker_path = props.config.worker_path.clone();
496    let theme_tokens = hoverfx_theme_token_interop();
497    rsx! {
498        div {
499            class: "{props.class}",
500            "data-dxh-provider": "true",
501            "data-dxh-scope": "{props.scope}",
502            "data-dxh-theme-event": "{theme_tokens.change_event}",
503            "data-dxh-theme-accent-token": "{theme_tokens.accent_token}",
504            "data-dxh-theme-muted-token": "{theme_tokens.muted_token}",
505            "data-dxh-theme-surface-token": "{theme_tokens.surface_token}",
506            "data-dxh-theme-text-token": "{theme_tokens.text_token}",
507            "data-dxh-theme-sand-color-token": "{theme_tokens.sand_color_token}",
508            "data-dxh-theme-sand-highlight-token": "{theme_tokens.sand_highlight_token}",
509            "data-dxh-runtime": "{runtime}",
510            "data-dxh-renderer": "{renderer}",
511            "data-dxh-auto-init": "{auto_init}",
512            "data-dxh-default-effect": "{default_effect}",
513            "data-dxh-radius": "{radius}",
514            "data-dxh-range": "{range}",
515            "data-dxh-shape": "{shape}",
516            "data-dxh-falloff": "{falloff}",
517            "data-dxh-strength": "{strength}",
518            "data-dxh-smoothing": "{smoothing}",
519            "data-dxh-max-active-elements": "{max_active}",
520            "data-dxh-runtime-path": "{runtime_path}",
521            "data-dxh-worker-path": "{worker_path}",
522            {props.children}
523        }
524    }
525}
526
527#[derive(Props, Clone, PartialEq)]
528pub struct HoverFxTargetProps {
529    #[props(default = DEFAULT_EFFECT.to_string())]
530    pub effect: String,
531    #[props(default)]
532    pub preset: Option<HoverFxPreset>,
533    #[props(default)]
534    pub radius: Option<u16>,
535    #[props(default)]
536    pub range: Option<u16>,
537    #[props(default)]
538    pub shape: Option<HoverFxShape>,
539    #[props(default)]
540    pub falloff: Option<HoverFxFalloff>,
541    #[props(default)]
542    pub strength: Option<f32>,
543    #[props(default)]
544    pub contained: bool,
545    #[props(default)]
546    pub controlled: bool,
547    #[props(default)]
548    pub text_reveal: Option<HoverFxTextRevealConfig>,
549    #[props(default)]
550    pub texture_reveal: Option<HoverFxTextureRevealConfig>,
551    #[props(default)]
552    pub sand: Option<HoverFxSandConfig>,
553    #[props(default)]
554    pub tooltip: Option<HoverFxTooltipConfig>,
555    #[props(default)]
556    pub tooltip_text: String,
557    #[props(default)]
558    pub tooltip_i18n_key: String,
559    #[props(default)]
560    pub text_contrast: Option<HoverFxTextContrastMode>,
561    #[props(default)]
562    pub textfx_interop: bool,
563    #[props(default)]
564    pub class: String,
565    #[props(default)]
566    pub id: String,
567    pub children: Element,
568}
569
570#[component]
571pub fn HoverFxTarget(props: HoverFxTargetProps) -> Element {
572    let effect = sanitized_effect(&props.effect, props.preset);
573    let radius = optional_u16_attr(props.radius);
574    let range = optional_u16_attr(props.range);
575    let shape = optional_shape_attr(props.shape);
576    let falloff = optional_falloff_attr(props.falloff);
577    let strength = optional_f32_attr(props.strength);
578    let contained = bool_attr(props.contained);
579    let controlled = bool_attr(props.controlled);
580    let text_reveal = optional_text_reveal_attr(props.text_reveal.as_ref());
581    let texture_reveal = optional_texture_reveal_attr(props.texture_reveal.as_ref());
582    let sand = optional_sand_attr(props.sand.as_ref());
583    let tooltip = optional_tooltip_attr(props.tooltip.as_ref());
584    let tooltip_text = optional_tooltip_text_attr(props.tooltip.as_ref(), &props.tooltip_text);
585    let tooltip_i18n_key =
586        optional_tooltip_i18n_key_attr(props.tooltip.as_ref(), &props.tooltip_i18n_key);
587    let text_contrast = optional_text_contrast_attr(props.text_contrast);
588    let textfx_config =
589        optional_textfx_config_attr(props.text_reveal.as_ref(), props.textfx_interop, &props.id);
590    let wallpaper_layer_style = HOVERFX_WALLPAPER_LAYER_STYLE;
591    let needs_textfx_handler = !textfx_config.is_empty()
592        || !tooltip.is_empty()
593        || !tooltip_text.is_empty()
594        || !tooltip_i18n_key.is_empty();
595
596    if textfx_config.is_empty() && !needs_textfx_handler {
597        rsx! {
598            div {
599                id: "{props.id}",
600                class: "{props.class}",
601                "data-dxh-target": "true",
602                "data-dxh-effect": "{effect}",
603                "data-dxh-radius": "{radius}",
604                "data-dxh-range": "{range}",
605                "data-dxh-shape": "{shape}",
606                "data-dxh-falloff": "{falloff}",
607                "data-dxh-strength": "{strength}",
608                "data-dxh-contain": "{contained}",
609                "data-dxh-controlled": "{controlled}",
610                "data-dxh-text-reveal": "{text_reveal}",
611                "data-dxh-texture-reveal": "{texture_reveal}",
612                "data-dxh-sand": "{sand}",
613                "data-dxh-tooltip": "{tooltip_text}",
614                "data-dxh-tooltip-i18n-key": "{tooltip_i18n_key}",
615                "data-dxh-tooltip-config": "{tooltip}",
616                "data-dxh-text-contrast": "{text_contrast}",
617                {props.children}
618            }
619        }
620    } else if textfx_config.is_empty() {
621        rsx! {
622            div {
623                id: "{props.id}",
624                class: "{props.class}",
625                "data-dxh-target": "true",
626                "data-dxh-effect": "{effect}",
627                "data-dxh-radius": "{radius}",
628                "data-dxh-range": "{range}",
629                "data-dxh-shape": "{shape}",
630                "data-dxh-falloff": "{falloff}",
631                "data-dxh-strength": "{strength}",
632                "data-dxh-contain": "{contained}",
633                "data-dxh-controlled": "{controlled}",
634                "data-dxh-text-reveal": "{text_reveal}",
635                "data-dxh-texture-reveal": "{texture_reveal}",
636                "data-dxh-sand": "{sand}",
637                "data-dxh-tooltip": "{tooltip_text}",
638                "data-dxh-tooltip-i18n-key": "{tooltip_i18n_key}",
639                "data-dxh-tooltip-config": "{tooltip}",
640                "data-dxh-text-contrast": "{text_contrast}",
641                "data-dxr-on-pointerover": "textfx.run",
642                {props.children}
643            }
644        }
645    } else {
646        rsx! {
647            div {
648                id: "{props.id}",
649                class: "{props.class}",
650                "data-dxh-target": "true",
651                "data-dxh-effect": "{effect}",
652                "data-dxh-radius": "{radius}",
653                "data-dxh-range": "{range}",
654                "data-dxh-shape": "{shape}",
655                "data-dxh-falloff": "{falloff}",
656                "data-dxh-strength": "{strength}",
657                "data-dxh-contain": "{contained}",
658                "data-dxh-controlled": "{controlled}",
659                "data-dxh-text-reveal": "{text_reveal}",
660                "data-dxh-texture-reveal": "{texture_reveal}",
661                "data-dxh-sand": "{sand}",
662                "data-dxh-tooltip": "{tooltip_text}",
663                "data-dxh-tooltip-i18n-key": "{tooltip_i18n_key}",
664                "data-dxh-tooltip-config": "{tooltip}",
665                "data-dxh-text-contrast": "{text_contrast}",
666                "data-dxr-on-pointerover": "textfx.run",
667                span {
668                    class: "dxh-text-reveal-layer",
669                    style: "{wallpaper_layer_style}",
670                    "aria-hidden": "true",
671                    "data-dxh-text-layer": "true",
672                    "data-dxt-textfx": "{textfx_config}"
673                }
674                {props.children}
675            }
676        }
677    }
678}
679
680#[derive(Props, Clone, PartialEq)]
681pub struct HoverFxCardProps {
682    #[props(default = DEFAULT_EFFECT.to_string())]
683    pub effect: String,
684    #[props(default)]
685    pub preset: Option<HoverFxPreset>,
686    #[props(default)]
687    pub radius: Option<u16>,
688    #[props(default)]
689    pub range: Option<u16>,
690    #[props(default)]
691    pub shape: Option<HoverFxShape>,
692    #[props(default)]
693    pub falloff: Option<HoverFxFalloff>,
694    #[props(default)]
695    pub strength: Option<f32>,
696    #[props(default)]
697    pub contained: bool,
698    #[props(default)]
699    pub controlled: bool,
700    #[props(default)]
701    pub text_reveal: Option<HoverFxTextRevealConfig>,
702    #[props(default)]
703    pub texture_reveal: Option<HoverFxTextureRevealConfig>,
704    #[props(default)]
705    pub sand: Option<HoverFxSandConfig>,
706    #[props(default)]
707    pub tooltip: Option<HoverFxTooltipConfig>,
708    #[props(default)]
709    pub tooltip_text: String,
710    #[props(default)]
711    pub tooltip_i18n_key: String,
712    #[props(default)]
713    pub text_contrast: Option<HoverFxTextContrastMode>,
714    #[props(default)]
715    pub textfx_interop: bool,
716    #[props(default)]
717    pub class: String,
718    #[props(default)]
719    pub id: String,
720    pub children: Element,
721}
722
723#[component]
724pub fn HoverFxCard(props: HoverFxCardProps) -> Element {
725    rsx! {
726        HoverFxTarget {
727            id: props.id,
728            class: props.class,
729            effect: props.effect,
730            preset: props.preset,
731            radius: props.radius,
732            range: props.range,
733            shape: props.shape,
734            falloff: props.falloff,
735            strength: props.strength,
736            contained: props.contained,
737            controlled: props.controlled,
738            text_reveal: props.text_reveal,
739            texture_reveal: props.texture_reveal,
740            sand: props.sand,
741            tooltip: props.tooltip,
742            tooltip_text: props.tooltip_text,
743            tooltip_i18n_key: props.tooltip_i18n_key,
744            text_contrast: props.text_contrast,
745            textfx_interop: props.textfx_interop,
746            div {
747                "data-dxh-card": "true",
748                {props.children}
749            }
750        }
751    }
752}
753
754#[derive(Props, Clone, PartialEq)]
755pub struct HoverFxRadiusSliderProps {
756    #[props(default = HOVERFX_RADIUS_HANDLER.to_string())]
757    pub handler: String,
758    #[props(default)]
759    pub class: String,
760    #[props(default = "Hover radius".to_string())]
761    pub label: String,
762    #[props(default = DEFAULT_HOVERFX_RADIUS_PX)]
763    pub value: u16,
764    #[props(default = DEFAULT_RADIUS_MIN)]
765    pub min: u16,
766    #[props(default = DEFAULT_RADIUS_MAX)]
767    pub max: u16,
768    #[props(default = DEFAULT_RADIUS_STEP)]
769    pub step: u16,
770    #[props(default)]
771    pub apply_to_all: bool,
772}
773
774#[component]
775pub fn HoverFxRadiusSlider(props: HoverFxRadiusSliderProps) -> Element {
776    let input_id = hoverfx_control_id("dxh-radius", &props.handler, &props.label);
777    let output_id = format!("{input_id}-output");
778    let min = props.min.min(props.max);
779    let max = props.max.max(props.min);
780    let value = props.value.clamp(min, max);
781    let step = props.step.max(1);
782    let apply_to = if props.apply_to_all { "all" } else { "target" };
783    rsx! {
784        label {
785            class: "{props.class}",
786            "data-dxh-control": "radius",
787            "data-dxh-apply-to": "{apply_to}",
788            "for": "{input_id}",
789            span {
790                "{props.label}: "
791                output {
792                    id: "{output_id}",
793                    "for": "{input_id}",
794                    "aria-live": "polite",
795                    "data-dxh-radius-current": "true",
796                    "{value}px"
797                }
798            }
799            input {
800                id: "{input_id}",
801                r#type: "range",
802                min: "{min}",
803                max: "{max}",
804                step: "{step}",
805                value: "{value}",
806                "aria-describedby": "{output_id}",
807                "data-dxr-on-input": "{props.handler}",
808                "data-dxh-radius-control": "true"
809            }
810        }
811    }
812}
813
814#[derive(Props, Clone, PartialEq)]
815pub struct HoverFxRangeSliderProps {
816    #[props(default = HOVERFX_RANGE_HANDLER.to_string())]
817    pub handler: String,
818    #[props(default)]
819    pub class: String,
820    #[props(default = "Hover range".to_string())]
821    pub label: String,
822    #[props(default = DEFAULT_HOVERFX_RANGE_PX)]
823    pub value: u16,
824    #[props(default = DEFAULT_RANGE_MIN)]
825    pub min: u16,
826    #[props(default = DEFAULT_RANGE_MAX)]
827    pub max: u16,
828    #[props(default = DEFAULT_RANGE_STEP)]
829    pub step: u16,
830    #[props(default = true)]
831    pub apply_to_all: bool,
832}
833
834#[component]
835pub fn HoverFxRangeSlider(props: HoverFxRangeSliderProps) -> Element {
836    let input_id = hoverfx_control_id("dxh-range", &props.handler, &props.label);
837    let output_id = format!("{input_id}-output");
838    let min = props.min.min(props.max);
839    let max = props.max.max(props.min);
840    let value = props.value.clamp(min, max);
841    let step = props.step.max(1);
842    let apply_to = if props.apply_to_all { "all" } else { "target" };
843    rsx! {
844        label {
845            class: "{props.class}",
846            "data-dxh-control": "range",
847            "data-dxh-apply-to": "{apply_to}",
848            "for": "{input_id}",
849            span {
850                "{props.label}: "
851                output {
852                    id: "{output_id}",
853                    "for": "{input_id}",
854                    "aria-live": "polite",
855                    "data-dxh-range-current": "true",
856                    "{value}px"
857                }
858            }
859            input {
860                id: "{input_id}",
861                r#type: "range",
862                min: "{min}",
863                max: "{max}",
864                step: "{step}",
865                value: "{value}",
866                "aria-describedby": "{output_id}",
867                "data-dxr-on-input": "{props.handler}",
868                "data-dxh-range-control": "true"
869            }
870        }
871    }
872}
873
874#[derive(Props, Clone, PartialEq)]
875pub struct HoverFxShapeSelectProps {
876    #[props(default = HOVERFX_SHAPE_HANDLER.to_string())]
877    pub handler: String,
878    #[props(default)]
879    pub class: String,
880    #[props(default = "Hover shape".to_string())]
881    pub label: String,
882    #[props(default = HoverFxShape::Circle)]
883    pub value: HoverFxShape,
884    #[props(default)]
885    pub apply_to_all: bool,
886}
887
888#[component]
889pub fn HoverFxShapeSelect(props: HoverFxShapeSelectProps) -> Element {
890    let select_id = hoverfx_control_id("dxh-shape", &props.handler, &props.label);
891    let label_id = format!("{select_id}-label");
892    let current_id = format!("{select_id}-current");
893    let current = hoverfx_shape_attr(props.value);
894    let current_label = hoverfx_shape_label(props.value);
895    let apply_to = if props.apply_to_all { "all" } else { "target" };
896    rsx! {
897        label {
898            class: "{props.class}",
899            "data-dxh-control": "shape",
900            "data-dxh-apply-to": "{apply_to}",
901            "for": "{select_id}",
902            span {
903                id: "{label_id}",
904                "{props.label}"
905            }
906            select {
907                id: "{select_id}",
908                value: "{current}",
909                "aria-labelledby": "{label_id} {current_id}",
910                "data-dxr-on-change": "{props.handler}",
911                "data-dxh-shape-control": "true",
912                for shape in [
913                    HoverFxShape::Circle,
914                    HoverFxShape::Square,
915                    HoverFxShape::RoundedRect,
916                    HoverFxShape::Polygon,
917                ] {
918                    option {
919                        value: "{hoverfx_shape_attr(shape)}",
920                        selected: shape == props.value,
921                        "{hoverfx_shape_label(shape)}"
922                    }
923                }
924            }
925            span {
926                id: "{current_id}",
927                "aria-live": "polite",
928                "data-dxh-shape-current": "true",
929                "{current_label}"
930            }
931        }
932    }
933}
934
935#[derive(Props, Clone, PartialEq)]
936pub struct HoverFxFalloffSelectProps {
937    #[props(default = HOVERFX_FALLOFF_HANDLER.to_string())]
938    pub handler: String,
939    #[props(default)]
940    pub class: String,
941    #[props(default = "Hover falloff".to_string())]
942    pub label: String,
943    #[props(default = HoverFxFalloff::Smooth)]
944    pub value: HoverFxFalloff,
945    #[props(default)]
946    pub apply_to_all: bool,
947}
948
949#[component]
950pub fn HoverFxFalloffSelect(props: HoverFxFalloffSelectProps) -> Element {
951    let select_id = hoverfx_control_id("dxh-falloff", &props.handler, &props.label);
952    let label_id = format!("{select_id}-label");
953    let current_id = format!("{select_id}-current");
954    let current = hoverfx_falloff_attr(props.value);
955    let current_label = hoverfx_falloff_label(props.value);
956    let apply_to = if props.apply_to_all { "all" } else { "target" };
957    rsx! {
958        label {
959            class: "{props.class}",
960            "data-dxh-control": "falloff",
961            "data-dxh-apply-to": "{apply_to}",
962            "for": "{select_id}",
963            span {
964                id: "{label_id}",
965                "{props.label}"
966            }
967            select {
968                id: "{select_id}",
969                value: "{current}",
970                "aria-labelledby": "{label_id} {current_id}",
971                "data-dxr-on-change": "{props.handler}",
972                "data-dxh-falloff-control": "true",
973                for falloff in [
974                    HoverFxFalloff::Hard,
975                    HoverFxFalloff::Linear,
976                    HoverFxFalloff::Smooth,
977                    HoverFxFalloff::Exponential,
978                ] {
979                    option {
980                        value: "{hoverfx_falloff_attr(falloff)}",
981                        selected: falloff == props.value,
982                        "{hoverfx_falloff_label(falloff)}"
983                    }
984                }
985            }
986            span {
987                id: "{current_id}",
988                "aria-live": "polite",
989                "data-dxh-falloff-current": "true",
990                "{current_label}"
991            }
992        }
993    }
994}
995
996#[derive(Props, Clone, PartialEq)]
997pub struct HoverFxStrengthSliderProps {
998    #[props(default = HOVERFX_STRENGTH_HANDLER.to_string())]
999    pub handler: String,
1000    #[props(default)]
1001    pub class: String,
1002    #[props(default = "Hover strength".to_string())]
1003    pub label: String,
1004    #[props(default = DEFAULT_STRENGTH_PERCENT)]
1005    pub value: u16,
1006    #[props(default = DEFAULT_STRENGTH_MIN)]
1007    pub min: u16,
1008    #[props(default = DEFAULT_STRENGTH_MAX)]
1009    pub max: u16,
1010    #[props(default = DEFAULT_STRENGTH_STEP)]
1011    pub step: u16,
1012    #[props(default)]
1013    pub apply_to_all: bool,
1014}
1015
1016#[component]
1017pub fn HoverFxStrengthSlider(props: HoverFxStrengthSliderProps) -> Element {
1018    let input_id = hoverfx_control_id("dxh-strength", &props.handler, &props.label);
1019    let output_id = format!("{input_id}-output");
1020    let min = props.min.min(props.max);
1021    let max = props.max.max(props.min);
1022    let value = props.value.clamp(min, max);
1023    let step = props.step.max(1);
1024    let apply_to = if props.apply_to_all { "all" } else { "target" };
1025    rsx! {
1026        label {
1027            class: "{props.class}",
1028            "data-dxh-control": "strength",
1029            "data-dxh-apply-to": "{apply_to}",
1030            "for": "{input_id}",
1031            span {
1032                "{props.label}: "
1033                output {
1034                    id: "{output_id}",
1035                    "for": "{input_id}",
1036                    "aria-live": "polite",
1037                    "data-dxh-strength-current": "true",
1038                    "{value}%"
1039                }
1040            }
1041            input {
1042                id: "{input_id}",
1043                r#type: "range",
1044                min: "{min}",
1045                max: "{max}",
1046                step: "{step}",
1047                value: "{value}",
1048                "aria-describedby": "{output_id}",
1049                "data-dxr-on-input": "{props.handler}",
1050                "data-dxh-strength-control": "true"
1051            }
1052        }
1053    }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059
1060    #[test]
1061    fn native_actions_include_expected_handlers() {
1062        let actions = hoverfx_native_package_actions(Some("/hoverfx"));
1063        let manifest = hoverfx_native_compatibility_manifest();
1064        assert_eq!(manifest.package, "dioxus-hoverfx");
1065        assert!(actions.iter().any(|action| action.action == "init"));
1066        assert!(actions.iter().any(|action| action.action == "refresh"));
1067        assert!(actions.iter().any(|action| action.action == "set-radius"));
1068        assert!(actions.iter().any(|action| action.action == "set-range"));
1069        assert!(actions.iter().any(|action| action.action == "set-shape"));
1070        assert!(actions.iter().any(|action| action.action == "set-falloff"));
1071        assert!(actions.iter().any(|action| action.action == "set-strength"));
1072        assert!(
1073            actions
1074                .iter()
1075                .any(|action| action.description.contains(HOVERFX_RADIUS_HANDLER))
1076        );
1077        assert!(
1078            actions
1079                .iter()
1080                .any(|action| action.description.contains(HOVERFX_RANGE_HANDLER))
1081        );
1082    }
1083
1084    #[test]
1085    fn helpers_emit_runtime_attribute_values() {
1086        assert_eq!(hoverfx_preset_attr(HoverFxPreset::SoftGlow), "soft-glow");
1087        assert_eq!(
1088            hoverfx_preset_attr(HoverFxPreset::BinaryReveal),
1089            "binary-reveal"
1090        );
1091        assert_eq!(
1092            hoverfx_preset_attr(HoverFxPreset::TextureReveal),
1093            "texture-reveal"
1094        );
1095        assert_eq!(hoverfx_preset_attr(HoverFxPreset::Sand), "sand");
1096        assert_eq!(
1097            hoverfx_shape_attr(HoverFxShape::RoundedRect),
1098            "rounded-rect"
1099        );
1100        assert_eq!(
1101            hoverfx_falloff_attr(HoverFxFalloff::Exponential),
1102            "exponential"
1103        );
1104        assert_eq!(
1105            hoverfx_text_contrast_attr(HoverFxTextContrastMode::Invert),
1106            "invert"
1107        );
1108    }
1109
1110    #[test]
1111    fn text_reveal_attrs_emit_hoverfx_and_optional_textfx_config() {
1112        let config = HoverFxTextRevealConfig::default();
1113        let hoverfx = optional_text_reveal_attr(Some(&config));
1114        assert!(hoverfx.contains(r#""charset":"01""#));
1115        assert!(hoverfx.contains(r#""animationSource":"auto""#));
1116        assert!(hoverfx.contains(r#""renderer":"glyph-atlas""#));
1117
1118        let textfx = optional_textfx_config_attr(Some(&config), true, "binary-card");
1119        assert!(textfx.contains(r#""effect":"scramble""#));
1120        assert!(textfx.contains(r#""charset":"01""#));
1121        assert!(textfx.contains(r#""speedMs":220"#));
1122        assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("position:absolute!important"));
1123        assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("inset:0!important"));
1124        assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("flex:none!important"));
1125        assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("grid-area:1 / 1!important"));
1126        assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("max-width:none!important"));
1127        assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("max-height:none!important"));
1128        assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("pointer-events:none!important"));
1129        assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("contain:layout paint style!important"));
1130        assert!(HOVERFX_WALLPAPER_LAYER_STYLE.contains("mix-blend-mode:var("));
1131        assert!(optional_textfx_config_attr(Some(&config), false, "binary-card").is_empty());
1132        assert!(
1133            optional_textfx_config_attr(
1134                Some(
1135                    &config
1136                        .clone()
1137                        .with_animation_source(HoverFxTextAnimationSource::HoverFx)
1138                ),
1139                true,
1140                "binary-card"
1141            )
1142            .is_empty()
1143        );
1144    }
1145
1146    #[test]
1147    fn texture_reveal_attr_emits_hoverfx_config() {
1148        let config =
1149            HoverFxTextureRevealConfig::default().with_mode(HoverFxTextureRevealMode::Halftone);
1150        let hoverfx = optional_texture_reveal_attr(Some(&config));
1151        assert!(hoverfx.contains(r#""mode":"halftone""#));
1152        assert!(optional_texture_reveal_attr(None).is_empty());
1153    }
1154
1155    #[test]
1156    fn sand_attr_emits_hoverfx_config() {
1157        let config = HoverFxSandConfig::default()
1158            .with_grain_size_px(1.4)
1159            .with_shimmer_strength(0.9)
1160            .with_shimmer_radius_px(280.0)
1161            .with_specular_strength(1.2)
1162            .with_color_source(HoverFxSandColorSource::Element)
1163            .with_animation_speed_ms(720);
1164        let hoverfx = optional_sand_attr(Some(&config));
1165        assert!(hoverfx.contains(r#""grainSizePx":"#));
1166        assert!(hoverfx.contains(r#""shimmerStrength":"#));
1167        assert!(hoverfx.contains(r#""shimmerRadiusPx":"#));
1168        assert!(hoverfx.contains(r#""specularStrength":"#));
1169        assert!(hoverfx.contains(r#""colorSource":"element""#));
1170        assert!(hoverfx.contains(r#""animationSpeedMs":720"#));
1171        assert!(optional_sand_attr(None).is_empty());
1172    }
1173
1174    #[test]
1175    fn text_contrast_attr_emits_mode() {
1176        assert_eq!(
1177            optional_text_contrast_attr(Some(HoverFxTextContrastMode::Auto)),
1178            "auto"
1179        );
1180        assert_eq!(
1181            optional_text_contrast_attr(Some(HoverFxTextContrastMode::Darken)),
1182            "darken"
1183        );
1184        assert_eq!(
1185            optional_text_contrast_attr(Some(HoverFxTextContrastMode::Invert)),
1186            "invert"
1187        );
1188        assert!(optional_text_contrast_attr(None).is_empty());
1189    }
1190
1191    #[test]
1192    fn theme_token_interop_metadata_uses_shared_contract() {
1193        let interop = hoverfx_theme_token_interop();
1194        assert_eq!(interop.change_event, "dioxus-theme:change");
1195        assert_eq!(interop.accent_token, dioxus_theme_core::THEME_TOKEN_ACCENT);
1196        assert_eq!(
1197            interop.surface_token,
1198            dioxus_theme_core::THEME_TOKEN_SURFACE
1199        );
1200        assert_eq!(
1201            interop.sand_color_token,
1202            dioxus_theme_core::THEME_TOKEN_SURFACE
1203        );
1204        assert_eq!(
1205            interop.sand_highlight_token,
1206            dioxus_theme_core::THEME_TOKEN_ACCENT
1207        );
1208    }
1209
1210    #[test]
1211    fn native_action_reports_handler() {
1212        let result =
1213            hoverfx_native_action(&HoverFxConfig::default(), HoverFxNativeAction::SetRadius);
1214        assert_eq!(
1215            result.outputs.get("handler"),
1216            Some(&HOVERFX_RADIUS_HANDLER.to_string())
1217        );
1218    }
1219
1220    #[test]
1221    fn dx_hoverfx_syntax_builds_worker_first_config() {
1222        use crate::dx::HoverFxConfigDx;
1223
1224        let config = crate::dx::hoverfx()
1225            .default("glass")
1226            .radius(24)
1227            .range(96)
1228            .worker()
1229            .lazy_layers();
1230
1231        assert_eq!(config.default_effect, "glass");
1232        assert_eq!(config.radius_px, 24);
1233        assert_eq!(config.range_px, 96);
1234        assert_eq!(config.renderer, HoverFxRenderer::WorkerFirst);
1235        assert!(config.performance.lazy_local_layers);
1236    }
1237
1238    #[test]
1239    fn dx_tooltip_syntax_builds_textfx_tooltip_config() {
1240        use crate::dx::HoverFxConfigDx;
1241
1242        let config = crate::dx::hoverfx().tooltips().tooltip().lazy_layers();
1243        let tooltip = crate::dx::tooltip("Explain this control")
1244            .key("hoverfx.tooltip.text")
1245            .offset(10)
1246            .opacity(0.84)
1247            .scramble()
1248            .dur_ms(220);
1249        let keyed = crate::dx::tooltip_key("hoverfx.tooltip.text", "Explain this control");
1250
1251        assert_eq!(config.default_effect, HoverFxPreset::Tooltip.as_attr());
1252        assert!(
1253            config
1254                .registry
1255                .contains_effect(HoverFxPreset::Tooltip.as_attr())
1256        );
1257        assert_eq!(tooltip.text, "Explain this control");
1258        assert_eq!(tooltip.i18n_key.as_deref(), Some("hoverfx.tooltip.text"));
1259        assert_eq!(keyed.i18n_key.as_deref(), Some("hoverfx.tooltip.text"));
1260        assert_eq!(tooltip.placement, HoverFxTooltipPlacement::Cursor);
1261        assert_eq!(tooltip.box_opacity, 0.84);
1262        assert_eq!(tooltip.duration_ms, 220);
1263        assert_eq!(tooltip.textfx_effect, "scramble");
1264    }
1265
1266    #[test]
1267    fn component_manifest_wraps_core_policy_and_native_hints() {
1268        let config = HoverFxConfig::new()
1269            .with_effect(HoverFxDefinition::from_preset(HoverFxPreset::Tooltip));
1270        let policy = hoverfx_route_policy().route("/hoverfx").tag("native");
1271        let manifest = hoverfx_component_manifest(&config, &policy);
1272        let explain = hoverfx_component_explain(&config, &policy);
1273        let hints = hoverfx_native_integration_hints(&config, &policy);
1274
1275        assert_eq!(manifest.route.as_deref(), Some("/hoverfx"));
1276        assert_eq!(explain.manifest.cache_key, manifest.cache_key);
1277        assert_eq!(hints["nativePackage"], "dioxus-hoverfx");
1278        assert!(hints["nativeActions"].parse::<usize>().unwrap() >= 1);
1279    }
1280
1281    #[test]
1282    fn tooltip_target_attrs_serialize_for_runtime_and_textfx() {
1283        let tooltip = HoverFxTooltipConfig::new("Use TextFX for the label")
1284            .key("hoverfx.tooltip.text")
1285            .cursor()
1286            .typewriter();
1287        let text = optional_tooltip_text_attr(Some(&tooltip), "");
1288        let key = optional_tooltip_i18n_key_attr(Some(&tooltip), "");
1289        let config = optional_tooltip_attr(Some(&tooltip));
1290
1291        assert_eq!(text, "Use TextFX for the label");
1292        assert_eq!(key, "hoverfx.tooltip.text");
1293        assert!(config.contains(r#""placement":"cursor""#));
1294        assert!(config.contains(r#""i18nKey":"hoverfx.tooltip.text""#));
1295        assert!(config.contains(r#""boxOpacity":0.88"#));
1296        assert!(config.contains(r#""textfxEffect":"typewriter""#));
1297        assert!(config.contains(r#""durationMs":260"#));
1298    }
1299}