Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/use-double-click-keyboard-activation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-simplikit': patch
---

`useDoubleClick` now runs `click` immediately for clicks that carry no click count (`event.detail === 0`), such as Enter/Space on a native button or a programmatic `element.click()`. The handler only recognised `detail` 1 and 2, so these clicks were dropped, and they also cancelled a pending mouse click. `doubleClick` is still only called for `detail === 2`.
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# useDoubleClick

`useDoubleClick` is a React hook that differentiates between single and double click events.
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.
For pointer clicks, 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.
Clicks that carry no click count (`event.detail === 0`, e.g. keyboard activation of a button) cannot be part of a double click, so `click` runs immediately for them and `doubleClick` is not called.

## Interface

Expand All @@ -25,7 +26,7 @@ function useDoubleClick<E extends HTMLElement>(
required: false,
defaultValue: '250',
description:
'The number of milliseconds to wait before triggering the single click callback. Defaults to 250ms.',
'The number of milliseconds a pointer single click waits for a possible second click before <code>click</code> runs. Clicks without a click count (<code>event.detail === 0</code>) run <code>click</code> immediately and ignore <code>delay</code>. Defaults to 250ms.',
},
{
name: 'props.click',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# useDoubleClick

`useDoubleClick` es un Hook de React que distingue entre los eventos de un solo clic y los de doble clic.
Retrasa la ejecución del callback de un solo clic durante el tiempo que especificas y la cancela si ocurre un segundo clic (es decir, un doble clic) dentro de ese período.
Para los clics de puntero, retrasa la ejecución del callback de un solo clic durante el tiempo que especificas y la cancela si ocurre un segundo clic (es decir, un doble clic) dentro de ese período.
Los clics que no llevan un conteo de clics (`event.detail === 0`, por ejemplo la activación de un botón con el teclado) no pueden formar parte de un doble clic, así que `click` se ejecuta de inmediato para ellos y `doubleClick` no se llama.

## Interfaz

Expand All @@ -25,7 +26,7 @@ function useDoubleClick<E extends HTMLElement>(
required: false,
defaultValue: '250',
description:
'El número de milisegundos de espera antes de ejecutar el callback de un solo clic. El valor predeterminado es 250 ms.',
'El número de milisegundos que un solo clic de puntero espera un posible segundo clic antes de ejecutar <code>click</code>. Los clics sin conteo de clics (<code>event.detail === 0</code>) ejecutan <code>click</code> de inmediato e ignoran <code>delay</code>. El valor predeterminado es 250 ms.',
},
{
name: 'props.click',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# useDoubleClick

`useDoubleClick` は、シングルクリックとダブルクリックのイベントを区別する React フックです。
シングルクリックのコールバックの実行を指定時間だけ遅らせ、その間に 2 回目のクリック(つまりダブルクリック)が発生した場合は、その実行をキャンセルします。
ポインターによるクリックでは、シングルクリックのコールバックの実行を指定時間だけ遅らせ、その間に 2 回目のクリック(つまりダブルクリック)が発生した場合は、その実行をキャンセルします。
クリック回数を持たないクリック(`event.detail === 0`。たとえばボタンのキーボード操作による実行)はダブルクリックの一部になり得ないため、`click` をすぐに実行し、`doubleClick` は呼び出しません。

## インターフェース

Expand All @@ -25,7 +26,7 @@ function useDoubleClick<E extends HTMLElement>(
required: false,
defaultValue: '250',
description:
'シングルクリックのコールバックを実行するまでの待機時間(ミリ秒単位)です。デフォルトは 250 ミリ秒です。',
'ポインターによるシングルクリックが 2 回目のクリックを待ってから <code>click</code> を実行するまでの時間(ミリ秒単位)です。クリック回数を持たないクリック(<code>event.detail === 0</code>)は <code>click</code> をすぐに実行し、<code>delay</code> は無視されます。デフォルトは 250 ミリ秒です。',
},
{
name: 'props.click',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# useDoubleClick

`useDoubleClick`는 단일 클릭과 더블 클릭 이벤트를 구분하는 리액트 훅이에요. 지정된 시간 동안 단일 클릭 콜백 실행을 지연시키고, 그 시간 안에 두 번째 클릭(즉, 더블 클릭)이 발생하면 이를 취소해요.
`useDoubleClick`는 단일 클릭과 더블 클릭 이벤트를 구분하는 리액트 훅이에요. 포인터 클릭의 경우 지정된 시간 동안 단일 클릭 콜백 실행을 지연시키고, 그 시간 안에 두 번째 클릭(즉, 더블 클릭)이 발생하면 이를 취소해요. 클릭 횟수 정보가 없는 클릭(`event.detail === 0`, 예: 버튼의 키보드 활성화)은 더블 클릭의 일부가 될 수 없어서, 이런 클릭에는 `click`을 바로 실행하고 `doubleClick`은 호출하지 않아요.

## 인터페이스

Expand All @@ -24,7 +24,7 @@ function useDoubleClick<E extends HTMLElement>(
required: false,
defaultValue: '250',
description:
'단일 클릭 콜백을 실행하기 전에 기다리는 밀리초 단위 수로, 기본값은 250ms예요.',
'포인터 단일 클릭이 두 번째 클릭을 기다린 뒤 <code>click</code>을 실행하기까지의 대기 시간(밀리초)이에요. 클릭 횟수 정보가 없는 클릭(<code>event.detail === 0</code>)은 <code>click</code>을 바로 실행하고 <code>delay</code>를 무시해요. 기본값은 250ms예요.',
},
{
name: 'props.click',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# useDoubleClick

`useDoubleClick` is a React hook that differentiates between single and double click events.
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.
For pointer clicks, 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.
Clicks that carry no click count (`event.detail === 0`, e.g. keyboard activation of a button) cannot be part of a double click, so `click` runs immediately for them and `doubleClick` is not called.

## Interface

Expand All @@ -25,7 +26,7 @@ function useDoubleClick<E extends HTMLElement>(
required: false,
defaultValue: '250',
description:
'The number of milliseconds to wait before triggering the single click callback. Defaults to 250ms.',
'The number of milliseconds a pointer single click waits for a possible second click before <code>click</code> runs. Clicks without a click count (<code>event.detail === 0</code>) run <code>click</code> immediately and ignore <code>delay</code>. Defaults to 250ms.',
},
{
name: 'props.click',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ describe('useDoubleClick', () => {
});

afterEach(() => {
// The unmount test spies on window.clearTimeout; restore it so tests added after it see the real timer API
vi.restoreAllMocks();
vi.useRealTimers();
});

Expand Down Expand Up @@ -144,4 +146,124 @@ describe('useDoubleClick', () => {

expect(clearTimeoutSpy).toHaveBeenCalled();
});

describe('activation without a click count (detail 0)', () => {
it('calls click immediately for keyboard-style activation', () => {
const { getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
const button = getByText('Test Button');

fireEvent.click(button, { detail: 0 });

expect(clickSpy).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(250);

expect(clickSpy).toHaveBeenCalledTimes(1);
expect(doubleClickSpy).not.toHaveBeenCalled();
});

it('calls click for a programmatic button.click()', () => {
const { getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);

getByText('Test Button').click();

expect(clickSpy).toHaveBeenCalledTimes(1);
expect(doubleClickSpy).not.toHaveBeenCalled();
});

it('forwards the original event to click', () => {
const received: MouseEvent<HTMLElement>[] = [];

function CaptureComponent() {
const handleEvent = useDoubleClick({ click: clickSpy, doubleClick: doubleClickSpy });

return (
<button
onClick={event => {
received.push(event);
handleEvent(event);
}}
>
Test Button
</button>
);
}

const { getByText } = render(<CaptureComponent />);

fireEvent.click(getByText('Test Button'), { detail: 0 });

expect(received).toHaveLength(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(clickSpy.mock.calls[0][0]).toBe(received[0]);
});

it('does not throw if click is not provided', () => {
const { getByText } = render(<TestComponent onDoubleClick={doubleClickSpy} />);
const button = getByText('Test Button');

expect(() => fireEvent.click(button, { detail: 0 })).not.toThrow();

vi.advanceTimersByTime(250);

expect(doubleClickSpy).not.toHaveBeenCalled();
});

it('calls click for every repeated activation', () => {
const { getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
const button = getByText('Test Button');

fireEvent.click(button, { detail: 0 });
fireEvent.click(button, { detail: 0 });
fireEvent.click(button, { detail: 0 });

expect(clickSpy).toHaveBeenCalledTimes(3);
expect(doubleClickSpy).not.toHaveBeenCalled();
});

it('does not cancel a pending mouse click', () => {
const { getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
const button = getByText('Test Button');

fireEvent.click(button, { detail: 1 });
vi.advanceTimersByTime(100);
fireEvent.click(button, { detail: 0 });

expect(clickSpy).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenLastCalledWith(expect.objectContaining({ detail: 0 }));

vi.advanceTimersByTime(150);

expect(clickSpy).toHaveBeenCalledTimes(2);
expect(clickSpy).toHaveBeenLastCalledWith(expect.objectContaining({ detail: 1 }));
expect(doubleClickSpy).not.toHaveBeenCalled();
});

it('keeps the mouse double click working after a detail 0 activation', () => {
const { getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
const button = getByText('Test Button');

fireEvent.click(button, { detail: 0 });
fireEvent.click(button, { detail: 1 });
fireEvent.click(button, { detail: 2 });

vi.advanceTimersByTime(250);

expect(clickSpy).toHaveBeenCalledTimes(1);
expect(doubleClickSpy).toHaveBeenCalledTimes(1);
});

it('does not call click after unmount', () => {
const { unmount, getByText } = render(<TestComponent onClick={clickSpy} onDoubleClick={doubleClickSpy} />);
const button = getByText('Test Button');

fireEvent.click(button, { detail: 1 });
fireEvent.click(button, { detail: 0 });
unmount();

vi.advanceTimersByTime(250);

expect(clickSpy).toHaveBeenCalledTimes(1);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @vitest-environment node
*
* SSR environment tests - runs in Node.js where window is truly undefined
*/
import { createElement } from 'react';
import { renderToString } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';

import { useDoubleClick } from './useDoubleClick.ts';

describe('useDoubleClick SSR environment', () => {
it('should be safe on server side rendering', () => {
const click = vi.fn();
const doubleClick = vi.fn();

function Button() {
const handleEvent = useDoubleClick({ click, doubleClick });

return createElement('button', { onClick: handleEvent }, 'Test Button');
}

expect(typeof window).toBe('undefined');
expect(renderToString(createElement(Button))).toBe('<button>Test Button</button>');
expect(click).not.toHaveBeenCalled();
expect(doubleClick).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ type UseDoubleClickProps<E extends HTMLElement> = {
/**
* @description
* `useDoubleClick` is a React hook that differentiates between single and double click events.
* 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.
* For pointer clicks, 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.
* Clicks that carry no click count (`event.detail === 0`, e.g. keyboard activation of a button) cannot be part of a double click, so `click` runs immediately for them and `doubleClick` is not called.
*
* @template {HTMLElement} E - The specific type of HTMLElement to be used with this hook (e.g., HTMLButtonElement, HTMLDivElement).
* @param {Object} props - Configuration options for click handling.
* @param {number} [props.delay=250] - The number of milliseconds to wait before triggering the single click callback. Defaults to 250ms.
* @param {number} [props.delay=250] - The number of milliseconds a pointer single click waits for a possible second click before `click` runs. Clicks without a click count (`event.detail === 0`) run `click` immediately and ignore `delay`. Defaults to 250ms.
* @param {(event: MouseEvent<E>) => void} [props.click] - The callback function to be executed on a single click.
* @param {(event: MouseEvent<E>) => void} props.doubleClick - The callback function to be executed on a double click. Required.
*
Expand Down Expand Up @@ -58,6 +59,13 @@ export function useDoubleClick<E extends HTMLElement = HTMLElement>({

const handleEvent = useCallback(
(event: MouseEvent<E>) => {
// Clicks without a click count (e.g. keyboard activation) do not need to wait for a second click.
// Return before clearClickTimeout so they leave a pending mouse click alone.
if (event.detail === 0) {
click?.(event);
return;
}

clearClickTimeout();

if (click && event.detail === 1) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# useDoubleClick

`useDoubleClick` 是一个区分单击和双击事件的 React Hook。它会将单击回调的执行延迟指定的一段时间,如果在这段时间内发生了第二次点击(即双击),则取消该回调。
`useDoubleClick` 是一个区分单击和双击事件的 React Hook。对于指针点击,它会将单击回调的执行延迟指定的一段时间,如果在这段时间内发生了第二次点击(即双击),则取消该回调。不带点击次数的点击(`event.detail === 0`,例如通过键盘激活按钮)不可能构成双击的一部分,因此会立即执行 `click`,且不会调用 `doubleClick`。

## 接口

Expand All @@ -24,7 +24,7 @@ function useDoubleClick<E extends HTMLElement>(
required: false,
defaultValue: '250',
description:
'触发单击回调之前等待的毫秒数。默认为 250 毫秒。',
'指针单击在执行 <code>click</code> 之前等待可能出现的第二次点击的毫秒数。不带点击次数的点击(<code>event.detail === 0</code>)会立即执行 <code>click</code>,并忽略 <code>delay</code>。默认为 250 毫秒。',
},
{
name: 'props.click',
Expand Down
Loading