Skip to content

Commit 2e2d480

Browse files
feat(hooks): add 'useDoubleClick' (#201)
Co-authored-by: seungrodotlee <seungrodotlee@gmail.com>
1 parent ca19c76 commit 2e2d480

3 files changed

Lines changed: 226 additions & 0 deletions

File tree

src/hooks/useDoubleClick/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useDoubleClick } from './useDoubleClick.ts';
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { MouseEvent } from 'react';
2+
import { fireEvent, render } from '@testing-library/react';
3+
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
4+
5+
import { useDoubleClick } from './useDoubleClick.ts';
6+
7+
function TestComponent({
8+
delay = 250,
9+
onClick,
10+
onDoubleClick,
11+
}: {
12+
delay?: number;
13+
onClick?: (event: MouseEvent<HTMLElement>) => void;
14+
onDoubleClick: (event: MouseEvent<HTMLElement>) => void;
15+
}) {
16+
const handleEvent = useDoubleClick({
17+
delay,
18+
click: onClick,
19+
doubleClick: onDoubleClick,
20+
});
21+
22+
return <button onClick={handleEvent}>Test Button</button>;
23+
}
24+
25+
describe('useDoubleClick', () => {
26+
let clickSpy: Mock;
27+
let doubleClickSpy: Mock;
28+
29+
beforeEach(() => {
30+
vi.useFakeTimers();
31+
clickSpy = vi.fn();
32+
doubleClickSpy = vi.fn();
33+
});
34+
35+
afterEach(() => {
36+
vi.clearAllTimers();
37+
vi.useRealTimers();
38+
});
39+
40+
it('calls single click handler after delay if no double click', async () => {
41+
const { getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
42+
const button = getByText('Test Button');
43+
44+
fireEvent.click(button, { detail: 1 });
45+
46+
expect(clickSpy).not.toHaveBeenCalled();
47+
expect(doubleClickSpy).not.toHaveBeenCalled();
48+
49+
vi.advanceTimersByTime(250);
50+
51+
expect(clickSpy).toHaveBeenCalledTimes(1);
52+
expect(doubleClickSpy).not.toHaveBeenCalled();
53+
});
54+
55+
it('calls double click handler instead of single click when clicked twice quickly', () => {
56+
const { getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
57+
const button = getByText('Test Button');
58+
59+
fireEvent.click(button, { detail: 1 });
60+
fireEvent.click(button, { detail: 2 });
61+
62+
vi.advanceTimersByTime(250);
63+
64+
expect(clickSpy).not.toHaveBeenCalled();
65+
expect(doubleClickSpy).toHaveBeenCalledTimes(1);
66+
});
67+
68+
it('does not throw if click is not provided', () => {
69+
const { getByText } = render(<TestComponent onDoubleClick={doubleClickSpy} />);
70+
const button = getByText('Test Button');
71+
72+
fireEvent.click(button, { detail: 1 });
73+
74+
vi.advanceTimersByTime(250);
75+
76+
expect(doubleClickSpy).not.toHaveBeenCalled();
77+
});
78+
79+
it('calls double click handler only once on double click', () => {
80+
const { getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
81+
const button = getByText('Test Button');
82+
83+
fireEvent.click(button, { detail: 1 });
84+
fireEvent.click(button, { detail: 2 });
85+
86+
vi.advanceTimersByTime(250);
87+
88+
expect(clickSpy).not.toHaveBeenCalled();
89+
expect(doubleClickSpy).toHaveBeenCalledTimes(1);
90+
});
91+
92+
it('resets timeout if component unmounts early', () => {
93+
const { unmount, getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
94+
const button = getByText('Test Button');
95+
96+
fireEvent.click(button, { detail: 1 });
97+
unmount();
98+
99+
vi.advanceTimersByTime(250);
100+
101+
expect(clickSpy).not.toHaveBeenCalled();
102+
expect(doubleClickSpy).not.toHaveBeenCalled();
103+
});
104+
105+
it('respects custom delay time', () => {
106+
const { getByText } = render(<TestComponent delay={500} onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
107+
const button = getByText('Test Button');
108+
109+
fireEvent.click(button, { detail: 1 });
110+
111+
vi.advanceTimersByTime(250);
112+
113+
expect(clickSpy).not.toHaveBeenCalled();
114+
115+
vi.advanceTimersByTime(250);
116+
117+
expect(clickSpy).toHaveBeenCalledTimes(1);
118+
});
119+
120+
it('does not call click if doubleClick is triggered before delay', () => {
121+
const { getByText } = render(<TestComponent delay={300} onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
122+
const button = getByText('Test Button');
123+
124+
fireEvent.click(button, { detail: 1 });
125+
126+
vi.advanceTimersByTime(150);
127+
128+
fireEvent.click(button, { detail: 2 });
129+
130+
vi.advanceTimersByTime(150);
131+
132+
expect(clickSpy).not.toHaveBeenCalled();
133+
expect(doubleClickSpy).toHaveBeenCalledTimes(1);
134+
});
135+
136+
it('cleans up timers on unmount to prevent memory leaks', () => {
137+
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');
138+
const { unmount, getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
139+
const button = getByText('Test Button');
140+
141+
fireEvent.click(button, { detail: 1 });
142+
unmount();
143+
144+
vi.advanceTimersByTime(250);
145+
146+
expect(clearTimeoutSpy).toHaveBeenCalled();
147+
});
148+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { MouseEvent, useCallback, useEffect, useRef } from 'react';
2+
3+
import { usePreservedCallback } from '../usePreservedCallback/index.ts';
4+
5+
type UseDoubleClickProps<E extends HTMLElement> = {
6+
delay?: number;
7+
click?: (event: MouseEvent<E>) => void;
8+
doubleClick: (event: MouseEvent<E>) => void;
9+
};
10+
11+
/**
12+
* @description
13+
* `useDoubleClick` is a React hook that differentiates between single and double click events.
14+
* It delays the single click callback execution for a specified time, and cancels it if a second click (i.e. a double click) occurs within that time.
15+
*
16+
* @template {HTMLElement} E - The specific type of HTMLElement to be used with this hook (e.g., HTMLButtonElement, HTMLDivElement).
17+
* @param {Object} params - Configuration options for click handling.
18+
* @param {number} [params.delay=250] - The number of milliseconds to wait before triggering the single click callback. Defaults to 250ms.
19+
* @param {(event: MouseEvent<E>) => void} [params.click] - The callback function to be executed on a single click.
20+
* @param {(event: MouseEvent<E>) => void} params.doubleClick - The callback function to be executed on a double click. Required.
21+
*
22+
* @returns {(event: MouseEvent<E>) => void} A click handler function to attach to an element's `onClick` event.
23+
*
24+
* @example
25+
* function GalleryCard() {
26+
* const [selected, setSelected] = useState(false);
27+
*
28+
* const handleClick = () => setSelected((prev) => !prev);
29+
* const handleDoubleClick = () => alert('Zoom in!');
30+
*
31+
* const handleEvent = useDoubleClick({
32+
* click: handleClick,
33+
* doubleClick: handleDoubleClick,
34+
* });
35+
*
36+
* return (
37+
* <div onClick={handleEvent}>
38+
* {selected ? 'Selected' : 'Not selected'}
39+
* </div>
40+
* );
41+
* }
42+
*/
43+
export function useDoubleClick<E extends HTMLElement = HTMLElement>({
44+
delay = 250,
45+
click,
46+
doubleClick,
47+
}: UseDoubleClickProps<E>) {
48+
const clickTimeout = useRef<number>(null);
49+
50+
const clearClickTimeout = usePreservedCallback(() => {
51+
if (clickTimeout.current != null) {
52+
window.clearTimeout(clickTimeout.current);
53+
clickTimeout.current = null;
54+
}
55+
});
56+
57+
useEffect(() => () => clearClickTimeout(), [clearClickTimeout]);
58+
59+
const handleEvent = useCallback(
60+
(event: MouseEvent<E>) => {
61+
clearClickTimeout();
62+
63+
if (click && event.detail === 1) {
64+
clickTimeout.current = window.setTimeout(() => {
65+
click(event);
66+
}, delay);
67+
}
68+
69+
if (event.detail === 2) {
70+
doubleClick(event);
71+
}
72+
},
73+
[click, doubleClick, delay, clearClickTimeout]
74+
);
75+
76+
return handleEvent;
77+
}

0 commit comments

Comments
 (0)