@praxisjs/composables 0.1.5 → 1.0.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.
@@ -1,212 +1,405 @@
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
- });
8
+ // ── WindowSize ────────────────────────────────────────────────────────────────
21
9
 
22
- // ---------- useWindowSize ----------
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 });
23
14
 
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 });
28
-
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", () => {
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 { x, y } = useScrollPosition(el);
69
+ const sp = new ScrollPosition(el);
70
+ const { x, y } = sp.setup() as { x: () => number; y: () => number };
61
71
 
62
- Object.defineProperty(el, "scrollLeft", { configurable: true, value: 50 });
63
- Object.defineProperty(el, "scrollTop", { configurable: true, value: 80 });
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(80);
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();
139
+ });
140
+ });
141
+
142
+ // ── Intersection ──────────────────────────────────────────────────────────────
143
+
144
+ describe("Intersection", () => {
145
+ let observerCallback: IntersectionObserverCallback;
146
+
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
+ // ── ElementSize (additional) ──────────────────────────────────────────────────
193
+
194
+ describe("ElementSize (additional)", () => {
195
+ it("ref.current changes to a new element: old observer is disconnected and new one started", () => {
196
+ const disconnectFn = vi.fn();
197
+ const observeFn = vi.fn();
198
+
199
+ vi.stubGlobal("ResizeObserver", class {
200
+ observe = observeFn;
201
+ disconnect = disconnectFn;
202
+ });
203
+
204
+ const el1 = document.createElement("div");
205
+ const ref = { current: el1 as HTMLElement | null };
206
+ const es = new ElementSize(ref);
207
+ es.setup();
208
+
209
+ // Disconnect old observer and create new one for el2
210
+ es.onUnmount();
211
+ expect(disconnectFn).toHaveBeenCalled();
212
+
213
+ const el2 = document.createElement("div");
214
+ ref.current = el2;
215
+ const es2 = new ElementSize(ref);
216
+ es2.setup();
217
+ expect(observeFn).toHaveBeenCalledWith(el2);
218
+ });
219
+ });
220
+
221
+ // ── Intersection (additional) ─────────────────────────────────────────────────
222
+
223
+ describe("Intersection (additional)", () => {
224
+ let observerCallback: IntersectionObserverCallback;
225
+
226
+ beforeEach(() => {
227
+ vi.stubGlobal("IntersectionObserver", class {
228
+ constructor(cb: IntersectionObserverCallback) { observerCallback = cb; }
229
+ observe = vi.fn();
230
+ disconnect = vi.fn();
231
+ });
232
+ });
233
+
234
+ it("visibility toggles false after being true", () => {
235
+ const el = document.createElement("div");
236
+ const ref = { current: el };
237
+ const int = new Intersection(ref);
238
+ const { visible } = int.setup() as { visible: () => boolean };
239
+
240
+ observerCallback(
241
+ [{ isIntersecting: true } as IntersectionObserverEntry],
242
+ {} as IntersectionObserver,
243
+ );
244
+ expect(visible()).toBe(true);
245
+
246
+ observerCallback(
247
+ [{ isIntersecting: false } as IntersectionObserverEntry],
248
+ {} as IntersectionObserver,
249
+ );
250
+ expect(visible()).toBe(false);
251
+ });
252
+ });
253
+
254
+ // ── ScrollPosition (additional) ───────────────────────────────────────────────
255
+
256
+ describe("ScrollPosition (additional)", () => {
257
+ it("setup() called twice registers only one scroll listener", () => {
258
+ const el = document.createElement("div");
259
+ const add = vi.spyOn(el, "addEventListener");
260
+ const sp = new ScrollPosition(el);
261
+ sp.setup();
262
+ sp.setup();
263
+ const scrollCalls = add.mock.calls.filter((c) => c[0] === "scroll").length;
264
+ expect(scrollCalls).toBe(1);
68
265
  });
69
266
  });
70
267
 
71
- // ---------- useFocus ----------
268
+ // ── Focus ─────────────────────────────────────────────────────────────────────
72
269
 
73
- describe("useFocus", () => {
270
+ describe("Focus", () => {
74
271
  it("starts as not focused", () => {
75
272
  const el = document.createElement("input");
76
- document.body.appendChild(el);
77
273
  const ref = { current: el };
78
- const focused = useFocus(ref);
274
+ const focus = new Focus(ref);
275
+ const { focused } = focus.setup() as { focused: () => boolean };
79
276
  expect(focused()).toBe(false);
80
- document.body.removeChild(el);
81
277
  });
82
278
 
83
- it("becomes true on focus event", () => {
279
+ it("sets focused=true on focus event", () => {
84
280
  const el = document.createElement("input");
85
281
  document.body.appendChild(el);
86
282
  const ref = { current: el };
87
- const focused = useFocus(ref);
88
- el.dispatchEvent(new FocusEvent("focus"));
283
+ const focus = new Focus(ref);
284
+ const { focused } = focus.setup() as { focused: () => boolean };
285
+
286
+ el.dispatchEvent(new Event("focus"));
89
287
  expect(focused()).toBe(true);
288
+
90
289
  document.body.removeChild(el);
91
290
  });
92
291
 
93
- it("becomes false on blur event", () => {
292
+ it("sets focused=false on blur event", () => {
94
293
  const el = document.createElement("input");
95
294
  document.body.appendChild(el);
96
295
  const ref = { current: el };
97
- const focused = useFocus(ref);
98
- el.dispatchEvent(new FocusEvent("focus"));
99
- el.dispatchEvent(new FocusEvent("blur"));
296
+ const focus = new Focus(ref);
297
+ const { focused } = focus.setup() as { focused: () => boolean };
298
+
299
+ el.dispatchEvent(new Event("focus"));
300
+ el.dispatchEvent(new Event("blur"));
100
301
  expect(focused()).toBe(false);
302
+
101
303
  document.body.removeChild(el);
102
304
  });
103
305
 
104
- it("does nothing when ref.current is null", () => {
105
- const ref: { current: HTMLElement | null } = { current: null };
106
- expect(() => useFocus(ref)).not.toThrow();
306
+ it("does not throw when ref is null", () => {
307
+ const ref = { current: null };
308
+ const focus = new Focus(ref);
309
+ expect(() => focus.setup()).not.toThrow();
107
310
  });
108
- });
109
311
 
110
- // ---------- useElementSize ----------
312
+ it("removes focus/blur listeners when reactive ref changes (effect cleanup)", () => {
313
+ const el1 = document.createElement("input");
314
+ document.body.appendChild(el1);
111
315
 
112
- describe("useElementSize", () => {
113
- let observerCallback: ResizeObserverCallback = () => {};
114
- let mockDisconnect: ReturnType<typeof vi.fn>;
316
+ // Make ref.current reactive so the effect tracks it and re-runs on change
317
+ const refSignal = signal<HTMLElement | null>(el1);
318
+ const ref = { get current() { return refSignal(); } };
115
319
 
116
- beforeEach(() => {
117
- mockDisconnect = vi.fn();
118
- observerCallback = () => {};
320
+ const focus = new Focus(ref);
321
+ focus.setup();
119
322
 
120
- // Class-based mock so `new ResizeObserver(cb)` works correctly
121
- globalThis.ResizeObserver = class {
122
- constructor(cb: ResizeObserverCallback) { observerCallback = cb; }
123
- observe = vi.fn();
124
- disconnect = mockDisconnect;
125
- } as unknown as typeof ResizeObserver;
126
- });
323
+ const removeEl1 = vi.spyOn(el1, "removeEventListener");
127
324
 
128
- afterEach(() => {
129
- // @ts-expect-error – removing test stub
130
- delete globalThis.ResizeObserver;
131
- });
325
+ // Changing the signal causes the effect to re-run, calling cleanup for el1
326
+ // This covers the cleanup function at dom.ts:134-135
327
+ const el2 = document.createElement("input");
328
+ document.body.appendChild(el2);
329
+ refSignal.set(el2);
132
330
 
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);
136
- expect(width()).toBe(0);
137
- expect(height()).toBe(0);
138
- });
331
+ expect(removeEl1).toHaveBeenCalledWith("focus", expect.any(Function));
332
+ expect(removeEl1).toHaveBeenCalledWith("blur", expect.any(Function));
139
333
 
140
- it("calls stop() to disconnect the ResizeObserver", () => {
141
- const el = document.createElement("div");
142
- const ref = { current: el };
143
- const { stop } = useElementSize(ref);
144
- stop();
145
- expect(mockDisconnect).toHaveBeenCalled();
334
+ document.body.removeChild(el1);
335
+ document.body.removeChild(el2);
146
336
  });
147
337
 
148
- it("updates width and height when ResizeObserver fires", () => {
149
- const el = document.createElement("div");
338
+ it("setup() called twice registers only one listener pair", () => {
339
+ const el = document.createElement("input");
340
+ document.body.appendChild(el);
341
+ const add = vi.spyOn(el, "addEventListener");
150
342
  const ref = { current: el };
151
- const { width, height } = useElementSize(ref);
152
-
153
- observerCallback(
154
- [{ contentRect: { width: 300, height: 200 } } as ResizeObserverEntry],
155
- null as unknown as ResizeObserver,
156
- );
157
-
158
- expect(width()).toBe(300);
159
- expect(height()).toBe(200);
343
+ const focus = new Focus(ref);
344
+ focus.setup();
345
+ focus.setup();
346
+ const focusCalls = add.mock.calls.filter((c) => c[0] === "focus").length;
347
+ expect(focusCalls).toBe(1);
348
+ document.body.removeChild(el);
160
349
  });
161
350
  });
162
351
 
163
- // ---------- useIntersection ----------
164
-
165
- describe("useIntersection", () => {
166
- let intersectionCallback: IntersectionObserverCallback = () => {};
167
- let capturedOptions: IntersectionObserverInit | undefined;
352
+ // ── WindowSize (additional) ───────────────────────────────────────────────────
168
353
 
169
- beforeEach(() => {
170
- intersectionCallback = () => {};
171
- capturedOptions = undefined;
172
-
173
- globalThis.IntersectionObserver = class {
174
- constructor(cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) {
175
- intersectionCallback = cb;
176
- capturedOptions = opts;
177
- }
178
- observe = vi.fn();
179
- disconnect = vi.fn();
180
- } as unknown as typeof IntersectionObserver;
354
+ describe("WindowSize (additional)", () => {
355
+ it("setup() called twice returns the same view object", () => {
356
+ const ws = new WindowSize();
357
+ const view1 = ws.setup();
358
+ const view2 = ws.setup();
359
+ expect(view1).toBe(view2);
360
+ ws.onUnmount();
181
361
  });
182
362
 
183
- afterEach(() => {
184
- // @ts-expect-error – removing test stub
185
- delete globalThis.IntersectionObserver;
363
+ it("setup() called twice registers only one resize listener", () => {
364
+ const add = vi.spyOn(window, "addEventListener");
365
+ const ws = new WindowSize();
366
+ ws.setup();
367
+ ws.setup();
368
+ const resizeCalls = add.mock.calls.filter((c) => c[0] === "resize").length;
369
+ expect(resizeCalls).toBe(1);
370
+ ws.onUnmount();
371
+ vi.restoreAllMocks();
186
372
  });
373
+ });
187
374
 
188
- it("starts as false when ref.current is null", () => {
189
- const ref: { current: HTMLElement | null } = { current: null };
190
- const visible = useIntersection(ref);
191
- expect(visible()).toBe(false);
192
- });
375
+ // ── ElementSize (cache) ───────────────────────────────────────────────────────
193
376
 
194
- it("becomes true when IntersectionObserver fires with isIntersecting=true", () => {
377
+ describe("ElementSize (cache)", () => {
378
+ it("setup() called twice returns the same view object", () => {
195
379
  const el = document.createElement("div");
380
+ document.body.appendChild(el);
196
381
  const ref = { current: el };
197
- const visible = useIntersection(ref);
198
-
199
- intersectionCallback(
200
- [{ isIntersecting: true } as IntersectionObserverEntry],
201
- null as unknown as IntersectionObserver,
202
- );
203
- expect(visible()).toBe(true);
382
+ const es = new ElementSize(ref);
383
+ const view1 = es.setup();
384
+ const view2 = es.setup();
385
+ expect(view1).toBe(view2);
386
+ es.onUnmount();
387
+ document.body.removeChild(el);
204
388
  });
389
+ });
390
+
391
+ // ── Intersection (cache) ──────────────────────────────────────────────────────
205
392
 
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);
393
+ describe("Intersection (cache)", () => {
394
+ it("setup() called twice returns the same view object", () => {
395
+ const el = document.createElement("div");
396
+ document.body.appendChild(el);
397
+ const ref = { current: el };
398
+ const inter = new Intersection(ref);
399
+ const view1 = inter.setup();
400
+ const view2 = inter.setup();
401
+ expect(view1).toBe(view2);
402
+ inter.onUnmount();
403
+ document.body.removeChild(el);
211
404
  });
212
405
  });