Repository: streamich/react-use
Pull request #922 (2020-01-23 05:13:19)

feat: add useLongPress hook
# Description

#876 


## Type of change

<!-- Check all relevant options. -->
- [ ] Bug fix _(non-breaking change which fixes an issue)_
- [x] New feature _(non-breaking change which adds functionality)_
- [ ] **Breaking change** _(fix or feature that would cause existing functionality to not work as before)_

# Checklist
- [x] Read the [Contributing Guide](https://github.com/streamich/react-use/blob/master/CONTRIBUTING.md)
- [x] Perform a code self-review
- [x] Comment the code, particularly in hard-to-understand areas
- [x] Add documentation
- [x] Add hook's story at Storybook
- [x] Cover changes with tests
- [x] Ensure the test suite passes (`yarn test`)
- [x] Provide 100% tests coverage
- [x] Make sure code lints (`yarn lint`). Fix it with `yarn lint:fix` in case of failure.
- [x] Make sure types are fine (`yarn lint:types`).

<!-- If you can't check all the checkboxes right now - check what you can, create a Draft PR, make some changes if needed and get back to it when you will be able to put some marks in list. -->



Patch:
diff --git a/README.md b/README.md
index 6138cc28..e00ccf41 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,7 @@
   - [`useIntersection`](./docs/useIntersection.md) &mdash; tracks an HTML element's intersection. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-useintersection--demo)
   - [`useKey`](./docs/useKey.md), [`useKeyPress`](./docs/useKeyPress.md), [`useKeyboardJs`](./docs/useKeyboardJs.md), and [`useKeyPressEvent`](./docs/useKeyPressEvent.md) &mdash; track keys. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usekeypressevent--demo)
   - [`useLocation`](./docs/useLocation.md) and [`useSearchParam`](./docs/useSearchParam.md) &mdash; tracks page navigation bar location state.
+  - [`useLongPress`](./docs/useLongPress.md) &mdash; tracks long press gesture of some element.
   - [`useMedia`](./docs/useMedia.md) &mdash; tracks state of a CSS media query. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usemedia--demo)
   - [`useMediaDevices`](./docs/useMediaDevices.md) &mdash; tracks state of connected hardware devices.
   - [`useMotion`](./docs/useMotion.md) &mdash; tracks state of device's motion sensor.
diff --git a/docs/useLongPress.md b/docs/useLongPress.md
new file mode 100644
index 00000000..59109166
--- /dev/null
+++ b/docs/useLongPress.md
@@ -0,0 +1,46 @@
+# `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 defaultOptions = {
+    isPreventDefault: true,
+    delay: 300,
+  };
+  const longPressEvent = useLongPress(onLongPress, defaultOptions);
+
+  return <button {...longPressEvent}>useLongPress</button>;
+};
+```
+
+## Reference
+
+```ts
+const {
+  onMouseDown,
+  onTouchStart,
+  onMouseUp,
+  onMouseLeave,
+  onTouchEnd
+} = useLongPress(
+  callback: (e: TouchEvent | MouseEvent) => void,
+  options?: {
+    isPreventDefault?: true,
+    delay?: 300
+  }
+)
+```
+
+- `callback` &mdash; callback function.
+- `options?` &mdash; optional parameter.
+  - `isPreventDefault?` &mdash; whether to call `event.preventDefault()` of `touchend` event, for preventing ghost click on mobile devices in some cases, defaults to `true`.
+  - `delay?` &mdash; delay in milliseconds after which to calls provided callback, defaults to `300`.
diff --git a/src/index.ts b/src/index.ts
index d4730ca7..65b516ce 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 00000000..a1635b71
--- /dev/null
+++ b/src/useLongPress.ts
@@ -0,0 +1,57 @@
+import { useCallback, useRef } from 'react';
+
+interface Options {
+  isPreventDefault?: boolean;
+  delay?: number;
+}
+
+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,
+  { isPreventDefault = true, delay = 300 }: Options = {}
+) => {
+  const timeout = useRef<ReturnType<typeof setTimeout>>();
+  const target = useRef<EventTarget>();
+
+  const start = useCallback(
+    (event: TouchEvent | MouseEvent) => {
+      // prevent ghost click on mobile devices
+      if (isPreventDefault && event.target) {
+        event.target.addEventListener('touchend', preventDefault, { passive: false });
+        target.current = event.target;
+      }
+      timeout.current = setTimeout(() => callback(event), delay);
+    },
+    [callback, delay]
+  );
+
+  const clear = useCallback(() => {
+    // clearTimeout and removeEventListener
+    timeout.current && clearTimeout(timeout.current);
+
+    if (isPreventDefault && 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 00000000..184d3e05
--- /dev/null
+++ b/stories/useLongPress.story.tsx
@@ -0,0 +1,22 @@
+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 defaultOptions = {
+    isPreventDefault: true,
+    delay: 300,
+  };
+  const longPressEvent = useLongPress(onLongPress, defaultOptions);
+
+  return <button {...longPressEvent}>useLongPress</button>;
+};
+
+storiesOf('Sensors|useLongPress', module)
+  .add('Docs', () => <ShowDocs md={require('../docs/useLongPress.md')} />)
+  .add('Demo', () => <Demo />);


Test patch:
diff --git a/tests/useLongPress.test.tsx b/tests/useLongPress.test.tsx
new file mode 100644
index 00000000..652e2e7a
--- /dev/null
+++ b/tests/useLongPress.test.tsx
@@ -0,0 +1,124 @@
+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, { delay: 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 { result } = renderHook(() => useLongPress(callback));
+  const { onTouchStart } = result.current;
+
+  expect(callback).toHaveBeenCalledTimes(0);
+  onTouchStart(touchStart);
+
+  jest.advanceTimersByTime(defaultDelay - 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);
+});