Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/empty-spies-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

add `IsHovered` utility
2 changes: 2 additions & 0 deletions packages/runed/src/lib/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/is-hovered/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./is-hovered.svelte.js";
181 changes: 181 additions & 0 deletions packages/runed/src/lib/utilities/is-hovered/is-hovered.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
};

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<HTMLElement | SVGElement> = (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);
}
};
};
}
101 changes: 101 additions & 0 deletions packages/runed/src/lib/utilities/is-hovered/is-hovered.test.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<PointerEventInit> = {}) => {
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);
});
});
54 changes: 54 additions & 0 deletions sites/docs/src/content/utilities/is-hovered.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
title: IsHovered
description: Handles pointer hover interactions for an element using Svelte attachments.
category: Elements
---

<script>
import Demo from '$lib/components/demos/is-hovered.svelte';
</script>

`IsHovered` handles pointer hover interactions for an element. It normalizes behavior across
browsers and platforms, and ignores emulated mouse events on touch devices.

## Demo

<Demo />

## Usage

```svelte
<script lang="ts">
import { IsHovered } from "runed";

const hovered = new IsHovered();
</script>

<div {@attach hovered.attach}>
{#if hovered.current}
I am hovered!
{:else}
Hover me!
{/if}
</div>
```

## Type Definition

```ts
export class IsHovered {
constructor(options?: IsHoveredOptions);
readonly current: boolean;
readonly attach: Attachment<HTMLElement | SVGElement>;
}

export type IsHoveredHandlers = {
onHoverStart?: (e: IsHoveredEvent) => void;
onHoverEnd?: (e: IsHoveredEvent) => void;
onHoverChange?: (isHovered: boolean) => void;
};

export type IsHoveredOptions = IsHoveredHandlers & {
isDisabled?: MaybeGetter<boolean>;
};
```
27 changes: 27 additions & 0 deletions sites/docs/src/lib/components/demos/is-hovered.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script lang="ts">
import { IsHovered } from "runed";
import { DemoContainer } from "@svecodocs/kit";

const hovered = new IsHovered();
</script>

<DemoContainer>
<div class="flex flex-col items-center justify-center p-8">
<div
class="bg-muted relative flex cursor-help items-center gap-2 rounded-lg px-4 py-2 transition-all"
{@attach hovered.attach}
>
<span class="text-sm font-medium">The secret password is:</span>
<span
class="bg-foreground/10 rounded px-2 py-0.5 font-mono text-sm transition-all duration-150"
class:blur-sm={!hovered.current}
class:select-none={!hovered.current}
class:opacity-50={!hovered.current}
>
hunter2
</span>
</div>

<p class="text-muted-foreground mt-4 text-xs">Hover over the box to reveal the secret.</p>
</div>
</DemoContainer>