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
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ export * from "./watch/index.js";
export * from "./scroll-state/index.js";
export * from "./bool-attr/index.js";
export * from "./on-cleanup/index.js";
export * from "./use-mouse-position/index.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./use-mouse-position.svelte.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { extract } from "../extract/extract.svelte.js";
import { useEventListener } from "../use-event-listener/use-event-listener.svelte.js";
import type { MaybeElementGetter } from "$lib/internal/types.js";
import { defaultWindow, type ConfigurableWindow } from "$lib/internal/configurable-globals.js";

export type UseMousePositionOptions = ConfigurableWindow & {
/**
* Whether to start tracking immediately.
* If `false`, the listener is not active until `start()` is called.
*
* @default true
*/
immediate?: boolean;
};

export type UseMousePositionReturn = {
/** Cursor x coordinate relative to the viewport (`clientX`). */
readonly x: number;
/** Cursor y coordinate relative to the viewport (`clientY`). */
readonly y: number;
/**
* X coordinate relative to the target element's top-left corner.
* Negative when cursor is to the left of the element, greater than
* element width when to the right. `0` when no target is provided.
*/
readonly elementX: number;
/**
* Y coordinate relative to the target element's top-left corner.
* Negative when cursor is above the element, greater than
* element height when below. `0` when no target is provided.
*/
readonly elementY: number;
/** Whether the cursor is currently inside the target element's bounding rect. */
readonly isInside: boolean;
/** Whether the listener is currently active. */
readonly enabled: boolean;
/** Stop listening for pointer events. */
stop: () => void;
/** Start listening for pointer events. */
start: () => void;
};

