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