@musecat/functionkit 1.0.2 → 1.1.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.
Files changed (72) hide show
  1. package/.agents/references/hooks/useIntersectionObserver.md +4 -0
  2. package/.agents/references/hooks/useInterval.md +4 -0
  3. package/.agents/references/hooks/usePreservedCallback.md +4 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.md +22 -0
  6. package/.github/PULL_REQUEST_TEMPLATE.md +22 -0
  7. package/.github/workflows/ci.yml +20 -0
  8. package/.local/state/gh/device-id +1 -0
  9. package/.prettierrc +8 -0
  10. package/AGENTS.md +14 -0
  11. package/CODE_OF_CONDUCT.md +55 -0
  12. package/CONTRIBUTING.md +39 -0
  13. package/biome.json +25 -0
  14. package/index.ts +1 -1
  15. package/package.json +21 -2
  16. package/packages/components/ScrolltoTop.tsx +1 -1
  17. package/packages/components/ViewportPortal.tsx +1 -2
  18. package/packages/cookie/cookie.shared.ts +7 -33
  19. package/packages/cookie/cookieNames.shared.ts +9 -0
  20. package/packages/datetime/dateTime.client.ts +1 -3
  21. package/packages/datetime/dateTime.server.ts +1 -3
  22. package/packages/datetime/dateTime.shared.ts +9 -47
  23. package/packages/hooks/useCheckInvisible.ts +1 -1
  24. package/packages/hooks/useClientDateTime.ts +3 -16
  25. package/packages/hooks/useDebounce.ts +2 -7
  26. package/packages/hooks/useGeolocation.ts +1 -5
  27. package/packages/hooks/useInterval.ts +1 -2
  28. package/packages/hooks/useKeyboardListNavigation.ts +10 -38
  29. package/packages/hooks/useLongPress.ts +13 -24
  30. package/packages/hooks/useRelativeDateTime.ts +1 -4
  31. package/packages/hooks/useViewportHeight.ts +1 -2
  32. package/packages/hooks/useViewportMatch.ts +0 -4
  33. package/packages/utils/buildContext.tsx +2 -3
  34. package/packages/utils/floatingMotion.ts +14 -20
  35. package/packages/utils/subscribeKeyboardHeight.ts +3 -0
  36. package/tests/components/ScrolltoTop.test.tsx +1 -1
  37. package/tests/components/SwitchCase.test.tsx +1 -2
  38. package/tests/components/ViewportPortal.test.tsx +10 -6
  39. package/tests/cookie/cookie.test.ts +47 -11
  40. package/tests/datetime/datetime.test.ts +257 -46
  41. package/tests/hooks/useAvoidKeyboard.test.ts +32 -1
  42. package/tests/hooks/useCheckInvisible.test.ts +36 -5
  43. package/tests/hooks/useCheckScroll.test.ts +2 -2
  44. package/tests/hooks/useClientDateTime.test.ts +2 -4
  45. package/tests/hooks/useDebounce.test.ts +30 -5
  46. package/tests/hooks/useDebouncedCallback.test.ts +41 -12
  47. package/tests/hooks/useDoubleClick.test.ts +58 -7
  48. package/tests/hooks/useGeolocation.test.ts +155 -4
  49. package/tests/hooks/useHasMounted.test.ts +1 -1
  50. package/tests/hooks/useIntersectionObserver.test.ts +70 -5
  51. package/tests/hooks/useInterval.test.ts +28 -3
  52. package/tests/hooks/useKeyboardHeight.test.ts +1 -1
  53. package/tests/hooks/useKeyboardListNavigation.test.ts +340 -18
  54. package/tests/hooks/useLongPress.test.ts +177 -15
  55. package/tests/hooks/usePreservedCallback.test.ts +7 -9
  56. package/tests/hooks/usePreservedReference.test.ts +19 -12
  57. package/tests/hooks/useRefEffect.test.ts +43 -6
  58. package/tests/hooks/useRelativeDateTime.test.ts +8 -6
  59. package/tests/hooks/useTimeout.test.ts +43 -5
  60. package/tests/hooks/useToggleState.test.ts +14 -6
  61. package/tests/hooks/useViewportHeight.test.ts +24 -2
  62. package/tests/hooks/useViewportMatch.test.ts +1 -1
  63. package/tests/utils/browserStorage.test.ts +68 -8
  64. package/tests/utils/buildContext.test.tsx +1 -2
  65. package/tests/utils/checkDevice.test.ts +62 -6
  66. package/tests/utils/clipboardShare.test.ts +79 -5
  67. package/tests/utils/floatingMotion.test.ts +60 -9
  68. package/tests/utils/keyboardTarget.test.ts +42 -1
  69. package/tests/utils/mergeRefs.test.ts +33 -1
  70. package/tests/utils/seen.test.ts +16 -7
  71. package/tests/utils/subscribeKeyboardHeight.test.ts +124 -2
  72. package/vitest.config.ts +7 -1