/**
* Reactively tracks the cursor's position, both globally (viewport coordinates)
* and relative to an optional target element.
*
* @param target - Optional element to compute relative coordinates against.
* @param options - Configuration options.
*
* @see {@link https://runed.dev/docs/utilities/use-mouse-position}
*/
export function useMousePosition(
target?: MaybeElementGetter,
options?: UseMousePositionOptions
): UseMousePositionReturn {
const { immediate = true, window = defaultWindow } = options ?? {};

let x = $state(0);
let y = $state(0);
let enabled = $state(immediate);

const el = $derived(extract(target));

// Single rect query shared by elementX, elementY, and isInside.
const rect = $derived.by(() => {
if (!el) return null;
// Subscribe to x/y so the rect is recomputed each time the cursor moves.
void x;
void y;
return el.getBoundingClientRect();
});

const elementX = $derived(rect ? x - rect.left : 0);
const elementY = $derived(rect ? y - rect.top : 0);
const isInside = $derived(
rect ? x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom : false
);

const handler = (event: PointerEvent) => {
if (!enabled) return;
x = event.clientX;
y = event.clientY;
};

useEventListener(() => window, "pointermove", handler, { passive: true });

return {
get x() {
return x;
},
get y() {
return y;
},
get elementX() {
return elementX;
},
get elementY() {
return elementY;
},
get isInside() {
return isInside;
},
get enabled() {
return enabled;
},
stop: () => {
enabled = false;
},
start: () => {
enabled = true;
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, beforeEach, afterEach } from "vitest";
import { flushSync } from "svelte";
import { useMousePosition } from "./use-mouse-position.svelte.js";
import { testWithEffect } from "$lib/test/util.svelte.js";

// jsdom doesn't provide PointerEvent — same mock pattern as onClickOutside tests.
class MockPointerEvent extends Event {
clientX: number;
clientY: number;
pointerType: string;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(type: string, options: any = {}) {
super(type, { bubbles: true, ...options });
this.clientX = options.clientX ?? 0;
this.clientY = options.clientY ?? 0;
this.pointerType = options.pointerType ?? "mouse";
}
}

const PointerEvent = globalThis.PointerEvent ?? MockPointerEvent;

describe("useMousePosition", () => {
const dispatchPointerMove = (x: number, y: number) => {
window.dispatchEvent(
new PointerEvent("pointermove", {
bubbles: true,
clientX: x,
clientY: y,
})
);
};

describe("global position", () => {
testWithEffect("starts at 0, 0", () => {
const mouse = useMousePosition();
expect(mouse.x).toBe(0);
expect(mouse.y).toBe(0);
});

testWithEffect("tracks cursor position on pointermove", () => {
const mouse = useMousePosition();
flushSync();

dispatchPointerMove(150, 200);
flushSync();

expect(mouse.x).toBe(150);
expect(mouse.y).toBe(200);
});

testWithEffect("updates on subsequent moves", () => {
const mouse = useMousePosition();
flushSync();

dispatchPointerMove(10, 20);
flushSync();
expect(mouse.x).toBe(10);
expect(mouse.y).toBe(20);

dispatchPointerMove(300, 400);
flushSync();
expect(mouse.x).toBe(300);
expect(mouse.y).toBe(400);
});
});

describe("element-relative position", () => {
let target: HTMLDivElement;

beforeEach(() => {
target = document.createElement("div");
document.body.appendChild(target);
target.getBoundingClientRect = () => ({
x: 50,
y: 50,
width: 100,
height: 100,
top: 50,
right: 150,
bottom: 150,
left: 50,
toJSON() {},
});
});

afterEach(() => {
target.remove();
});

testWithEffect("computes elementX and elementY relative to target", () => {
const mouse = useMousePosition(() => target);
flushSync();

dispatchPointerMove(75, 80);
flushSync();

expect(mouse.elementX).toBe(25);
expect(mouse.elementY).toBe(30);
});

testWithEffect("returns negative values when cursor is above/left of target", () => {
const mouse = useMousePosition(() => target);
flushSync();

dispatchPointerMove(30, 20);
flushSync();

expect(mouse.elementX).toBe(-20);
expect(mouse.elementY).toBe(-30);
});

testWithEffect("returns values exceeding dimensions when cursor is beyond target", () => {
const mouse = useMousePosition(() => target);
flushSync();

dispatchPointerMove(200, 250);
flushSync();

// 200 - 50 = 150, which is > width (100)
expect(mouse.elementX).toBe(150);
// 250 - 50 = 200, which is > height (100)
expect(mouse.elementY).toBe(200);
});

testWithEffect("isInside is true when cursor is within target bounds", () => {
const mouse = useMousePosition(() => target);
flushSync();

dispatchPointerMove(100, 100);
flushSync();

expect(mouse.isInside).toBe(true);
});

testWithEffect("isInside is false when cursor is outside target bounds", () => {
const mouse = useMousePosition(() => target);
flushSync();

dispatchPointerMove(200, 200);
flushSync();

expect(mouse.isInside).toBe(false);
});

testWithEffect("elementX/elementY are 0 when no target is provided", () => {
const mouse = useMousePosition();
flushSync();

dispatchPointerMove(100, 100);
flushSync();

expect(mouse.elementX).toBe(0);
expect(mouse.elementY).toBe(0);
expect(mouse.isInside).toBe(false);
});
});

describe("start / stop", () => {
testWithEffect("is enabled by default", () => {
const mouse = useMousePosition();
expect(mouse.enabled).toBe(true);
});

testWithEffect("ignores events when stopped", () => {
const mouse = useMousePosition();
flushSync();

mouse.stop();
dispatchPointerMove(100, 200);
flushSync();

expect(mouse.x).toBe(0);
expect(mouse.y).toBe(0);
expect(mouse.enabled).toBe(false);
});

testWithEffect("resumes tracking after start", () => {
const mouse = useMousePosition();
flushSync();

mouse.stop();
dispatchPointerMove(100, 200);
flushSync();
expect(mouse.x).toBe(0);

mouse.start();
dispatchPointerMove(300, 400);
flushSync();

expect(mouse.x).toBe(300);
expect(mouse.y).toBe(400);
expect(mouse.enabled).toBe(true);
});

testWithEffect("respects immediate: false", () => {
const mouse = useMousePosition(undefined, { immediate: false });
flushSync();

expect(mouse.enabled).toBe(false);

dispatchPointerMove(100, 200);
flushSync();

expect(mouse.x).toBe(0);
expect(mouse.y).toBe(0);

mouse.start();
dispatchPointerMove(100, 200);
flushSync();

expect(mouse.x).toBe(100);
expect(mouse.y).toBe(200);
});
});
});
Loading