@praxisjs/composables 0.1.4 → 1.0.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.
- package/CHANGELOG.md +54 -0
- package/dist/__tests__/browser.test.js +152 -59
- package/dist/__tests__/browser.test.js.map +1 -1
- package/dist/__tests__/dom.test.js +174 -47
- package/dist/__tests__/dom.test.js.map +1 -1
- package/dist/__tests__/utilities.test.js +155 -183
- package/dist/__tests__/utilities.test.js.map +1 -1
- package/dist/browser.d.ts +55 -12
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +105 -49
- package/dist/browser.js.map +1 -1
- package/dist/dom.d.ts +60 -25
- package/dist/dom.d.ts.map +1 -1
- package/dist/dom.js +108 -74
- package/dist/dom.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/utilities.d.ts +68 -26
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +102 -94
- package/dist/utilities.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/browser.test.ts +174 -65
- package/src/__tests__/dom.test.ts +207 -49
- package/src/__tests__/utilities.test.ts +187 -204
- package/src/browser.ts +134 -53
- package/src/dom.ts +129 -82
- package/src/index.ts +11 -18
- package/src/utilities.ts +144 -111
|
@@ -1,118 +1,227 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { MediaQuery, ColorScheme, Mouse, KeyCombo, Idle } from "../browser";
|
|
5
|
+
|
|
6
|
+
function mockMatchMedia(matches: boolean, onAdd?: (_: string, fn: (e: MediaQueryListEvent) => void) => void) {
|
|
7
|
+
const mql = {
|
|
8
|
+
matches,
|
|
9
|
+
addEventListener: onAdd ?? vi.fn(),
|
|
10
|
+
removeEventListener: vi.fn(),
|
|
11
|
+
} as unknown as MediaQueryList;
|
|
12
|
+
vi.stubGlobal("matchMedia", () => mql);
|
|
13
|
+
return mql;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── MediaQuery ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe("MediaQuery", () => {
|
|
19
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
20
|
+
|
|
21
|
+
it("reads initial match state", () => {
|
|
22
|
+
mockMatchMedia(true);
|
|
23
|
+
const mq = new MediaQuery("(min-width: 768px)");
|
|
24
|
+
const { matches } = mq.setup() as { matches: () => boolean };
|
|
25
|
+
expect(matches()).toBe(true);
|
|
26
|
+
});
|
|
3
27
|
|
|
4
|
-
|
|
28
|
+
it("updates matches when change event fires", () => {
|
|
29
|
+
let changeHandler: ((e: MediaQueryListEvent) => void) | undefined;
|
|
30
|
+
mockMatchMedia(false, (_: string, fn) => { changeHandler = fn; });
|
|
5
31
|
|
|
6
|
-
|
|
32
|
+
const mq = new MediaQuery("(min-width: 768px)");
|
|
33
|
+
const { matches } = mq.setup() as { matches: () => boolean };
|
|
34
|
+
expect(matches()).toBe(false);
|
|
7
35
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
writable: true,
|
|
12
|
-
value: vi.fn((query: string) => ({
|
|
13
|
-
matches: query === "(prefers-color-scheme: dark)",
|
|
14
|
-
addEventListener: vi.fn(),
|
|
15
|
-
removeEventListener: vi.fn(),
|
|
16
|
-
})),
|
|
17
|
-
});
|
|
36
|
+
changeHandler!({ matches: true } as MediaQueryListEvent);
|
|
37
|
+
expect(matches()).toBe(true);
|
|
38
|
+
});
|
|
18
39
|
|
|
19
|
-
|
|
20
|
-
|
|
40
|
+
it("removes event listener on unmount", () => {
|
|
41
|
+
const mql = mockMatchMedia(false);
|
|
42
|
+
const mq = new MediaQuery("(min-width: 768px)");
|
|
43
|
+
mq.setup();
|
|
44
|
+
mq.onUnmount();
|
|
45
|
+
expect((mql.removeEventListener as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("change", expect.any(Function));
|
|
46
|
+
});
|
|
21
47
|
|
|
22
|
-
|
|
23
|
-
|
|
48
|
+
it("onUnmount is safe to call without setup (covers false branch)", () => {
|
|
49
|
+
const mq = new MediaQuery("(min-width: 768px)");
|
|
50
|
+
expect(() => mq.onUnmount()).not.toThrow();
|
|
24
51
|
});
|
|
25
52
|
});
|
|
26
53
|
|
|
27
|
-
//
|
|
54
|
+
// ── ColorScheme ───────────────────────────────────────────────────────────────
|
|
28
55
|
|
|
29
|
-
describe("
|
|
30
|
-
|
|
31
|
-
Object.defineProperty(window, "matchMedia", {
|
|
32
|
-
writable: true,
|
|
33
|
-
value: vi.fn(() => ({
|
|
34
|
-
matches: true,
|
|
35
|
-
addEventListener: vi.fn(),
|
|
36
|
-
removeEventListener: vi.fn(),
|
|
37
|
-
})),
|
|
38
|
-
});
|
|
56
|
+
describe("ColorScheme", () => {
|
|
57
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
39
58
|
|
|
40
|
-
|
|
59
|
+
it("exposes isDark and isLight as computed inverses", () => {
|
|
60
|
+
let changeHandler: ((e: MediaQueryListEvent) => void) | undefined;
|
|
61
|
+
mockMatchMedia(false, (_: string, fn) => { changeHandler = fn; });
|
|
62
|
+
|
|
63
|
+
const cs = new ColorScheme();
|
|
64
|
+
const { isDark, isLight } = cs.setup() as { isDark: () => boolean; isLight: () => boolean };
|
|
65
|
+
|
|
66
|
+
expect(isDark()).toBe(false);
|
|
67
|
+
expect(isLight()).toBe(true);
|
|
68
|
+
|
|
69
|
+
changeHandler!({ matches: true } as MediaQueryListEvent);
|
|
41
70
|
expect(isDark()).toBe(true);
|
|
42
71
|
expect(isLight()).toBe(false);
|
|
43
72
|
});
|
|
73
|
+
|
|
74
|
+
it("removes event listener on unmount", () => {
|
|
75
|
+
const mql = mockMatchMedia(false);
|
|
76
|
+
const cs = new ColorScheme();
|
|
77
|
+
cs.setup();
|
|
78
|
+
cs.onUnmount();
|
|
79
|
+
expect((mql.removeEventListener as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("change", expect.any(Function));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("onUnmount is safe to call without setup (covers false branch)", () => {
|
|
83
|
+
const cs = new ColorScheme();
|
|
84
|
+
expect(() => cs.onUnmount()).not.toThrow();
|
|
85
|
+
});
|
|
44
86
|
});
|
|
45
87
|
|
|
46
|
-
//
|
|
88
|
+
// ── Mouse ─────────────────────────────────────────────────────────────────────
|
|
47
89
|
|
|
48
|
-
describe("
|
|
90
|
+
describe("Mouse", () => {
|
|
49
91
|
it("starts at (0, 0)", () => {
|
|
50
|
-
const
|
|
92
|
+
const mouse = new Mouse();
|
|
93
|
+
const { x, y } = mouse.setup() as { x: () => number; y: () => number };
|
|
51
94
|
expect(x()).toBe(0);
|
|
52
95
|
expect(y()).toBe(0);
|
|
53
96
|
});
|
|
54
97
|
|
|
55
|
-
it("updates on mousemove
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
);
|
|
60
|
-
expect(x()).toBe(
|
|
61
|
-
expect(y()).toBe(
|
|
98
|
+
it("updates x and y on mousemove", () => {
|
|
99
|
+
const mouse = new Mouse();
|
|
100
|
+
const { x, y } = mouse.setup() as { x: () => number; y: () => number };
|
|
101
|
+
|
|
102
|
+
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 120, clientY: 80 }));
|
|
103
|
+
expect(x()).toBe(120);
|
|
104
|
+
expect(y()).toBe(80);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("removes listener on unmount", () => {
|
|
108
|
+
const remove = vi.spyOn(window, "removeEventListener");
|
|
109
|
+
const mouse = new Mouse();
|
|
110
|
+
mouse.setup();
|
|
111
|
+
mouse.onUnmount();
|
|
112
|
+
expect(remove).toHaveBeenCalledWith("mousemove", expect.any(Function));
|
|
62
113
|
});
|
|
63
114
|
});
|
|
64
115
|
|
|
65
|
-
//
|
|
116
|
+
// ── KeyCombo ──────────────────────────────────────────────────────────────────
|
|
66
117
|
|
|
67
|
-
describe("
|
|
68
|
-
it("starts as
|
|
69
|
-
const
|
|
118
|
+
describe("KeyCombo", () => {
|
|
119
|
+
it("starts as not pressed", () => {
|
|
120
|
+
const kc = new KeyCombo("ctrl+s");
|
|
121
|
+
const { pressed } = kc.setup() as { pressed: () => boolean };
|
|
70
122
|
expect(pressed()).toBe(false);
|
|
71
123
|
});
|
|
72
124
|
|
|
73
|
-
it("
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
125
|
+
it("sets pressed=true when matching combo is pressed", () => {
|
|
126
|
+
const kc = new KeyCombo("ctrl+s");
|
|
127
|
+
const { pressed } = kc.setup() as { pressed: () => boolean };
|
|
128
|
+
|
|
129
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, key: "s" }));
|
|
78
130
|
expect(pressed()).toBe(true);
|
|
79
131
|
});
|
|
80
132
|
|
|
81
|
-
it("resets
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
);
|
|
133
|
+
it("resets pressed on keyup", () => {
|
|
134
|
+
const kc = new KeyCombo("ctrl+s");
|
|
135
|
+
const { pressed } = kc.setup() as { pressed: () => boolean };
|
|
136
|
+
|
|
137
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, key: "s" }));
|
|
86
138
|
window.dispatchEvent(new KeyboardEvent("keyup"));
|
|
87
139
|
expect(pressed()).toBe(false);
|
|
88
140
|
});
|
|
141
|
+
|
|
142
|
+
it("does not set pressed for wrong key", () => {
|
|
143
|
+
const kc = new KeyCombo("ctrl+s");
|
|
144
|
+
const { pressed } = kc.setup() as { pressed: () => boolean };
|
|
145
|
+
|
|
146
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, key: "z" }));
|
|
147
|
+
expect(pressed()).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("removes listeners on unmount", () => {
|
|
151
|
+
const remove = vi.spyOn(window, "removeEventListener");
|
|
152
|
+
const kc = new KeyCombo("ctrl+s");
|
|
153
|
+
kc.setup();
|
|
154
|
+
kc.onUnmount();
|
|
155
|
+
expect(remove).toHaveBeenCalledWith("keydown", expect.any(Function));
|
|
156
|
+
expect(remove).toHaveBeenCalledWith("keyup", expect.any(Function));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("matches combo without ctrl modifier (ctrl branch: `: true`)", () => {
|
|
160
|
+
const kc = new KeyCombo("s");
|
|
161
|
+
const { pressed } = kc.setup() as { pressed: () => boolean };
|
|
162
|
+
// No ctrlKey needed — parts doesn't include "ctrl" so ctrl = true always
|
|
163
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "s" }));
|
|
164
|
+
expect(pressed()).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("matches combo with shift modifier (shift branch: `e.shiftKey`)", () => {
|
|
168
|
+
const kc = new KeyCombo("ctrl+shift+s");
|
|
169
|
+
const { pressed } = kc.setup() as { pressed: () => boolean };
|
|
170
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, shiftKey: true, key: "s" }));
|
|
171
|
+
expect(pressed()).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("does not match when shift is required but not pressed", () => {
|
|
175
|
+
const kc = new KeyCombo("ctrl+shift+s");
|
|
176
|
+
const { pressed } = kc.setup() as { pressed: () => boolean };
|
|
177
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, shiftKey: false, key: "s" }));
|
|
178
|
+
expect(pressed()).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("matches combo with alt modifier (alt branch: `e.altKey`)", () => {
|
|
182
|
+
const kc = new KeyCombo("ctrl+alt+s");
|
|
183
|
+
const { pressed } = kc.setup() as { pressed: () => boolean };
|
|
184
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, altKey: true, key: "s" }));
|
|
185
|
+
expect(pressed()).toBe(true);
|
|
186
|
+
});
|
|
89
187
|
});
|
|
90
188
|
|
|
91
|
-
//
|
|
189
|
+
// ── Idle ──────────────────────────────────────────────────────────────────────
|
|
92
190
|
|
|
93
|
-
describe("
|
|
191
|
+
describe("Idle", () => {
|
|
94
192
|
it("starts as not idle", () => {
|
|
95
193
|
vi.useFakeTimers();
|
|
96
|
-
const idle =
|
|
97
|
-
|
|
194
|
+
const idle = new Idle(500);
|
|
195
|
+
const { idle: isIdle } = idle.setup() as { idle: () => boolean };
|
|
196
|
+
expect(isIdle()).toBe(false);
|
|
98
197
|
vi.useRealTimers();
|
|
99
198
|
});
|
|
100
199
|
|
|
101
|
-
it("becomes idle after
|
|
200
|
+
it("becomes idle after timeout", () => {
|
|
102
201
|
vi.useFakeTimers();
|
|
103
|
-
const
|
|
202
|
+
const composable = new Idle(500);
|
|
203
|
+
const { idle: isIdle } = composable.setup() as { idle: () => boolean };
|
|
104
204
|
vi.advanceTimersByTime(500);
|
|
105
|
-
expect(
|
|
205
|
+
expect(isIdle()).toBe(true);
|
|
106
206
|
vi.useRealTimers();
|
|
107
207
|
});
|
|
108
208
|
|
|
109
|
-
it("resets idle on
|
|
209
|
+
it("resets idle timer on activity", () => {
|
|
110
210
|
vi.useFakeTimers();
|
|
111
|
-
const
|
|
112
|
-
|
|
211
|
+
const composable = new Idle(500);
|
|
212
|
+
const { idle: isIdle } = composable.setup() as { idle: () => boolean };
|
|
213
|
+
vi.advanceTimersByTime(400);
|
|
113
214
|
window.dispatchEvent(new MouseEvent("mousemove"));
|
|
114
|
-
vi.advanceTimersByTime(
|
|
115
|
-
expect(
|
|
215
|
+
vi.advanceTimersByTime(400);
|
|
216
|
+
expect(isIdle()).toBe(false);
|
|
116
217
|
vi.useRealTimers();
|
|
117
218
|
});
|
|
219
|
+
|
|
220
|
+
it("removes listeners on unmount", () => {
|
|
221
|
+
const remove = vi.spyOn(window, "removeEventListener");
|
|
222
|
+
const composable = new Idle(500);
|
|
223
|
+
composable.setup();
|
|
224
|
+
composable.onUnmount();
|
|
225
|
+
expect(remove).toHaveBeenCalled();
|
|
226
|
+
});
|
|
118
227
|
});
|
|
@@ -1,103 +1,261 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { signal } from "@praxisjs/core/internal";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import { WindowSize, ScrollPosition, ElementSize, Intersection, Focus } from "../dom";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
it("starts with current = null", () => {
|
|
10
|
-
const ref = createRef();
|
|
11
|
-
expect(ref.current).toBeNull();
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("can be assigned an element", () => {
|
|
15
|
-
const ref = createRef();
|
|
16
|
-
const el = document.createElement("div");
|
|
17
|
-
ref.current = el;
|
|
18
|
-
expect(ref.current).toBe(el);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
// ---------- useWindowSize ----------
|
|
8
|
+
// ── WindowSize ────────────────────────────────────────────────────────────────
|
|
23
9
|
|
|
24
|
-
describe("
|
|
25
|
-
it("
|
|
26
|
-
Object.defineProperty(window, "innerWidth", {
|
|
27
|
-
Object.defineProperty(window, "innerHeight", {
|
|
10
|
+
describe("WindowSize", () => {
|
|
11
|
+
it("reads initial window dimensions", () => {
|
|
12
|
+
Object.defineProperty(window, "innerWidth", { value: 1024, configurable: true });
|
|
13
|
+
Object.defineProperty(window, "innerHeight", { value: 768, configurable: true });
|
|
28
14
|
|
|
29
|
-
const
|
|
15
|
+
const ws = new WindowSize();
|
|
16
|
+
const { width, height } = ws.setup() as { width: () => number; height: () => number };
|
|
30
17
|
expect(width()).toBe(1024);
|
|
31
18
|
expect(height()).toBe(768);
|
|
32
19
|
});
|
|
33
20
|
|
|
34
21
|
it("updates on resize event", () => {
|
|
35
|
-
Object.defineProperty(window, "innerWidth", {
|
|
36
|
-
Object.defineProperty(window, "innerHeight", {
|
|
22
|
+
Object.defineProperty(window, "innerWidth", { value: 800, configurable: true });
|
|
23
|
+
Object.defineProperty(window, "innerHeight", { value: 600, configurable: true });
|
|
37
24
|
|
|
38
|
-
const
|
|
25
|
+
const ws = new WindowSize();
|
|
26
|
+
const { width, height } = ws.setup() as { width: () => number; height: () => number };
|
|
39
27
|
|
|
40
|
-
Object.defineProperty(window, "innerWidth", {
|
|
41
|
-
Object.defineProperty(window, "innerHeight", {
|
|
28
|
+
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
|
29
|
+
Object.defineProperty(window, "innerHeight", { value: 900, configurable: true });
|
|
42
30
|
window.dispatchEvent(new Event("resize"));
|
|
43
31
|
|
|
44
32
|
expect(width()).toBe(1280);
|
|
45
|
-
expect(height()).toBe(
|
|
33
|
+
expect(height()).toBe(900);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("removes listener on unmount", () => {
|
|
37
|
+
const remove = vi.spyOn(window, "removeEventListener");
|
|
38
|
+
const ws = new WindowSize();
|
|
39
|
+
ws.setup();
|
|
40
|
+
ws.onUnmount();
|
|
41
|
+
expect(remove).toHaveBeenCalledWith("resize", expect.any(Function));
|
|
46
42
|
});
|
|
47
43
|
});
|
|
48
44
|
|
|
49
|
-
//
|
|
45
|
+
// ── ScrollPosition ────────────────────────────────────────────────────────────
|
|
50
46
|
|
|
51
|
-
describe("
|
|
47
|
+
describe("ScrollPosition", () => {
|
|
52
48
|
it("starts at (0, 0)", () => {
|
|
53
|
-
const
|
|
49
|
+
const sp = new ScrollPosition(window);
|
|
50
|
+
const { x, y } = sp.setup() as { x: () => number; y: () => number };
|
|
54
51
|
expect(x()).toBe(0);
|
|
55
52
|
expect(y()).toBe(0);
|
|
56
53
|
});
|
|
57
54
|
|
|
58
|
-
it("updates on
|
|
55
|
+
it("updates on scroll event for window", () => {
|
|
56
|
+
const sp = new ScrollPosition(window);
|
|
57
|
+
const { x, y } = sp.setup() as { x: () => number; y: () => number };
|
|
58
|
+
|
|
59
|
+
Object.defineProperty(window, "scrollX", { value: 100, configurable: true });
|
|
60
|
+
Object.defineProperty(window, "scrollY", { value: 200, configurable: true });
|
|
61
|
+
window.dispatchEvent(new Event("scroll"));
|
|
62
|
+
|
|
63
|
+
expect(x()).toBe(100);
|
|
64
|
+
expect(y()).toBe(200);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("updates on scroll event for element", () => {
|
|
59
68
|
const el = document.createElement("div");
|
|
60
|
-
const
|
|
69
|
+
const sp = new ScrollPosition(el);
|
|
70
|
+
const { x, y } = sp.setup() as { x: () => number; y: () => number };
|
|
61
71
|
|
|
62
|
-
Object.defineProperty(el, "scrollLeft", {
|
|
63
|
-
Object.defineProperty(el, "scrollTop", {
|
|
72
|
+
Object.defineProperty(el, "scrollLeft", { value: 50, configurable: true });
|
|
73
|
+
Object.defineProperty(el, "scrollTop", { value: 75, configurable: true });
|
|
64
74
|
el.dispatchEvent(new Event("scroll"));
|
|
65
75
|
|
|
66
76
|
expect(x()).toBe(50);
|
|
67
|
-
expect(y()).toBe(
|
|
77
|
+
expect(y()).toBe(75);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("removes listener on unmount", () => {
|
|
81
|
+
const el = document.createElement("div");
|
|
82
|
+
const remove = vi.spyOn(el, "removeEventListener");
|
|
83
|
+
const sp = new ScrollPosition(el);
|
|
84
|
+
sp.setup();
|
|
85
|
+
sp.onUnmount();
|
|
86
|
+
expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── ElementSize ───────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("ElementSize", () => {
|
|
93
|
+
let observerCallback: ResizeObserverCallback;
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
vi.stubGlobal("ResizeObserver", class {
|
|
97
|
+
constructor(cb: ResizeObserverCallback) { observerCallback = cb; }
|
|
98
|
+
observe = vi.fn();
|
|
99
|
+
disconnect = vi.fn();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("starts at (0, 0) with no element", () => {
|
|
104
|
+
const ref = { current: null };
|
|
105
|
+
const es = new ElementSize(ref);
|
|
106
|
+
const { width, height } = es.setup() as { width: () => number; height: () => number };
|
|
107
|
+
expect(width()).toBe(0);
|
|
108
|
+
expect(height()).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("updates via ResizeObserver callback", () => {
|
|
112
|
+
const el = document.createElement("div");
|
|
113
|
+
const ref = { current: el };
|
|
114
|
+
const es = new ElementSize(ref);
|
|
115
|
+
const { width, height } = es.setup() as { width: () => number; height: () => number };
|
|
116
|
+
|
|
117
|
+
observerCallback(
|
|
118
|
+
[{ contentRect: { width: 300, height: 150 } } as ResizeObserverEntry],
|
|
119
|
+
{} as ResizeObserver,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(width()).toBe(300);
|
|
123
|
+
expect(height()).toBe(150);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("disconnects observer on unmount", () => {
|
|
127
|
+
const disconnect = vi.fn();
|
|
128
|
+
vi.stubGlobal("ResizeObserver", class {
|
|
129
|
+
constructor(cb: ResizeObserverCallback) { observerCallback = cb; }
|
|
130
|
+
observe = vi.fn();
|
|
131
|
+
disconnect = disconnect;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const ref = { current: null };
|
|
135
|
+
const es = new ElementSize(ref);
|
|
136
|
+
es.setup();
|
|
137
|
+
es.onUnmount();
|
|
138
|
+
expect(disconnect).toHaveBeenCalled();
|
|
68
139
|
});
|
|
69
140
|
});
|
|
70
141
|
|
|
71
|
-
//
|
|
142
|
+
// ── Intersection ──────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
describe("Intersection", () => {
|
|
145
|
+
let observerCallback: IntersectionObserverCallback;
|
|
72
146
|
|
|
73
|
-
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
vi.stubGlobal("IntersectionObserver", class {
|
|
149
|
+
constructor(cb: IntersectionObserverCallback) { observerCallback = cb; }
|
|
150
|
+
observe = vi.fn();
|
|
151
|
+
disconnect = vi.fn();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("starts as not visible", () => {
|
|
156
|
+
const ref = { current: null };
|
|
157
|
+
const int = new Intersection(ref);
|
|
158
|
+
const { visible } = int.setup() as { visible: () => boolean };
|
|
159
|
+
expect(visible()).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("becomes visible when intersecting", () => {
|
|
163
|
+
const el = document.createElement("div");
|
|
164
|
+
const ref = { current: el };
|
|
165
|
+
const int = new Intersection(ref);
|
|
166
|
+
const { visible } = int.setup() as { visible: () => boolean };
|
|
167
|
+
|
|
168
|
+
observerCallback(
|
|
169
|
+
[{ isIntersecting: true } as IntersectionObserverEntry],
|
|
170
|
+
{} as IntersectionObserver,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(visible()).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("disconnects on unmount", () => {
|
|
177
|
+
const disconnect = vi.fn();
|
|
178
|
+
vi.stubGlobal("IntersectionObserver", class {
|
|
179
|
+
constructor(cb: IntersectionObserverCallback) { observerCallback = cb; }
|
|
180
|
+
observe = vi.fn();
|
|
181
|
+
disconnect = disconnect;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const ref = { current: null };
|
|
185
|
+
const int = new Intersection(ref);
|
|
186
|
+
int.setup();
|
|
187
|
+
int.onUnmount();
|
|
188
|
+
expect(disconnect).toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Focus ─────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
describe("Focus", () => {
|
|
74
195
|
it("starts as not focused", () => {
|
|
75
196
|
const el = document.createElement("input");
|
|
76
|
-
document.body.appendChild(el);
|
|
77
197
|
const ref = { current: el };
|
|
78
|
-
const
|
|
198
|
+
const focus = new Focus(ref);
|
|
199
|
+
const { focused } = focus.setup() as { focused: () => boolean };
|
|
79
200
|
expect(focused()).toBe(false);
|
|
80
|
-
document.body.removeChild(el);
|
|
81
201
|
});
|
|
82
202
|
|
|
83
|
-
it("
|
|
203
|
+
it("sets focused=true on focus event", () => {
|
|
84
204
|
const el = document.createElement("input");
|
|
85
205
|
document.body.appendChild(el);
|
|
86
206
|
const ref = { current: el };
|
|
87
|
-
const
|
|
88
|
-
|
|
207
|
+
const focus = new Focus(ref);
|
|
208
|
+
const { focused } = focus.setup() as { focused: () => boolean };
|
|
209
|
+
|
|
210
|
+
el.dispatchEvent(new Event("focus"));
|
|
89
211
|
expect(focused()).toBe(true);
|
|
212
|
+
|
|
90
213
|
document.body.removeChild(el);
|
|
91
214
|
});
|
|
92
215
|
|
|
93
|
-
it("
|
|
216
|
+
it("sets focused=false on blur event", () => {
|
|
94
217
|
const el = document.createElement("input");
|
|
95
218
|
document.body.appendChild(el);
|
|
96
219
|
const ref = { current: el };
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
220
|
+
const focus = new Focus(ref);
|
|
221
|
+
const { focused } = focus.setup() as { focused: () => boolean };
|
|
222
|
+
|
|
223
|
+
el.dispatchEvent(new Event("focus"));
|
|
224
|
+
el.dispatchEvent(new Event("blur"));
|
|
100
225
|
expect(focused()).toBe(false);
|
|
226
|
+
|
|
101
227
|
document.body.removeChild(el);
|
|
102
228
|
});
|
|
229
|
+
|
|
230
|
+
it("does not throw when ref is null", () => {
|
|
231
|
+
const ref = { current: null };
|
|
232
|
+
const focus = new Focus(ref);
|
|
233
|
+
expect(() => focus.setup()).not.toThrow();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("removes focus/blur listeners when reactive ref changes (effect cleanup)", () => {
|
|
237
|
+
const el1 = document.createElement("input");
|
|
238
|
+
document.body.appendChild(el1);
|
|
239
|
+
|
|
240
|
+
// Make ref.current reactive so the effect tracks it and re-runs on change
|
|
241
|
+
const refSignal = signal<HTMLElement | null>(el1);
|
|
242
|
+
const ref = { get current() { return refSignal(); } };
|
|
243
|
+
|
|
244
|
+
const focus = new Focus(ref);
|
|
245
|
+
focus.setup();
|
|
246
|
+
|
|
247
|
+
const removeEl1 = vi.spyOn(el1, "removeEventListener");
|
|
248
|
+
|
|
249
|
+
// Changing the signal causes the effect to re-run, calling cleanup for el1
|
|
250
|
+
// This covers the cleanup function at dom.ts:134-135
|
|
251
|
+
const el2 = document.createElement("input");
|
|
252
|
+
document.body.appendChild(el2);
|
|
253
|
+
refSignal.set(el2);
|
|
254
|
+
|
|
255
|
+
expect(removeEl1).toHaveBeenCalledWith("focus", expect.any(Function));
|
|
256
|
+
expect(removeEl1).toHaveBeenCalledWith("blur", expect.any(Function));
|
|
257
|
+
|
|
258
|
+
document.body.removeChild(el1);
|
|
259
|
+
document.body.removeChild(el2);
|
|
260
|
+
});
|
|
103
261
|
});
|