@supermousejs/core 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ Object.defineProperty(window, "matchMedia", {
2
+ writable: true,
3
+ value: (query: string) => ({
4
+ matches: false,
5
+ media: query,
6
+ onchange: null,
7
+ addListener: () => {},
8
+ removeListener: () => {},
9
+ addEventListener: () => {},
10
+ removeEventListener: () => {},
11
+ dispatchEvent: () => false
12
+ })
13
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Supermouse } from "../Supermouse";
3
+
4
+ describe("Supermouse core", () => {
5
+ it("registers and looks up plugins by name", () => {
6
+ const app = new Supermouse();
7
+
8
+ const plugin = {
9
+ name: "test-plugin",
10
+ install() {},
11
+ update() {}
12
+ };
13
+
14
+ app.use(plugin);
15
+
16
+ expect(app.getPlugin("test-plugin")).toBe(plugin);
17
+ expect(app.getPlugin("missing-plugin")).toBeUndefined();
18
+ });
19
+ });
package/src/types.ts CHANGED
@@ -32,21 +32,21 @@ export interface InteractionState {
32
32
  }
33
33
 
34
34
  export interface MouseState {
35
- /** The raw position of the input pointer (mouse/touch). */
35
+ /** The raw position from the latest pointer event, before smoothing is applied. */
36
36
  pointer: MousePosition;
37
- /** The target position the cursor logic wants to reach. */
37
+ /** The current goal position that the core loop is driving toward. */
38
38
  target: MousePosition;
39
39
  /** The smoothed/interpolated position used for rendering. */
40
40
  smooth: MousePosition;
41
- /** The current velocity vector of the smooth position. */
41
+ /** The current movement vector derived from the smoothed state. */
42
42
  velocity: MousePosition;
43
- /** The angle of movement in degrees. Calculated from velocity. */
43
+ /** The current movement angle in degrees, derived from velocity. */
44
44
  angle: number;
45
45
  /** Whether the pointer is currently pressed down. */
46
46
  isDown: boolean;
47
47
  /** Whether the pointer is currently hovering over a registered interactive element. */
48
48
  isHover: boolean;
49
- /** Whether the native cursor is currently forced visible by internal logic (e.g. input elements). */
49
+ /** Whether the runtime has temporarily restored the native cursor due to native-input heuristics. */
50
50
  isNative: boolean;
51
51
  /**
52
52
  * If set, this overrides all auto-detection logic.
@@ -95,20 +95,24 @@ export interface SupermouseOptions {
95
95
  autoDisableOnMobile?: boolean;
96
96
  /**
97
97
  * Strategy for detecting when to fallback to the native cursor.
98
- * - `true` / `'auto'`: Checks both HTML tags and CSS cursor styles (Accurate but slower).
98
+ * - `'auto'`: Checks both HTML tags and CSS cursor styles (Accurate but slower).
99
99
  * - `'tag'`: Checks only semantic tags like <input>, <textarea> (Fastest, prevents layout thrashing).
100
100
  * - `'css'`: Checks only computed CSS cursor styles (Slow, triggers reflow).
101
- * - `false`: Never fallback to native cursor.
101
+ * - `null`: Never fallback to native cursor.
102
+ *
102
103
  * @default 'auto'
103
104
  */
104
- ignoreOnNative?: boolean | NativeIgnoreStrategy;
105
+ ignoreOnNative?: NativeIgnoreStrategy | null;
105
106
  /**
106
- * Whether to hide the native cursor via global CSS injection.
107
+ * Whether to hide the native cursor via scoped CSS injection on the container.
108
+ * When enabled, the stage toggles `cursor: none` on the configured container and
109
+ * on registered hover targets.
107
110
  * @default true
108
111
  */
109
112
  hideCursor?: boolean;
110
113
  /**
111
- * Whether to hide the custom cursor when the mouse leaves the browser window.
114
+ * Whether to hide the custom cursor when the pointer leaves the browser viewport.
115
+ * When enabled the runtime clears the cursor back to an off-screen position to avoid stale hover state.
112
116
  * @default true
113
117
  */
114
118
  hideOnLeave?: boolean;
@@ -128,18 +132,29 @@ export interface SupermouseOptions {
128
132
  autoStart?: boolean;
129
133
  /**
130
134
  * Semantic rules mapping CSS selectors to interaction state.
135
+ * Rules are evaluated against hovered elements and merged into `state.interaction`.
136
+ * Matching data attributes on the same element are also read and can override or enrich the final object.
131
137
  * @example { 'button': { icon: 'pointer' } }
132
138
  */
133
139
  rules?: Record<string, InteractionState>;
134
140
  /**
135
141
  * Custom strategy to resolve interaction state from a hovered element.
136
- * Overrides the default data-attribute scraping.
142
+ * When provided, this callback bypasses the default `rules` + `data-[prefix]-*` scraping and
143
+ * returns the interaction payload directly for the current hover target.
137
144
  */
138
145
  resolveInteraction?: (target: HTMLElement) => InteractionState;
146
+ /**
147
+ * The prefix used for data attributes to store hover metadata.
148
+ * For example, if dataPrefix is "supermouse", then the attribute would be "data-supermouse-*".
149
+ * This allows for multiple instances of Supermouse to coexist without conflicting data attributes.
150
+ * @default "supermouse"
151
+ */
152
+ dataPrefix?: string;
139
153
  }
140
154
 
141
155
  /**
142
156
  * Allows a property to be a static value or a function that returns the value based on state.
157
+ * This is primarily useful for plugin option definitions that should react to the current runtime state.
143
158
  */
144
159
  export type ValueOrGetter<T> = T | ((state: MouseState) => T);
145
160
 
@@ -156,7 +171,7 @@ export interface SupermousePlugin {
156
171
 
157
172
  /** Called when `app.use()` is executed. */
158
173
  install?: (instance: Supermouse) => void;
159
- /** Called on every animation frame. */
174
+ /** Called on every animation frame with the frame delta time in milliseconds. */
160
175
  update?: (instance: Supermouse, deltaTime: number) => void;
161
176
  /** Called when the plugin is removed or the app is destroyed. */
162
177
  destroy?: (instance: Supermouse) => void;
package/tsconfig.json CHANGED
@@ -1,9 +1,9 @@
1
- {
2
- "extends": "../../tsconfig.composite-lib.json",
3
- "compilerOptions": {
4
- "rootDir": "src",
5
- "outDir": "dist"
6
- },
7
- "include": ["src"],
8
- "references": []
9
- }
1
+ {
2
+ "extends": "../../tsconfig.composite-lib.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src"],
8
+ "references": []
9
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "jsdom",
6
+ globals: true,
7
+ setupFiles: ["src/__tests__/setup.ts"],
8
+ include: ["src/**/*.test.ts"]
9
+ }
10
+ });
@@ -1,231 +0,0 @@
1
- import type { MouseState, SupermouseOptions } from "../types";
2
-
3
- /**
4
- * This class listens to browser events and mutates the shared `MouseState` object.
5
- *
6
- * @internal This is an internal system class instantiated by `Supermouse`.
7
- */
8
- export class Input {
9
- private mediaQueryList?: MediaQueryList;
10
- private mediaQueryHandler?: (e: MediaQueryListEvent) => void;
11
- private motionQuery?: MediaQueryList;
12
-
13
- /**
14
- * Master switch for input processing.
15
- * Toggled by `Supermouse.enable()`/`disable()` or automatically by device capability checks.
16
- */
17
- public isEnabled: boolean = true;
18
-
19
- constructor(
20
- private state: MouseState,
21
- private options: SupermouseOptions,
22
- private getHoverSelector: () => string,
23
- private onEnableChange: (enabled: boolean) => void
24
- ) {
25
- this.checkDeviceCapability();
26
- this.checkMotionPreference();
27
- this.bindEvents();
28
- }
29
-
30
- /**
31
- * Automatically disables the custom cursor on devices without fine pointer control.
32
- * Relies on `matchMedia('(pointer: fine)')`.
33
- */
34
- private checkDeviceCapability() {
35
- if (!this.options.autoDisableOnMobile) return;
36
-
37
- this.mediaQueryList = window.matchMedia("(pointer: fine)");
38
- this.updateEnabledState(this.mediaQueryList.matches);
39
-
40
- this.mediaQueryHandler = (e: MediaQueryListEvent) => {
41
- this.updateEnabledState(e.matches);
42
- };
43
- this.mediaQueryList.addEventListener("change", this.mediaQueryHandler);
44
- }
45
-
46
- /**
47
- * Checks for `prefers-reduced-motion`.
48
- * If true, the core physics engine will switch to instant snapping (high damping) to avoid motion sickness.
49
- */
50
- private checkMotionPreference() {
51
- this.motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
52
- this.state.reducedMotion = this.motionQuery.matches;
53
-
54
- this.motionQuery.addEventListener("change", (e) => {
55
- this.state.reducedMotion = e.matches;
56
- });
57
- }
58
-
59
- private updateEnabledState(enabled: boolean) {
60
- this.isEnabled = enabled;
61
- this.onEnableChange(enabled);
62
- }
63
-
64
- private parseDOMInteraction(element: HTMLElement) {
65
- if (this.options.resolveInteraction) {
66
- this.state.interaction = this.options.resolveInteraction(element);
67
- return;
68
- }
69
-
70
- const data: Record<string, string | boolean> = {};
71
-
72
- if (this.options.rules) {
73
- for (const [selector, rules] of Object.entries(this.options.rules)) {
74
- if (element.matches(selector)) {
75
- Object.assign(data, rules);
76
- }
77
- }
78
- }
79
-
80
- const dataset = element.dataset;
81
- for (const key in dataset) {
82
- if (key.startsWith("supermouse")) {
83
- const prop = key.slice(10);
84
- if (prop) {
85
- const cleanKey = prop.charAt(0).toLowerCase() + prop.slice(1);
86
- const val = dataset[key];
87
- if (val !== undefined) {
88
- data[cleanKey] = val === "" ? true : val;
89
- }
90
- }
91
- }
92
- }
93
-
94
- this.state.interaction = data;
95
- }
96
-
97
- private handleMove(e: PointerEvent) {
98
- if (!this.isEnabled) return;
99
-
100
- if (this.options.autoDisableOnMobile && e.pointerType === "touch") return;
101
-
102
- let x = e.clientX;
103
- let y = e.clientY;
104
-
105
- if (this.options.container && this.options.container !== document.body) {
106
- const rect = this.options.container.getBoundingClientRect();
107
- x -= rect.left;
108
- y -= rect.top;
109
- }
110
-
111
- this.state.pointer.x = x;
112
- this.state.pointer.y = y;
113
-
114
- if (!this.state.hasReceivedInput) {
115
- this.state.hasReceivedInput = true;
116
- this.state.target.x = this.state.smooth.x = x;
117
- this.state.target.y = this.state.smooth.y = y;
118
- }
119
- }
120
-
121
- private handleDown(): void {
122
- if (this.isEnabled) this.state.isDown = true;
123
- }
124
-
125
- private handleUp(): void {
126
- if (this.isEnabled) this.state.isDown = false;
127
- }
128
-
129
- private handleMouseOver(e: MouseEvent) {
130
- if (!this.isEnabled) return;
131
- const target = e.target as HTMLElement;
132
-
133
- if (target.closest("[data-supermouse-ignore]")) {
134
- this.state.isNative = true;
135
- return;
136
- }
137
-
138
- const selector = this.getHoverSelector();
139
- const hoverable = target.closest(selector);
140
-
141
- if (hoverable) {
142
- this.state.isHover = true;
143
- this.state.hoverTarget = hoverable as HTMLElement;
144
- this.parseDOMInteraction(this.state.hoverTarget);
145
- }
146
-
147
- const strategy = this.options.ignoreOnNative;
148
-
149
- if (strategy) {
150
- const checkTags = strategy === true || strategy === "auto" || strategy === "tag";
151
- const checkCSS = strategy === true || strategy === "auto" || strategy === "css";
152
- let isNative = false;
153
-
154
- if (checkTags) {
155
- const tag = target.localName;
156
- if (tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable) {
157
- isNative = true;
158
- }
159
- }
160
-
161
- if (!isNative && checkCSS) {
162
- const style = window.getComputedStyle(target).cursor;
163
- const supermouseAllowed = ["default", "auto", "pointer", "none", "inherit"];
164
- if (!supermouseAllowed.includes(style)) {
165
- isNative = true;
166
- }
167
- }
168
-
169
- if (isNative) {
170
- this.state.isNative = true;
171
- }
172
- }
173
- }
174
-
175
- private handleMouseOut(e: MouseEvent) {
176
- if (!this.isEnabled) return;
177
- const target = e.target as HTMLElement;
178
-
179
- if (target === this.state.hoverTarget || target.contains(this.state.hoverTarget)) {
180
- if (!e.relatedTarget || !this.state.hoverTarget?.contains(e.relatedTarget as Node)) {
181
- this.state.isHover = false;
182
- this.state.hoverTarget = null;
183
- this.state.interaction = {};
184
- }
185
- }
186
-
187
- if (this.state.isNative) {
188
- this.state.isNative = false;
189
- }
190
- }
191
-
192
- private handleWindowLeave(): void {
193
- if (this.options.hideOnLeave) {
194
- this.state.hasReceivedInput = false;
195
- }
196
- }
197
-
198
- public clearHover() {
199
- this.state.isHover = false;
200
- this.state.hoverTarget = null;
201
- this.state.isNative = false;
202
- }
203
-
204
- private bindEvents() {
205
- window.addEventListener("pointermove", this.handleMove.bind(this), { passive: true });
206
- window.addEventListener("pointerdown", this.handleDown.bind(this), { passive: true });
207
- window.addEventListener("pointerup", this.handleUp.bind(this));
208
-
209
- document.addEventListener("mouseover", this.handleMouseOver.bind(this));
210
- document.addEventListener("mouseout", this.handleMouseOut.bind(this));
211
- document.addEventListener("mouseleave", this.handleWindowLeave.bind(this));
212
- }
213
-
214
- public destroy() {
215
- if (this.mediaQueryList && this.mediaQueryHandler) {
216
- this.mediaQueryList.removeEventListener("change", this.mediaQueryHandler);
217
- }
218
- if (this.motionQuery) {
219
- // Modern browsers support removeEventListener on MediaQueryList
220
- this.motionQuery.onchange = null;
221
- }
222
-
223
- window.removeEventListener("pointermove", this.handleMove);
224
- window.removeEventListener("pointerdown", this.handleDown);
225
- window.removeEventListener("pointerup", this.handleUp);
226
-
227
- document.removeEventListener("mouseover", this.handleMouseOver);
228
- document.removeEventListener("mouseout", this.handleMouseOut);
229
- document.removeEventListener("mouseleave", this.handleWindowLeave);
230
- }
231
- }
@@ -1,126 +0,0 @@
1
- let stageCount = 0;
2
-
3
- export class Stage {
4
- /** The container element appended to the document. */
5
- public readonly element: HTMLDivElement;
6
- private styleTag: HTMLStyleElement;
7
- private id: string;
8
- private scopeClass: string;
9
-
10
- private currentCursorState: "none" | "auto" | "" | null = null;
11
-
12
- private selectors: Set<string> = new Set([
13
- "a",
14
- "button",
15
- "input",
16
- "textarea",
17
- "select",
18
- '[role="button"]',
19
- "[tabindex]"
20
- ]);
21
-
22
- constructor(
23
- private container: HTMLElement = document.body,
24
- private hideNativeCursor: boolean
25
- ) {
26
- if (!container || !(container instanceof HTMLElement)) {
27
- throw new Error(`[Supermouse] Invalid container: ${container}. Must be an HTMLElement.`);
28
- }
29
-
30
- const instanceId = stageCount++;
31
- this.id = `supermouse-style-${instanceId}`;
32
- this.scopeClass = `supermouse-scope-${instanceId}`;
33
-
34
- const isBody = container === document.body;
35
-
36
- this.element = document.createElement("div");
37
- Object.assign(this.element.style, {
38
- position: isBody ? "fixed" : "absolute",
39
- top: "0",
40
- left: "0",
41
- width: "100%",
42
- height: "100%",
43
- pointerEvents: "none",
44
- zIndex: "9999",
45
- opacity: "1",
46
- transition: "opacity 0.15s ease"
47
- });
48
-
49
- if (!isBody) {
50
- const computed = window.getComputedStyle(container);
51
- if (computed.position === "static") {
52
- container.style.position = "relative";
53
- }
54
- }
55
-
56
- container.appendChild(this.element);
57
-
58
- this.styleTag = document.createElement("style");
59
- this.styleTag.id = this.id;
60
- document.head.appendChild(this.styleTag);
61
-
62
- this.container.classList.add(this.scopeClass);
63
-
64
- if (this.hideNativeCursor) {
65
- this.setNativeCursor("none");
66
- }
67
- }
68
-
69
- /**
70
- * Adds a new CSS selector to the Hide Native Cursor list.
71
- * Called by `Supermouse` (and subsequently plugins) during install to ensure
72
- * the native cursor is hidden on their specific interactive targets.
73
- */
74
- public addSelector(selector: string) {
75
- this.selectors.add(selector);
76
- if (this.hideNativeCursor) {
77
- this.updateCursorCSS();
78
- }
79
- }
80
-
81
- public setVisibility(visible: boolean) {
82
- this.element.style.opacity = visible ? "1" : "0";
83
- }
84
-
85
- /**
86
- * Toggles the visibility of the native cursor via CSS injection.
87
- * @param type 'none' to hide, 'auto' to show.
88
- */
89
- public setNativeCursor(type: "none" | "auto" | "") {
90
- if (!this.hideNativeCursor && type === "none") return;
91
-
92
- if (type === this.currentCursorState) return;
93
- this.currentCursorState = type;
94
-
95
- if (type === "none") {
96
- this.container.style.cursor = "none";
97
- this.updateCursorCSS();
98
- } else {
99
- this.container.style.cursor = "";
100
- this.styleTag.innerText = "";
101
- }
102
- }
103
-
104
- private updateCursorCSS() {
105
- const rawSelectors = Array.from(this.selectors);
106
- if (rawSelectors.length === 0) {
107
- this.styleTag.innerText = "";
108
- return;
109
- }
110
-
111
- const scopedSelectors = rawSelectors.map((s) => `.${this.scopeClass} ${s}`).join(", ");
112
-
113
- this.styleTag.innerText = `
114
- ${scopedSelectors} {
115
- cursor: none !important;
116
- }
117
- `;
118
- }
119
-
120
- public destroy() {
121
- this.element.remove();
122
- this.styleTag.remove();
123
- this.container.style.cursor = "";
124
- this.container.classList.remove(this.scopeClass);
125
- }
126
- }
@@ -1,2 +0,0 @@
1
- export * from "./Stage";
2
- export * from "./Input";
package/src/utils/math.ts DELETED
@@ -1,11 +0,0 @@
1
- export function lerp(start: number, end: number, factor: number): number {
2
- return start + (end - start) * factor;
3
- }
4
-
5
- export function damp(a: number, b: number, lambda: number, dt: number): number {
6
- return lerp(a, b, 1 - Math.exp(-lambda * dt));
7
- }
8
-
9
- export function angle(x: number, y: number): number {
10
- return Math.atan2(y, x) * (180 / Math.PI);
11
- }