diff --git a/.changeset/empty-spies-cross.md b/.changeset/empty-spies-cross.md new file mode 100644 index 00000000..03a34643 --- /dev/null +++ b/.changeset/empty-spies-cross.md @@ -0,0 +1,5 @@ +--- +"runed": minor +--- + +add `IsHovered` utility diff --git a/packages/runed/src/lib/utilities/index.ts b/packages/runed/src/lib/utilities/index.ts index 55f528b8..db3bf97d 100644 --- a/packages/runed/src/lib/utilities/index.ts +++ b/packages/runed/src/lib/utilities/index.ts @@ -8,6 +8,8 @@ export * from "./extract/index.js"; export * from "./finite-state-machine/index.js"; export * from "./is-focus-within/index.js"; export * from "./is-idle/index.js"; +export * from "./is-hovered/index.js"; +export * from "./is-hovered/index.js"; export * from "./is-in-viewport/index.js"; export * from "./is-mounted/index.js"; export * from "./is-document-visible/index.js"; diff --git a/packages/runed/src/lib/utilities/is-hovered/index.ts b/packages/runed/src/lib/utilities/is-hovered/index.ts new file mode 100644 index 00000000..efa08ddb --- /dev/null +++ b/packages/runed/src/lib/utilities/is-hovered/index.ts @@ -0,0 +1 @@ +export * from "./is-hovered.svelte.js"; diff --git a/packages/runed/src/lib/utilities/is-hovered/is-hovered.svelte.ts b/packages/runed/src/lib/utilities/is-hovered/is-hovered.svelte.ts new file mode 100644 index 00000000..cc8ed1a6 --- /dev/null +++ b/packages/runed/src/lib/utilities/is-hovered/is-hovered.svelte.ts @@ -0,0 +1,181 @@ +import { on } from "svelte/events"; +import type { Attachment } from "svelte/attachments"; +import { extract } from "../extract/extract.svelte.js"; +import type { MaybeGetter } from "$lib/internal/types.js"; +import { noop } from "$lib/internal/utils/function.js"; + +export type IsHoveredHandlers = { + /** Callback when hover interaction starts */ + onHoverStart?: (e: IsHoveredEvent) => void; + /** Callback when hover interaction ends */ + onHoverEnd?: (e: IsHoveredEvent) => void; + /** Callback when hover state changes */ + onHoverChange?: (isHovered: boolean) => void; +}; + +export type IsHoveredOptions = IsHoveredHandlers & { + /** Whether the hover events should be disabled */ + isDisabled?: MaybeGetter; +}; + +export class IsHoveredEvent { + type: "hoverstart" | "hoverend"; + pointerType: "mouse" | "pen"; + target: Element; + + constructor(type: "hoverstart" | "hoverend", pointerType: "mouse" | "pen", originalEvent: Event) { + this.type = type; + this.pointerType = pointerType; + this.target = originalEvent.currentTarget as Element; + } +} + +// iOS fires onPointerEnter twice: once with pointerType="touch" and again with pointerType="mouse". +// We want to ignore these emulated events so they do not trigger hover behavior. +// See https://bugs.webkit.org/show_bug.cgi?id=214609 +// As of 2024-01-08, this bug has been resolved at the end of 2022, however, we want +// to support older versions of iOS and revisit the necessity of this in the future +let globalIgnoreEmulatedMouseEvents = false; +let hoverCount = 0; + +function setGlobalIgnoreEmulatedMouseEvents() { + globalIgnoreEmulatedMouseEvents = true; + + // Clear globalIgnoreEmulatedMouseEvents after a short timeout. iOS fires onPointerEnter + // with pointerType="mouse" immediately after onPointerUp and before onFocus. On other + // devices that don't have this quirk, we don't want to ignore a mouse hover sometime in + // the distant future because a user previously touched the element. + setTimeout(() => { + globalIgnoreEmulatedMouseEvents = false; + }, 50); +} + +function handleGlobalPointerEvent(e: PointerEvent) { + if (e.pointerType === "touch") { + setGlobalIgnoreEmulatedMouseEvents(); + } +} + +function setupGlobalTouchEvents() { + if (typeof document === "undefined") { + return; + } + + let unsubListener = noop; + + if (typeof PointerEvent !== "undefined") { + unsubListener = on(document, "pointerup", handleGlobalPointerEvent as EventListener); + } else { + unsubListener = on(document, "touchend", setGlobalIgnoreEmulatedMouseEvents as EventListener); + } + + hoverCount++; + return () => { + hoverCount--; + if (hoverCount > 0) { + return; + } + + unsubListener(); + }; +} + +/** + * Handles pointer hover interactions for an element. Normalizes behavior + * across browsers and platforms, and ignores emulated mouse events on touch devices. + * + * @see {@link https://runed.dev/docs/utilities/is-hovered} + */ +export class IsHovered { + #current = $state(false); + #options: IsHoveredOptions; + #ignoreEmulatedMouseEvents = false; + + constructor(options: IsHoveredOptions = {}) { + this.#options = options; + + $effect(() => { + const isDisabled = extract(this.#options.isDisabled) ?? false; + if (isDisabled && this.#current) { + this.#current = false; + this.#options.onHoverChange?.(false); + } + }); + } + + get current(): boolean { + return this.#current; + } + + #triggerHoverStart(originalEvent: MouseEvent | PointerEvent, pointerType: "mouse" | "pen") { + const isDisabled = extract(this.#options.isDisabled) ?? false; + if (isDisabled || this.#current) return; + + this.#current = true; + const event = new IsHoveredEvent("hoverstart", pointerType, originalEvent); + + this.#options.onHoverStart?.(event); + this.#options.onHoverChange?.(true); + } + + #triggerHoverEnd(originalEvent: MouseEvent | PointerEvent, pointerType: "mouse" | "pen") { + if (!this.#current) return; + this.#current = false; + const event = new IsHoveredEvent("hoverend", pointerType, originalEvent); + + this.#options.onHoverEnd?.(event); + this.#options.onHoverChange?.(false); + } + + readonly attach: Attachment = (node) => { + const cleanupGlobal = setupGlobalTouchEvents(); + + let unsub: () => void; + + if (typeof PointerEvent !== "undefined") { + const unsubEnter = on(node, "pointerenter", ((e: PointerEvent) => { + if ( + e.pointerType === "touch" || + (globalIgnoreEmulatedMouseEvents && e.pointerType === "mouse") + ) + return; + this.#triggerHoverStart(e, e.pointerType as "mouse" | "pen"); + }) as EventListener); + const unsubLeave = on(node, "pointerleave", ((e: PointerEvent) => { + if (e.pointerType === "touch") return; + this.#triggerHoverEnd(e, e.pointerType as "mouse" | "pen"); + }) as EventListener); + unsub = () => { + unsubEnter(); + unsubLeave(); + }; + } else { + const unsubTouch = on(node, "touchstart", () => { + this.#ignoreEmulatedMouseEvents = true; + }); + const unsubEnter = on(node, "mouseenter", ((e: MouseEvent) => { + if (!this.#ignoreEmulatedMouseEvents && !globalIgnoreEmulatedMouseEvents) { + this.#triggerHoverStart(e, "mouse"); + } + this.#ignoreEmulatedMouseEvents = false; + }) as EventListener); + const unsubLeave = on(node, "mouseleave", ((e: MouseEvent) => { + this.#triggerHoverEnd(e, "mouse"); + }) as EventListener); + unsub = () => { + unsubTouch(); + unsubEnter(); + unsubLeave(); + }; + } + + return () => { + unsub(); + cleanupGlobal?.(); + if (this.#current) { + this.#current = false; + this.#options.onHoverChange?.(false); + } + }; + }; +} diff --git a/packages/runed/src/lib/utilities/is-hovered/is-hovered.test.svelte.ts b/packages/runed/src/lib/utilities/is-hovered/is-hovered.test.svelte.ts new file mode 100644 index 00000000..d8ccfde0 --- /dev/null +++ b/packages/runed/src/lib/utilities/is-hovered/is-hovered.test.svelte.ts @@ -0,0 +1,101 @@ +import { describe, expect, vi, beforeEach, afterEach } from "vitest"; +import { tick } from "svelte"; +import { IsHovered } from "./is-hovered.svelte.js"; +import { testWithEffect } from "$lib/test/util.svelte.js"; + +describe("IsHovered", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + class MockPointerEvent extends Event { + pointerType: string; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(type: string, options: any = {}) { + super(type, { bubbles: true, ...options }); + this.pointerType = options.pointerType ?? "mouse"; + } + } + + const PointerEventMock = + globalThis.PointerEvent ?? (MockPointerEvent as unknown as typeof globalThis.PointerEvent); + vi.stubGlobal("PointerEvent", PointerEventMock); + + const createPointerEvent = (type: string, options: Partial = {}) => { + return new PointerEventMock(type, { + bubbles: true, + cancelable: true, + pointerType: "mouse", + ...options, + }); + }; + + testWithEffect("should be false initially", () => { + const hovered = new IsHovered(); + expect(hovered.current).toBe(false); + }); + + testWithEffect("should be true when pointer enters", async () => { + const hovered = new IsHovered(); + hovered.attach(container); + await tick(); + + container.dispatchEvent(createPointerEvent("pointerenter")); + expect(hovered.current).toBe(true); + }); + + testWithEffect("should be false when pointer leaves", async () => { + const hovered = new IsHovered(); + hovered.attach(container); + await tick(); + + container.dispatchEvent(createPointerEvent("pointerenter")); + expect(hovered.current).toBe(true); + + container.dispatchEvent(createPointerEvent("pointerleave")); + expect(hovered.current).toBe(false); + }); + + testWithEffect("should respect isDisabled", async () => { + let isDisabled = $state(false); + const hovered = new IsHovered({ isDisabled: () => isDisabled }); + hovered.attach(container); + await tick(); + + container.dispatchEvent(createPointerEvent("pointerenter")); + expect(hovered.current).toBe(true); + + isDisabled = true; + await tick(); + expect(hovered.current).toBe(false); + + container.dispatchEvent(createPointerEvent("pointerenter")); + expect(hovered.current).toBe(false); + }); + + testWithEffect("should call callbacks", async () => { + const onHoverStart = vi.fn(); + const onHoverEnd = vi.fn(); + const onHoverChange = vi.fn(); + + const hovered = new IsHovered({ onHoverStart, onHoverEnd, onHoverChange }); + hovered.attach(container); + await tick(); + + container.dispatchEvent(createPointerEvent("pointerenter")); + expect(onHoverStart).toHaveBeenCalledOnce(); + expect(onHoverChange).toHaveBeenCalledWith(true); + + container.dispatchEvent(createPointerEvent("pointerleave")); + expect(onHoverEnd).toHaveBeenCalledOnce(); + expect(onHoverChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/sites/docs/src/content/utilities/is-hovered.md b/sites/docs/src/content/utilities/is-hovered.md new file mode 100644 index 00000000..79ae211c --- /dev/null +++ b/sites/docs/src/content/utilities/is-hovered.md @@ -0,0 +1,54 @@ +--- +title: IsHovered +description: Handles pointer hover interactions for an element using Svelte attachments. +category: Elements +--- + + + +`IsHovered` handles pointer hover interactions for an element. It normalizes behavior across +browsers and platforms, and ignores emulated mouse events on touch devices. + +## Demo + + + +## Usage + +```svelte + + +
+ {#if hovered.current} + I am hovered! + {:else} + Hover me! + {/if} +
+``` + +## Type Definition + +```ts +export class IsHovered { + constructor(options?: IsHoveredOptions); + readonly current: boolean; + readonly attach: Attachment; +} + +export type IsHoveredHandlers = { + onHoverStart?: (e: IsHoveredEvent) => void; + onHoverEnd?: (e: IsHoveredEvent) => void; + onHoverChange?: (isHovered: boolean) => void; +}; + +export type IsHoveredOptions = IsHoveredHandlers & { + isDisabled?: MaybeGetter; +}; +``` 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..ab8130f9 --- /dev/null +++ b/sites/docs/src/lib/components/demos/is-hovered.svelte @@ -0,0 +1,27 @@ + + + +
+
+ The secret password is: + + hunter2 + +
+ +

Hover over the box to reveal the secret.

+
+