diff --git a/docs/useLongPress.md b/docs/useLongPress.md new file mode 100644 index 0000000000..d70429cd11 --- /dev/null +++ b/docs/useLongPress.md @@ -0,0 +1,38 @@ +# `useLongPress` + +React sensor hook that fires a callback after long pressing. + +## Usage + +```jsx +import { useLongPress } from 'react-use'; + +const Demo = () => { + const onLongPress = () => { + console.log('calls callback after long pressing 300ms'); + }; + + const defaultDelay = 300; + const longPressEvent = useLongPress(onLongPress, defaultDelay); + + return ; +}; +``` + +## Reference + +```ts +const { + onMouseDown, + onTouchStart, + onMouseUp, + onMouseLeave, + onTouchEnd +} = useLongPress( + callback: (e: TouchEvent | MouseEvent) => void, + delay?: number = 300 +) +``` + +- `callback` — callback function. +- `delay` — delay in milliseconds after which to calls provided callback, defaults to `300`. diff --git a/src/index.ts b/src/index.ts index d4730ca747..65b516ce00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ export { default as useLocalStorage } from './useLocalStorage'; export { default as useLocation } from './useLocation'; export { default as useLockBodyScroll } from './useLockBodyScroll'; export { default as useLogger } from './useLogger'; +export { default as useLongPress } from './useLongPress'; export { default as useMap } from './useMap'; export { default as useMedia } from './useMedia'; export { default as useMediaDevices } from './useMediaDevices'; diff --git a/src/useLongPress.ts b/src/useLongPress.ts new file mode 100644 index 0000000000..6d4b4a6a0c --- /dev/null +++ b/src/useLongPress.ts @@ -0,0 +1,48 @@ +import { useCallback, useRef } from 'react'; + +const isTouchEvent = (event: Event): event is TouchEvent => { + return 'touches' in event; +}; + +const preventDefault = (event: Event) => { + if (!isTouchEvent(event)) return; + + if (event.touches.length < 2 && event.preventDefault) { + event.preventDefault(); + } +}; + +const useLongPress = (callback: (e: TouchEvent | MouseEvent) => void, delay: number = 300) => { + const timeout = useRef>(); + const target = useRef(); + + const start = useCallback( + (event: TouchEvent | MouseEvent) => { + // prevent ghost click on mobile devices + if (event.target) { + target.current = event.target; + event.target.addEventListener('touchend', preventDefault, { passive: false }); + } + + timeout.current = setTimeout(() => callback(event), delay); + }, + [callback, delay] + ); + + const clear = useCallback(() => { + // clearTimeout and removeEventListener + timeout.current && clearTimeout(timeout.current); + + target.current && target.current.removeEventListener('touchend', preventDefault); + }, []); + + return { + onMouseDown: (e: any) => start(e), + onTouchStart: (e: any) => start(e), + onMouseUp: clear, + onMouseLeave: clear, + onTouchEnd: clear, + } as const; +}; + +export default useLongPress; diff --git a/stories/useLongPress.story.tsx b/stories/useLongPress.story.tsx new file mode 100644 index 0000000000..06d9eecbf4 --- /dev/null +++ b/stories/useLongPress.story.tsx @@ -0,0 +1,19 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useLongPress } from '../src'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const onLongPress = () => { + console.log('calls callback after long pressing 300ms'); + }; + + const defaultDelay = 300; + const longPressEvent = useLongPress(onLongPress, defaultDelay); + + return ; +}; + +storiesOf('Sensors|useLongPress', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/tests/useLongPress.test.tsx b/tests/useLongPress.test.tsx new file mode 100644 index 0000000000..6feeadc01e --- /dev/null +++ b/tests/useLongPress.test.tsx @@ -0,0 +1,125 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useLongPress from '../src/useLongPress'; + +const callback = jest.fn(); +const defaultDelay = 300; +const mouseDown = new MouseEvent('mousedown'); +const touchStart = new TouchEvent('touchstart'); + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + callback.mockRestore(); + jest.clearAllTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +it('should not call provided callback without trigger any event', () => { + renderHook(() => useLongPress(callback)); + + expect(callback).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(defaultDelay); + + expect(callback).toHaveBeenCalledTimes(0); +}); + +it('should call provided callback onMouseDown', () => { + const { result } = renderHook(() => useLongPress(callback)); + const { onMouseDown } = result.current; + + expect(callback).toHaveBeenCalledTimes(0); + onMouseDown(mouseDown); + + jest.advanceTimersByTime(defaultDelay - 20); + expect(callback).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(20); + expect(callback).toHaveBeenCalledTimes(1); +}); + +it('should call provided callback with custom delay', () => { + const customDelay = 1000; + const { result } = renderHook(() => useLongPress(callback, customDelay)); + const { onMouseDown } = result.current; + + expect(callback).toHaveBeenCalledTimes(0); + onMouseDown(mouseDown); + + jest.advanceTimersByTime(customDelay - 20); + expect(callback).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(20); + expect(callback).toHaveBeenCalledTimes(1); +}); + +it('should not call provided callback if interrupted by onMouseLeave', () => { + const { result } = renderHook(() => useLongPress(callback)); + const { onMouseDown, onMouseLeave } = result.current; + + expect(callback).toHaveBeenCalledTimes(0); + onMouseDown(mouseDown); + + jest.advanceTimersByTime(defaultDelay - 20); + expect(callback).toHaveBeenCalledTimes(0); + + onMouseLeave(); + + jest.advanceTimersByTime(20); + expect(callback).toHaveBeenCalledTimes(0); + expect(setTimeout).toHaveBeenCalledTimes(1); +}); + +it('should not call provided callback if interrupted by onMouseUp', () => { + const { result } = renderHook(() => useLongPress(callback)); + const { onMouseDown, onMouseUp } = result.current; + + expect(callback).toHaveBeenCalledTimes(0); + onMouseDown(mouseDown); + + jest.advanceTimersByTime(defaultDelay - 20); + expect(callback).toHaveBeenCalledTimes(0); + + onMouseUp(); + + jest.advanceTimersByTime(20); + expect(callback).toHaveBeenCalledTimes(0); + expect(setTimeout).toHaveBeenCalledTimes(1); +}); + +it('should call provided callback onTouchStart', () => { + const customDelay = 1000; + const { result } = renderHook(() => useLongPress(callback, customDelay)); + const { onMouseDown } = result.current; + + expect(callback).toHaveBeenCalledTimes(0); + onMouseDown(mouseDown); + + jest.advanceTimersByTime(customDelay - 20); + expect(callback).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(20); + expect(callback).toHaveBeenCalledTimes(1); +}); + +it('should not call provided callback if interrupted by onTouchEnd', () => { + const { result } = renderHook(() => useLongPress(callback)); + const { onTouchStart, onTouchEnd } = result.current; + + expect(callback).toHaveBeenCalledTimes(0); + onTouchStart(touchStart); + + jest.advanceTimersByTime(defaultDelay - 20); + expect(callback).toHaveBeenCalledTimes(0); + + onTouchEnd(); + + jest.advanceTimersByTime(20); + expect(callback).toHaveBeenCalledTimes(0); + expect(setTimeout).toHaveBeenCalledTimes(1); +});