diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0043d40 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Global +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 97e483c..a35d39f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,16 +15,22 @@ module.exports = { }, }, rules: { + // eslint rules 'no-use-before-define': 'off', 'prefer-rest-params': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/ban-ts-comment': 'off', 'no-param-reassign': 'off', 'no-void': 'off', 'consistent-return': 'off', 'no-restricted-syntax': 'off', 'arrow-body-style': 'off', + 'no-console': 'off', 'no-undef': 'off', 'prefer-arrow-callback': 'off', + // react rules + 'react/prop-types': 'off', + // typescript rules + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-empty-function': 'off', }, }; diff --git a/README.md b/README.md index 5ac1a4e..8b9de94 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![npm version](https://badge.fury.io/js/@rennalabs%2Fhooks.svg)](https://badge.fury.io/js/@rennalabs%2Fhooks) -A library of useful hooks. +A library of react hooks for state and UI management. ## Install @@ -31,12 +31,14 @@ yarn add @rennalabs/hooks - [`useOnClickOutside()`](#useOnClickOutside) - [`useMediaQuery()`](#useMediaQuery) - [`usePrefersReducedMotion()`](#usePrefersReducedMotion) - - [`useRandomInterval()`](#useRandomInterval) - - [`useInterval()`](#useInterval) - [`useTimeout()`](#useTimeout) + - [`useInterval()`](#useInterval) + - [`useRandomInterval()`](#useRandomInterval) - [`useCounter()`](#useCounter) - [`useHover()`](#useHover) - [`useOs()`](#useOs) + - [`useMousePosition()`](#useMousePosition) + - [`useFullscreen()`](#useFullscreen) ## Hooks @@ -61,7 +63,11 @@ import { useNetworkStatus } from '@rennalabs/hooks'; const Example = () => { const { isOnline, offlineAt } = useNetworkStatus(); - // ... + return ( +
+ {`App went offline at ${offlineAt.toString()}`} +
+ ); }; ``` @@ -82,7 +88,7 @@ import { useWindowScrollPosition } from '@rennalabs/hooks'; const Example = () => { const { x, y } = useWindowScrollPosition(); - // ... + return
{`Scroll position is { x: ${x}, y: ${y} }`}
; }; ``` @@ -103,7 +109,7 @@ import { useWindowSize } from '@rennalabs/hooks'; const Example = () => { const { width, height } = useWindowSize(); - // ... + return
{`window size is ${width}x${height}`}
; }; ``` @@ -131,7 +137,12 @@ const Example = () => { // to the value in local storage. const [name, setName] = useLocalStorage('name', 'Bob'); - // ... + return ( +
+

{`Saved name: ${name}`}

+ setName(e.target.value)} /> +
+ ); }; ``` @@ -139,24 +150,81 @@ const Example = () => { #### Arguments -- `ref: useRef`: A ref to an element created with useRef -- `func: function`: a function to be fired within an effect when a click outside the ref is detected +- `handler: function`: function that will be called on outside click. +- `events?: string[]`: optional list of events that indicate outside click. +- `nodes?: HTMLElement[]`: optional list of nodes that should not trigger outside click event. + +Hook returns React ref object that should be passed to element on which outside clicks should be captured. #### Example ```js +import { useState } from 'react'; import { useOnClickOutside } from '@rennalabs/hooks'; -const Example = () => { - // Create a ref that we add to the element for which we want to detect outside clicks - const ref = useRef(); - // State for our modal - const [isModalOpen, setModalOpen] = useState(false); - // Call hook passing in the ref and a function to call on outside click - useOnClickOutside(ref, () => setModalOpen(false)); - - // ... -}; +function Demo() { + const [opened, setOpened] = useState(false); + const ref = useOnClickOutside(() => setOpened(false)); + + return ( + <> + + + {opened && ( + + Click outside to close + + )} + + ); +} +``` + +#### Example with Events + +```js +import { useState } from 'react'; +import { useOnClickOutside } from '@rennalabs/hooks'; + +function Demo() { + const [opened, setOpened] = useState(false); + const ref = useClickOutside(() => setOpened(false), ['mouseup', 'touchend']); + + return ( + <> + + + {opened && ( + + Click outside to close + + )} + + ); +} +``` + +#### Example with nodes + +```js +import { useState } from 'react'; +import { useOnClickOutside } from '@rennalabs/hooks'; + +function Demo() { + const [dropdown, setDropdown] = useState(null); + const [control, setControl] = useState(null); + + useClickOutside(() => console.log('clicked outside'), null, [control, dropdown]); + + return ( +
+
Control
+
+
Dropdown
+
+
+ ); +} ``` ### `useMediaQuery()` @@ -205,36 +273,32 @@ const Example = ({ isBig }) => { }; ``` -### `useRandomInterval()` +### `useTimeout()` -A hook itended for animations and microinteractions that fire on a spontaneous interval. [More info here...](https://joshwcomeau.com/snippets/react-hooks/use-random-interval) +A declarative adaptation of `setTimeout` based on [Dan Abramov's blog post](https://overreacted.io/making-setinterval-declarative-with-react-hooks/) about `setInterval` #### Arguments - `callback: function` -- `minDelay?: number` -- `maxDelay?: number` +- `delay: number` #### Example ```js -import { useRandomInterval } from '@rennalabs/hooks'; - -function LaggyClock() { - // Update between every 1 and 4 seconds - const delay = [1000, 4000]; +import { useTimeout } from '@rennalabs/hooks'; - const [currentTime, setCurrentTime] = React.useState(Date.now); +function Example() { + const [message, setMessage] = useState('changing in 2 seconds...'); - useRandomInterval(() => setCurrentTime(Date.now()), ...delay); + useTimeout(() => setMessage('changed!'), 2000); - return <>It is currently {new Date(currentTime).toString()}.; + return {message}; } ``` ### `useInterval()` -A hook based on [Dan Abramov's blog post](https://overreacted.io/making-setinterval-declarative-with-react-hooks/) about `setInterval`. +A hook wrapper around window.setInterval #### Arguments @@ -244,50 +308,67 @@ A hook based on [Dan Abramov's blog post](https://overreacted.io/making-setinter #### Example ```js +import { useState, useEffect } from 'react'; import { useInterval } from '@rennalabs/hooks'; -function Example() { - let [count, setCount] = useState(0); - let [delay, setDelay] = useState(1000); - - useInterval(() => { - // Your custom logic here - setCount(count + 1); - }, delay); +function Demo() { + const [seconds, setSeconds] = useState(0); + const interval = useInterval(() => setSeconds(s => s + 1), 1000); - function handleDelayChange(e) { - setDelay(Number(e.target.value)); - } + useEffect(() => { + interval.start(); + return interval.stop; + }, []); return ( - - {count} - - +
+

