@praxisjs/composables 0.1.5 → 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.
@@ -1,212 +1,261 @@
1
1
  // @vitest-environment jsdom
2
- import { beforeEach, afterEach, describe, it, expect, vi } from "vitest";
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
3
 
4
- import { createRef, useWindowSize, useScrollPosition, useFocus, useElementSize, useIntersection } from "../dom";
4
+ import { signal } from "@praxisjs/core/internal";
5
5
 
6
- // ---------- createRef ----------
6
+ import { WindowSize, ScrollPosition, ElementSize, Intersection, Focus } from "../dom";
7
7
 
8
- describe("createRef", () => {
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("useWindowSize", () => {
25
- it("returns the current window dimensions", () => {
26
- Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 });
27
- Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: 768 });
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 { width, height } = useWindowSize();
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", { writable: true, configurable: true, value: 800 });
36
- Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: 600 });
22
+ Object.defineProperty(window, "innerWidth", { value: 800, configurable: true });
23
+ Object.defineProperty(window, "innerHeight", { value: 600, configurable: true });
37
24
 
38
- const { width, height } = useWindowSize();
25
+ const ws = new WindowSize();
26
+ const { width, height } = ws.setup() as { width: () => number; height: () => number };
39
27
 
40
- Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1280 });
41
- Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: 720 });
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(720);
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
- // ---------- useScrollPosition ----------
45
+ // ── ScrollPosition ────────────────────────────────────────────────────────────
50
46
 
