@supermousejs/core 2.3.0 → 2.4.1

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,121 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { Supermouse } from "../Supermouse";
3
+
4
+ describe("Supermouse integration", () => {
5
+ let app: Supermouse;
6
+ let container: HTMLElement;
7
+
8
+ afterEach(() => {
9
+ app?.destroy();
10
+ document.body.innerHTML = "";
11
+ document.head.innerHTML = "";
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ it("disable() resets plugin-set cursorMode and restores native cursor", () => {
16
+ container = document.createElement("div");
17
+ document.body.appendChild(container);
18
+ app = new Supermouse({ container, autoStart: false });
19
+
20
+ const smartIconLike = {
21
+ name: "fake-smart-icon",
22
+ install(instance: Supermouse) {
23
+ instance.setCursor("custom");
24
+ },
25
+ update() {}
26
+ };
27
+ app.use(smartIconLike);
28
+
29
+ expect(app.state.cursorMode).toBe("custom");
30
+
31
+ window.dispatchEvent(
32
+ new PointerEvent("pointermove", { clientX: 100, clientY: 100, pointerType: "mouse" })
33
+ );
34
+ app.start();
35
+ app.step(performance.now() + 16);
36
+
37
+ expect(app.stage.style.opacity).toBe("1"); // custom cursor visible
38
+
39
+ app.disable();
40
+
41
+ expect(app.state.cursorMode).toBe("custom");
42
+ expect(app.stage.style.opacity).toBe("0");
43
+ expect(container.style.cursor).toBe("");
44
+
45
+ app.step(performance.now() + 32);
46
+ expect(container.style.cursor).toBe("");
47
+ expect(app.stage.style.opacity).toBe("0");
48
+ });
49
+
50
+ it("disable() also works when plugin registers extra hover targets", () => {
51
+ container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ app = new Supermouse({ container, autoStart: false });
54
+
55
+ const smartIconLike = {
56
+ name: "fake-smart-icon-2",
57
+ install(instance: Supermouse) {
58
+ instance.setCursor("custom");
59
+ instance.registerHoverTarget("p, h1, h2, h3, span");
60
+ },
61
+ update() {}
62
+ };
63
+ app.use(smartIconLike);
64
+
65
+ window.dispatchEvent(
66
+ new PointerEvent("pointermove", { clientX: 50, clientY: 50, pointerType: "mouse" })
67
+ );
68
+ app.start();
69
+ app.step(performance.now() + 16);
70
+
71
+ expect(app.state.cursorMode).toBe("custom");
72
+
73
+ app.disable();
74
+
75
+ expect(app.state.cursorMode).toBe("custom");
76
+ expect(app.stage.style.opacity).toBe("0");
77
+ expect(container.style.cursor).toBe("");
78
+
79
+ expect(container.classList.contains("supermouse-hide-0")).toBe(false);
80
+ });
81
+
82
+ it("nested instance with cursor 'both' does not inherit global inline cursor:none", () => {
83
+ // Global instance on body
84
+ const globalApp = new Supermouse({
85
+ container: document.body,
86
+ cursor: "auto",
87
+ autoStart: false
88
+ });
89
+
90
+ // Create nested preview container
91
+ const previewContainer = document.createElement("div");
92
+ document.body.appendChild(previewContainer);
93
+
94
+ // Preview instance with cursor 'both'
95
+ const previewApp = new Supermouse({
96
+ container: previewContainer,
97
+ cursor: "both",
98
+ autoStart: false
99
+ });
100
+
101
+ // Simulate entering preview: suspend global
102
+ globalApp.suspend();
103
+
104
+ // Global body must not have inline cursor:none
105
+ expect(document.body.style.cursor).toBe("");
106
+ expect(document.body.classList.contains("supermouse-hide-0")).toBe(false);
107
+
108
+ // Preview container must also not have inline cursor:none (its own 'both' mode shouldn't)
109
+ expect(previewContainer.style.cursor).toBe("");
110
+
111
+ // An empty div inside preview should inherit nothing from body
112
+ const emptyDiv = document.createElement("div");
113
+ previewContainer.appendChild(emptyDiv);
114
+ // In real browser, computed cursor would be 'auto', but we can at least check no inline
115
+ expect(emptyDiv.style.cursor).toBe("");
116
+
117
+ globalApp.destroy();
118
+ previewApp.destroy();
119
+ previewContainer.remove();
120
+ });
121
+ });
@@ -0,0 +1,199 @@
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
2
+ import { Supermouse } from "../Supermouse";
3
+
4
+ describe("Supermouse lifecycle", () => {
5
+ let app: Supermouse;
6
+ let container: HTMLElement;
7
+
8
+ beforeEach(() => {
9
+ container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ });
12
+
13
+ afterEach(() => {
14
+ app?.destroy();
15
+ document.body.innerHTML = "";
16
+ document.head.innerHTML = "";
17
+ vi.restoreAllMocks();
18
+ vi.useRealTimers();
19
+ });
20
+
21
+ function movePointer(x: number, y: number) {
22
+ window.dispatchEvent(
23
+ new PointerEvent("pointermove", {
24
+ clientX: x,
25
+ clientY: y,
26
+ pointerType: "mouse",
27
+ bubbles: true
28
+ })
29
+ );
30
+ }
31
+
32
+ const hasClassPrefix = (el: HTMLElement, prefix: string) =>
33
+ Array.from(el.classList).some((c) => c.startsWith(prefix));
34
+
35
+ it("auto-starts the loop when autoStart is true", () => {
36
+ const startLoopSpy = vi.spyOn(Supermouse.prototype as any, "startLoop");
37
+ app = new Supermouse({ autoStart: true, container });
38
+ expect(startLoopSpy).toHaveBeenCalledTimes(1);
39
+ });
40
+
41
+ it("does not auto-start when autoStart is false", () => {
42
+ const startLoopSpy = vi.spyOn(Supermouse.prototype as any, "startLoop");
43
+ app = new Supermouse({ autoStart: false, container });
44
+ expect(startLoopSpy).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it("start() starts the loop and is idempotent", () => {
48
+ app = new Supermouse({ autoStart: false, container });
49
+ const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
50
+ app.start();
51
+ expect(rafSpy).toHaveBeenCalledTimes(1);
52
+ app.start();
53
+ expect(rafSpy).toHaveBeenCalledTimes(1);
54
+ });
55
+
56
+ it("disable() disables input, restores native cursor, and resets physics", () => {
57
+ app = new Supermouse({ autoStart: false, container });
58
+ movePointer(50, 60);
59
+ app.start();
60
+
61
+ app.disable();
62
+
63
+ expect(app.input.isEnabled).toBe(false);
64
+ expect(container.style.cursor).toBe(""); // original (empty)
65
+ expect(app.state.target).toEqual({ x: -100, y: -100 });
66
+ expect(app.state.smooth).toEqual({ x: -100, y: -100 });
67
+ expect(app.state.velocity).toEqual({ x: 0, y: 0 });
68
+ expect(app.state.displacement).toEqual({ x: 0, y: 0 });
69
+ expect(app.state.hasReceivedInput).toBe(false);
70
+ });
71
+
72
+ it("enable() re-enables input, snaps to current pointer, and hides native cursor", () => {
73
+ app = new Supermouse({ autoStart: false, container });
74
+ movePointer(200, 250);
75
+ app.start();
76
+ app.disable();
77
+ app.enable();
78
+
79
+ expect(app.input.isEnabled).toBe(true);
80
+ expect(app.state.hasReceivedInput).toBe(true);
81
+ expect(app.state.target).toEqual({ x: 200, y: 250 });
82
+ expect(app.state.smooth).toEqual({ x: 200, y: 250 });
83
+
84
+ expect(hasClassPrefix(container, "supermouse-hide-")).toBe(true);
85
+ expect(container.style.cursor).toBe("none");
86
+ });
87
+
88
+ it("suspend() disables input, clears hover, hides stage", () => {
89
+ app = new Supermouse({ autoStart: false, container });
90
+ // Simulate hovering an element
91
+ const link = document.createElement("a");
92
+ container.appendChild(link);
93
+ link.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
94
+ expect(app.state.isHover).toBe(true);
95
+
96
+ app.start();
97
+ app.suspend();
98
+
99
+ expect(app.input.isEnabled).toBe(false);
100
+ expect(app.state.isHover).toBe(false);
101
+ expect(app.state.hoverTarget).toBeNull();
102
+ expect(app.state.interaction).toEqual({});
103
+ expect(app.stage.style.opacity).toBe("0");
104
+ });
105
+
106
+ it("resume() re-enables input, snaps physics, and shows stage", () => {
107
+ app = new Supermouse({ autoStart: false, container });
108
+ movePointer(300, 400);
109
+ app.start();
110
+ app.suspend();
111
+
112
+ const updateSpy = vi.fn();
113
+ app.use({ name: "test", update: updateSpy });
114
+ app.resume();
115
+
116
+ expect(app.input.isEnabled).toBe(true);
117
+ expect(app.state.target).toEqual({ x: 300, y: 400 });
118
+ expect(app.state.smooth).toEqual({ x: 300, y: 400 });
119
+ expect(app.stage.style.opacity).toBe("1");
120
+ expect(updateSpy).toHaveBeenCalledWith(app, 0);
121
+ });
122
+
123
+ it("manual step advances physics and updates velocity/displacement", () => {
124
+ app = new Supermouse({ autoStart: false, container });
125
+ app.input.isEnabled = true;
126
+ app.state.hasReceivedInput = true;
127
+ app.state.pointer = { x: 100, y: 100 };
128
+ app.state.target = { x: 100, y: 100 };
129
+ app.state.smooth = { x: 0, y: 0 };
130
+ app.start();
131
+
132
+ const now = performance.now() + 16;
133
+ app.step(now);
134
+
135
+ expect(app.state.smooth.x).toBeGreaterThan(0);
136
+ expect(app.state.smooth.x).toBeLessThan(100);
137
+ expect(app.state.velocity.x).not.toBe(0);
138
+ expect(app.state.displacement.x).toBeCloseTo(100 - app.state.smooth.x);
139
+ expect(app.state.displacement.y).toBeCloseTo(100 - app.state.smooth.y);
140
+ });
141
+
142
+ it("pauses loop on document hidden and resumes on visible", () => {
143
+ app = new Supermouse({ autoStart: false, container });
144
+ const cancelSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
145
+ const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
146
+ app.start();
147
+
148
+ Object.defineProperty(document, "hidden", { value: true, configurable: true });
149
+ Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
150
+ document.dispatchEvent(new Event("visibilitychange"));
151
+
152
+ expect(cancelSpy).toHaveBeenCalled();
153
+
154
+ Object.defineProperty(document, "hidden", { value: false, configurable: true });
155
+ Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
156
+ document.dispatchEvent(new Event("visibilitychange"));
157
+
158
+ expect(rafSpy).toHaveBeenCalledTimes(2); // initial + resume
159
+ });
160
+
161
+ it("destroy() stops loop, removes event listeners, stage, and plugins", () => {
162
+ const destroyPluginSpy = vi.fn();
163
+
164
+ app = new Supermouse({ autoStart: false, container });
165
+ const stageEl = app.stage;
166
+ const styleTag = document.querySelector('style[id^="supermouse-style-"]');
167
+ app.use({ name: "plugin", destroy: destroyPluginSpy });
168
+
169
+ app.destroy();
170
+
171
+ expect(app.isRunning).toBe(false);
172
+ expect(document.body.contains(stageEl)).toBe(false);
173
+ expect(document.head.contains(styleTag)).toBe(false);
174
+ expect(destroyPluginSpy).toHaveBeenCalled();
175
+ expect(container.style.cursor).toBe("");
176
+ expect(container.classList.contains("supermouse-scope")).toBe(false);
177
+ });
178
+
179
+ it("disable() hides the custom cursor stage", () => {
180
+ app = new Supermouse({ autoStart: false });
181
+ // Simulate pointer presence
182
+ window.dispatchEvent(
183
+ new PointerEvent("pointermove", { clientX: 100, clientY: 100, pointerType: "mouse" })
184
+ );
185
+ expect(app.stage.style.opacity).toBe("1");
186
+
187
+ app.disable();
188
+
189
+ expect(app.stage.style.opacity).toBe("0");
190
+ expect(container.style.cursor).toBe("");
191
+ });
192
+
193
+ it("disable() preserves cursorMode", () => {
194
+ app = new Supermouse({ autoStart: false });
195
+ app.setCursor("custom");
196
+ app.disable();
197
+ expect(app.state.cursorMode).toBe("custom");
198
+ });
199
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { Supermouse } from "../Supermouse";
3
+
4
+ describe("Nested instances cursor inheritance", () => {
5
+ let globalApp: Supermouse;
6
+ let previewApp: Supermouse;
7
+ let body: HTMLElement;
8
+ let previewContainer: HTMLElement;
9
+
10
+ afterEach(() => {
11
+ globalApp?.destroy();
12
+ previewApp?.destroy();
13
+ document.body.innerHTML = "";
14
+ document.head.innerHTML = "";
15
+ });
16
+
17
+ it.fails(
18
+ "preview container should set inline cursor to auto when in 'both' mode to override inherited none",
19
+ () => {
20
+ body = document.body;
21
+ globalApp = new Supermouse({ container: body, cursor: "auto", autoStart: false });
22
+
23
+ // Force global instance to hide native cursor
24
+ globalApp.setCursor("custom");
25
+ globalApp.step(performance.now() + 16);
26
+ expect(body.style.cursor).toBe("none");
27
+
28
+ previewContainer = document.createElement("div");
29
+ body.appendChild(previewContainer);
30
+
31
+ previewApp = new Supermouse({
32
+ container: previewContainer,
33
+ cursor: "both",
34
+ autoStart: false
35
+ });
36
+ previewApp.step(performance.now() + 16);
37
+
38
+ // The preview container should have its own inline cursor set to 'auto'
39
+ // to override the inherited 'none' from body.
40
+ expect(previewContainer.style.cursor).toBe("auto");
41
+ }
42
+ );
43
+
44
+ it.fails("nested both mode should show native cursor", () => {
45
+ const global = new Supermouse({ container: document.body, cursor: "auto", autoStart: false });
46
+ global.setCursor("custom");
47
+ global.step(performance.now() + 16);
48
+ expect(document.body.style.cursor).toBe("none");
49
+
50
+ const preview = document.createElement("div");
51
+ document.body.appendChild(preview);
52
+ const local = new Supermouse({ container: preview, cursor: "both", autoStart: false });
53
+ local.step(performance.now() + 16);
54
+
55
+ // The preview container should have inline cursor 'auto', showing native cursor
56
+ expect(preview.style.cursor).toBe("auto");
57
+ });
58
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { Supermouse } from "../Supermouse";
3
+
4
+ describe("Supermouse plugin system", () => {
5
+ let app: Supermouse;
6
+
7
+ afterEach(() => {
8
+ app?.destroy();
9
+ document.body.innerHTML = "";
10
+ document.head.innerHTML = "";
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ it("registers and looks up plugins by name", () => {
15
+ app = new Supermouse({ autoStart: false });
16
+ const plugin = { name: "test-plugin", install() {}, update() {} };
17
+ app.use(plugin);
18
+ expect(app.getPlugin("test-plugin")).toBe(plugin);
19
+ expect(app.getPlugin("missing-plugin")).toBeUndefined();
20
+ });
21
+
22
+ it("calls install hook when plugin is added", () => {
23
+ const install = vi.fn();
24
+ app = new Supermouse({ autoStart: false });
25
+ app.use({ name: "plugin", install });
26
+ expect(install).toHaveBeenCalledWith(app);
27
+ });
28
+
29
+ it("prevents duplicate plugin names and logs warning", () => {
30
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
31
+ app = new Supermouse({ autoStart: false });
32
+ const plugin = { name: "dupe", update() {} };
33
+ app.use(plugin);
34
+ app.use(plugin);
35
+ expect(warn).toHaveBeenCalled();
36
+ expect(app.plugins?.length).toBe(1); // private, but accessible in test
37
+ });
38
+
39
+ it("sorts plugins by priority (lower runs first)", async () => {
40
+ app = new Supermouse({ autoStart: false });
41
+ const order: number[] = [];
42
+ app.use({
43
+ name: "low",
44
+ priority: 10,
45
+ update: () => order.push(10)
46
+ });
47
+ app.use({
48
+ name: "high",
49
+ priority: 1,
50
+ update: () => order.push(1)
51
+ });
52
+
53
+ // Manually step once (simulate a frame)
54
+ app.start();
55
+ app.step(performance.now() + 16);
56
+ app.destroy(); // stop the loop
57
+
58
+ expect(order).toEqual([1, 10]);
59
+ });
60
+
61
+ it("enables and disables plugins via methods", () => {
62
+ app = new Supermouse({ autoStart: false });
63
+ const onEnable = vi.fn();
64
+ const onDisable = vi.fn();
65
+ const plugin = {
66
+ name: "toggle-plugin",
67
+ isEnabled: true,
68
+ onEnable,
69
+ onDisable,
70
+ element: document.createElement("div")
71
+ };
72
+ app.use(plugin);
73
+
74
+ app.disablePlugin("toggle-plugin");
75
+ expect(plugin.isEnabled).toBe(false);
76
+ expect(onDisable).toHaveBeenCalledWith(app);
77
+ expect(plugin.element.style.display).toBe("none");
78
+
79
+ app.enablePlugin("toggle-plugin");
80
+ expect(plugin.isEnabled).toBe(true);
81
+ expect(onEnable).toHaveBeenCalledWith(app);
82
+ expect(plugin.element.style.display).toBe("");
83
+ });
84
+
85
+ it("toggles plugin state", () => {
86
+ app = new Supermouse({ autoStart: false });
87
+ const plugin = { name: "toggle", isEnabled: true };
88
+ app.use(plugin);
89
+ app.togglePlugin("toggle");
90
+ expect(plugin.isEnabled).toBe(false);
91
+ app.togglePlugin("toggle");
92
+ expect(plugin.isEnabled).toBe(true);
93
+ });
94
+
95
+ it("disables and cleans up crashed plugins", () => {
96
+ app = new Supermouse({ autoStart: false });
97
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
98
+ const plugin = {
99
+ name: "crasher",
100
+ isEnabled: true,
101
+ update: () => {
102
+ throw new Error("boom");
103
+ },
104
+ element: document.createElement("div"),
105
+ onDisable: vi.fn(),
106
+ destroy: vi.fn()
107
+ };
108
+ app.use(plugin);
109
+
110
+ app.start();
111
+ app.step(performance.now() + 16);
112
+ app.destroy();
113
+
114
+ expect(error).toHaveBeenCalled();
115
+ expect(plugin.isEnabled).toBe(false);
116
+ expect(plugin.onDisable).toHaveBeenCalled();
117
+ expect(plugin.destroy).toHaveBeenCalled();
118
+ });
119
+
120
+ it("calls destroy hooks on all plugins when instance is destroyed", () => {
121
+ app = new Supermouse({ autoStart: false });
122
+ const destroy1 = vi.fn();
123
+ const destroy2 = vi.fn();
124
+ app.use({ name: "p1", destroy: destroy1 });
125
+ app.use({ name: "p2", destroy: destroy2 });
126
+ app.destroy();
127
+ expect(destroy1).toHaveBeenCalled();
128
+ expect(destroy2).toHaveBeenCalled();
129
+ });
130
+ });
@@ -1,7 +1,6 @@
1
- Object.defineProperty(window, "matchMedia", {
2
- writable: true,
3
- value: (query: string) => ({
4
- matches: false,
1
+ window.matchMedia = (query: string) => {
2
+ return {
3
+ matches: query === "(pointer: fine)",
5
4
  media: query,
6
5
  onchange: null,
7
6
  addListener: () => {},
@@ -9,5 +8,14 @@ Object.defineProperty(window, "matchMedia", {
9
8
  addEventListener: () => {},
10
9
  removeEventListener: () => {},
11
10
  dispatchEvent: () => false
12
- })
13
- });
11
+ } as MediaQueryList;
12
+ };
13
+
14
+ if (!window.ResizeObserver) {
15
+ class ResizeObserverMock {
16
+ observe() {}
17
+ unobserve() {}
18
+ disconnect() {}
19
+ }
20
+ window.ResizeObserver = ResizeObserverMock as any;
21
+ }