+ Page loaded {seconds} seconds ago +

+ +
); } ``` -### `useTimeout()` +### `useRandomInterval()` -A declarative adaptation of `setTimeout` based on [Dan Abramov's blog post](https://overreacted.io/making-setinterval-declarative-with-react-hooks/) about `setInterval` +A hook itended for animations and microinteractions that fire on a spontaneous interval. #### Arguments - `callback: function` -- `delay: number` +- `minDelay?: number` +- `maxDelay?: number` #### Example ```js -import { useTimeout } from '@rennalabs/hooks'; +import { useState, useEffect } from 'react'; +import { useRandomInterval } from '@rennalabs/hooks'; -function Example() { - const [message, setMessage] = useState('changing in 2 seconds...'); +function LaggyTimer() { + // Update between every 1 and 4 seconds + const delay = [1000, 4000]; + const [seconds, setSeconds] = useState(0); - useTimeout(() => setMessage('changed!'), 2000); + const interval = useRandomInterval(() => setSeconds(s => s + 1), ...delay); - return {message}; + useEffect(() => { + interval.start(); + return interval.stop; + }, []); + + return ( +
+

It has been {seconds} seconds.

+ +
+ ); } ``` @@ -340,7 +421,7 @@ function Demo() { ### `useOs()` -useOs detects user's os. Possible values are: undetermined, macos, ios, windows, android, linux. If os cannot be identified, for example, during server side rendering undetermined will be returned. +useOs detects user's operating system. Possible values are: undetermined, macos, ios, windows, android, linux. If os cannot be identified, for example, during server side rendering undetermined will be returned. #### Example @@ -357,6 +438,71 @@ function Demo() { } ``` +### `useMousePosition()` + +Get mouse position relative to viewport or given element. + +#### Example + +```js +import { useMousePosition } from '@rennalabs/hooks'; + +function Demo() { + const { ref, x, y } = useMousePosition(); + + return ( + <> + Mouse coordinates are {`{ x: ${x}, y: ${y} }`} + + ); +} +``` + +### `useFullscreen()` + +useFullscreen allows to enter/exit fullscreen for given element using the Fullscreen API. By default, if you don't provide ref, hook will target document.documentElement: + +#### Example + +```js +import { useFullscreen } from '@rennalabs/hooks'; + +function Demo() { + const { toggle, fullscreen } = useFullscreen(); + + return ( + + ); +} +``` + +#### Example with custom element + +```js +import { useFullscreen } from '@rennalabs/hooks'; + +function Demo() { + const { ref, toggle, fullscreen } = useFullscreen(); + + return ( + <> + Unsplash Image to make Fullscreen + + + + ); +} +``` + --- [![CircleCI](https://circleci.com/gh/Renna-Labs/hooks.svg?style=svg)](https://circleci.com/gh/Renna-Labs/hooks) diff --git a/package.json b/package.json index 9002edd..c02fdef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rennalabs/hooks", - "version": "1.0.1", + "version": "1.1.0", "description": "A library of useful hooks.", "main": "dist/index.js", "typings": "types/index.d.ts", @@ -9,8 +9,8 @@ "types" ], "scripts": { - "start": "NODE_ENV=development babel -w ./src --out-dir ./dist --extensions \".ts,.tsx\"", - "build": "NODE_ENV=production npm run clean && babel ./src --out-dir ./dist --extensions \".ts,.tsx\" && tsc", + "start": "NODE_ENV=development babel -w ./src --out-dir ./dist --ignore \"src/**/*.test.ts\",\"src/**/*.test.tsx\" --extensions \".ts,.tsx\"", + "build": "NODE_ENV=production npm run clean && babel ./src --out-dir ./dist --ignore \"src/**/*.test.ts\",\"src/**/*.test.tsx\" --extensions \".ts,.tsx\" && tsc", "clean": "rimraf types dist", "format": "prettier --write \"src/*.{js,ts,tsx,json,md}\"", "test": "jest", @@ -56,6 +56,9 @@ "homepage": "https://github.com/Renna-Labs/hooks#readme", "jest": { "testEnvironment": "jest-environment-jsdom", + "setupFilesAfterEnv": [ + "./tests/setupTests.ts" + ], "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(tsx?|ts)$", "moduleDirectories": [ "./node_modules", @@ -79,7 +82,7 @@ "@babel/preset-react": "^7.x.x", "@babel/preset-typescript": "^7.x.x", "@rennalabs/eslint-config": "^1.0.2", - "@rennalabs/prettier-config": "^1.0.2", + "@rennalabs/prettier-config": "1.0.3", "@rennalabs/tsconfig": "1.0.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", diff --git a/src/index.ts b/src/index.ts index 56309ad..cf9da9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,19 @@ export * from './utils'; -export { useOs } from './useOs'; -export { useHover } from './useHover'; -export { useCounter } from './useCounter'; -export { useTimeout } from './useTimeout'; -export { useInterval } from './useInterval'; -export { useMediaQuery } from './useMediaQuery'; -export { useWindowSize } from './useWindowSize'; -export { useLocalStorage } from './useLocalStorage'; -export { useNetworkStatus } from './useNetworkStatus'; -export { useOnClickOutside } from './useOnClickOutside'; -export { useRandomInterval } from './useRandomInterval'; -export { useWindowScrollPosition } from './useWindowScrollPosition'; -export { usePrefersReducedMotion } from './usePrefersReducedMotion'; +export { useOs } from './useOs/useOs'; +export { useHover } from './useHover/useHover'; +export { useCounter } from './useCounter/useCounter'; +export { useTimeout } from './useTimeout/useTimeout'; +export { useInterval } from './useInterval/useInterval'; +export { useFullscreen } from './useFullscreen/useFullscreen'; +export { useMediaQuery } from './useMediaQuery/useMediaQuery'; +export { useWindowSize } from './useWindowSize/useWindowSize'; +export { useElementSize } from './useElementSize/useElementSize'; +export { useLocalStorage } from './useLocalStorage/useLocalStorage'; +export { useResizeObserver } from './useElementSize/useElementSize'; +export { useMousePosition } from './useMousePosition/useMousePosition'; +export { useNetworkStatus } from './useNetworkStatus/useNetworkStatus'; +export { useOnClickOutside } from './useOnClickOutside/useOnClickOutside'; +export { useRandomInterval } from './useRandomInterval/useRandomInterval'; +export { useWindowScrollPosition } from './useWindowScrollPosition/useWindowScrollPosition'; +export { usePrefersReducedMotion } from './usePrefersReducedMotion/usePrefersReducedMotion'; diff --git a/tests/clamp.test.ts b/src/useCounter/clamp.test.ts similarity index 82% rename from tests/clamp.test.ts rename to src/useCounter/clamp.test.ts index 7b2a1cd..198f3cd 100644 --- a/tests/clamp.test.ts +++ b/src/useCounter/clamp.test.ts @@ -1,4 +1,4 @@ -import { clamp } from '../src/utils'; +import { clamp } from '../utils'; describe('clamp', () => { it('clamps given value', () => { diff --git a/tests/useCounter.test.tsx b/src/useCounter/useCounter.test.tsx similarity index 96% rename from tests/useCounter.test.tsx rename to src/useCounter/useCounter.test.tsx index b46e56c..c684488 100644 --- a/tests/useCounter.test.tsx +++ b/src/useCounter/useCounter.test.tsx @@ -1,5 +1,5 @@ import { renderHook, act } from '@testing-library/react'; -import { useCounter } from '../src/useCounter'; +import { useCounter } from './useCounter'; describe('useCounter', () => { it('correctly returns initial state', () => { diff --git a/src/useCounter.ts b/src/useCounter/useCounter.ts similarity index 95% rename from src/useCounter.ts rename to src/useCounter/useCounter.ts index 6f54d9d..7d7d46c 100644 --- a/src/useCounter.ts +++ b/src/useCounter/useCounter.ts @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { clamp } from './utils'; +import { clamp } from '../utils'; const DEFAULT_OPTIONS = { min: -Infinity, diff --git a/src/useElementSize/useElementSize.ts b/src/useElementSize/useElementSize.ts new file mode 100644 index 0000000..082affb --- /dev/null +++ b/src/useElementSize/useElementSize.ts @@ -0,0 +1,62 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +type ObserverRect = Omit; + +const defaultState: ObserverRect = { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0, +}; + +export function useResizeObserver() { + const frameID = useRef(0); + const ref = useRef(null); + + const [rect, setRect] = useState(defaultState); + + const observer = useMemo( + () => + typeof window !== 'undefined' + ? new ResizeObserver((entries: any) => { + const entry = entries[0]; + + if (entry) { + cancelAnimationFrame(frameID.current); + + frameID.current = requestAnimationFrame(() => { + if (ref.current) { + setRect(entry.contentRect); + } + }); + } + }) + : null, + [], + ); + + useEffect(() => { + if (ref.current && observer) { + observer.observe(ref.current); + } + + return () => { + if (observer) {observer.disconnect();} + + if (frameID.current) { + cancelAnimationFrame(frameID.current); + } + }; + }, [ref.current]); + console.log(ref, rect); + return [ref, rect] as const; +} + +export function useElementSize() { + const [ref, { width, height }] = useResizeObserver(); + return { ref, width, height }; +} diff --git a/src/useFullscreen/useFullscreen.ts b/src/useFullscreen/useFullscreen.ts new file mode 100644 index 0000000..bf1aed6 --- /dev/null +++ b/src/useFullscreen/useFullscreen.ts @@ -0,0 +1,120 @@ +import { useCallback, useRef, useState, useEffect } from 'react'; + +function getFullscreenElement(): HTMLElement | null { + const _document = window.document as any; + + const fullscreenElement = + _document.fullscreenElement || + _document.webkitFullscreenElement || + _document.mozFullScreenElement || + _document.msFullscreenElement; + + return fullscreenElement; +} + +async function exitFullscreen() { + const _document = window.document as any; + + if (typeof _document.exitFullscreen === 'function') {return _document.exitFullscreen();} + if (typeof _document.msExitFullscreen === 'function') {return _document.msExitFullscreen();} + if (typeof _document.webkitExitFullscreen === 'function') {return _document.webkitExitFullscreen();} + if (typeof _document.mozCancelFullScreen === 'function') {return _document.mozCancelFullScreen();} + + return null; +} + +async function enterFullScreen(element: HTMLElement) { + const _element = element as any; + + return ( + _element.requestFullscreen?.() || + _element.msRequestFullscreen?.() || + _element.webkitEnterFullscreen?.() || + _element.webkitRequestFullscreen?.() || + _element.mozRequestFullscreen?.() + ); +} + +const prefixes = ['', 'webkit', 'moz', 'ms']; + +function addEvents( + element: HTMLElement, + { + onFullScreen, + onError, + }: { onFullScreen: (event: Event) => void; onError: (event: Event) => void }, +) { + prefixes.forEach(prefix => { + element.addEventListener(`${prefix}fullscreenchange`, onFullScreen); + element.addEventListener(`${prefix}fullscreenerror`, onError); + }); + + return () => { + prefixes.forEach(prefix => { + element.removeEventListener(`${prefix}fullscreenchange`, onFullScreen); + element.removeEventListener(`${prefix}fullscreenerror`, onError); + }); + }; +} + +export function useFullscreen() { + const [fullscreen, setFullscreen] = useState(false); + + const _ref = useRef(); + + const handleFullscreenChange = useCallback( + (event: Event) => { + setFullscreen(event.target === getFullscreenElement()); + }, + [setFullscreen], + ); + + const handleFullscreenError = useCallback( + (event: Event) => { + setFullscreen(false); + // eslint-disable-next-line no-console + console.error( + `[@mantine/hooks] use-fullscreen: Error attempting full-screen mode method: ${event} (${event.target})`, + ); + }, + [setFullscreen], + ); + + const toggle = useCallback(async () => { + if (!getFullscreenElement()) { + // @ts-ignore + await enterFullScreen(_ref.current); + } else { + await exitFullscreen(); + } + }, []); + + const ref = useCallback((element: T | null) => { + if (element === null) { + _ref.current = window.document.documentElement as T; + } else { + _ref.current = element; + } + }, []); + + useEffect(() => { + if (!_ref.current && window.document) { + _ref.current = window.document.documentElement as T; + return addEvents(_ref.current, { + onFullScreen: handleFullscreenChange, + onError: handleFullscreenError, + }); + } + + if (_ref.current) { + return addEvents(_ref.current, { + onFullScreen: handleFullscreenChange, + onError: handleFullscreenError, + }); + } + + return undefined; + }, []); + + return { ref, toggle, fullscreen } as const; +} diff --git a/tests/useHover.test.tsx b/src/useHover/useHover.test.tsx similarity index 86% rename from tests/useHover.test.tsx rename to src/useHover/useHover.test.tsx index c73912d..967579e 100644 --- a/tests/useHover.test.tsx +++ b/src/useHover/useHover.test.tsx @@ -1,8 +1,6 @@ -import React from 'react'; -import '@testing-library/jest-dom'; import { fireEvent, render, screen } from '@testing-library/react'; -import { useHover } from '../src/useHover'; +import { useHover } from './useHover'; const Target: React.FunctionComponent = () => { const { ref, hovered } = useHover(); diff --git a/src/useHover.ts b/src/useHover/useHover.ts similarity index 90% rename from src/useHover.ts rename to src/useHover/useHover.ts index e209b7f..55a26e5 100644 --- a/src/useHover.ts +++ b/src/useHover/useHover.ts @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; export function useHover() { const [hovered, setHovered] = useState(false); diff --git a/src/useInterval.ts b/src/useInterval.ts deleted file mode 100644 index 7bd9034..0000000 --- a/src/useInterval.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useRef } from 'react'; - -export function useInterval(callback: () => void, delay: number | null) { - const savedCallback = useRef<() => void>(() => {}); - - // Remember the latest callback. - useEffect(() => { - savedCallback.current = callback; - }, [callback]); - - // Set up the interval. - useEffect(() => { - function tick() { - savedCallback.current(); - } - if (delay !== null) { - let id = setInterval(tick, delay); - return () => clearInterval(id); - } - }, [delay]); -} diff --git a/src/useInterval/useInterval.test.tsx b/src/useInterval/useInterval.test.tsx new file mode 100644 index 0000000..646d5b0 --- /dev/null +++ b/src/useInterval/useInterval.test.tsx @@ -0,0 +1,156 @@ +import { renderHook, act } from '@testing-library/react'; +import { useInterval } from './useInterval'; + +const defaultTimeout = 2000; + +const callback = jest.fn(); + +const setupTimer = (timeout: number = defaultTimeout) => ({ + timeout, + advanceTimerToNextTick: () => jest.advanceTimersByTime(timeout), +}); + +const setupHook = ( + cb: (...args: any[]) => void = callback, + timeout: number = defaultTimeout, +): any => renderHook(() => useInterval(cb, timeout)); + +describe('useInterval', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.spyOn(global, 'setInterval'); + jest.spyOn(global, 'clearInterval'); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('initialize', () => { + const { result } = setupHook(); + + const { start, stop, toggle, active } = result.current; + + expect(typeof active).toBe('boolean'); + expect(typeof start).toBe('function'); + expect(typeof stop).toBe('function'); + expect(typeof toggle).toBe('function'); + }); + + it('callback should NOT fire before calling start function', () => { + const { advanceTimerToNextTick } = setupTimer(); + setupHook(); + advanceTimerToNextTick(); + expect(callback).not.toHaveBeenCalled(); + expect(setInterval).not.toHaveBeenCalled(); + expect(clearInterval).not.toHaveBeenCalled(); + }); + + it('should run after timeout exceeded', () => { + const { advanceTimerToNextTick } = setupTimer(); + const { result } = setupHook(); + + advanceTimerToNextTick(); + expect(callback).not.toHaveBeenCalled(); + + expect(result.current.active).toBe(false); + + act(() => { + result.current.start(); + }); + + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), defaultTimeout); + + expect(result.current.active).toBe(true); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(1); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(2); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(3); + }); + + it('should stop after stop fn call', () => { + const { advanceTimerToNextTick } = setupTimer(); + + const { result } = setupHook(); + + advanceTimerToNextTick(); + expect(callback).not.toHaveBeenCalled(); + + expect(result.current.active).toBe(false); + + act(() => { + result.current.start(); + }); + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), defaultTimeout); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(1); + + expect(result.current.active).toBe(true); + + act(() => { + result.current.stop(); + }); + + expect(clearInterval).toHaveBeenCalled(); + + expect(result.current.active).toBe(false); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should toggle between active states', () => { + const { advanceTimerToNextTick } = setupTimer(); + + const { result } = setupHook(); + advanceTimerToNextTick(); + expect(callback).not.toHaveBeenCalled(); + expect(result.current.active).toBe(false); + + act(() => { + result.current.toggle(); + }); + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), defaultTimeout); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(1); + expect(result.current.active).toBe(true); + + act(() => { + result.current.toggle(); + }); + + expect(clearInterval).toHaveBeenCalled(); + + expect(result.current.active).toBe(false); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(1); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(1); + + act(() => { + result.current.toggle(); + }); + + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), defaultTimeout); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(2); + + advanceTimerToNextTick(); + expect(callback).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/useInterval/useInterval.ts b/src/useInterval/useInterval.ts new file mode 100644 index 0000000..436fbca --- /dev/null +++ b/src/useInterval/useInterval.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useInterval(fn: () => void, interval: number) { + const [active, setActive] = useState(false); + const intervalRef = useRef(); + const fnRef = useRef<() => void>(); + + useEffect(() => { + fnRef.current = fn; + }, [fn]); + + const start = () => { + setActive(old => { + if (!old && !intervalRef.current) { + // @ts-ignore + intervalRef.current = window.setInterval(fnRef.current, interval); + } + return true; + }); + }; + + const stop = () => { + setActive(false); + window.clearInterval(intervalRef.current); + intervalRef.current = undefined; + }; + + const toggle = () => { + if (active) { + stop(); + } else { + start(); + } + }; + + return { start, stop, toggle, active }; +} diff --git a/src/useLocalStorage.ts b/src/useLocalStorage/useLocalStorage.ts similarity index 100% rename from src/useLocalStorage.ts rename to src/useLocalStorage/useLocalStorage.ts diff --git a/src/useMediaQuery.ts b/src/useMediaQuery.ts deleted file mode 100644 index 99eae66..0000000 --- a/src/useMediaQuery.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useState, useEffect } from 'react'; -import { isClient, isAPISupported } from './utils'; - -const errorMessage = - 'matchMedia is not supported, this could happen both because window.matchMedia is not supported by' + - " your current browser or you're using the useMediaQuery hook whilst server side rendering."; - -/** - * Accepts a media query string then uses the - * [window.matchMedia](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) API to determine if it - * matches with the current document.
- * It also monitor the document changes to detect when it matches or stops matching the media query.
- * Returns the validity state of the given media query. - * - */ - -type IsMediaQueryReturnType = boolean | null; - -export const useMediaQuery = (mediaQuery: string): IsMediaQueryReturnType => { - if (!isClient || !isAPISupported('matchMedia')) { - console.warn(errorMessage); - return null; - } - - const [isVerified, setIsVerified] = useState(!!window.matchMedia(mediaQuery).matches); - - useEffect(() => { - const mediaQueryList = window.matchMedia(mediaQuery); - const documentChangeHandler = () => setIsVerified(!!mediaQueryList.matches); - - mediaQueryList.addListener(documentChangeHandler); - - documentChangeHandler(); - return () => { - mediaQueryList.removeListener(documentChangeHandler); - }; - }, [mediaQuery]); - - return isVerified; -}; diff --git a/src/useMediaQuery/useMediaQuery.ts b/src/useMediaQuery/useMediaQuery.ts new file mode 100644 index 0000000..1f7eafe --- /dev/null +++ b/src/useMediaQuery/useMediaQuery.ts @@ -0,0 +1,42 @@ +import { useState, useEffect } from 'react'; +import { isClient, isAPISupported } from '../utils'; + +function getInitialValue(query: string) { + if (isClient && isAPISupported('matchMedia')) { + return window.matchMedia(query).matches; + } + + return false; +} + +type IsMediaQueryReturnType = boolean | null; + +/** + * Accepts a media query string then uses the + * [window.matchMedia](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) API to determine if it + * matches with the current document.
+ * It also monitor the document changes to detect when it matches or stops matching the media query.
+ * Returns the validity state of the given media query. + * + */ +export const useMediaQuery = (mediaQuery: string): IsMediaQueryReturnType => { + const [matches, setMatches] = useState(getInitialValue(mediaQuery)); + + useEffect(() => { + if (isClient && isAPISupported('matchMedia')) { + const mediaQueryList = window.matchMedia(mediaQuery); + const documentChangeHandler = () => setMatches(!!mediaQueryList.matches); + + mediaQueryList.addListener(documentChangeHandler); + + documentChangeHandler(); + return () => { + mediaQueryList.removeListener(documentChangeHandler); + }; + } + + return undefined; + }, [mediaQuery]); + + return matches; +}; diff --git a/src/useMousePosition/useMousePosition.test.tsx b/src/useMousePosition/useMousePosition.test.tsx new file mode 100644 index 0000000..eb83d19 --- /dev/null +++ b/src/useMousePosition/useMousePosition.test.tsx @@ -0,0 +1,58 @@ +import { renderHook, fireEvent, render, screen } from '@testing-library/react'; + +import { useMousePosition } from './useMousePosition'; + +const Target: React.FunctionComponent = () => { + const { ref, x, y } = useMousePosition(); + + return ( +
+ {`{ x: ${x}, y: ${y} }`} +
+ ); +}; + +describe('useMousePosition', () => { + test('initial position is (0, 0)', () => { + const { result } = renderHook(() => useMousePosition()); + + expect(result.current).toEqual({ ref: expect.any(Object), x: 0, y: 0 }); + }); + + test('mouse move updates the position', () => { + render(); + const target = screen.getByTestId('target'); + + // work around to pass pageX and pageY to the event + const customEvent = new MouseEvent('mousemove', { + clientX: 123, + clientY: 456, + bubbles: true, + }) as MouseEvent & { pageX: number; pageY: number }; + + customEvent.pageX = 123; + customEvent.pageY = 456; + + fireEvent(target, customEvent); + + expect(target).toHaveTextContent('{ x: 123, y: 456 }'); + }); + + test('mouse move updates the position without a ref', () => { + const { result } = renderHook(() => useMousePosition()); + + fireEvent.mouseMove(document, { clientX: 123, clientY: 456 }); + + expect(result.current).toEqual({ ref: expect.any(Object), x: 123, y: 456 }); + }); + + test('resetOnExit option resets the position on mouse leave', () => { + const { result } = renderHook(() => useMousePosition({ resetOnExit: true })); + + fireEvent.mouseMove(document, { clientX: 123, clientY: 456 }); + + fireEvent.mouseLeave(document); + + expect(result.current).toEqual({ ref: expect.any(Object), x: 0, y: 0 }); + }); +}); diff --git a/src/useMousePosition/useMousePosition.ts b/src/useMousePosition/useMousePosition.ts new file mode 100644 index 0000000..fb774f4 --- /dev/null +++ b/src/useMousePosition/useMousePosition.ts @@ -0,0 +1,48 @@ +import { type MouseEvent, useEffect, useRef, useState } from 'react'; + +export function useMousePosition( + options: { resetOnExit?: boolean } = { resetOnExit: false }, +) { + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const ref = useRef(); + + const setMousePosition = (event: MouseEvent) => { + if (ref.current) { + const rect = event.currentTarget.getBoundingClientRect(); + + const x = Math.max( + 0, + Math.round(event.pageX - rect.left - (window.pageXOffset || window.scrollX)), + ); + + const y = Math.max( + 0, + Math.round(event.pageY - rect.top - (window.pageYOffset || window.scrollY)), + ); + + setPosition({ x, y }); + } else { + setPosition({ x: event.clientX, y: event.clientY }); + } + }; + + const resetMousePosition = () => setPosition({ x: 0, y: 0 }); + + useEffect(() => { + const element = ref?.current ? ref.current : document; + element.addEventListener('mousemove', setMousePosition as any); + if (options.resetOnExit) { + element.addEventListener('mouseleave', resetMousePosition as any); + } + + return () => { + element.removeEventListener('mousemove', setMousePosition as any); + if (options.resetOnExit) { + element.removeEventListener('mouseleave', resetMousePosition as any); + } + }; + }, [ref.current]); + + return { ref, ...position }; +} diff --git a/src/useNetworkStatus.ts b/src/useNetworkStatus/useNetworkStatus.ts similarity index 100% rename from src/useNetworkStatus.ts rename to src/useNetworkStatus/useNetworkStatus.ts diff --git a/src/useOnClickOutside.ts b/src/useOnClickOutside.ts deleted file mode 100644 index ce226aa..0000000 --- a/src/useOnClickOutside.ts +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useEffect } from 'react'; - -export function useOnClickOutside(ref: any, handler: (event: any) => void) { - useEffect( - () => { - const listener = (event: any) => { - // Do nothing if clicking ref's element or descendent elements - if (!ref.current || ref.current.contains(event.target)) { - return; - } - - handler(event); - }; - - document.addEventListener('mousedown', listener); - document.addEventListener('touchstart', listener); - - return () => { - document.removeEventListener('mousedown', listener); - document.removeEventListener('touchstart', listener); - }; - }, - // Add ref and handler to effect dependencies - // It's worth noting that because passed in handler is a new ... - // ... function on every render that will cause this effect ... - // ... callback/cleanup to run every render. It's not a big deal ... - // ... but to optimize you can wrap handler in useCallback before ... - // ... passing it into this hook. - [ref, handler], - ); -} diff --git a/src/useOnClickOutside/useOnClickOutside.test.tsx b/src/useOnClickOutside/useOnClickOutside.test.tsx new file mode 100644 index 0000000..3e3fa7c --- /dev/null +++ b/src/useOnClickOutside/useOnClickOutside.test.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useOnClickOutside } from './useOnClickOutside'; + +interface UseOnClickOutsideProps { + handler: () => void; + events?: string[] | null; + nodes?: HTMLElement[]; +} + +const Target: React.FunctionComponent = ({ handler, events, nodes }) => { + const ref = useOnClickOutside(handler, events, nodes); + return
; +}; + +describe('useClickOutside', () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('calls `handler` function when clicked outside target (no `events` given)', async () => { + const handler = jest.fn(); + + render( + <> + +
+ , + ); + + const target = screen.getByTestId('target'); + const outsideTarget = screen.getByTestId('outside-target'); + + expect(handler).toHaveBeenCalledTimes(0); + + await userEvent.click(target); + expect(handler).toHaveBeenCalledTimes(0); + + await userEvent.click(outsideTarget); + expect(handler).toHaveBeenCalledTimes(1); + + await userEvent.click(outsideTarget); + expect(handler).toHaveBeenCalledTimes(2); + + await userEvent.click(target); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it('calls `handler` only on given `events`', async () => { + const handler = jest.fn(); + const events = ['keydown']; + + render( + <> + +
+ , + ); + + const target = screen.getByTestId('target'); + const outsideTarget = screen.getByTestId('outside-target'); + + await userEvent.click(target); + await userEvent.click(outsideTarget); + expect(handler).toHaveBeenCalledTimes(0); + + await userEvent.type(target, '{enter}'); + await userEvent.type(outsideTarget, '{enter}'); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it('ignores clicks outside the given `nodes`', async () => { + const handler = jest.fn(); + + const Wrapper: React.FunctionComponent = () => { + const [ignore, setIgnore] = useState(null); + return ( + <> + +
+ + ); + }; + + render( +
+ +
, + ); + + const ignoreClicks = screen.getByTestId('ignore-clicks'); + + await userEvent.click(ignoreClicks); + expect(handler).toHaveBeenCalledTimes(0); + + const target = screen.getByTestId('target'); + await userEvent.click(target); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/useOnClickOutside/useOnClickOutside.ts b/src/useOnClickOutside/useOnClickOutside.ts new file mode 100644 index 0000000..2a862a3 --- /dev/null +++ b/src/useOnClickOutside/useOnClickOutside.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react'; + +const DEFAULT_EVENTS = ['mousedown', 'touchstart']; + +export function useOnClickOutside( + handler: () => void, + events?: string[] | null, + nodes?: (HTMLElement | null)[], +) { + const ref = useRef(); + + useEffect(() => { + const listener = (event: any) => { + const { target } = event ?? {}; + if (Array.isArray(nodes)) { + const shouldIgnore = + target?.hasAttribute('data-ignore-outside-clicks') || + (!document.body.contains(target) && target.tagName !== 'HTML'); + const shouldTrigger = nodes.every(node => !!node && !event.composedPath().includes(node)); + shouldTrigger && !shouldIgnore && handler(); + } else if (ref.current && !ref.current.contains(target)) { + handler(); + } + }; + + (events || DEFAULT_EVENTS).forEach(fn => document.addEventListener(fn, listener)); + + return () => { + (events || DEFAULT_EVENTS).forEach(fn => document.removeEventListener(fn, listener)); + }; + }, [ref, handler, nodes]); + + return ref; +} diff --git a/src/useOs.ts b/src/useOs/useOs.ts similarity index 100% rename from src/useOs.ts rename to src/useOs/useOs.ts diff --git a/src/usePrefersReducedMotion.ts b/src/usePrefersReducedMotion/usePrefersReducedMotion.ts similarity index 100% rename from src/usePrefersReducedMotion.ts rename to src/usePrefersReducedMotion/usePrefersReducedMotion.ts diff --git a/src/useRandomInterval/useRandomInterval.test.tsx b/src/useRandomInterval/useRandomInterval.test.tsx new file mode 100644 index 0000000..abad531 --- /dev/null +++ b/src/useRandomInterval/useRandomInterval.test.tsx @@ -0,0 +1,93 @@ +import { act, renderHook } from '@testing-library/react'; +import { useRandomInterval } from './useRandomInterval'; +import '../../tests/toBeWithinRange'; + +jest.useFakeTimers(); + +const callback = jest.fn(); + +const minDelay = 100; +const maxDelay = 200; + +describe('useRandomInterval', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('initialize', () => { + const { result } = renderHook(() => useRandomInterval(callback, minDelay, maxDelay)); + + const { start, stop, toggle, active } = result.current; + + expect(typeof active).toBe('boolean'); + expect(typeof start).toBe('function'); + expect(typeof stop).toBe('function'); + expect(typeof toggle).toBe('function'); + }); + + it('calls the callback at random intervals', () => { + const { result } = renderHook(() => useRandomInterval(callback, minDelay, maxDelay)); + + const maxTime = maxDelay * 2; + const range = { min: maxTime / maxDelay, max: maxTime / minDelay }; + + act(() => { + result.current.start(); + jest.advanceTimersByTime(maxDelay * 2); + }); + + expect(result.current.active).toBe(true); + expect(callback.mock.calls.length).toBeWithinRange(range.min, range.max); + }); + + it('stops the interval when the stop function is called', () => { + const { result } = renderHook(() => useRandomInterval(callback, minDelay, maxDelay)); + + const maxTime = maxDelay * 2; + const range = { min: maxTime / maxDelay, max: maxTime / minDelay }; + + act(() => { + result.current.start(); + jest.advanceTimersByTime(maxDelay * 2); + }); + + expect(result.current.active).toBe(true); + expect(callback.mock.calls.length).toBeWithinRange(range.min, range.max); + + act(() => { + result.current.stop(); + jest.advanceTimersByTime(maxDelay * 2); + }); + + expect(result.current.active).toBe(false); + expect(callback.mock.calls.length).toBeWithinRange(range.min, range.max); + }); + + it('starts the interval when the toggle function is called', () => { + const { result } = renderHook(() => useRandomInterval(callback, minDelay, maxDelay)); + + const maxTime = maxDelay * 4; + const range = { min: maxTime / maxDelay, max: maxTime / minDelay }; + + act(() => { + result.current.start(); + jest.advanceTimersByTime(maxDelay * 2); + }); + + expect(result.current.active).toBe(true); + + act(() => { + result.current.stop(); + }); + + expect(result.current.active).toBe(false); + + act(() => { + result.current.toggle(); + jest.advanceTimersByTime(maxDelay * 2); + }); + + expect(result.current.active).toBe(true); + expect(callback.mock.calls.length).toBeWithinRange(range.min, range.max); + }); +}); diff --git a/src/useRandomInterval.ts b/src/useRandomInterval/useRandomInterval.ts similarity index 55% rename from src/useRandomInterval.ts rename to src/useRandomInterval/useRandomInterval.ts index f098c49..d382faa 100644 --- a/src/useRandomInterval.ts +++ b/src/useRandomInterval/useRandomInterval.ts @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useRef } from 'react'; - -// Utility helper for random number generation -const random = (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { random } from '../utils'; interface UseRandomIntervalReturnType { - cancel: () => void; + start: () => void; + stop: () => void; + toggle: () => void; + active: boolean; } export const useRandomInterval = ( @@ -14,13 +15,15 @@ export const useRandomInterval = ( ): UseRandomIntervalReturnType => { const timeoutId = useRef(null); const savedCallback = useRef<() => void>(callback); + const [active, setIsActive] = useState(false); useEffect(() => { savedCallback.current = callback; }); - useEffect(() => { - let isEnabled = typeof minDelay === 'number' && typeof maxDelay === 'number'; + const start = useCallback(() => { + const isEnabled = typeof minDelay === 'number' && typeof maxDelay === 'number'; + if (isEnabled) { const handleTick = () => { const nextTickAt = random(minDelay, maxDelay); @@ -30,14 +33,26 @@ export const useRandomInterval = ( }, nextTickAt); }; handleTick(); + setIsActive(true); } + }, [minDelay, maxDelay]); + useEffect(() => { return () => window.clearTimeout(timeoutId.current!); }, [minDelay, maxDelay]); - const cancel = useCallback(() => { + const stop = useCallback(() => { window.clearTimeout(timeoutId.current!); + setIsActive(false); }, []); - return { cancel }; + const toggle = useCallback(() => { + if (active) { + stop(); + } else { + start(); + } + }, [active, start, stop]); + + return { start, stop, toggle, active }; }; diff --git a/tests/useTimeout.test.tsx b/src/useTimeout/useTimeout.test.tsx similarity index 94% rename from tests/useTimeout.test.tsx rename to src/useTimeout/useTimeout.test.tsx index b13ecf1..b16c9a6 100644 --- a/tests/useTimeout.test.tsx +++ b/src/useTimeout/useTimeout.test.tsx @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react'; -import { useTimeout } from '../src/useTimeout'; + +import { useTimeout } from './useTimeout'; const defaultTimeout = 2000; diff --git a/src/useTimeout.ts b/src/useTimeout/useTimeout.ts similarity index 100% rename from src/useTimeout.ts rename to src/useTimeout/useTimeout.ts diff --git a/tests/useWindowScrollPosition.test.tsx b/src/useWindowScrollPosition/useWindowScrollPosition.test.tsx similarity index 82% rename from tests/useWindowScrollPosition.test.tsx rename to src/useWindowScrollPosition/useWindowScrollPosition.test.tsx index 977e504..f3e6163 100644 --- a/tests/useWindowScrollPosition.test.tsx +++ b/src/useWindowScrollPosition/useWindowScrollPosition.test.tsx @@ -1,8 +1,6 @@ -import React from 'react'; -import '@testing-library/jest-dom'; import { render, cleanup } from '@testing-library/react'; -import { useWindowScrollPosition } from '../src/useWindowScrollPosition'; +import { useWindowScrollPosition } from './useWindowScrollPosition'; function App() { const { x, y } = useWindowScrollPosition(); diff --git a/src/useWindowScrollPosition.ts b/src/useWindowScrollPosition/useWindowScrollPosition.ts similarity index 95% rename from src/useWindowScrollPosition.ts rename to src/useWindowScrollPosition/useWindowScrollPosition.ts index 0d6587e..785e7c1 100644 --- a/src/useWindowScrollPosition.ts +++ b/src/useWindowScrollPosition/useWindowScrollPosition.ts @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { throttle } from './utils'; +import { throttle } from '../utils'; interface ScrollPosition { x: number; diff --git a/tests/useWindowSize.test.tsx b/src/useWindowSize/useWindowSize.test.tsx similarity index 83% rename from tests/useWindowSize.test.tsx rename to src/useWindowSize/useWindowSize.test.tsx index 778aa76..e398d6e 100644 --- a/tests/useWindowSize.test.tsx +++ b/src/useWindowSize/useWindowSize.test.tsx @@ -1,8 +1,6 @@ -import React from 'react'; -import '@testing-library/jest-dom'; import { render, cleanup } from '@testing-library/react'; -import { useWindowSize } from '../src/useWindowSize'; +import { useWindowSize } from './useWindowSize'; function App() { const { width, height } = useWindowSize(); diff --git a/src/useWindowSize.ts b/src/useWindowSize/useWindowSize.ts similarity index 95% rename from src/useWindowSize.ts rename to src/useWindowSize/useWindowSize.ts index 30259f9..169a697 100644 --- a/src/useWindowSize.ts +++ b/src/useWindowSize/useWindowSize.ts @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { throttle } from './utils'; +import { throttle } from '../utils'; const events = new Set<() => void>(); const onResize = () => events.forEach(fn => fn()); diff --git a/src/utils.ts b/src/utils.ts index 6c7809d..02b4f87 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,8 @@ export function throttle void>( threshold = 250, scope?: any, ): T { - let last: number; let deferTimer: number; + let last: number; + let deferTimer: number; return function(this: any) { const context = scope || this; @@ -49,3 +50,6 @@ export const isClient: boolean = typeof window === 'object'; export function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } + +// Utility helper for random number generation +export const random = (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min; diff --git a/tests/setupTests.ts b/tests/setupTests.ts new file mode 100644 index 0000000..be5c782 --- /dev/null +++ b/tests/setupTests.ts @@ -0,0 +1,4 @@ +import React from 'react'; +import '@testing-library/jest-dom'; + +global.React = React; diff --git a/tests/toBeWithinRange.ts b/tests/toBeWithinRange.ts new file mode 100644 index 0000000..ed71ac1 --- /dev/null +++ b/tests/toBeWithinRange.ts @@ -0,0 +1,44 @@ +import { expect } from '@jest/globals'; +import type { MatcherFunction } from 'expect'; + +const toBeWithinRange: MatcherFunction<[floor: unknown, ceiling: unknown]> = + // `floor` and `ceiling` get types from the line above + // it is recommended to type them as `unknown` and to validate the values + function(actual, floor, ceiling) { + if (typeof actual !== 'number' || typeof floor !== 'number' || typeof ceiling !== 'number') { + throw new Error('These must be of type number!'); + } + + const pass = actual >= floor && actual <= ceiling; + if (pass) { + return { + message: () => + // `this` context will have correct typings + `expected ${this.utils.printReceived( + actual, + )} not to be within range ${this.utils.printExpected(`${floor} - ${ceiling}`)}`, + pass: true, + }; + } else { + return { + message: () => + `expected ${this.utils.printReceived( + actual, + )} to be within range ${this.utils.printExpected(`${floor} - ${ceiling}`)}`, + pass: false, + }; + } + }; + +expect.extend({ + toBeWithinRange, +}); + +declare module 'expect' { + interface AsymmetricMatchers { + toBeWithinRange(floor: number, ceiling: number): void; + } + interface Matchers { + toBeWithinRange(floor: number, ceiling: number): R; + } +} diff --git a/tests/useOnClickOutside.test.tsx b/tests/useOnClickOutside.test.tsx deleted file mode 100644 index 4a242fb..0000000 --- a/tests/useOnClickOutside.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useRef } from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { useOnClickOutside } from '../src/useOnClickOutside'; - -interface TargetProps { - handler: () => void; -} - -const Target: React.FunctionComponent = ({ handler }) => { - const ref = useRef(); - useOnClickOutside(ref, handler); - // @ts-ignore - return
; -}; - -describe('useOnClickOutside', () => { - afterAll(() => { - jest.clearAllMocks(); - }); - - it('calls `handler` function when clicked outside target', async () => { - const handler = jest.fn(); - - render( - <> - -
- , - ); - - const target = screen.getByTestId('target'); - const outsideTarget = screen.getByTestId('outside-target'); - - expect(handler).toHaveBeenCalledTimes(0); - - await userEvent.click(target); - expect(handler).toHaveBeenCalledTimes(0); - - await userEvent.click(outsideTarget); - expect(handler).toHaveBeenCalledTimes(1); - - await userEvent.click(outsideTarget); - expect(handler).toHaveBeenCalledTimes(2); - - await userEvent.click(target); - expect(handler).toHaveBeenCalledTimes(2); - }); -});