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}