From f4e1c15e8bc7e87c0a47962f13daa15f6488cb96 Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Tue, 31 Jan 2023 20:32:40 -0800 Subject: [PATCH 01/10] refactor(useMediaQuery): move ssr conditional inside effect --- package.json | 2 +- src/useMediaQuery.ts | 42 ++++++++++++++++++++++-------------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 9002edd..29ce91e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rennalabs/hooks", - "version": "1.0.1", + "version": "1.0.2", "description": "A library of useful hooks.", "main": "dist/index.js", "typings": "types/index.d.ts", diff --git a/src/useMediaQuery.ts b/src/useMediaQuery.ts index 99eae66..b7b070f 100644 --- a/src/useMediaQuery.ts +++ b/src/useMediaQuery.ts @@ -1,9 +1,15 @@ 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."; +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 @@ -13,28 +19,24 @@ const errorMessage = * 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); + const [matches, setMatches] = useState(getInitialValue(mediaQuery)); useEffect(() => { - const mediaQueryList = window.matchMedia(mediaQuery); - const documentChangeHandler = () => setIsVerified(!!mediaQueryList.matches); + if (isClient && isAPISupported('matchMedia')) { + const mediaQueryList = window.matchMedia(mediaQuery); + const documentChangeHandler = () => setMatches(!!mediaQueryList.matches); + + mediaQueryList.addListener(documentChangeHandler); - mediaQueryList.addListener(documentChangeHandler); + documentChangeHandler(); + return () => { + mediaQueryList.removeListener(documentChangeHandler); + }; + } - documentChangeHandler(); - return () => { - mediaQueryList.removeListener(documentChangeHandler); - }; + return undefined; }, [mediaQuery]); - return isVerified; + return matches; }; From 8128e4d7477ac09e3c83286a656edeefbe86362f Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Wed, 1 Feb 2023 07:09:36 -0800 Subject: [PATCH 02/10] refactor(useOnClickOutside): pass ref from hook & add support for multiple nodes + events --- .editorconfig | 13 ++++++ README.md | 79 +++++++++++++++++++++++++++----- package.json | 2 +- src/useOnClickOutside.ts | 55 +++++++++++----------- tests/useOnClickOutside.test.tsx | 69 ++++++++++++++++++++++++---- 5 files changed, 171 insertions(+), 47 deletions(-) create mode 100644 .editorconfig 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/README.md b/README.md index 5ac1a4e..c055f18 100644 --- a/README.md +++ b/README.md @@ -139,24 +139,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()` diff --git a/package.json b/package.json index 29ce91e..fcb362a 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,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/useOnClickOutside.ts b/src/useOnClickOutside.ts index ce226aa..2a862a3 100644 --- a/src/useOnClickOutside.ts +++ b/src/useOnClickOutside.ts @@ -1,31 +1,34 @@ -import React, { useEffect } from 'react'; +import { useEffect, useRef } 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; - } +const DEFAULT_EVENTS = ['mousedown', 'touchstart']; - handler(event); - }; +export function useOnClickOutside( + handler: () => void, + events?: string[] | null, + nodes?: (HTMLElement | null)[], +) { + const ref = useRef(); - document.addEventListener('mousedown', listener); - document.addEventListener('touchstart', listener); + 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(); + } + }; - 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], - ); + (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/tests/useOnClickOutside.test.tsx b/tests/useOnClickOutside.test.tsx index 4a242fb..56dbeb2 100644 --- a/tests/useOnClickOutside.test.tsx +++ b/tests/useOnClickOutside.test.tsx @@ -1,26 +1,25 @@ -import React, { useRef } from 'react'; +import React, { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; - import { useOnClickOutside } from '../src/useOnClickOutside'; -interface TargetProps { +interface UseOnClickOutsideProps { handler: () => void; + events?: string[] | null; + nodes?: HTMLElement[]; } -const Target: React.FunctionComponent = ({ handler }) => { - const ref = useRef(); - useOnClickOutside(ref, handler); - // @ts-ignore +const Target: React.FunctionComponent = ({ handler, events, nodes }) => { + const ref = useOnClickOutside(handler, events, nodes); return
; }; -describe('useOnClickOutside', () => { +describe('useClickOutside', () => { afterAll(() => { jest.clearAllMocks(); }); - it('calls `handler` function when clicked outside target', async () => { + it('calls `handler` function when clicked outside target (no `events` given)', async () => { const handler = jest.fn(); render( @@ -47,4 +46,56 @@ describe('useOnClickOutside', () => { 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); + }); }); From 6e5c9d68965c66f63459d18807a2acb3c495c31d Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Wed, 1 Feb 2023 08:49:31 -0800 Subject: [PATCH 03/10] feat: useMousePosition --- README.md | 23 ++++++++++++- src/index.ts | 1 + src/useMousePosition.ts | 44 ++++++++++++++++++++++++ tests/useMousePosition.test.tsx | 60 +++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 src/useMousePosition.ts create mode 100644 tests/useMousePosition.test.tsx diff --git a/README.md b/README.md index c055f18..c3e3aaf 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ yarn add @rennalabs/hooks - [`useCounter()`](#useCounter) - [`useHover()`](#useHover) - [`useOs()`](#useOs) + - [`useMousePosition()`](#useMousePosition) ## Hooks @@ -397,7 +398,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 @@ -414,6 +415,26 @@ 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} }`} + + ); +} +``` + --- [![CircleCI](https://circleci.com/gh/Renna-Labs/hooks.svg?style=svg)](https://circleci.com/gh/Renna-Labs/hooks) diff --git a/src/index.ts b/src/index.ts index 56309ad..4085f5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { useInterval } from './useInterval'; export { useMediaQuery } from './useMediaQuery'; export { useWindowSize } from './useWindowSize'; export { useLocalStorage } from './useLocalStorage'; +export { useMousePosition } from './useMousePosition'; export { useNetworkStatus } from './useNetworkStatus'; export { useOnClickOutside } from './useOnClickOutside'; export { useRandomInterval } from './useRandomInterval'; diff --git a/src/useMousePosition.ts b/src/useMousePosition.ts new file mode 100644 index 0000000..c1479f7 --- /dev/null +++ b/src/useMousePosition.ts @@ -0,0 +1,44 @@ +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/tests/useMousePosition.test.tsx b/tests/useMousePosition.test.tsx new file mode 100644 index 0000000..905f8ad --- /dev/null +++ b/tests/useMousePosition.test.tsx @@ -0,0 +1,60 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { renderHook, fireEvent, render, screen } from '@testing-library/react'; + +import { useMousePosition } from '../src/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 }); + }); +}); From 2d7f6a3e9e4dc3a94bad55225d04bad0512bc33c Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Wed, 1 Feb 2023 14:40:25 -0800 Subject: [PATCH 04/10] refactor: colocate hooks & tests --- .eslintrc.js | 8 ++++-- src/index.ts | 28 +++++++++---------- {tests => src/useCounter}/clamp.test.ts | 2 +- {tests => src/useCounter}/useCounter.test.tsx | 2 +- src/{ => useCounter}/useCounter.ts | 2 +- {tests => src/useHover}/useHover.test.tsx | 2 +- src/{ => useHover}/useHover.ts | 2 +- src/{ => useInterval}/useInterval.ts | 2 +- src/{ => useLocalStorage}/useLocalStorage.ts | 0 src/{ => useMediaQuery}/useMediaQuery.ts | 2 +- .../useMousePosition.test.tsx | 2 +- .../useMousePosition.ts | 8 ++++-- .../useNetworkStatus.ts | 0 .../useOnClickOutside.test.tsx | 2 +- .../useOnClickOutside.ts | 0 src/{ => useOs}/useOs.ts | 0 .../usePrefersReducedMotion.ts | 0 .../useRandomInterval.ts | 2 +- {tests => src/useTimeout}/useTimeout.test.tsx | 2 +- src/{ => useTimeout}/useTimeout.ts | 0 .../useWindowScrollPosition.test.tsx | 2 +- .../useWindowScrollPosition.ts | 2 +- .../useWindowSize}/useWindowSize.test.tsx | 2 +- src/{ => useWindowSize}/useWindowSize.ts | 2 +- 24 files changed, 41 insertions(+), 33 deletions(-) rename {tests => src/useCounter}/clamp.test.ts (82%) rename {tests => src/useCounter}/useCounter.test.tsx (96%) rename src/{ => useCounter}/useCounter.ts (95%) rename {tests => src/useHover}/useHover.test.tsx (94%) rename src/{ => useHover}/useHover.ts (90%) rename src/{ => useInterval}/useInterval.ts (91%) rename src/{ => useLocalStorage}/useLocalStorage.ts (100%) rename src/{ => useMediaQuery}/useMediaQuery.ts (95%) rename {tests => src/useMousePosition}/useMousePosition.test.tsx (96%) rename src/{ => useMousePosition}/useMousePosition.ts (83%) rename src/{ => useNetworkStatus}/useNetworkStatus.ts (100%) rename {tests => src/useOnClickOutside}/useOnClickOutside.test.tsx (97%) rename src/{ => useOnClickOutside}/useOnClickOutside.ts (100%) rename src/{ => useOs}/useOs.ts (100%) rename src/{ => usePrefersReducedMotion}/usePrefersReducedMotion.ts (100%) rename src/{ => useRandomInterval}/useRandomInterval.ts (92%) rename {tests => src/useTimeout}/useTimeout.test.tsx (94%) rename src/{ => useTimeout}/useTimeout.ts (100%) rename {tests => src/useWindowScrollPosition}/useWindowScrollPosition.test.tsx (90%) rename src/{ => useWindowScrollPosition}/useWindowScrollPosition.ts (95%) rename {tests => src/useWindowSize}/useWindowSize.test.tsx (92%) rename src/{ => useWindowSize}/useWindowSize.ts (95%) diff --git a/.eslintrc.js b/.eslintrc.js index 97e483c..0c392c9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,16 +15,20 @@ 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', + // typescript rules + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-empty-function': 'off', }, }; diff --git a/src/index.ts b/src/index.ts index 4085f5c..55939f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,16 @@ 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 { useMousePosition } from './useMousePosition'; -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 { useMediaQuery } from './useMediaQuery/useMediaQuery'; +export { useWindowSize } from './useWindowSize/useWindowSize'; +export { useLocalStorage } from './useLocalStorage/useLocalStorage'; +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/tests/useHover.test.tsx b/src/useHover/useHover.test.tsx similarity index 94% rename from tests/useHover.test.tsx rename to src/useHover/useHover.test.tsx index c73912d..24e4fa2 100644 --- a/tests/useHover.test.tsx +++ b/src/useHover/useHover.test.tsx @@ -2,7 +2,7 @@ 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/useInterval.ts similarity index 91% rename from src/useInterval.ts rename to src/useInterval/useInterval.ts index 7bd9034..e7cf8e5 100644 --- a/src/useInterval.ts +++ b/src/useInterval/useInterval.ts @@ -14,7 +14,7 @@ export function useInterval(callback: () => void, delay: number | null) { savedCallback.current(); } if (delay !== null) { - let id = setInterval(tick, delay); + const id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]); 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/useMediaQuery.ts similarity index 95% rename from src/useMediaQuery.ts rename to src/useMediaQuery/useMediaQuery.ts index b7b070f..1f7eafe 100644 --- a/src/useMediaQuery.ts +++ b/src/useMediaQuery/useMediaQuery.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { isClient, isAPISupported } from './utils'; +import { isClient, isAPISupported } from '../utils'; function getInitialValue(query: string) { if (isClient && isAPISupported('matchMedia')) { diff --git a/tests/useMousePosition.test.tsx b/src/useMousePosition/useMousePosition.test.tsx similarity index 96% rename from tests/useMousePosition.test.tsx rename to src/useMousePosition/useMousePosition.test.tsx index 905f8ad..b169d39 100644 --- a/tests/useMousePosition.test.tsx +++ b/src/useMousePosition/useMousePosition.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom'; import React from 'react'; import { renderHook, fireEvent, render, screen } from '@testing-library/react'; -import { useMousePosition } from '../src/useMousePosition'; +import { useMousePosition } from './useMousePosition'; const Target: React.FunctionComponent = () => { const { ref, x, y } = useMousePosition(); diff --git a/src/useMousePosition.ts b/src/useMousePosition/useMousePosition.ts similarity index 83% rename from src/useMousePosition.ts rename to src/useMousePosition/useMousePosition.ts index c1479f7..fb774f4 100644 --- a/src/useMousePosition.ts +++ b/src/useMousePosition/useMousePosition.ts @@ -32,11 +32,15 @@ export function useMousePosition( useEffect(() => { const element = ref?.current ? ref.current : document; element.addEventListener('mousemove', setMousePosition as any); - if (options.resetOnExit) {element.addEventListener('mouseleave', resetMousePosition 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);} + if (options.resetOnExit) { + element.removeEventListener('mouseleave', resetMousePosition as any); + } }; }, [ref.current]); 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/tests/useOnClickOutside.test.tsx b/src/useOnClickOutside/useOnClickOutside.test.tsx similarity index 97% rename from tests/useOnClickOutside.test.tsx rename to src/useOnClickOutside/useOnClickOutside.test.tsx index 56dbeb2..1e0401d 100644 --- a/tests/useOnClickOutside.test.tsx +++ b/src/useOnClickOutside/useOnClickOutside.test.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useOnClickOutside } from '../src/useOnClickOutside'; +import { useOnClickOutside } from './useOnClickOutside'; interface UseOnClickOutsideProps { handler: () => void; diff --git a/src/useOnClickOutside.ts b/src/useOnClickOutside/useOnClickOutside.ts similarity index 100% rename from src/useOnClickOutside.ts rename to src/useOnClickOutside/useOnClickOutside.ts 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.ts b/src/useRandomInterval/useRandomInterval.ts similarity index 92% rename from src/useRandomInterval.ts rename to src/useRandomInterval/useRandomInterval.ts index f098c49..02820fc 100644 --- a/src/useRandomInterval.ts +++ b/src/useRandomInterval/useRandomInterval.ts @@ -20,7 +20,7 @@ export const useRandomInterval = ( }); useEffect(() => { - let isEnabled = typeof minDelay === 'number' && typeof maxDelay === 'number'; + const isEnabled = typeof minDelay === 'number' && typeof maxDelay === 'number'; if (isEnabled) { const handleTick = () => { const nextTickAt = random(minDelay, maxDelay); 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..b566a2a 100644 --- a/tests/useTimeout.test.tsx +++ b/src/useTimeout/useTimeout.test.tsx @@ -1,5 +1,5 @@ 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 90% rename from tests/useWindowScrollPosition.test.tsx rename to src/useWindowScrollPosition/useWindowScrollPosition.test.tsx index 977e504..7a3cf85 100644 --- a/tests/useWindowScrollPosition.test.tsx +++ b/src/useWindowScrollPosition/useWindowScrollPosition.test.tsx @@ -2,7 +2,7 @@ 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 92% rename from tests/useWindowSize.test.tsx rename to src/useWindowSize/useWindowSize.test.tsx index 778aa76..b499d4c 100644 --- a/tests/useWindowSize.test.tsx +++ b/src/useWindowSize/useWindowSize.test.tsx @@ -2,7 +2,7 @@ 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()); From 5bf69efef00df230baa2bd509eb1710d7384130f Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Wed, 1 Feb 2023 14:49:02 -0800 Subject: [PATCH 05/10] fix: build cmd & bump version --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index fcb362a..2e26141 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rennalabs/hooks", - "version": "1.0.2", + "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", From 178e7eb97ac238e6cc51eff78e7d66e1e6ff657e Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Wed, 1 Feb 2023 15:15:13 -0800 Subject: [PATCH 06/10] feat: useFullscreen --- README.md | 46 +++++++++++ src/index.ts | 1 + src/useFullscreen/useFullscreen.ts | 120 +++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 src/useFullscreen/useFullscreen.ts diff --git a/README.md b/README.md index c3e3aaf..5325519 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ yarn add @rennalabs/hooks - [`useHover()`](#useHover) - [`useOs()`](#useOs) - [`useMousePosition()`](#useMousePosition) + - [`useFullscreen()`](#useFullscreen) ## Hooks @@ -435,6 +436,51 @@ function Demo() { } ``` +### `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/src/index.ts b/src/index.ts index 55939f1..3ab7bb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ 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 { useLocalStorage } from './useLocalStorage/useLocalStorage'; 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; +} From 913280b4f76d1f95fddf530194996e9ec1e18c3b Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Wed, 1 Feb 2023 16:26:15 -0800 Subject: [PATCH 07/10] feat: useResizeObserver & useElementSize --- .eslintrc.js | 2 + package.json | 3 + src/index.ts | 2 + src/useElementSize/useElementSize.ts | 62 +++++++++++++++++++ src/useHover/useHover.test.tsx | 2 - .../useMousePosition.test.tsx | 2 - .../useOnClickOutside.test.tsx | 3 +- src/useTimeout/useTimeout.test.tsx | 1 + .../useWindowScrollPosition.test.tsx | 2 - src/useWindowSize/useWindowSize.test.tsx | 2 - tests/setupTests.ts | 4 ++ 11 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 src/useElementSize/useElementSize.ts create mode 100644 tests/setupTests.ts diff --git a/.eslintrc.js b/.eslintrc.js index 0c392c9..a35d39f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,6 +26,8 @@ module.exports = { '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', diff --git a/package.json b/package.json index 2e26141..c02fdef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 3ab7bb9..c789865 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,9 +8,11 @@ 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 { useMousePosition } from './useMousePosition/useMousePosition'; export { useNetworkStatus } from './useNetworkStatus/useNetworkStatus'; +export { useResizeObserver } from './useElementSize/useElementSize'; export { useOnClickOutside } from './useOnClickOutside/useOnClickOutside'; export { useRandomInterval } from './useRandomInterval/useRandomInterval'; export { useWindowScrollPosition } from './useWindowScrollPosition/useWindowScrollPosition'; 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/useHover/useHover.test.tsx b/src/useHover/useHover.test.tsx index 24e4fa2..967579e 100644 --- a/src/useHover/useHover.test.tsx +++ b/src/useHover/useHover.test.tsx @@ -1,5 +1,3 @@ -import React from 'react'; -import '@testing-library/jest-dom'; import { fireEvent, render, screen } from '@testing-library/react'; import { useHover } from './useHover'; diff --git a/src/useMousePosition/useMousePosition.test.tsx b/src/useMousePosition/useMousePosition.test.tsx index b169d39..eb83d19 100644 --- a/src/useMousePosition/useMousePosition.test.tsx +++ b/src/useMousePosition/useMousePosition.test.tsx @@ -1,5 +1,3 @@ -import '@testing-library/jest-dom'; -import React from 'react'; import { renderHook, fireEvent, render, screen } from '@testing-library/react'; import { useMousePosition } from './useMousePosition'; diff --git a/src/useOnClickOutside/useOnClickOutside.test.tsx b/src/useOnClickOutside/useOnClickOutside.test.tsx index 1e0401d..3e3fa7c 100644 --- a/src/useOnClickOutside/useOnClickOutside.test.tsx +++ b/src/useOnClickOutside/useOnClickOutside.test.tsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; + import { useOnClickOutside } from './useOnClickOutside'; interface UseOnClickOutsideProps { diff --git a/src/useTimeout/useTimeout.test.tsx b/src/useTimeout/useTimeout.test.tsx index b566a2a..b16c9a6 100644 --- a/src/useTimeout/useTimeout.test.tsx +++ b/src/useTimeout/useTimeout.test.tsx @@ -1,4 +1,5 @@ import { renderHook } from '@testing-library/react'; + import { useTimeout } from './useTimeout'; const defaultTimeout = 2000; diff --git a/src/useWindowScrollPosition/useWindowScrollPosition.test.tsx b/src/useWindowScrollPosition/useWindowScrollPosition.test.tsx index 7a3cf85..f3e6163 100644 --- a/src/useWindowScrollPosition/useWindowScrollPosition.test.tsx +++ b/src/useWindowScrollPosition/useWindowScrollPosition.test.tsx @@ -1,5 +1,3 @@ -import React from 'react'; -import '@testing-library/jest-dom'; import { render, cleanup } from '@testing-library/react'; import { useWindowScrollPosition } from './useWindowScrollPosition'; diff --git a/src/useWindowSize/useWindowSize.test.tsx b/src/useWindowSize/useWindowSize.test.tsx index b499d4c..e398d6e 100644 --- a/src/useWindowSize/useWindowSize.test.tsx +++ b/src/useWindowSize/useWindowSize.test.tsx @@ -1,5 +1,3 @@ -import React from 'react'; -import '@testing-library/jest-dom'; import { render, cleanup } from '@testing-library/react'; import { useWindowSize } from './useWindowSize'; 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; From 98cf3d86b413ddd058578fa33d04c344e78d8d9d Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Wed, 1 Feb 2023 18:46:49 -0800 Subject: [PATCH 08/10] docs: flesh out examples --- README.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5325519..f500ad8 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 @@ -63,7 +63,11 @@ import { useNetworkStatus } from '@rennalabs/hooks'; const Example = () => { const { isOnline, offlineAt } = useNetworkStatus(); - // ... + return ( +
+ {`App went offline at ${offlineAt.toString()}`} +
+ ); }; ``` @@ -84,7 +88,7 @@ import { useWindowScrollPosition } from '@rennalabs/hooks'; const Example = () => { const { x, y } = useWindowScrollPosition(); - // ... + return
{`Scroll position is { x: ${x}, y: ${y} }`}
; }; ``` @@ -105,7 +109,7 @@ import { useWindowSize } from '@rennalabs/hooks'; const Example = () => { const { width, height } = useWindowSize(); - // ... + return
{`window size is ${width}x${height}`}
; }; ``` @@ -133,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)} /> +
+ ); }; ``` From 7c9bc2008320f60e391b866125d856b8e6e14f75 Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Wed, 1 Feb 2023 20:31:14 -0800 Subject: [PATCH 09/10] refactor: useInterval, useRandomInterval + tests --- README.md | 89 +++++----- src/useInterval/useInterval.test.tsx | 156 ++++++++++++++++++ src/useInterval/useInterval.ts | 46 ++++-- .../useRandomInterval.test.tsx | 93 +++++++++++ src/useRandomInterval/useRandomInterval.ts | 31 +++- src/utils.ts | 6 +- tests/toBeWithinRange.ts | 44 +++++ 7 files changed, 403 insertions(+), 62 deletions(-) create mode 100644 src/useInterval/useInterval.test.tsx create mode 100644 src/useRandomInterval/useRandomInterval.test.tsx create mode 100644 tests/toBeWithinRange.ts diff --git a/README.md b/README.md index f500ad8..659d812 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ 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) @@ -273,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 @@ -312,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.

