@zentauri-ui/zentauri-components 1.9.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +32 -5
- package/cli/registry.json +14 -0
- package/dist/{chunk-L4PDJ6IB.mjs → chunk-44NX3DAZ.mjs} +3 -3
- package/dist/{chunk-L4PDJ6IB.mjs.map → chunk-44NX3DAZ.mjs.map} +1 -1
- package/dist/chunk-FAMHSJTK.js +19 -0
- package/dist/{chunk-AQHY4S33.js.map → chunk-FAMHSJTK.js.map} +1 -1
- package/dist/chunk-I42UYWYA.mjs +128 -0
- package/dist/chunk-I42UYWYA.mjs.map +1 -0
- package/dist/{chunk-5J6QMTES.js → chunk-IKXO5SJ4.js} +21 -5
- package/dist/chunk-IKXO5SJ4.js.map +1 -0
- package/dist/{chunk-OPUO55TO.mjs → chunk-JXSM2EHC.mjs} +3 -3
- package/dist/{chunk-OPUO55TO.mjs.map → chunk-JXSM2EHC.mjs.map} +1 -1
- package/dist/{chunk-LQPKZ5ZD.js → chunk-LS4GY2ZQ.js} +6 -6
- package/dist/{chunk-LQPKZ5ZD.js.map → chunk-LS4GY2ZQ.js.map} +1 -1
- package/dist/{chunk-VIKQGO4W.mjs → chunk-VQQHVKEU.mjs} +21 -5
- package/dist/{chunk-VIKQGO4W.mjs.map → chunk-VQQHVKEU.mjs.map} +1 -1
- package/dist/chunk-ZVRGLG35.js +144 -0
- package/dist/chunk-ZVRGLG35.js.map +1 -0
- package/dist/design-system/combobox.d.ts +124 -0
- package/dist/design-system/combobox.d.ts.map +1 -0
- package/dist/design-system/facade.js +6 -5
- package/dist/design-system/facade.js.map +1 -1
- package/dist/design-system/facade.mjs +5 -4
- package/dist/design-system/facade.mjs.map +1 -1
- package/dist/design-system/index.d.ts +1 -0
- package/dist/design-system/index.d.ts.map +1 -1
- package/dist/hooks/index.d.ts +13 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useCookie/index.d.ts +2 -0
- package/dist/hooks/useCookie/index.d.ts.map +1 -0
- package/dist/hooks/useCookie/useCookie.d.ts +36 -0
- package/dist/hooks/useCookie/useCookie.d.ts.map +1 -0
- package/dist/hooks/useCookie.js +82 -0
- package/dist/hooks/useCookie.js.map +1 -0
- package/dist/hooks/useCookie.mjs +80 -0
- package/dist/hooks/useCookie.mjs.map +1 -0
- package/dist/hooks/useCountdown/index.d.ts +2 -0
- package/dist/hooks/useCountdown/index.d.ts.map +1 -0
- package/dist/hooks/useCountdown/useCountdown.d.ts +40 -0
- package/dist/hooks/useCountdown/useCountdown.d.ts.map +1 -0
- package/dist/hooks/useCountdown.js +60 -0
- package/dist/hooks/useCountdown.js.map +1 -0
- package/dist/hooks/useCountdown.mjs +58 -0
- package/dist/hooks/useCountdown.mjs.map +1 -0
- package/dist/hooks/useEventListener/index.d.ts +2 -0
- package/dist/hooks/useEventListener/index.d.ts.map +1 -0
- package/dist/hooks/useEventListener/useEventListener.d.ts +22 -0
- package/dist/hooks/useEventListener/useEventListener.d.ts.map +1 -0
- package/dist/hooks/useEventListener.js +45 -0
- package/dist/hooks/useEventListener.js.map +1 -0
- package/dist/hooks/useEventListener.mjs +43 -0
- package/dist/hooks/useEventListener.mjs.map +1 -0
- package/dist/hooks/useGeolocation/index.d.ts +2 -0
- package/dist/hooks/useGeolocation/index.d.ts.map +1 -0
- package/dist/hooks/useGeolocation/useGeolocation.d.ts +48 -0
- package/dist/hooks/useGeolocation/useGeolocation.d.ts.map +1 -0
- package/dist/hooks/useGeolocation.js +111 -0
- package/dist/hooks/useGeolocation.js.map +1 -0
- package/dist/hooks/useGeolocation.mjs +109 -0
- package/dist/hooks/useGeolocation.mjs.map +1 -0
- package/dist/hooks/useHotkeys/index.d.ts +2 -0
- package/dist/hooks/useHotkeys/index.d.ts.map +1 -0
- package/dist/hooks/useHotkeys/useHotkeys.d.ts +24 -0
- package/dist/hooks/useHotkeys/useHotkeys.d.ts.map +1 -0
- package/dist/hooks/useHotkeys.js +86 -0
- package/dist/hooks/useHotkeys.js.map +1 -0
- package/dist/hooks/useHotkeys.mjs +84 -0
- package/dist/hooks/useHotkeys.mjs.map +1 -0
- package/dist/hooks/useIdleTimeout/index.d.ts +2 -0
- package/dist/hooks/useIdleTimeout/index.d.ts.map +1 -0
- package/dist/hooks/useIdleTimeout/useIdleTimeout.d.ts +31 -0
- package/dist/hooks/useIdleTimeout/useIdleTimeout.d.ts.map +1 -0
- package/dist/hooks/useIdleTimeout.js +77 -0
- package/dist/hooks/useIdleTimeout.js.map +1 -0
- package/dist/hooks/useIdleTimeout.mjs +75 -0
- package/dist/hooks/useIdleTimeout.mjs.map +1 -0
- package/dist/hooks/useInterval/index.d.ts +2 -0
- package/dist/hooks/useInterval/index.d.ts.map +1 -0
- package/dist/hooks/useInterval/useInterval.d.ts +12 -0
- package/dist/hooks/useInterval/useInterval.d.ts.map +1 -0
- package/dist/hooks/useInterval.js +27 -0
- package/dist/hooks/useInterval.js.map +1 -0
- package/dist/hooks/useInterval.mjs +25 -0
- package/dist/hooks/useInterval.mjs.map +1 -0
- package/dist/hooks/useKeyPress/index.d.ts +2 -0
- package/dist/hooks/useKeyPress/index.d.ts.map +1 -0
- package/dist/hooks/useKeyPress/useKeyPress.d.ts +15 -0
- package/dist/hooks/useKeyPress/useKeyPress.d.ts.map +1 -0
- package/dist/hooks/useKeyPress.js +47 -0
- package/dist/hooks/useKeyPress.js.map +1 -0
- package/dist/hooks/useKeyPress.mjs +45 -0
- package/dist/hooks/useKeyPress.mjs.map +1 -0
- package/dist/hooks/useLongPress/index.d.ts +2 -0
- package/dist/hooks/useLongPress/index.d.ts.map +1 -0
- package/dist/hooks/useLongPress/useLongPress.d.ts +46 -0
- package/dist/hooks/useLongPress/useLongPress.d.ts.map +1 -0
- package/dist/hooks/useLongPress.js +116 -0
- package/dist/hooks/useLongPress.js.map +1 -0
- package/dist/hooks/useLongPress.mjs +114 -0
- package/dist/hooks/useLongPress.mjs.map +1 -0
- package/dist/hooks/usePrevious/index.d.ts +2 -0
- package/dist/hooks/usePrevious/index.d.ts.map +1 -0
- package/dist/hooks/usePrevious/usePrevious.d.ts +13 -0
- package/dist/hooks/usePrevious/usePrevious.d.ts.map +1 -0
- package/dist/hooks/usePrevious.js +17 -0
- package/dist/hooks/usePrevious.js.map +1 -0
- package/dist/hooks/usePrevious.mjs +15 -0
- package/dist/hooks/usePrevious.mjs.map +1 -0
- package/dist/hooks/useScrollPosition/index.d.ts +2 -0
- package/dist/hooks/useScrollPosition/index.d.ts.map +1 -0
- package/dist/hooks/useScrollPosition/useScrollPosition.d.ts +37 -0
- package/dist/hooks/useScrollPosition/useScrollPosition.d.ts.map +1 -0
- package/dist/hooks/useScrollPosition.js +41 -0
- package/dist/hooks/useScrollPosition.js.map +1 -0
- package/dist/hooks/useScrollPosition.mjs +39 -0
- package/dist/hooks/useScrollPosition.mjs.map +1 -0
- package/dist/hooks/useTimeout/index.d.ts +2 -0
- package/dist/hooks/useTimeout/index.d.ts.map +1 -0
- package/dist/hooks/useTimeout/useTimeout.d.ts +19 -0
- package/dist/hooks/useTimeout/useTimeout.d.ts.map +1 -0
- package/dist/hooks/useTimeout.js +38 -0
- package/dist/hooks/useTimeout.js.map +1 -0
- package/dist/hooks/useTimeout.mjs +36 -0
- package/dist/hooks/useTimeout.mjs.map +1 -0
- package/dist/hooks/useVirtualList/index.d.ts +2 -0
- package/dist/hooks/useVirtualList/index.d.ts.map +1 -0
- package/dist/hooks/useVirtualList/useVirtualList.d.ts +47 -0
- package/dist/hooks/useVirtualList/useVirtualList.d.ts.map +1 -0
- package/dist/hooks/useVirtualList.js +87 -0
- package/dist/hooks/useVirtualList.js.map +1 -0
- package/dist/hooks/useVirtualList.mjs +85 -0
- package/dist/hooks/useVirtualList.mjs.map +1 -0
- package/dist/lib/facade.d.ts.map +1 -1
- package/dist/ui/buttons/animated.js +8 -7
- package/dist/ui/buttons/animated.js.map +1 -1
- package/dist/ui/buttons/animated.mjs +6 -5
- package/dist/ui/buttons/animated.mjs.map +1 -1
- package/dist/ui/buttons.js +9 -8
- package/dist/ui/buttons.mjs +7 -6
- package/dist/ui/combobox/combobox-base.d.ts +37 -0
- package/dist/ui/combobox/combobox-base.d.ts.map +1 -0
- package/dist/ui/combobox/combobox.d.ts +6 -0
- package/dist/ui/combobox/combobox.d.ts.map +1 -0
- package/dist/ui/combobox/index.d.ts +4 -0
- package/dist/ui/combobox/index.d.ts.map +1 -0
- package/dist/ui/combobox/types.d.ts +70 -0
- package/dist/ui/combobox/types.d.ts.map +1 -0
- package/dist/ui/combobox/variants.d.ts +17 -0
- package/dist/ui/combobox/variants.d.ts.map +1 -0
- package/dist/ui/combobox.js +510 -0
- package/dist/ui/combobox.js.map +1 -0
- package/dist/ui/combobox.mjs +495 -0
- package/dist/ui/combobox.mjs.map +1 -0
- package/dist/ui/dynamic-stepper.js +18 -17
- package/dist/ui/dynamic-stepper.js.map +1 -1
- package/dist/ui/dynamic-stepper.mjs +7 -6
- package/dist/ui/dynamic-stepper.mjs.map +1 -1
- package/dist/ui/pagination.js +14 -13
- package/dist/ui/pagination.js.map +1 -1
- package/dist/ui/pagination.mjs +6 -5
- package/dist/ui/pagination.mjs.map +1 -1
- package/package.json +1 -1
- package/src/design-system/combobox.ts +204 -0
- package/src/design-system/index.ts +1 -0
- package/src/hooks/index.ts +50 -0
- package/src/hooks/useCookie/index.ts +5 -0
- package/src/hooks/useCookie/useCookie.test.ts +57 -0
- package/src/hooks/useCookie/useCookie.ts +133 -0
- package/src/hooks/useCountdown/index.ts +5 -0
- package/src/hooks/useCountdown/useCountdown.test.ts +113 -0
- package/src/hooks/useCountdown/useCountdown.ts +106 -0
- package/src/hooks/useEventListener/index.ts +4 -0
- package/src/hooks/useEventListener/useEventListener.test.ts +60 -0
- package/src/hooks/useEventListener/useEventListener.ts +98 -0
- package/src/hooks/useGeolocation/index.ts +6 -0
- package/src/hooks/useGeolocation/useGeolocation.test.ts +108 -0
- package/src/hooks/useGeolocation/useGeolocation.ts +173 -0
- package/src/hooks/useHotkeys/index.ts +5 -0
- package/src/hooks/useHotkeys/useHotkeys.test.ts +82 -0
- package/src/hooks/useHotkeys/useHotkeys.ts +130 -0
- package/src/hooks/useIdleTimeout/index.ts +5 -0
- package/src/hooks/useIdleTimeout/useIdleTimeout.test.ts +97 -0
- package/src/hooks/useIdleTimeout/useIdleTimeout.ts +111 -0
- package/src/hooks/useInterval/index.ts +1 -0
- package/src/hooks/useInterval/useInterval.test.ts +56 -0
- package/src/hooks/useInterval/useInterval.ts +36 -0
- package/src/hooks/useKeyPress/index.ts +1 -0
- package/src/hooks/useKeyPress/useKeyPress.test.ts +67 -0
- package/src/hooks/useKeyPress/useKeyPress.ts +65 -0
- package/src/hooks/useLongPress/index.ts +5 -0
- package/src/hooks/useLongPress/useLongPress.test.ts +180 -0
- package/src/hooks/useLongPress/useLongPress.ts +177 -0
- package/src/hooks/usePrevious/index.ts +1 -0
- package/src/hooks/usePrevious/usePrevious.test.ts +33 -0
- package/src/hooks/usePrevious/usePrevious.ts +24 -0
- package/src/hooks/useScrollPosition/index.ts +5 -0
- package/src/hooks/useScrollPosition/useScrollPosition.test.ts +69 -0
- package/src/hooks/useScrollPosition/useScrollPosition.ts +88 -0
- package/src/hooks/useTimeout/index.ts +1 -0
- package/src/hooks/useTimeout/useTimeout.test.ts +63 -0
- package/src/hooks/useTimeout/useTimeout.ts +58 -0
- package/src/hooks/useVirtualList/index.ts +6 -0
- package/src/hooks/useVirtualList/useVirtualList.test.ts +102 -0
- package/src/hooks/useVirtualList/useVirtualList.ts +144 -0
- package/src/lib/facade.test.ts +7 -7
- package/src/lib/facade.ts +6 -2
- package/src/ui/combobox/combobox-base.tsx +552 -0
- package/src/ui/combobox/combobox.test.tsx +292 -0
- package/src/ui/combobox/combobox.tsx +8 -0
- package/src/ui/combobox/index.ts +33 -0
- package/src/ui/combobox/types.ts +91 -0
- package/src/ui/combobox/variants.ts +58 -0
- package/dist/chunk-5J6QMTES.js.map +0 -1
- package/dist/chunk-AQHY4S33.js +0 -19
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type {
|
|
4
|
+
MouseEvent as ReactMouseEvent,
|
|
5
|
+
PointerEvent as ReactPointerEvent,
|
|
6
|
+
} from "react";
|
|
7
|
+
|
|
8
|
+
import { useLongPress } from "./useLongPress";
|
|
9
|
+
|
|
10
|
+
function pointerEvent(x = 0, y = 0, button = 0): ReactPointerEvent {
|
|
11
|
+
return { clientX: x, clientY: y, button } as ReactPointerEvent;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("useLongPress", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.useFakeTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.useRealTimers();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should fire the callback after the threshold", () => {
|
|
24
|
+
const callback = vi.fn();
|
|
25
|
+
const { result } = renderHook(() =>
|
|
26
|
+
useLongPress(callback, { thresholdMs: 400 }),
|
|
27
|
+
);
|
|
28
|
+
act(() => {
|
|
29
|
+
result.current.onPointerDown(pointerEvent());
|
|
30
|
+
});
|
|
31
|
+
act(() => {
|
|
32
|
+
vi.advanceTimersByTime(399);
|
|
33
|
+
});
|
|
34
|
+
expect(callback).not.toHaveBeenCalled();
|
|
35
|
+
act(() => {
|
|
36
|
+
vi.advanceTimersByTime(1);
|
|
37
|
+
});
|
|
38
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should cancel when released early", () => {
|
|
42
|
+
const callback = vi.fn();
|
|
43
|
+
const onCancel = vi.fn();
|
|
44
|
+
const { result } = renderHook(() =>
|
|
45
|
+
useLongPress(callback, { thresholdMs: 400, onCancel }),
|
|
46
|
+
);
|
|
47
|
+
act(() => {
|
|
48
|
+
result.current.onPointerDown(pointerEvent());
|
|
49
|
+
vi.advanceTimersByTime(200);
|
|
50
|
+
result.current.onPointerUp(pointerEvent());
|
|
51
|
+
vi.advanceTimersByTime(400);
|
|
52
|
+
});
|
|
53
|
+
expect(callback).not.toHaveBeenCalled();
|
|
54
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should call onStart and onFinish around a completed press", () => {
|
|
58
|
+
const callback = vi.fn();
|
|
59
|
+
const onStart = vi.fn();
|
|
60
|
+
const onFinish = vi.fn();
|
|
61
|
+
const { result } = renderHook(() =>
|
|
62
|
+
useLongPress(callback, { thresholdMs: 300, onStart, onFinish }),
|
|
63
|
+
);
|
|
64
|
+
act(() => {
|
|
65
|
+
result.current.onPointerDown(pointerEvent());
|
|
66
|
+
});
|
|
67
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
68
|
+
act(() => {
|
|
69
|
+
vi.advanceTimersByTime(300);
|
|
70
|
+
});
|
|
71
|
+
act(() => {
|
|
72
|
+
result.current.onPointerUp(pointerEvent());
|
|
73
|
+
});
|
|
74
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should cancel when the pointer travels beyond the tolerance", () => {
|
|
79
|
+
const callback = vi.fn();
|
|
80
|
+
const onCancel = vi.fn();
|
|
81
|
+
const { result } = renderHook(() =>
|
|
82
|
+
useLongPress(callback, {
|
|
83
|
+
thresholdMs: 300,
|
|
84
|
+
moveTolerancePx: 10,
|
|
85
|
+
onCancel,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
act(() => {
|
|
89
|
+
result.current.onPointerDown(pointerEvent(0, 0));
|
|
90
|
+
result.current.onPointerMove(pointerEvent(4, 4));
|
|
91
|
+
vi.advanceTimersByTime(100);
|
|
92
|
+
result.current.onPointerMove(pointerEvent(30, 0));
|
|
93
|
+
vi.advanceTimersByTime(300);
|
|
94
|
+
});
|
|
95
|
+
expect(callback).not.toHaveBeenCalled();
|
|
96
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should cancel when the pointer leaves the target", () => {
|
|
100
|
+
const callback = vi.fn();
|
|
101
|
+
const onCancel = vi.fn();
|
|
102
|
+
const { result } = renderHook(() =>
|
|
103
|
+
useLongPress(callback, { thresholdMs: 300, onCancel }),
|
|
104
|
+
);
|
|
105
|
+
act(() => {
|
|
106
|
+
result.current.onPointerDown(pointerEvent());
|
|
107
|
+
vi.advanceTimersByTime(100);
|
|
108
|
+
result.current.onPointerLeave(pointerEvent());
|
|
109
|
+
vi.advanceTimersByTime(300);
|
|
110
|
+
});
|
|
111
|
+
expect(callback).not.toHaveBeenCalled();
|
|
112
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should cancel when onPointerCancel fires", () => {
|
|
116
|
+
const callback = vi.fn();
|
|
117
|
+
const onCancel = vi.fn();
|
|
118
|
+
const { result } = renderHook(() =>
|
|
119
|
+
useLongPress(callback, { thresholdMs: 300, onCancel }),
|
|
120
|
+
);
|
|
121
|
+
act(() => {
|
|
122
|
+
result.current.onPointerDown(pointerEvent());
|
|
123
|
+
vi.advanceTimersByTime(100);
|
|
124
|
+
result.current.onPointerCancel(pointerEvent());
|
|
125
|
+
vi.advanceTimersByTime(300);
|
|
126
|
+
});
|
|
127
|
+
expect(callback).not.toHaveBeenCalled();
|
|
128
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should ignore non-primary mouse buttons", () => {
|
|
132
|
+
const callback = vi.fn();
|
|
133
|
+
const { result } = renderHook(() =>
|
|
134
|
+
useLongPress(callback, { thresholdMs: 300 }),
|
|
135
|
+
);
|
|
136
|
+
act(() => {
|
|
137
|
+
result.current.onPointerDown(pointerEvent(0, 0, 2)); // right click
|
|
138
|
+
vi.advanceTimersByTime(400);
|
|
139
|
+
});
|
|
140
|
+
expect(callback).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should suppress click after a successful long press", () => {
|
|
144
|
+
const callback = vi.fn();
|
|
145
|
+
const { result } = renderHook(() =>
|
|
146
|
+
useLongPress(callback, { thresholdMs: 300 }),
|
|
147
|
+
);
|
|
148
|
+
act(() => {
|
|
149
|
+
result.current.onPointerDown(pointerEvent());
|
|
150
|
+
vi.advanceTimersByTime(300);
|
|
151
|
+
result.current.onPointerUp(pointerEvent());
|
|
152
|
+
});
|
|
153
|
+
const mockEvent = {
|
|
154
|
+
preventDefault: vi.fn(),
|
|
155
|
+
} as unknown as ReactMouseEvent;
|
|
156
|
+
act(() => {
|
|
157
|
+
result.current.onClick(mockEvent);
|
|
158
|
+
});
|
|
159
|
+
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should not suppress click after a short press", () => {
|
|
163
|
+
const callback = vi.fn();
|
|
164
|
+
const { result } = renderHook(() =>
|
|
165
|
+
useLongPress(callback, { thresholdMs: 300 }),
|
|
166
|
+
);
|
|
167
|
+
act(() => {
|
|
168
|
+
result.current.onPointerDown(pointerEvent());
|
|
169
|
+
vi.advanceTimersByTime(100);
|
|
170
|
+
result.current.onPointerUp(pointerEvent());
|
|
171
|
+
});
|
|
172
|
+
const mockEvent = {
|
|
173
|
+
preventDefault: vi.fn(),
|
|
174
|
+
} as unknown as ReactMouseEvent;
|
|
175
|
+
act(() => {
|
|
176
|
+
result.current.onClick(mockEvent);
|
|
177
|
+
});
|
|
178
|
+
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
MouseEvent as ReactMouseEvent,
|
|
5
|
+
PointerEvent as ReactPointerEvent,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
8
|
+
|
|
9
|
+
export type UseLongPressOptions = {
|
|
10
|
+
/** Hold duration in milliseconds before the press counts as "long" (default `500`). */
|
|
11
|
+
thresholdMs?: number;
|
|
12
|
+
/** Pointer travel in pixels that cancels the press (default `10`). */
|
|
13
|
+
moveTolerancePx?: number;
|
|
14
|
+
/** Called when the pointer goes down (press attempt starts). */
|
|
15
|
+
onStart?: (event: ReactPointerEvent) => void;
|
|
16
|
+
/** Called on release after a long press fired. */
|
|
17
|
+
onFinish?: (event: ReactPointerEvent) => void;
|
|
18
|
+
/** Called when the press is released or cancelled before the threshold. */
|
|
19
|
+
onCancel?: (event: ReactPointerEvent) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type UseLongPressHandlers = {
|
|
23
|
+
onPointerDown: (event: ReactPointerEvent) => void;
|
|
24
|
+
onPointerMove: (event: ReactPointerEvent) => void;
|
|
25
|
+
onPointerUp: (event: ReactPointerEvent) => void;
|
|
26
|
+
onPointerLeave: (event: ReactPointerEvent) => void;
|
|
27
|
+
/** Handles browser-level pointer cancellation (e.g. system interruption, scroll takeover). */
|
|
28
|
+
onPointerCancel: (event: ReactPointerEvent) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Suppresses the synthetic `click` event that fires after a successful long press.
|
|
31
|
+
* Spread alongside the other handlers to prevent unintended navigation or button actions.
|
|
32
|
+
*/
|
|
33
|
+
onClick: (event: ReactMouseEvent) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Long-press gesture detection built on pointer events, so it works for mouse, touch, and pen.
|
|
38
|
+
*
|
|
39
|
+
* Spread the returned handlers onto the target element. After the pointer is held
|
|
40
|
+
* `thresholdMs` without travelling more than `moveTolerancePx`, `callback` fires once;
|
|
41
|
+
* releasing afterwards calls `onFinish`, while early release / movement / leaving the
|
|
42
|
+
* element calls `onCancel`.
|
|
43
|
+
*
|
|
44
|
+
* Only the primary button (button 0 / left click / first touch contact) starts a long press;
|
|
45
|
+
* right and middle mouse buttons are ignored.
|
|
46
|
+
*
|
|
47
|
+
* Pair with `touch-action` / `select-none` CSS on touch targets to suppress native
|
|
48
|
+
* scrolling or text selection during the hold where needed.
|
|
49
|
+
*
|
|
50
|
+
* @param callback - Invoked once when the press crosses the threshold.
|
|
51
|
+
* @param options - {@link UseLongPressOptions}
|
|
52
|
+
* @returns Spreadable pointer handlers ({@link UseLongPressHandlers}).
|
|
53
|
+
*/
|
|
54
|
+
export function useLongPress(
|
|
55
|
+
callback: (event: ReactPointerEvent) => void,
|
|
56
|
+
options: UseLongPressOptions = {},
|
|
57
|
+
): UseLongPressHandlers {
|
|
58
|
+
const {
|
|
59
|
+
thresholdMs = 500,
|
|
60
|
+
moveTolerancePx = 10,
|
|
61
|
+
onStart,
|
|
62
|
+
onFinish,
|
|
63
|
+
onCancel,
|
|
64
|
+
} = options;
|
|
65
|
+
|
|
66
|
+
const callbackRef = useRef(callback);
|
|
67
|
+
const onStartRef = useRef(onStart);
|
|
68
|
+
const onFinishRef = useRef(onFinish);
|
|
69
|
+
const onCancelRef = useRef(onCancel);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
callbackRef.current = callback;
|
|
73
|
+
onStartRef.current = onStart;
|
|
74
|
+
onFinishRef.current = onFinish;
|
|
75
|
+
onCancelRef.current = onCancel;
|
|
76
|
+
}, [callback, onCancel, onFinish, onStart]);
|
|
77
|
+
|
|
78
|
+
const timeoutRef = useRef<number | undefined>(undefined);
|
|
79
|
+
const triggeredRef = useRef(false);
|
|
80
|
+
const pressingRef = useRef(false);
|
|
81
|
+
const originRef = useRef({ x: 0, y: 0 });
|
|
82
|
+
|
|
83
|
+
const stopTimer = useCallback(() => {
|
|
84
|
+
if (timeoutRef.current !== undefined) {
|
|
85
|
+
window.clearTimeout(timeoutRef.current);
|
|
86
|
+
timeoutRef.current = undefined;
|
|
87
|
+
}
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
useEffect(() => stopTimer, [stopTimer]);
|
|
91
|
+
|
|
92
|
+
const cancel = useCallback(
|
|
93
|
+
(event: ReactPointerEvent) => {
|
|
94
|
+
if (!pressingRef.current) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
pressingRef.current = false;
|
|
98
|
+
stopTimer();
|
|
99
|
+
if (!triggeredRef.current) {
|
|
100
|
+
onCancelRef.current?.(event);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
[stopTimer],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const onPointerDown = useCallback(
|
|
107
|
+
(event: ReactPointerEvent) => {
|
|
108
|
+
// Only respond to the primary button (left click / first touch contact).
|
|
109
|
+
// event.button > 0 catches right (2) and middle (1) mouse buttons.
|
|
110
|
+
if (event.button > 0) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
pressingRef.current = true;
|
|
114
|
+
triggeredRef.current = false;
|
|
115
|
+
originRef.current = { x: event.clientX, y: event.clientY };
|
|
116
|
+
onStartRef.current?.(event);
|
|
117
|
+
stopTimer();
|
|
118
|
+
timeoutRef.current = window.setTimeout(() => {
|
|
119
|
+
timeoutRef.current = undefined;
|
|
120
|
+
if (pressingRef.current) {
|
|
121
|
+
triggeredRef.current = true;
|
|
122
|
+
callbackRef.current(event);
|
|
123
|
+
}
|
|
124
|
+
}, thresholdMs);
|
|
125
|
+
},
|
|
126
|
+
[stopTimer, thresholdMs],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const onPointerMove = useCallback(
|
|
130
|
+
(event: ReactPointerEvent) => {
|
|
131
|
+
if (!pressingRef.current || triggeredRef.current) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const dx = event.clientX - originRef.current.x;
|
|
135
|
+
const dy = event.clientY - originRef.current.y;
|
|
136
|
+
if (Math.hypot(dx, dy) > moveTolerancePx) {
|
|
137
|
+
cancel(event);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
[cancel, moveTolerancePx],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const onPointerUp = useCallback(
|
|
144
|
+
(event: ReactPointerEvent) => {
|
|
145
|
+
if (!pressingRef.current) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const triggered = triggeredRef.current;
|
|
149
|
+
pressingRef.current = false;
|
|
150
|
+
stopTimer();
|
|
151
|
+
if (triggered) {
|
|
152
|
+
onFinishRef.current?.(event);
|
|
153
|
+
} else {
|
|
154
|
+
onCancelRef.current?.(event);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
[stopTimer],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// After a successful long press the browser fires a synthetic `click` event on pointer up.
|
|
161
|
+
// This handler suppresses it so navigation and button actions aren't triggered unexpectedly.
|
|
162
|
+
const onClick = useCallback((event: ReactMouseEvent) => {
|
|
163
|
+
if (triggeredRef.current) {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
triggeredRef.current = false;
|
|
166
|
+
}
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
onPointerDown,
|
|
171
|
+
onPointerMove,
|
|
172
|
+
onPointerUp,
|
|
173
|
+
onPointerLeave: cancel,
|
|
174
|
+
onPointerCancel: cancel,
|
|
175
|
+
onClick,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { usePrevious } from "./usePrevious";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { usePrevious } from "./usePrevious";
|
|
5
|
+
|
|
6
|
+
describe("usePrevious", () => {
|
|
7
|
+
it("should return undefined on first render", () => {
|
|
8
|
+
const { result } = renderHook(() => usePrevious(1));
|
|
9
|
+
expect(result.current).toBeUndefined();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should return the previous value after an update", () => {
|
|
13
|
+
const { result, rerender } = renderHook(
|
|
14
|
+
({ value }: { value: number }) => usePrevious(value),
|
|
15
|
+
{ initialProps: { value: 1 } },
|
|
16
|
+
);
|
|
17
|
+
rerender({ value: 2 });
|
|
18
|
+
expect(result.current).toBe(1);
|
|
19
|
+
rerender({ value: 3 });
|
|
20
|
+
expect(result.current).toBe(2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should track non-primitive values", () => {
|
|
24
|
+
const a = { id: "a" };
|
|
25
|
+
const b = { id: "b" };
|
|
26
|
+
const { result, rerender } = renderHook(
|
|
27
|
+
({ value }: { value: object }) => usePrevious(value),
|
|
28
|
+
{ initialProps: { value: a } },
|
|
29
|
+
);
|
|
30
|
+
rerender({ value: b });
|
|
31
|
+
expect(result.current).toBe(a);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the value passed in on the previous render (`undefined` on first render).
|
|
7
|
+
*
|
|
8
|
+
* The ref is updated in an effect after each committed render, so during a render
|
|
9
|
+
* you always see the value from the previous one — handy for diffing props/state,
|
|
10
|
+
* animation direction, or "changed since last render" checks.
|
|
11
|
+
*
|
|
12
|
+
* @typeParam T - Tracked value type.
|
|
13
|
+
* @param value - The current value to track.
|
|
14
|
+
* @returns The value from the previous render, or `undefined` before the first update.
|
|
15
|
+
*/
|
|
16
|
+
export function usePrevious<T>(value: T): T | undefined {
|
|
17
|
+
const previousRef = useRef<T | undefined>(undefined);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
previousRef.current = value;
|
|
21
|
+
}, [value]);
|
|
22
|
+
|
|
23
|
+
return previousRef.current;
|
|
24
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { useScrollPosition } from "./useScrollPosition";
|
|
5
|
+
|
|
6
|
+
describe("useScrollPosition", () => {
|
|
7
|
+
it("should start at the current window offset", () => {
|
|
8
|
+
const { result } = renderHook(() => useScrollPosition());
|
|
9
|
+
expect(result.current).toMatchObject({ x: 0, y: 0 });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should update from window scroll events", () => {
|
|
13
|
+
const { result } = renderHook(() => useScrollPosition());
|
|
14
|
+
act(() => {
|
|
15
|
+
Object.defineProperty(window, "scrollY", {
|
|
16
|
+
configurable: true,
|
|
17
|
+
value: 120,
|
|
18
|
+
});
|
|
19
|
+
Object.defineProperty(window, "scrollX", {
|
|
20
|
+
configurable: true,
|
|
21
|
+
value: 40,
|
|
22
|
+
});
|
|
23
|
+
window.dispatchEvent(new Event("scroll"));
|
|
24
|
+
});
|
|
25
|
+
expect(result.current).toMatchObject({ x: 40, y: 120 });
|
|
26
|
+
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
|
|
27
|
+
Object.defineProperty(window, "scrollX", { configurable: true, value: 0 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should track an element target", () => {
|
|
31
|
+
const element = document.createElement("div");
|
|
32
|
+
const ref = { current: element };
|
|
33
|
+
const { result } = renderHook(() => useScrollPosition({ target: ref }));
|
|
34
|
+
act(() => {
|
|
35
|
+
element.scrollTop = 75;
|
|
36
|
+
element.scrollLeft = 5;
|
|
37
|
+
element.dispatchEvent(new Event("scroll"));
|
|
38
|
+
});
|
|
39
|
+
expect(result.current).toMatchObject({ x: 5, y: 75 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should stop listening on unmount", () => {
|
|
43
|
+
const element = document.createElement("div");
|
|
44
|
+
const ref = { current: element };
|
|
45
|
+
const { result, unmount } = renderHook(() =>
|
|
46
|
+
useScrollPosition({ target: ref }),
|
|
47
|
+
);
|
|
48
|
+
unmount();
|
|
49
|
+
act(() => {
|
|
50
|
+
element.scrollTop = 300;
|
|
51
|
+
element.dispatchEvent(new Event("scroll"));
|
|
52
|
+
});
|
|
53
|
+
expect(result.current).toMatchObject({ x: 0, y: 0 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should track an element attached via setRef callback ref", () => {
|
|
57
|
+
const { result } = renderHook(() => useScrollPosition());
|
|
58
|
+
const element = document.createElement("div");
|
|
59
|
+
act(() => {
|
|
60
|
+
result.current.setRef(element);
|
|
61
|
+
});
|
|
62
|
+
act(() => {
|
|
63
|
+
element.scrollTop = 50;
|
|
64
|
+
element.scrollLeft = 10;
|
|
65
|
+
element.dispatchEvent(new Event("scroll"));
|
|
66
|
+
});
|
|
67
|
+
expect(result.current).toMatchObject({ x: 10, y: 50 });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { RefCallback, RefObject } from "react";
|
|
4
|
+
import { useCallback, useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
export type ScrollPosition = {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type UseScrollPositionParams<T extends HTMLElement = HTMLElement> = {
|
|
12
|
+
/**
|
|
13
|
+
* Scroll container to observe via a pre-populated RefObject.
|
|
14
|
+
* For elements that mount asynchronously, use the returned `setRef` callback ref instead.
|
|
15
|
+
*/
|
|
16
|
+
target?: RefObject<T | null>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type UseScrollPositionResult<T extends HTMLElement = HTMLElement> =
|
|
20
|
+
ScrollPosition & {
|
|
21
|
+
/**
|
|
22
|
+
* Callback ref to attach to a scroll container — works correctly with elements
|
|
23
|
+
* that are null on initial render (lazy / conditional mounts). Pass this as `ref`
|
|
24
|
+
* on the scrollable element when you cannot guarantee the ref is populated at mount.
|
|
25
|
+
*/
|
|
26
|
+
setRef: RefCallback<T>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tracks the scroll offset of the window (default) or a scrollable element.
|
|
31
|
+
*
|
|
32
|
+
* - Window mode reads `scrollX` / `scrollY`; element mode reads `scrollLeft` / `scrollTop`.
|
|
33
|
+
* - Subscribes with a passive `scroll` listener and reads the initial position on mount.
|
|
34
|
+
* - Pass a pre-populated `target` RefObject **or** use the returned `setRef` callback ref on
|
|
35
|
+
* the scrollable element. Prefer `setRef` for elements that may be null on the first render
|
|
36
|
+
* (conditional mounts, portals) — it stores the element in state so the effect re-attaches
|
|
37
|
+
* correctly when the element becomes available.
|
|
38
|
+
* - For high-frequency consumers, derive throttled values downstream (e.g. with
|
|
39
|
+
* `useThrottledCallback`) rather than throttling the source of truth.
|
|
40
|
+
*
|
|
41
|
+
* @param params - {@link UseScrollPositionParams}
|
|
42
|
+
* @returns Latest `{ x, y }` scroll offset in pixels plus a `setRef` callback ref.
|
|
43
|
+
*/
|
|
44
|
+
export function useScrollPosition<T extends HTMLElement = HTMLElement>(
|
|
45
|
+
params: UseScrollPositionParams<T> = {},
|
|
46
|
+
): UseScrollPositionResult<T> {
|
|
47
|
+
const { target } = params;
|
|
48
|
+
|
|
49
|
+
// Track the element in state so the scroll listener effect reruns when the element
|
|
50
|
+
// is assigned (handles callback-ref / lazy-mount patterns).
|
|
51
|
+
const [element, setElement] = useState<T | null>(
|
|
52
|
+
() => target?.current ?? null,
|
|
53
|
+
);
|
|
54
|
+
const [position, setPosition] = useState<ScrollPosition>({ x: 0, y: 0 });
|
|
55
|
+
|
|
56
|
+
// Sync element state when the target RefObject changes (pre-populated refs).
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (target?.current != null) {
|
|
59
|
+
setElement(target.current);
|
|
60
|
+
}
|
|
61
|
+
}, [target]);
|
|
62
|
+
|
|
63
|
+
const setRef = useCallback((node: T | null) => {
|
|
64
|
+
setElement(node);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const node: Window | T | null =
|
|
69
|
+
element ?? (typeof window === "undefined" ? null : window);
|
|
70
|
+
if (node == null) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const read = (): ScrollPosition =>
|
|
74
|
+
element == null
|
|
75
|
+
? { x: window.scrollX, y: window.scrollY }
|
|
76
|
+
: { x: element.scrollLeft, y: element.scrollTop };
|
|
77
|
+
const onScroll = () => {
|
|
78
|
+
setPosition(read());
|
|
79
|
+
};
|
|
80
|
+
onScroll();
|
|
81
|
+
node.addEventListener("scroll", onScroll, { passive: true });
|
|
82
|
+
return () => {
|
|
83
|
+
node.removeEventListener("scroll", onScroll);
|
|
84
|
+
};
|
|
85
|
+
}, [element]);
|
|
86
|
+
|
|
87
|
+
return { x: position.x, y: position.y, setRef };
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useTimeout, type UseTimeoutResult } from "./useTimeout";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { useTimeout } from "./useTimeout";
|
|
5
|
+
|
|
6
|
+
describe("useTimeout", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should fire once after the delay", () => {
|
|
16
|
+
const callback = vi.fn();
|
|
17
|
+
renderHook(() => useTimeout(callback, 200));
|
|
18
|
+
vi.advanceTimersByTime(199);
|
|
19
|
+
expect(callback).not.toHaveBeenCalled();
|
|
20
|
+
vi.advanceTimersByTime(1);
|
|
21
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
22
|
+
vi.advanceTimersByTime(1000);
|
|
23
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should not schedule when delay is null", () => {
|
|
27
|
+
const callback = vi.fn();
|
|
28
|
+
renderHook(() => useTimeout(callback, null));
|
|
29
|
+
vi.advanceTimersByTime(1000);
|
|
30
|
+
expect(callback).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should cancel via clear", () => {
|
|
34
|
+
const callback = vi.fn();
|
|
35
|
+
const { result } = renderHook(() => useTimeout(callback, 200));
|
|
36
|
+
act(() => {
|
|
37
|
+
result.current.clear();
|
|
38
|
+
});
|
|
39
|
+
vi.advanceTimersByTime(500);
|
|
40
|
+
expect(callback).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should restart the delay via reset", () => {
|
|
44
|
+
const callback = vi.fn();
|
|
45
|
+
const { result } = renderHook(() => useTimeout(callback, 200));
|
|
46
|
+
vi.advanceTimersByTime(150);
|
|
47
|
+
act(() => {
|
|
48
|
+
result.current.reset();
|
|
49
|
+
});
|
|
50
|
+
vi.advanceTimersByTime(150);
|
|
51
|
+
expect(callback).not.toHaveBeenCalled();
|
|
52
|
+
vi.advanceTimersByTime(50);
|
|
53
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should cancel the pending timeout on unmount", () => {
|
|
57
|
+
const callback = vi.fn();
|
|
58
|
+
const { unmount } = renderHook(() => useTimeout(callback, 200));
|
|
59
|
+
unmount();
|
|
60
|
+
vi.advanceTimersByTime(500);
|
|
61
|
+
expect(callback).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
});
|