@@ -1,19 +1,15 @@
1
- import { describe, test, expect, vi } from "vitest";
2
- import { renderHook, act } from "@testing-library/react";
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, test, vi } from "vitest";
3
3
  import { useKeyboardListNavigation } from "@/packages/hooks/useKeyboardListNavigation";
4
4
 
5
5
  describe("useKeyboardListNavigation", () => {
6
6
  test("initializes with activeIndex -1", () => {
7
- const { result } = renderHook(() =>
8
- useKeyboardListNavigation({ itemCount: 3 }),
9
- );
7
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
10
8
  expect(result.current.activeIndex).toBe(-1);
11
9
  });
12
10
 
13
11
  test("handleListKeyDown with ArrowDown moves to first item when none active", () => {
14
- const { result } = renderHook(() =>
15
- useKeyboardListNavigation({ itemCount: 3 }),
16
- );
12
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
17
13
 
18
14
  const container = document.createElement("div");
19
15
  const items = [0, 1, 2].map(() => {
@@ -32,14 +28,14 @@ describe("useKeyboardListNavigation", () => {
32
28
  target: container,
33
29
  } as unknown as React.KeyboardEvent<HTMLElement>;
34
30
 
35
- act(() => { result.current.handleListKeyDown(event); });
31
+ act(() => {
32
+ result.current.handleListKeyDown(event);
33
+ });
36
34
  expect(result.current.activeIndex).toBe(0);
37
35
  });
38
36
 
39
37
  test("handleListKeyDown with ArrowUp moves to previous item", () => {
40
- const { result } = renderHook(() =>
41
- useKeyboardListNavigation({ itemCount: 3 }),
42
- );
38
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
43
39
 
44
40
  const container = document.createElement("div");
45
41
  const items = [0, 1, 2].map(() => {
@@ -50,7 +46,9 @@ describe("useKeyboardListNavigation", () => {
50
46
  return el;
51
47
  });
52
48
  items.forEach((el, i) => result.current.setItemRef(i, el));
53
- act(() => { result.current.setActiveIndex(1); });
49
+ act(() => {
50
+ result.current.setActiveIndex(1);
51
+ });
54
52
 
55
53
  const event = {
56
54
  key: "ArrowUp",
@@ -59,14 +57,336 @@ describe("useKeyboardListNavigation", () => {
59
57
  target: container,
60
58
  } as unknown as React.KeyboardEvent<HTMLElement>;
61
59
 
62
- act(() => { result.current.handleListKeyDown(event); });
60
+ act(() => {
61
+ result.current.handleListKeyDown(event);
62
+ });
63
63
  expect(result.current.activeIndex).toBe(0);
64
64
  });
65
65
 
66
+ test("ArrowDown moves to next item when one is active", () => {
67
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
68
+
69
+ const container = document.createElement("div");
70
+ const items = [0, 1, 2].map(() => {
71
+ const el = document.createElement("div");
72
+ el.setAttribute("tabindex", "-1");
73
+ el.scrollIntoView = vi.fn();
74
+ container.appendChild(el);
75
+ return el;
76
+ });
77
+ items.forEach((el, i) => result.current.setItemRef(i, el));
78
+ act(() => {
79
+ result.current.setActiveIndex(0);
80
+ });
81
+
82
+ const event = {
83
+ key: "ArrowDown",
84
+ defaultPrevented: false,
85
+ preventDefault: vi.fn(),
86
+ target: container,
87
+ } as unknown as React.KeyboardEvent<HTMLElement>;
88
+
89
+ act(() => {
90
+ result.current.handleListKeyDown(event);
91
+ });
92
+ expect(result.current.activeIndex).toBe(1);
93
+ });
94
+
95
+ test("ArrowUp with no active index goes to last item", () => {
96
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
97
+
98
+ const container = document.createElement("div");
99
+ const items = [0, 1, 2].map(() => {
100
+ const el = document.createElement("div");
101
+ el.setAttribute("tabindex", "-1");
102
+ el.scrollIntoView = vi.fn();
103
+ container.appendChild(el);
104
+ return el;
105
+ });
106
+ items.forEach((el, i) => result.current.setItemRef(i, el));
107
+
108
+ const event = {
109
+ key: "ArrowUp",
110
+ defaultPrevented: false,
111
+ preventDefault: vi.fn(),
112
+ target: container,
113
+ } as unknown as React.KeyboardEvent<HTMLElement>;
114
+
115
+ act(() => {
116
+ result.current.handleListKeyDown(event);
117
+ });
118
+ expect(result.current.activeIndex).toBe(2);
119
+ });
120
+
121
+ test("unrecognized key passes preventDefault and falls through", () => {
122
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
123
+
124
+ const container = document.createElement("div");
125
+ const input = document.createElement("input");
126
+ container.appendChild(input);
127
+
128
+ const event = {
129
+ key: "Tab",
130
+ defaultPrevented: false,
131
+ preventDefault: vi.fn(),
132
+ target: container,
133
+ } as unknown as React.KeyboardEvent<HTMLElement>;
134
+
135
+ act(() => {
136
+ result.current.handleListKeyDown(event);
137
+ });
138
+ expect(event.preventDefault).not.toHaveBeenCalled();
139
+ });
140
+
141
+ test("clickActiveItem returns false when no active index", () => {
142
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
143
+ expect(result.current.clickActiveItem()).toBe(false);
144
+ });
145
+
146
+ test("focusBoundaryItem returns false when itemCount is 0", () => {
147
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 0 }));
148
+ expect(result.current.focusBoundaryItem("ArrowDown")).toBe(false);
149
+ });
150
+
151
+ test("focusBoundaryItem returns false for unmatched key", () => {
152
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
153
+ expect(result.current.focusBoundaryItem("Tab")).toBe(false);
154
+ });
155
+
156
+ test("focusBoundaryItem returns false when target ref is missing", () => {
157
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
158
+ expect(result.current.focusBoundaryItem("ArrowDown")).toBe(false);
159
+ });
160
+
161
+ test("focusBoundaryItem returns true for Home through moveActiveItem fallthrough", () => {
162
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
163
+ const item0 = document.createElement("div");
164
+ item0.setAttribute("tabindex", "-1");
165
+ item0.focus = vi.fn();
166
+ item0.scrollIntoView = vi.fn();
167
+ result.current.setItemRef(0, item0);
168
+
169
+ const event = {
170
+ key: "Home",
171
+ defaultPrevented: false,
172
+ preventDefault: vi.fn(),
173
+ target: document.createElement("div"),
174
+ } as unknown as React.KeyboardEvent<HTMLElement>;
175
+
176
+ act(() => {
177
+ result.current.handleListKeyDown(event);
178
+ });
179
+ expect(result.current.activeIndex).toBe(0);
180
+ });
181
+
182
+ test("handleListKeyDown with null target returns early", () => {
183
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
184
+
185
+ const event = {
186
+ key: "ArrowDown",
187
+ defaultPrevented: false,
188
+ preventDefault: vi.fn(),
189
+ target: null,
190
+ } as unknown as React.KeyboardEvent<HTMLElement>;
191
+
192
+ act(() => {
193
+ result.current.handleListKeyDown(event);
194
+ });
195
+ expect(result.current.activeIndex).toBe(-1);
196
+ });
197
+
198
+ test("Enter key on active item calls clickActiveItem", () => {
199
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
200
+
201
+ const container = document.createElement("div");
202
+ const items = [0, 1, 2].map(() => {
203
+ const el = document.createElement("div");
204
+ el.setAttribute("tabindex", "-1");
205
+ el.scrollIntoView = vi.fn();
206
+ el.click = vi.fn();
207
+ container.appendChild(el);
208
+ return el;
209
+ });
210
+ items.forEach((el, i) => result.current.setItemRef(i, el));
211
+
212
+ act(() => {
213
+ result.current.setActiveIndex(1);
214
+ });
215
+
216
+ const event = {
217
+ key: "Enter",
218
+ defaultPrevented: false,
219
+ preventDefault: vi.fn(),
220
+ target: container,
221
+ } as unknown as React.KeyboardEvent<HTMLElement>;
222
+
223
+ act(() => {
224
+ result.current.handleListKeyDown(event);
225
+ });
226
+ expect(items[1].click).toHaveBeenCalledOnce();
227
+ });
228
+
229
+ test("Space key on active item calls clickActiveItem", () => {
230
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
231
+
232
+ const container = document.createElement("div");
233
+ const items = [0, 1, 2].map(() => {
234
+ const el = document.createElement("div");
235
+ el.setAttribute("tabindex", "-1");
236
+ el.scrollIntoView = vi.fn();
237
+ el.click = vi.fn();
238
+ container.appendChild(el);
239
+ return el;
240
+ });
241
+ items.forEach((el, i) => result.current.setItemRef(i, el));
242
+
243
+ act(() => {
244
+ result.current.setActiveIndex(1);
245
+ });
246
+
247
+ const event = {
248
+ key: " ",
249
+ defaultPrevented: false,
250
+ preventDefault: vi.fn(),
251
+ target: container,
252
+ } as unknown as React.KeyboardEvent<HTMLElement>;
253
+
254
+ act(() => {
255
+ result.current.handleListKeyDown(event);
256
+ });
257
+ expect(items[1].click).toHaveBeenCalledOnce();
258
+ });
259
+
260
+ test("Home key focuses first item when no active index", () => {
261
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
262
+
263
+ const container = document.createElement("div");
264
+ const item0 = document.createElement("div");
265
+ item0.setAttribute("tabindex", "-1");
266
+ item0.scrollIntoView = vi.fn();
267
+ item0.focus = vi.fn();
268
+ container.appendChild(item0);
269
+ result.current.setItemRef(0, item0);
270
+
271
+ const event = {
272
+ key: "Home",
273
+ defaultPrevented: false,
274
+ preventDefault: vi.fn(),
275
+ target: container,
276
+ } as unknown as React.KeyboardEvent<HTMLElement>;
277
+
278
+ act(() => {
279
+ result.current.handleListKeyDown(event);
280
+ });
281
+ expect(result.current.activeIndex).toBe(0);
282
+ });
283
+
284
+ test("returns early when itemCount is 0", () => {
285
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 0 }));
286
+
287
+ const event = {
288
+ key: "ArrowDown",
289
+ defaultPrevented: false,
290
+ preventDefault: vi.fn(),
291
+ target: document.createElement("div"),
292
+ } as unknown as React.KeyboardEvent<HTMLElement>;
293
+
294
+ act(() => {
295
+ result.current.handleListKeyDown(event);
296
+ });
297
+ expect(result.current.activeIndex).toBe(-1);
298
+ });
299
+
300
+ test("returns early when event is defaultPrevented", () => {
301
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
302
+
303
+ const event = {
304
+ key: "ArrowDown",
305
+ defaultPrevented: true,
306
+ preventDefault: vi.fn(),
307
+ target: document.createElement("div"),
308
+ } as unknown as React.KeyboardEvent<HTMLElement>;
309
+
310
+ act(() => {
311
+ result.current.handleListKeyDown(event);
312
+ });
313
+ expect(result.current.activeIndex).toBe(-1);
314
+ });
315
+
316
+ test("ignores events on editable target", () => {
317
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
318
+
319
+ const container = document.createElement("div");
320
+ const input = document.createElement("input");
321
+ container.appendChild(input);
322
+
323
+ const event = {
324
+ key: "ArrowDown",
325
+ defaultPrevented: false,
326
+ preventDefault: vi.fn(),
327
+ target: input,
328
+ } as unknown as React.KeyboardEvent<HTMLElement>;
329
+
330
+ act(() => {
331
+ result.current.handleListKeyDown(event);
332
+ });
333
+ expect(result.current.activeIndex).toBe(-1);
334
+ });
335
+
336
+ test("ignores events outside container", () => {
337
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
338
+
339
+ const container = document.createElement("div");
340
+ const outside = document.createElement("div");
341
+ document.body.appendChild(outside);
342
+
343
+ const event = {
344
+ key: "ArrowDown",
345
+ defaultPrevented: false,
346
+ preventDefault: vi.fn(),
347
+ target: outside,
348
+ } as unknown as React.KeyboardEvent<HTMLElement>;
349
+
350
+ act(() => {
351
+ result.current.handleListKeyDown(event, { container });
352
+ });
353
+ expect(result.current.activeIndex).toBe(-1);
354
+ });
355
+
356
+ test("End key focuses last item via focusBoundaryItem", () => {
357
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
358
+
359
+ const container = document.createElement("div");
360
+ const items = [0, 1, 2].map(() => {
361
+ const el = document.createElement("div");
362
+ el.setAttribute("tabindex", "-1");
363
+ el.scrollIntoView = vi.fn();
364
+ el.focus = vi.fn();
365
+ container.appendChild(el);
366
+ return el;
367
+ });
368
+ items.forEach((el, i) => result.current.setItemRef(i, el));
369
+
370
+ // Set activeIndex to 0 so ArrowUp falls through to focusBoundaryItem
371
+ act(() => {
372
+ result.current.setActiveIndex(0);
373
+ });
374
+
375
+ const event = {
376
+ key: "End",
377
+ defaultPrevented: false,
378
+ preventDefault: vi.fn(),
379
+ target: container,
380
+ } as unknown as React.KeyboardEvent<HTMLElement>;
381
+
382
+ act(() => {
383
+ result.current.handleListKeyDown(event);
384
+ });
385
+ expect(result.current.activeIndex).toBe(2);
386
+ });
387
+
66
388
  test("Escape resets activeIndex to -1", () => {
67
- const { result } = renderHook(() =>
68
- useKeyboardListNavigation({ itemCount: 3 }),
69
- );
389
+ const { result } = renderHook(() => useKeyboardListNavigation({ itemCount: 3 }));
70
390
 
71
391
  const event = {
72
392
  key: "Escape",
@@ -76,7 +396,9 @@ describe("useKeyboardListNavigation", () => {
76
396
  } as unknown as React.KeyboardEvent<HTMLElement>;
77
397
 
78
398
  result.current.setActiveIndex(1);
79
- act(() => { result.current.handleListKeyDown(event); });
399
+ act(() => {
400
+ result.current.handleListKeyDown(event);
401
+ });
80
402
  expect(result.current.activeIndex).toBe(-1);
81
403
  });
82
404
  });
@@ -1,26 +1,45 @@
1
- import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { renderHook, act } from "@testing-library/react";
1
+ import { act, renderHook } from "@testing-library/react";
3
2
  import type React from "react";
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
4
  import { useLongPress } from "@/packages/hooks/useLongPress";
5
5
 
6
- function createMouseEvent(type: string): React.MouseEvent<HTMLElement> {
6
+ function createMouseEvent(
7
+ type: string,
8
+ overrides?: Partial<MouseEventInit>,
9
+ ): React.MouseEvent<HTMLElement> {
7
10
  return {
8
11
  type,
9
- nativeEvent: new MouseEvent(type, { clientX: 0, clientY: 0 }),
10
- clientX: 0,
11
- clientY: 0,
12
+ nativeEvent: new MouseEvent(type, { clientX: 0, clientY: 0, ...overrides }),
13
+ clientX: overrides?.clientX ?? 0,
14
+ clientY: overrides?.clientY ?? 0,
12
15
  } as unknown as React.MouseEvent<HTMLElement>;
13
16
  }
14
17
 
18
+ function createTouchEvent(type: string): React.TouchEvent<HTMLElement> {
19
+ return {
20
+ type,
21
+ nativeEvent: new TouchEvent(type, {
22
+ touches: [{ clientX: 0, clientY: 0 } as Touch],
23
+ changedTouches: [{ clientX: 0, clientY: 0 } as Touch],
24
+ }),
25
+ } as unknown as React.TouchEvent<HTMLElement>;
26
+ }
27
+
15
28
  describe("useLongPress", () => {
16
- beforeEach(() => { vi.useFakeTimers(); });
17
- afterEach(() => { vi.useRealTimers(); });
29
+ beforeEach(() => {
30
+ vi.useFakeTimers();
31
+ });
32
+ afterEach(() => {
33
+ vi.useRealTimers();
34
+ });
18
35
 
19
36
  test("fires onLongPress after delay", () => {
20
37
  const onLongPress = vi.fn();
21
38
  const { result } = renderHook(() => useLongPress(onLongPress, { delay: 200 }));
22
39
 
23
- act(() => { result.current.onMouseDown(createMouseEvent("mousedown")); });
40
+ act(() => {
41
+ result.current.onMouseDown(createMouseEvent("mousedown"));
42
+ });
24
43
  expect(onLongPress).not.toHaveBeenCalled();
25
44
  vi.advanceTimersByTime(200);
26
45
  expect(onLongPress).toHaveBeenCalledOnce();
@@ -29,24 +48,167 @@ describe("useLongPress", () => {
29
48
  test("fires onClick when released before delay", () => {
30
49
  const onLongPress = vi.fn();
31
50
  const onClick = vi.fn();
51
+ const { result } = renderHook(() => useLongPress(onLongPress, { delay: 200, onClick }));
52
+
53
+ act(() => {
54
+ result.current.onMouseDown(createMouseEvent("mousedown"));
55
+ });
56
+ act(() => {
57
+ result.current.onMouseUp(createMouseEvent("mouseup"));
58
+ });
59
+ vi.advanceTimersByTime(200);
60
+ expect(onLongPress).not.toHaveBeenCalled();
61
+ expect(onClick).toHaveBeenCalledOnce();
62
+ });
63
+
64
+ test("no click callback fires when released before delay without onClick handler", () => {
65
+ const onLongPress = vi.fn();
66
+ const { result } = renderHook(() => useLongPress(onLongPress, { delay: 200 }));
67
+
68
+ act(() => {
69
+ result.current.onMouseDown(createMouseEvent("mousedown"));
70
+ });
71
+ act(() => {
72
+ result.current.onMouseUp(createMouseEvent("mouseup"));
73
+ });
74
+ vi.advanceTimersByTime(200);
75
+ expect(onLongPress).not.toHaveBeenCalled();
76
+ });
77
+
78
+ test("uses mouse position without touches for getClientPosition", () => {
79
+ const onLongPress = vi.fn();
32
80
  const { result } = renderHook(() =>
33
- useLongPress(onLongPress, { delay: 200, onClick }),
81
+ useLongPress(onLongPress, { delay: 200, moveThreshold: { x: 50 } }),
34
82
  );
35
83
 
36
- act(() => { result.current.onMouseDown(createMouseEvent("mousedown")); });
37
- act(() => { result.current.onMouseUp(createMouseEvent("mouseup")); });
84
+ act(() => {
85
+ result.current.onMouseDown(createMouseEvent("mousedown", { clientX: 100, clientY: 200 }));
86
+ });
87
+ vi.advanceTimersByTime(50);
88
+ // Move less than threshold — should not cancel
89
+ act(() => {
90
+ result.current.onMouseMove(createMouseEvent("mousemove", { clientX: 110, clientY: 200 }));
91
+ });
92
+ vi.advanceTimersByTime(150);
93
+ expect(onLongPress).toHaveBeenCalledOnce();
94
+ });
95
+
96
+ test("fires onLongPressEnd when released after long press", () => {
97
+ const onLongPress = vi.fn();
98
+ const onLongPressEnd = vi.fn();
99
+ const { result } = renderHook(() => useLongPress(onLongPress, { delay: 200, onLongPressEnd }));
100
+
101
+ act(() => {
102
+ result.current.onMouseDown(createMouseEvent("mousedown"));
103
+ });
104
+ vi.advanceTimersByTime(200);
105
+ expect(onLongPress).toHaveBeenCalledOnce();
106
+
107
+ act(() => {
108
+ result.current.onMouseUp(createMouseEvent("mouseup"));
109
+ });
110
+ expect(onLongPressEnd).toHaveBeenCalledOnce();
111
+ });
112
+
113
+ test("cancels long press when moved beyond threshold (x)", () => {
114
+ const onLongPress = vi.fn();
115
+ const { result } = renderHook(() =>
116
+ useLongPress(onLongPress, {
117
+ delay: 200,
118
+ moveThreshold: { x: 10 },
119
+ }),
120
+ );
121
+
122
+ act(() => {
123
+ result.current.onMouseDown(createMouseEvent("mousedown"));
124
+ });
125
+ act(() => {
126
+ result.current.onMouseMove(createMouseEvent("mousemove", { clientX: 30, clientY: 0 }));
127
+ });
128
+ vi.advanceTimersByTime(200);
129
+ expect(onLongPress).not.toHaveBeenCalled();
130
+ });
131
+
132
+ test("cancels long press when moved beyond threshold (y)", () => {
133
+ const onLongPress = vi.fn();
134
+ const { result } = renderHook(() =>
135
+ useLongPress(onLongPress, {
136
+ delay: 200,
137
+ moveThreshold: { y: 10 },
138
+ }),
139
+ );
140
+
141
+ act(() => {
142
+ result.current.onMouseDown(createMouseEvent("mousedown"));
143
+ });
144
+ act(() => {
145
+ result.current.onMouseMove(createMouseEvent("mousemove", { clientX: 0, clientY: 30 }));
146
+ });
38
147
  vi.advanceTimersByTime(200);
39
148
  expect(onLongPress).not.toHaveBeenCalled();
40
- expect(onClick).toHaveBeenCalledOnce();
149
+ });
150
+
151
+ test("does not cancel when moved within threshold", () => {
152
+ const onLongPress = vi.fn();
153
+ const { result } = renderHook(() =>
154
+ useLongPress(onLongPress, {
155
+ delay: 200,
156
+ moveThreshold: { x: 50, y: 50 },
157
+ }),
158
+ );
159
+
160
+ act(() => {
161
+ result.current.onMouseDown(createMouseEvent("mousedown"));
162
+ });
163
+ act(() => {
164
+ result.current.onMouseMove(createMouseEvent("mousemove", { clientX: 20, clientY: 20 }));
165
+ });
166
+ vi.advanceTimersByTime(200);
167
+ expect(onLongPress).toHaveBeenCalledOnce();
168
+ });
169
+
170
+ test("works with touch events", () => {
171
+ const onLongPress = vi.fn();
172
+ const { result } = renderHook(() => useLongPress(onLongPress, { delay: 200 }));
173
+
174
+ act(() => {
175
+ result.current.onTouchStart(createTouchEvent("touchstart"));
176
+ });
177
+ vi.advanceTimersByTime(200);
178
+ expect(onLongPress).toHaveBeenCalledOnce();
179
+
180
+ act(() => {
181
+ result.current.onTouchEnd(createTouchEvent("touchend"));
182
+ });
41
183
  });
42
184
 
43
185
  test("cancels on mouse leave", () => {
44
186
  const onLongPress = vi.fn();
45
187
  const { result } = renderHook(() => useLongPress(onLongPress, { delay: 200 }));
46
188
 
47
- act(() => { result.current.onMouseDown(createMouseEvent("mousedown")); });
48
- act(() => { result.current.onMouseLeave(createMouseEvent("mouseleave")); });
189
+ act(() => {
190
+ result.current.onMouseDown(createMouseEvent("mousedown"));
191
+ });
192
+ act(() => {
193
+ result.current.onMouseLeave(createMouseEvent("mouseleave"));
194
+ });
49
195
  vi.advanceTimersByTime(200);
50
196
  expect(onLongPress).not.toHaveBeenCalled();
51
197
  });
198
+
199
+ test("mouse up after mouse leave does not fire onClick (timeout already cleared)", () => {
200
+ const onClick = vi.fn();
201
+ const { result } = renderHook(() => useLongPress(() => {}, { delay: 200, onClick }));
202
+
203
+ act(() => {
204
+ result.current.onMouseDown(createMouseEvent("mousedown"));
205
+ });
206
+ act(() => {
207
+ result.current.onMouseLeave(createMouseEvent("mouseleave"));
208
+ });
209
+ act(() => {
210
+ result.current.onMouseUp(createMouseEvent("mouseup"));
211
+ });
212
+ expect(onClick).not.toHaveBeenCalled();
213
+ });
52
214
  });
@@ -1,13 +1,12 @@
1
- import { describe, test, expect, vi } from "vitest";
2
1
  import { renderHook } from "@testing-library/react";
2
+ import { describe, expect, test, vi } from "vitest";
3
3
  import { usePreservedCallback } from "@/packages/hooks/usePreservedCallback";
4
4
 
5
5
  describe("usePreservedCallback", () => {
6
6
  test("returns a stable function reference across renders", () => {
7
- const { result, rerender } = renderHook(
8
- ({ cb }) => usePreservedCallback(cb),
9
- { initialProps: { cb: vi.fn() } },
10
- );
7
+ const { result, rerender } = renderHook(({ cb }) => usePreservedCallback(cb), {
8
+ initialProps: { cb: vi.fn() },
9
+ });
11
10
  const firstRef = result.current;
12
11
  rerender({ cb: vi.fn() });
13
12
  expect(result.current).toBe(firstRef);
@@ -16,10 +15,9 @@ describe("usePreservedCallback", () => {
16
15
  test("invokes the latest callback", () => {
17
16
  const fn1 = vi.fn();
18
17
  const fn2 = vi.fn();
19
- const { result, rerender } = renderHook(
20
- ({ cb }) => usePreservedCallback(cb),
21
- { initialProps: { cb: fn1 } },
22
- );
18
+ const { result, rerender } = renderHook(({ cb }) => usePreservedCallback(cb), {
19
+ initialProps: { cb: fn1 },
20
+ });
23
21
  rerender({ cb: fn2 });
24
22
  result.current();
25
23
  expect(fn2).toHaveBeenCalledOnce();