From 670f945d0740663243cb478fea9050d74195a170 Mon Sep 17 00:00:00 2001 From: KooYS Date: Tue, 27 Oct 2026 16:02:29 +0900 Subject: [PATCH 1/2] fix(useDoubleClick): handle keyboard and programmatic clicks --- .../use-double-click-keyboard-activation.md | 5 + .../references/useDoubleClick.md | 5 +- .../hooks/useDoubleClick/es/useDoubleClick.md | 5 +- .../hooks/useDoubleClick/ja/useDoubleClick.md | 5 +- .../hooks/useDoubleClick/ko/useDoubleClick.md | 4 +- .../hooks/useDoubleClick/useDoubleClick.md | 5 +- .../useDoubleClick/useDoubleClick.spec.tsx | 103 ++++++++++++++++++ .../useDoubleClick/useDoubleClick.ssr.test.ts | 28 +++++ .../hooks/useDoubleClick/useDoubleClick.ts | 12 +- .../useDoubleClick/zh-Hans/useDoubleClick.md | 4 +- 10 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 .changeset/use-double-click-keyboard-activation.md create mode 100644 packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ssr.test.ts diff --git a/.changeset/use-double-click-keyboard-activation.md b/.changeset/use-double-click-keyboard-activation.md new file mode 100644 index 00000000..38a320b3 --- /dev/null +++ b/.changeset/use-double-click-keyboard-activation.md @@ -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`. diff --git a/packages/plugin/skills/react-simplikit/references/useDoubleClick.md b/packages/plugin/skills/react-simplikit/references/useDoubleClick.md index 61b45ee6..3e7d1e99 100644 --- a/packages/plugin/skills/react-simplikit/references/useDoubleClick.md +++ b/packages/plugin/skills/react-simplikit/references/useDoubleClick.md @@ -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 @@ -25,7 +26,7 @@ function useDoubleClick( 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 click runs. Clicks without a click count (event.detail === 0) run click immediately and ignore delay. Defaults to 250ms.', }, { name: 'props.click', diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/es/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/es/useDoubleClick.md index 135d05a2..4dbff0d5 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/es/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/es/useDoubleClick.md @@ -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 @@ -25,7 +26,7 @@ function useDoubleClick( 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 click. Los clics sin conteo de clics (event.detail === 0) ejecutan click de inmediato e ignoran delay. El valor predeterminado es 250 ms.', }, { name: 'props.click', diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/ja/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/ja/useDoubleClick.md index 62e6a21a..0390f781 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/ja/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/ja/useDoubleClick.md @@ -1,7 +1,8 @@ # useDoubleClick `useDoubleClick` は、シングルクリックとダブルクリックのイベントを区別する React フックです。 -シングルクリックのコールバックの実行を指定時間だけ遅らせ、その間に 2 回目のクリック(つまりダブルクリック)が発生した場合は、その実行をキャンセルします。 +ポインターによるクリックでは、シングルクリックのコールバックの実行を指定時間だけ遅らせ、その間に 2 回目のクリック(つまりダブルクリック)が発生した場合は、その実行をキャンセルします。 +クリック回数を持たないクリック(`event.detail === 0`。たとえばボタンのキーボード操作による実行)はダブルクリックの一部になり得ないため、`click` をすぐに実行し、`doubleClick` は呼び出しません。 ## インターフェース @@ -25,7 +26,7 @@ function useDoubleClick( required: false, defaultValue: '250', description: - 'シングルクリックのコールバックを実行するまでの待機時間(ミリ秒単位)です。デフォルトは 250 ミリ秒です。', + 'ポインターによるシングルクリックが 2 回目のクリックを待ってから click を実行するまでの時間(ミリ秒単位)です。クリック回数を持たないクリック(event.detail === 0)は click をすぐに実行し、delay は無視されます。デフォルトは 250 ミリ秒です。', }, { name: 'props.click', diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/ko/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/ko/useDoubleClick.md index 9461f253..941abdc9 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/ko/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/ko/useDoubleClick.md @@ -1,6 +1,6 @@ # useDoubleClick -`useDoubleClick`는 단일 클릭과 더블 클릭 이벤트를 구분하는 리액트 훅이에요. 지정된 시간 동안 단일 클릭 콜백 실행을 지연시키고, 그 시간 안에 두 번째 클릭(즉, 더블 클릭)이 발생하면 이를 취소해요. +`useDoubleClick`는 단일 클릭과 더블 클릭 이벤트를 구분하는 리액트 훅이에요. 포인터 클릭의 경우 지정된 시간 동안 단일 클릭 콜백 실행을 지연시키고, 그 시간 안에 두 번째 클릭(즉, 더블 클릭)이 발생하면 이를 취소해요. 클릭 횟수 정보가 없는 클릭(`event.detail === 0`, 예: 버튼의 키보드 활성화)은 더블 클릭의 일부가 될 수 없어서, 이런 클릭에는 `click`을 바로 실행하고 `doubleClick`은 호출하지 않아요. ## 인터페이스 @@ -24,7 +24,7 @@ function useDoubleClick( required: false, defaultValue: '250', description: - '단일 클릭 콜백을 실행하기 전에 기다리는 밀리초 단위 수로, 기본값은 250ms예요.', + '포인터 단일 클릭이 두 번째 클릭을 기다린 뒤 click을 실행하기까지의 대기 시간(밀리초)이에요. 클릭 횟수 정보가 없는 클릭(event.detail === 0)은 click을 바로 실행하고 delay를 무시해요. 기본값은 250ms예요.', }, { name: 'props.click', diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.md index 61b45ee6..3e7d1e99 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.md @@ -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 @@ -25,7 +26,7 @@ function useDoubleClick( 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 click runs. Clicks without a click count (event.detail === 0) run click immediately and ignore delay. Defaults to 250ms.', }, { name: 'props.click', diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx index cf481b19..997fc0fc 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx +++ b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx @@ -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(); }); @@ -144,4 +146,105 @@ describe('useDoubleClick', () => { expect(clearTimeoutSpy).toHaveBeenCalled(); }); + + describe('activation without a click count (detail 0)', () => { + it('calls click immediately for keyboard-style activation', () => { + const { getByText } = render(); + 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(); + + getByText('Test Button').click(); + + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(doubleClickSpy).not.toHaveBeenCalled(); + }); + + it('forwards the original event to click', () => { + const { getByText } = render(); + + fireEvent.click(getByText('Test Button'), { detail: 0 }); + + expect(clickSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'click', detail: 0 })); + }); + + it('does not throw if click is not provided', () => { + const { getByText } = render(); + 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(); + 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(); + 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(); + 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(); + const button = getByText('Test Button'); + + fireEvent.click(button, { detail: 1 }); + fireEvent.click(button, { detail: 0 }); + unmount(); + + vi.advanceTimersByTime(250); + + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ssr.test.ts b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ssr.test.ts new file mode 100644 index 00000000..98d10e35 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ssr.test.ts @@ -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(''); + expect(click).not.toHaveBeenCalled(); + expect(doubleClick).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ts b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ts index 4614a9ab..f2cb84fa 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ts +++ b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ts @@ -11,11 +11,12 @@ type UseDoubleClickProps = { /** * @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) => void} [props.click] - The callback function to be executed on a single click. * @param {(event: MouseEvent) => void} props.doubleClick - The callback function to be executed on a double click. Required. * @@ -58,6 +59,13 @@ export function useDoubleClick({ const handleEvent = useCallback( (event: MouseEvent) => { + // 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) { diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/zh-Hans/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/zh-Hans/useDoubleClick.md index 5324ec15..dfee62df 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/zh-Hans/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/zh-Hans/useDoubleClick.md @@ -1,6 +1,6 @@ # useDoubleClick -`useDoubleClick` 是一个区分单击和双击事件的 React Hook。它会将单击回调的执行延迟指定的一段时间,如果在这段时间内发生了第二次点击(即双击),则取消该回调。 +`useDoubleClick` 是一个区分单击和双击事件的 React Hook。对于指针点击,它会将单击回调的执行延迟指定的一段时间,如果在这段时间内发生了第二次点击(即双击),则取消该回调。不带点击次数的点击(`event.detail === 0`,例如通过键盘激活按钮)不可能构成双击的一部分,因此会立即执行 `click`,且不会调用 `doubleClick`。 ## 接口 @@ -24,7 +24,7 @@ function useDoubleClick( required: false, defaultValue: '250', description: - '触发单击回调之前等待的毫秒数。默认为 250 毫秒。', + '指针单击在执行 click 之前等待可能出现的第二次点击的毫秒数。不带点击次数的点击(event.detail === 0)会立即执行 click,并忽略 delay。默认为 250 毫秒。', }, { name: 'props.click', From 06bd593717168ee912ae260274297169ee06e9d9 Mon Sep 17 00:00:00 2001 From: KooYS Date: Tue, 27 Oct 2026 16:34:33 +0900 Subject: [PATCH 2/2] test(useDoubleClick): verify original click event identity --- .../useDoubleClick/useDoubleClick.spec.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx index 997fc0fc..3377e7da 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx +++ b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx @@ -172,11 +172,30 @@ describe('useDoubleClick', () => { }); it('forwards the original event to click', () => { - const { getByText } = render(); + const received: MouseEvent[] = []; + + function CaptureComponent() { + const handleEvent = useDoubleClick({ click: clickSpy, doubleClick: doubleClickSpy }); + + return ( + + ); + } + + const { getByText } = render(); fireEvent.click(getByText('Test Button'), { detail: 0 }); - expect(clickSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'click', 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', () => {