+ +
+ ); } ``` 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 index e7cf8e5..436fbca 100644 --- a/src/useInterval/useInterval.ts +++ b/src/useInterval/useInterval.ts @@ -1,21 +1,37 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; -export function useInterval(callback: () => void, delay: number | null) { - const savedCallback = useRef<() => void>(() => {}); +export function useInterval(fn: () => void, interval: number) { + const [active, setActive] = useState(false); + const intervalRef = useRef(); + const fnRef = useRef<() => void>(); - // Remember the latest callback. useEffect(() => { - savedCallback.current = callback; - }, [callback]); + fnRef.current = fn; + }, [fn]); - // Set up the interval. - useEffect(() => { - function tick() { - savedCallback.current(); - } - if (delay !== null) { - const id = setInterval(tick, delay); - return () => clearInterval(id); + 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(); } - }, [delay]); + }; + + return { start, stop, toggle, active }; } 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/useRandomInterval.ts b/src/useRandomInterval/useRandomInterval.ts index 02820fc..d382faa 100644 --- a/src/useRandomInterval/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(() => { + 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/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/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; + } +} From f4ed533887f5d3b61c37fa32d72094bb517eb568 Mon Sep 17 00:00:00 2001 From: Emile Choghi Date: Wed, 1 Feb 2023 20:36:39 -0800 Subject: [PATCH 10/10] docs: fix examples --- README.md | 8 ++++---- src/index.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 659d812..8b9de94 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ function Demo() {

Page loaded {seconds} seconds ago

-
@@ -364,7 +364,7 @@ function LaggyTimer() { return (

It has been {seconds} seconds.

-
@@ -471,7 +471,7 @@ function Demo() { const { toggle, fullscreen } = useFullscreen(); return ( - ); @@ -495,7 +495,7 @@ function Demo() { width={200} /> - diff --git a/src/index.ts b/src/index.ts index c789865..cf9da9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,9 +10,9 @@ 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 { useResizeObserver } from './useElementSize/useElementSize'; export { useOnClickOutside } from './useOnClickOutside/useOnClickOutside'; export { useRandomInterval } from './useRandomInterval/useRandomInterval'; export { useWindowScrollPosition } from './useWindowScrollPosition/useWindowScrollPosition';