diff --git a/.changeset/empty-papayas-wash.md b/.changeset/empty-papayas-wash.md new file mode 100644 index 00000000..6a52ef26 --- /dev/null +++ b/.changeset/empty-papayas-wash.md @@ -0,0 +1,5 @@ +--- +"runed": minor +--- + +add `IsHovered` diff --git a/packages/runed/src/lib/utilities/IsHovered/IsHovered.svelte.ts b/packages/runed/src/lib/utilities/IsHovered/IsHovered.svelte.ts new file mode 100644 index 00000000..efcdc99a --- /dev/null +++ b/packages/runed/src/lib/utilities/IsHovered/IsHovered.svelte.ts @@ -0,0 +1,58 @@ +import { extract } from "../extract/extract.js"; + +import type { MaybeGetter } from "$lib/internal/types.js"; +import { addEventListener } from "$lib/internal/utils/event.js"; + +// Whether the primary input device supports hover +function isHoverSupported() { + return window.matchMedia("(hover: hover)").matches; +} + +// Whether the primary input device supports fine pointer accuracy +function hasFinePointer() { + return window.matchMedia("(pointer: fine)").matches; +} + +function canHover() { + return isHoverSupported() && hasFinePointer(); +} + +/** + * Tracks whether the user is hovering over the target element. + * @see {@link https://runed.dev/docs/utilities/is-hovered} + */ +export class IsHovered { + #node: MaybeGetter; + #target = $derived.by(() => extract(this.#node)); + #current = $state(false); + + constructor(node: MaybeGetter) { + this.#node = node; + + const handleMouseEnter = () => { + this.#current = true; + }; + const handleMouseLeave = () => { + this.#current = false; + }; + + $effect(() => { + if (!this.#target || !canHover()) { + return; + } + + const callbacks: VoidFunction[] = []; + + callbacks.push(addEventListener(this.#target, "mouseenter", handleMouseEnter)); + callbacks.push(addEventListener(this.#target, "mouseleave", handleMouseLeave)); + + return () => { + return () => callbacks.forEach((c) => c()); + }; + }); + } + + get current(): boolean { + return this.#current; + } +} diff --git a/packages/runed/src/lib/utilities/IsHovered/IsHovered.test.svelte.ts b/packages/runed/src/lib/utilities/IsHovered/IsHovered.test.svelte.ts new file mode 100644 index 00000000..d3fa341e --- /dev/null +++ b/packages/runed/src/lib/utilities/IsHovered/IsHovered.test.svelte.ts @@ -0,0 +1,33 @@ +import { fireEvent, render } from "@testing-library/svelte/svelte5"; +import { describe, expect, it } from "vitest"; +import TestIsHovered from "./TestIsHovered.svelte"; + +function setup() { + const result = render(TestIsHovered); + const hoverTarget = result.getByTestId("hover-target"); + return { + ...result, + hoverTarget, + }; +} + +describe("IsHovered", () => { + it("should be false on initial render", () => { + const { hoverTarget } = setup(); + expect(hoverTarget).toHaveTextContent("false"); + }); + + it("should be true when hovered", async () => { + const { hoverTarget } = setup(); + await fireEvent.mouseEnter(hoverTarget); + expect(hoverTarget).toHaveTextContent("true"); + }); + + it("should be false when unhovered", async () => { + const { hoverTarget } = setup(); + await fireEvent.mouseEnter(hoverTarget); + expect(hoverTarget).toHaveTextContent("true"); + await fireEvent.mouseLeave(hoverTarget); + expect(hoverTarget).toHaveTextContent("false"); + }); +}); diff --git a/packages/runed/src/lib/utilities/IsHovered/TestIsHovered.svelte b/packages/runed/src/lib/utilities/IsHovered/TestIsHovered.svelte new file mode 100644 index 00000000..0d32a0ed --- /dev/null +++ b/packages/runed/src/lib/utilities/IsHovered/TestIsHovered.svelte @@ -0,0 +1,11 @@ + + +
+ {isHovered.current ? "true" : "false"} +
diff --git a/packages/runed/src/lib/utilities/IsHovered/index.ts b/packages/runed/src/lib/utilities/IsHovered/index.ts new file mode 100644 index 00000000..0f9d8c84 --- /dev/null +++ b/packages/runed/src/lib/utilities/IsHovered/index.ts @@ -0,0 +1 @@ +export * from "./IsHovered.svelte.js"; \ No newline at end of file diff --git a/packages/runed/src/lib/utilities/index.ts b/packages/runed/src/lib/utilities/index.ts index e9614b7d..38879346 100644 --- a/packages/runed/src/lib/utilities/index.ts +++ b/packages/runed/src/lib/utilities/index.ts @@ -20,3 +20,4 @@ export * from "./AnimationFrames/index.js"; export * from "./useIntersectionObserver/index.js"; export * from "./IsFocusWithin/index.js"; export * from "./FiniteStateMachine/index.js"; +export * from "./IsHovered/index.js"; diff --git a/sites/docs/content/utilities/is-hovered.md b/sites/docs/content/utilities/is-hovered.md new file mode 100644 index 00000000..860a2d53 --- /dev/null +++ b/sites/docs/content/utilities/is-hovered.md @@ -0,0 +1,30 @@ +--- +title: IsHovered +description: Determine if an element is hovered. +category: Utilities +--- + + + +## Demo + + + +## Usage + +```svelte + + + + +

Is hovered: {isHovered.current ? "true" : "false"}

+``` diff --git a/sites/docs/src/lib/components/demos/is-hovered.svelte b/sites/docs/src/lib/components/demos/is-hovered.svelte new file mode 100644 index 00000000..96b2bd76 --- /dev/null +++ b/sites/docs/src/lib/components/demos/is-hovered.svelte @@ -0,0 +1,21 @@ + + + + + + + +

+ Is hovered: {isHovered.current ? "true" : "false"} +

+