@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,147 +1,387 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, it, expect, vi } from "vitest";
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
3
 
4
- import { useMediaQuery, useColorScheme, useMouse, useKeyCombo, useIdle } from "../browser";
4
+ import { MediaQuery, ColorScheme, Mouse, KeyCombo, Idle } from "../browser";
5
5
 
6
- // ---------- useMediaQuery ----------
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
+ }
7
15
 
8
- describe("useMediaQuery", () => {
9
- it("returns computed that reflects matchMedia result", () => {
10
- Object.defineProperty(window, "matchMedia", {
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
- });
16
+ // ── MediaQuery ────────────────────────────────────────────────────────────────
18
17
 
19
- const isDark = useMediaQuery("(prefers-color-scheme: dark)");
20
- expect(isDark()).toBe(true);
18
+ describe("MediaQuery", () => {
19
+ beforeEach(() => { vi.restoreAllMocks(); });
21
20
 
22
- const isLight = useMediaQuery("(prefers-color-scheme: light)");
23
- expect(isLight()).toBe(false);
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
+ });
27
+
28
+ it("updates matches when change event fires", () => {
29
+ let changeHandler: ((e: MediaQueryListEvent) => void) | undefined;
30
+ mockMatchMedia(false, (_: string, fn) => { changeHandler = fn; });
31
+
32
+ const mq = new MediaQuery("(min-width: 768px)");
33
+ const { matches } = mq.setup() as { matches: () => boolean };
34
+ expect(matches()).toBe(false);
35
+
36
+ changeHandler!({ matches: true } as MediaQueryListEvent);
37
+ expect(matches()).toBe(true);
38
+ });
39
+
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
+ });
47
+
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
- // ---------- useColorScheme ----------
54
+ // ── ColorScheme ───────────────────────────────────────────────────────────────
28
55
 
29
- describe("useColorScheme", () => {
30
- it("isDark and isLight are complementary", () => {
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
- const { isDark, isLight } = useColorScheme();
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
- // ---------- useMouse ----------
88
+ // ── Mouse ─────────────────────────────────────────────────────────────────────
47
89
 
48
- describe("useMouse", () => {
90
+ describe("Mouse", () => {
49
91
  it("starts at (0, 0)", () => {
50
- const { x, y } = useMouse();
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 event", () => {
56
- const { x, y } = useMouse();
57
- window.dispatchEvent(
58
- new MouseEvent("mousemove", { clientX: 100, clientY: 200 }),
59
- );
60
- expect(x()).toBe(100);
61
- expect(y()).toBe(200);
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
- // ---------- useKeyCombo ----------
116
+ // ── KeyCombo ──────────────────────────────────────────────────────────────────
66
117
 
67
- describe("useKeyCombo", () => {
68
- it("starts as false", () => {
69
- const pressed = useKeyCombo("ctrl+s");
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("becomes true on matching keydown", () => {
74
- const pressed = useKeyCombo("ctrl+s");
75
- window.dispatchEvent(
76
- new KeyboardEvent("keydown", { key: "s", ctrlKey: true }),
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 to false on keyup", () => {
82
- const pressed = useKeyCombo("ctrl+s");
83
- window.dispatchEvent(
84
- new KeyboardEvent("keydown", { key: "s", ctrlKey: true }),
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
- // ---------- useIdle ----------
189
+ // ── Idle ──────────────────────────────────────────────────────────────────────
92
190
 
93
- describe("useIdle", () => {
191
+ describe("Idle", () => {
94
192
  it("starts as not idle", () => {
95
193
  vi.useFakeTimers();
96
- const idle = useIdle(500);
97
- expect(idle()).toBe(false);
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 the timeout elapses", () => {
200
+ it("becomes idle after timeout", () => {
102
201
  vi.useFakeTimers();
103
- const idle = useIdle(500);
202
+ const composable = new Idle(500);
203
+ const { idle: isIdle } = composable.setup() as { idle: () => boolean };
104
204
  vi.advanceTimersByTime(500);
105
- expect(idle()).toBe(true);
205
+ expect(isIdle()).toBe(true);
106
206
  vi.useRealTimers();
107
207
  });
108
208
 
109
- it("resets idle on user activity", () => {
209
+ it("resets idle timer on activity", () => {
110
210
  vi.useFakeTimers();
111
- const idle = useIdle(500);
112
- vi.advanceTimersByTime(300);
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(300); // not enough since activity reset the timer
115
- expect(idle()).toBe(false);
215
+ vi.advanceTimersByTime(400);
216
+ expect(isIdle()).toBe(false);
217
+ vi.useRealTimers();
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
+ });
227
+
228
+ it("fires on scroll event", () => {
229
+ vi.useFakeTimers();
230
+ const composable = new Idle(500);
231
+ const { idle: isIdle } = composable.setup() as { idle: () => boolean };
232
+ vi.advanceTimersByTime(500);
233
+ expect(isIdle()).toBe(true);
234
+ window.dispatchEvent(new Event("scroll"));
235
+ expect(isIdle()).toBe(false);
236
+ vi.useRealTimers();
237
+ });
238
+
239
+ it("fires on click event", () => {
240
+ vi.useFakeTimers();
241
+ const composable = new Idle(500);
242
+ const { idle: isIdle } = composable.setup() as { idle: () => boolean };
243
+ vi.advanceTimersByTime(500);
244
+ expect(isIdle()).toBe(true);
245
+ window.dispatchEvent(new MouseEvent("click"));
246
+ expect(isIdle()).toBe(false);
247
+ vi.useRealTimers();
248
+ });
249
+
250
+ it("fires on keydown event", () => {
251
+ vi.useFakeTimers();
252
+ const composable = new Idle(500);
253
+ const { idle: isIdle } = composable.setup() as { idle: () => boolean };
254
+ vi.advanceTimersByTime(500);
255
+ expect(isIdle()).toBe(true);
256
+ window.dispatchEvent(new KeyboardEvent("keydown"));
257
+ expect(isIdle()).toBe(false);
258
+ vi.useRealTimers();
259
+ });
260
+
261
+ it("activity after becoming idle resets the timer", () => {
262
+ vi.useFakeTimers();
263
+ const composable = new Idle(500);
264
+ const { idle: isIdle } = composable.setup() as { idle: () => boolean };
265
+ vi.advanceTimersByTime(500);
266
+ expect(isIdle()).toBe(true);
267
+ window.dispatchEvent(new MouseEvent("mousemove"));
268
+ expect(isIdle()).toBe(false);
269
+ // Should become idle again after full timeout elapses
270
+ vi.advanceTimersByTime(500);
271
+ expect(isIdle()).toBe(true);
116
272
  vi.useRealTimers();
117
273
  });
118
274
  });
119
275
 
120
- // ---------- useMediaQuery — change event ----------
121
-
122
- describe("useMediaQuery change event", () => {
123
- it("updates when the media query match state changes", () => {
124
- let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
125
- Object.defineProperty(window, "matchMedia", {
126
- writable: true,
127
- value: vi.fn(() => ({
128
- matches: false,
129
- addEventListener: vi.fn((_event: string, handler: (e: MediaQueryListEvent) => void) => {
130
- changeHandler = handler;
131
- }),
132
- removeEventListener: vi.fn(),
133
- })),
134
- });
135
-
136
- const matches = useMediaQuery("(min-width: 768px)");
137
- expect(matches()).toBe(false);
276
+ // ── KeyCombo (additional) ─────────────────────────────────────────────────────
138
277
 
139
- // Simulate the media query becoming true
140
- changeHandler!({ matches: true } as MediaQueryListEvent);
141
- expect(matches()).toBe(true);
278
+ describe("KeyCombo (additional)", () => {
279
+ it("fires on meta+s keydown", () => {
280
+ const kc = new KeyCombo("meta+s");
281
+ const { pressed } = kc.setup() as { pressed: () => boolean };
282
+ window.dispatchEvent(new KeyboardEvent("keydown", { metaKey: true, key: "s" }));
283
+ expect(pressed()).toBe(true);
284
+ });
142
285
 
143
- // Simulate it reverting
144
- changeHandler!({ matches: false } as MediaQueryListEvent);
145
- expect(matches()).toBe(false);
286
+ it("modifier-only combo (ctrl+shift) never fires", () => {
287
+ const kc = new KeyCombo("ctrl+shift");
288
+ const { pressed } = kc.setup() as { pressed: () => boolean };
289
+ window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, shiftKey: true, key: "Control" }));
290
+ window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, shiftKey: true, key: "Shift" }));
291
+ window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, shiftKey: true, key: "a" }));
292
+ expect(pressed()).toBe(false);
293
+ });
294
+
295
+ it("whitespace in combo string (ctrl + s) is handled gracefully — fires on ctrl+s", () => {
296
+ // Parts are trimmed, so "ctrl + s" becomes ["ctrl", "s"] after trim
297
+ const kc = new KeyCombo("ctrl + s");
298
+ const { pressed } = kc.setup() as { pressed: () => boolean };
299
+ window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, key: "s" }));
300
+ expect(pressed()).toBe(true);
301
+ });
302
+
303
+ it("setup() called twice registers only one set of listeners", () => {
304
+ const add = vi.spyOn(window, "addEventListener");
305
+ const kc = new KeyCombo("ctrl+s");
306
+ kc.setup();
307
+ kc.setup();
308
+ const keydownCalls = add.mock.calls.filter((c) => c[0] === "keydown").length;
309
+ expect(keydownCalls).toBe(1);
310
+ });
311
+ });
312
+
313
+ // ── MediaQuery (additional) ───────────────────────────────────────────────────
314
+
315
+ describe("MediaQuery (additional)", () => {
316
+ beforeEach(() => { vi.restoreAllMocks(); });
317
+
318
+ it("setup() called twice registers only one listener", () => {
319
+ const mql = mockMatchMedia(false);
320
+ const mq = new MediaQuery("(min-width: 768px)");
321
+ mq.setup();
322
+ mq.setup();
323
+ expect((mql.addEventListener as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1);
324
+ });
325
+ });
326
+
327
+ // ── ColorScheme (additional) ──────────────────────────────────────────────────
328
+
329
+ describe("ColorScheme (additional)", () => {
330
+ beforeEach(() => { vi.restoreAllMocks(); });
331
+
332
+ it("setup() called twice registers only one listener", () => {
333
+ const mql = mockMatchMedia(false);
334
+ const cs = new ColorScheme();
335
+ cs.setup();
336
+ cs.setup();
337
+ expect((mql.addEventListener as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1);
338
+ });
339
+ });
340
+
341
+ // ── Mouse (additional) ────────────────────────────────────────────────────────
342
+
343
+ describe("Mouse (additional)", () => {
344
+ it("setup() called twice returns the same view object", () => {
345
+ const mouse = new Mouse();
346
+ const view1 = mouse.setup();
347
+ const view2 = mouse.setup();
348
+ expect(view1).toBe(view2);
349
+ });
350
+
351
+ it("setup() called twice registers only one mousemove listener", () => {
352
+ const add = vi.spyOn(window, "addEventListener");
353
+ const mouse = new Mouse();
354
+ mouse.setup();
355
+ mouse.setup();
356
+ const moveCalls = add.mock.calls.filter((c) => c[0] === "mousemove").length;
357
+ expect(moveCalls).toBe(1);
358
+ vi.restoreAllMocks();
359
+ });
360
+ });
361
+
362
+ // ── Idle (additional) ─────────────────────────────────────────────────────────
363
+
364
+ describe("Idle (additional)", () => {
365
+ it("setup() called twice returns the same view object", () => {
366
+ vi.useFakeTimers();
367
+ const idle = new Idle(500);
368
+ const view1 = idle.setup();
369
+ const view2 = idle.setup();
370
+ expect(view1).toBe(view2);
371
+ idle.onUnmount();
372
+ vi.useRealTimers();
373
+ });
374
+
375
+ it("setup() called twice registers activity listeners only once", () => {
376
+ vi.useFakeTimers();
377
+ const add = vi.spyOn(window, "addEventListener");
378
+ const idle = new Idle(500);
379
+ idle.setup();
380
+ idle.setup();
381
+ const moveCalls = add.mock.calls.filter((c) => c[0] === "mousemove").length;
382
+ expect(moveCalls).toBe(1);
383
+ idle.onUnmount();
384
+ vi.restoreAllMocks();
385
+ vi.useRealTimers();
146
386
  });
147
387
  });