51
- describe("useScrollPosition", () => {
47
+ describe("ScrollPosition", () => {
52
48
  it("starts at (0, 0)", () => {
53
- const { x, y } = useScrollPosition(window);
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 element scroll", () => {
59
- const el = document.createElement("div");
60
- const { x, y } = useScrollPosition(el);
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 };
61
58
 
62
- Object.defineProperty(el, "scrollLeft", { configurable: true, value: 50 });
63
- Object.defineProperty(el, "scrollTop", { configurable: true, value: 80 });
64
- el.dispatchEvent(new Event("scroll"));
59
+ Object.defineProperty(window, "scrollX", { value: 100, configurable: true });
60
+ Object.defineProperty(window, "scrollY", { value: 200, configurable: true });
61
+ window.dispatchEvent(new Event("scroll"));
65
62
 
66
- expect(x()).toBe(50);
67
- expect(y()).toBe(80);
63
+ expect(x()).toBe(100);
64
+ expect(y()).toBe(200);
68
65
  });
69
- });
70
-
71
- // ---------- useFocus ----------
72
66
 
73
- describe("useFocus", () => {
74
- it("starts as not focused", () => {
75
- const el = document.createElement("input");
76
- document.body.appendChild(el);
77
- const ref = { current: el };
78
- const focused = useFocus(ref);
79
- expect(focused()).toBe(false);
80
- document.body.removeChild(el);
81
- });
67
+ it("updates on scroll event for element", () => {
68
+ const el = document.createElement("div");
69
+ const sp = new ScrollPosition(el);
70
+ const { x, y } = sp.setup() as { x: () => number; y: () => number };
82
71
 
83
- it("becomes true on focus event", () => {
84
- const el = document.createElement("input");
85
- document.body.appendChild(el);
86
- const ref = { current: el };
87
- const focused = useFocus(ref);
88
- el.dispatchEvent(new FocusEvent("focus"));
89
- expect(focused()).toBe(true);
90
- document.body.removeChild(el);
91
- });
72
+ Object.defineProperty(el, "scrollLeft", { value: 50, configurable: true });
73
+ Object.defineProperty(el, "scrollTop", { value: 75, configurable: true });
74
+ el.dispatchEvent(new Event("scroll"));
92
75
 
93
- it("becomes false on blur event", () => {
94
- const el = document.createElement("input");
95
- document.body.appendChild(el);
96
- const ref = { current: el };
97
- const focused = useFocus(ref);
98
- el.dispatchEvent(new FocusEvent("focus"));
99
- el.dispatchEvent(new FocusEvent("blur"));
100
- expect(focused()).toBe(false);
101
- document.body.removeChild(el);
76
+ expect(x()).toBe(50);
77
+ expect(y()).toBe(75);
102
78
  });
103
79
 
104
- it("does nothing when ref.current is null", () => {
105
- const ref: { current: HTMLElement | null } = { current: null };
106
- expect(() => useFocus(ref)).not.toThrow();
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));
107
87
  });
108
88
  });
109
89
 
110
- // ---------- useElementSize ----------
90
+ // ── ElementSize ───────────────────────────────────────────────────────────────
111
91
 
112
- describe("useElementSize", () => {
113
- let observerCallback: ResizeObserverCallback = () => {};
114
- let mockDisconnect: ReturnType<typeof vi.fn>;
92
+ describe("ElementSize", () => {
93
+ let observerCallback: ResizeObserverCallback;
115
94
 
116
95
  beforeEach(() => {
117
- mockDisconnect = vi.fn();
118
- observerCallback = () => {};
119
-
120
- // Class-based mock so `new ResizeObserver(cb)` works correctly
121
- globalThis.ResizeObserver = class {
96
+ vi.stubGlobal("ResizeObserver", class {
122
97
  constructor(cb: ResizeObserverCallback) { observerCallback = cb; }
123
98
  observe = vi.fn();
124
- disconnect = mockDisconnect;
125
- } as unknown as typeof ResizeObserver;
126
- });
127
-
128
- afterEach(() => {
129
- // @ts-expect-error – removing test stub
130
- delete globalThis.ResizeObserver;
99
+ disconnect = vi.fn();
100
+ });
131
101
  });
132
102
 
133
- it("starts at (0, 0) when ref.current is null", () => {
134
- const ref: { current: HTMLElement | null } = { current: null };
135
- const { width, height } = useElementSize(ref);
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 };
136
107
  expect(width()).toBe(0);
137
108
  expect(height()).toBe(0);
138
109
  });
139
110
 
140
- it("calls stop() to disconnect the ResizeObserver", () => {
111
+ it("updates via ResizeObserver callback", () => {
141
112
  const el = document.createElement("div");
142
113
  const ref = { current: el };
143
- const { stop } = useElementSize(ref);
144
- stop();
145
- expect(mockDisconnect).toHaveBeenCalled();
146
- });
147
-
148
- it("updates width and height when ResizeObserver fires", () => {
149
- const el = document.createElement("div");
150
- const ref = { current: el };
151
- const { width, height } = useElementSize(ref);
114
+ const es = new ElementSize(ref);
115
+ const { width, height } = es.setup() as { width: () => number; height: () => number };
152
116
 
153
117
  observerCallback(
154
- [{ contentRect: { width: 300, height: 200 } } as ResizeObserverEntry],
155
- null as unknown as ResizeObserver,
118
+ [{ contentRect: { width: 300, height: 150 } } as ResizeObserverEntry],
119
+ {} as ResizeObserver,
156
120
  );
157
121
 
158
122
  expect(width()).toBe(300);
159
- expect(height()).toBe(200);
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();
160
139
  });
161
140
  });
162
141
 
163
- // ---------- useIntersection ----------
142
+ // ── Intersection ──────────────────────────────────────────────────────────────
164
143
 
165
- describe("useIntersection", () => {
166
- let intersectionCallback: IntersectionObserverCallback = () => {};
167
- let capturedOptions: IntersectionObserverInit | undefined;
144
+ describe("Intersection", () => {
145
+ let observerCallback: IntersectionObserverCallback;
168
146
 
169
147
  beforeEach(() => {
170
- intersectionCallback = () => {};
171
- capturedOptions = undefined;
172
-
173
- globalThis.IntersectionObserver = class {
174
- constructor(cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) {
175
- intersectionCallback = cb;
176
- capturedOptions = opts;
177
- }
148
+ vi.stubGlobal("IntersectionObserver", class {
149
+ constructor(cb: IntersectionObserverCallback) { observerCallback = cb; }
178
150
  observe = vi.fn();
179
151
  disconnect = vi.fn();
180
- } as unknown as typeof IntersectionObserver;
152
+ });
181
153
  });
182
154
 
183
- afterEach(() => {
184
- // @ts-expect-error – removing test stub
185
- delete globalThis.IntersectionObserver;
186
- });
187
-
188
- it("starts as false when ref.current is null", () => {
189
- const ref: { current: HTMLElement | null } = { current: null };
190
- const visible = useIntersection(ref);
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 };
191
159
  expect(visible()).toBe(false);
192
160
  });
193
161
 
194
- it("becomes true when IntersectionObserver fires with isIntersecting=true", () => {
162
+ it("becomes visible when intersecting", () => {
195
163
  const el = document.createElement("div");
196
164
  const ref = { current: el };
197
- const visible = useIntersection(ref);
165
+ const int = new Intersection(ref);
166
+ const { visible } = int.setup() as { visible: () => boolean };
198
167
 
199
- intersectionCallback(
168
+ observerCallback(
200
169
  [{ isIntersecting: true } as IntersectionObserverEntry],
201
- null as unknown as IntersectionObserver,
170
+ {} as IntersectionObserver,
202
171
  );
172
+
203
173
  expect(visible()).toBe(true);
204
174
  });
205
175
 
206
- it("passes options to IntersectionObserver", () => {
207
- const ref: { current: HTMLElement | null } = { current: null };
208
- const options = { threshold: 0.5 };
209
- useIntersection(ref, options);
210
- expect(capturedOptions).toEqual(options);
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", () => {
195
+ it("starts as not focused", () => {
196
+ const el = document.createElement("input");
197
+ const ref = { current: el };
198
+ const focus = new Focus(ref);
199
+ const { focused } = focus.setup() as { focused: () => boolean };
200
+ expect(focused()).toBe(false);
201
+ });
202
+
203
+ it("sets focused=true on focus event", () => {
204
+ const el = document.createElement("input");
205
+ document.body.appendChild(el);
206
+ const ref = { current: el };
207
+ const focus = new Focus(ref);
208
+ const { focused } = focus.setup() as { focused: () => boolean };
209
+
210
+ el.dispatchEvent(new Event("focus"));
211
+ expect(focused()).toBe(true);
212
+
213
+ document.body.removeChild(el);
214
+ });
215
+
216
+ it("sets focused=false on blur event", () => {
217
+ const el = document.createElement("input");
218
+ document.body.appendChild(el);
219
+ const ref = { current: el };
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"));
225
+ expect(focused()).toBe(false);
226
+
227
+ document.body.removeChild(el);
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);
211
260
  });
212
261
  });