;
};
```
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
+```
+
---
[![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(
+