@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.
- package/.agents/references/hooks/useIntersectionObserver.md +4 -0
- package/.agents/references/hooks/useInterval.md +4 -0
- package/.agents/references/hooks/usePreservedCallback.md +4 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +22 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +22 -0
- package/.github/workflows/ci.yml +20 -0
- package/.local/state/gh/device-id +1 -0
- package/.prettierrc +8 -0
- package/AGENTS.md +14 -0
- package/CODE_OF_CONDUCT.md +55 -0
- package/CONTRIBUTING.md +39 -0
- package/biome.json +25 -0
- package/index.ts +1 -1
- package/package.json +21 -2
- package/packages/components/ScrolltoTop.tsx +1 -1
- package/packages/components/ViewportPortal.tsx +1 -2
- package/packages/cookie/cookie.shared.ts +7 -33
- package/packages/cookie/cookieNames.shared.ts +9 -0
- package/packages/datetime/dateTime.client.ts +1 -3
- package/packages/datetime/dateTime.server.ts +1 -3
- package/packages/datetime/dateTime.shared.ts +9 -47
- package/packages/hooks/useCheckInvisible.ts +1 -1
- package/packages/hooks/useClientDateTime.ts +3 -16
- package/packages/hooks/useDebounce.ts +2 -7
- package/packages/hooks/useGeolocation.ts +1 -5
- package/packages/hooks/useInterval.ts +1 -2
- package/packages/hooks/useKeyboardListNavigation.ts +10 -38
- package/packages/hooks/useLongPress.ts +13 -24
- package/packages/hooks/useRelativeDateTime.ts +1 -4
- package/packages/hooks/useViewportHeight.ts +1 -2
- package/packages/hooks/useViewportMatch.ts +0 -4
- package/packages/utils/buildContext.tsx +2 -3
- package/packages/utils/floatingMotion.ts +14 -20
- package/packages/utils/subscribeKeyboardHeight.ts +3 -0
- package/tests/components/ScrolltoTop.test.tsx +1 -1
- package/tests/components/SwitchCase.test.tsx +1 -2
- package/tests/components/ViewportPortal.test.tsx +10 -6
- package/tests/cookie/cookie.test.ts +47 -11
- package/tests/datetime/datetime.test.ts +257 -46
- package/tests/hooks/useAvoidKeyboard.test.ts +32 -1
- package/tests/hooks/useCheckInvisible.test.ts +36 -5
- package/tests/hooks/useCheckScroll.test.ts +2 -2
- package/tests/hooks/useClientDateTime.test.ts +2 -4
- package/tests/hooks/useDebounce.test.ts +30 -5
- package/tests/hooks/useDebouncedCallback.test.ts +41 -12
- package/tests/hooks/useDoubleClick.test.ts +58 -7
- package/tests/hooks/useGeolocation.test.ts +155 -4
- package/tests/hooks/useHasMounted.test.ts +1 -1
- package/tests/hooks/useIntersectionObserver.test.ts +70 -5
- package/tests/hooks/useInterval.test.ts +28 -3
- package/tests/hooks/useKeyboardHeight.test.ts +1 -1
- package/tests/hooks/useKeyboardListNavigation.test.ts +340 -18
- package/tests/hooks/useLongPress.test.ts +177 -15
- package/tests/hooks/usePreservedCallback.test.ts +7 -9
- package/tests/hooks/usePreservedReference.test.ts +19 -12
- package/tests/hooks/useRefEffect.test.ts +43 -6
- package/tests/hooks/useRelativeDateTime.test.ts +8 -6
- package/tests/hooks/useTimeout.test.ts +43 -5
- package/tests/hooks/useToggleState.test.ts +14 -6
- package/tests/hooks/useViewportHeight.test.ts +24 -2
- package/tests/hooks/useViewportMatch.test.ts +1 -1
- package/tests/utils/browserStorage.test.ts +68 -8
- package/tests/utils/buildContext.test.tsx +1 -2
- package/tests/utils/checkDevice.test.ts +62 -6
- package/tests/utils/clipboardShare.test.ts +79 -5
- package/tests/utils/floatingMotion.test.ts +60 -9
- package/tests/utils/keyboardTarget.test.ts +42 -1
- package/tests/utils/mergeRefs.test.ts +33 -1
- package/tests/utils/seen.test.ts +16 -7
- package/tests/utils/subscribeKeyboardHeight.test.ts +124 -2
- package/vitest.config.ts +7 -1
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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(() => {
|
|
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(() => {
|
|
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(() => {
|
|
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(() => {
|
|
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 {
|
|
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(
|
|
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(() => {
|
|
17
|
-
|
|
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(() => {
|
|
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,
|
|
81
|
+
useLongPress(onLongPress, { delay: 200, moveThreshold: { x: 50 } }),
|
|
34
82
|
);
|
|
35
83
|
|
|
36
|
-
act(() => {
|
|
37
|
-
|
|
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
|
-
|
|
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(() => {
|
|
48
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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();
|