From b32eda4cea791e9632de2c5a0cdb6a411ac8f341 Mon Sep 17 00:00:00 2001 From: Leroy Korterink Date: Mon, 15 May 2023 23:19:33 +0200 Subject: [PATCH] Update useMediaQuery documentation #128 --- .../useClickedOutside/useClickedOutside.mdx | 56 ++++++++++++++ .../useClickedOutside.stories.tsx | 77 +++++++++++++++++++ .../useClickedOutside.test.tsx | 72 +++++++++++++++++ .../useClickedOutside/useClickedOutside.ts | 52 +++++++++++++ src/index.ts | 3 +- 5 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useClickedOutside/useClickedOutside.mdx create mode 100644 src/hooks/useClickedOutside/useClickedOutside.stories.tsx create mode 100644 src/hooks/useClickedOutside/useClickedOutside.test.tsx create mode 100644 src/hooks/useClickedOutside/useClickedOutside.ts diff --git a/src/hooks/useClickedOutside/useClickedOutside.mdx b/src/hooks/useClickedOutside/useClickedOutside.mdx new file mode 100644 index 0000000..a42474d --- /dev/null +++ b/src/hooks/useClickedOutside/useClickedOutside.mdx @@ -0,0 +1,56 @@ +import { Meta } from '@storybook/blocks'; +import { useRef } from 'react'; +import { useClickedOutside } from './useClickedOutside'; + + + +# useClickedOutside + +This hook registers a click event listener on the document that triggers a callback function when +the click event occurs outside of the specified element. + +## Reference + +```ts +function useClickedOutside( + target: Unreffable, + listener: (mouseEvent: MouseEvent) => void, + options?: EventListenerOptions, +): void; +``` + +## Example Usage + +Using the `useClickedOutside` hook with a RefObject: + +```jsx +import { useRef } from 'react'; +import { useClickedOutside } from './useClickedOutside'; + +function MyComponent() { + const ref = useRef(null); + + useClickedOutside(ref, () => { + console.log('Clicked outside!'); + }); + + return
Click outside of this element to trigger the callback.
; +} +``` + +Using the `useClickedOutside` hook with an element from state: + +```jsx +import { useRef } from 'react'; +import { useClickedOutside } from './useClickedOutside'; + +function MyComponent() { + const [element, setElement] = useState(); + + useClickedOutside(element, () => { + console.log('Clicked outside!'); + }); + + return
Click outside of this element to trigger the callback.
; +} +``` diff --git a/src/hooks/useClickedOutside/useClickedOutside.stories.tsx b/src/hooks/useClickedOutside/useClickedOutside.stories.tsx new file mode 100644 index 0000000..6d0cc9f --- /dev/null +++ b/src/hooks/useClickedOutside/useClickedOutside.stories.tsx @@ -0,0 +1,77 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +/* eslint-disable react/no-multi-comp */ +/* eslint-disable react/jsx-no-literals */ +import { type Meta, type StoryObj } from '@storybook/react'; +import { useRef, useState, type ReactElement } from 'react'; +import { useClickedOutside } from './useClickedOutside.js'; + +export default { + title: 'hooks/useClickedOutside', +} satisfies Meta; + +export const RefObject = { + render(): ReactElement { + const ref = useRef(null); + + useClickedOutside(ref, () => { + // eslint-disable-next-line no-console + console.log('Clicked outside!'); + }); + + return
Click outside of this element to trigger the callback.
; + }, +} satisfies StoryObj; + +export const State = { + render(): ReactElement { + const [element, setElement] = useState(null); + + useClickedOutside(element, () => { + // eslint-disable-next-line no-console + console.log('Clicked outside!'); + }); + + return
Click outside of this element to trigger the callback.
; + }, +} satisfies StoryObj; + +export const CustomOptions = { + render(): ReactElement { + const ref = useRef(null); + + useClickedOutside( + ref, + () => { + // eslint-disable-next-line no-console + console.log('Clicked outside!'); + }, + { capture: true }, + ); + + return
Click outside of this element to trigger the callback.
; + }, +} satisfies StoryObj; + +export const MultipleElements = { + render(): ReactElement { + const ref1 = useRef(null); + const ref2 = useRef(null); + + useClickedOutside(ref1, () => { + // eslint-disable-next-line no-console + console.log('Clicked outside of element 1!'); + }); + + useClickedOutside(ref2, () => { + // eslint-disable-next-line no-console + console.log('Clicked outside of element 2!'); + }); + + return ( + <> +
Click outside of this element to trigger the callback for element 1.
+
Click outside of this element to trigger the callback for element 2.
+ + ); + }, +} satisfies StoryObj; diff --git a/src/hooks/useClickedOutside/useClickedOutside.test.tsx b/src/hooks/useClickedOutside/useClickedOutside.test.tsx new file mode 100644 index 0000000..caa1edd --- /dev/null +++ b/src/hooks/useClickedOutside/useClickedOutside.test.tsx @@ -0,0 +1,72 @@ +/* eslint-disable react/no-multi-comp, react/jsx-no-literals */ +import { jest } from '@jest/globals'; +import { fireEvent, render } from '@testing-library/react'; +import { useRef, type ReactElement } from 'react'; +import { useClickedOutside } from './useClickedOutside.js'; + +describe('useClickedOutside', () => { + it('should call the callback function when clicked outside of the element', () => { + const callback = jest.fn(); + + function MyComponent(): ReactElement { + const ref = useRef(null); + useClickedOutside(ref, callback); + + return ( +
+ My Component +
+ ); + } + + render(); + + fireEvent.click(document.body, { clientX: 150, clientY: 150 }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should call the callback function when clicked inside of the element', () => { + const callback = jest.fn(); + + function MyComponent(): ReactElement { + const ref = useRef(null); + useClickedOutside(ref, callback); + + return ( +
+ My Component +
+ ); + } + + render(); + + fireEvent.click(document.body, { clientX: 50, clientY: 50 }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ clientX: 50, clientY: 50 })); + }); + + it('should call the callback function when clicked inside of the element stored in state', () => { + const callback = jest.fn(); + + function MyComponent(): ReactElement { + const ref = useRef(null); + useClickedOutside(ref, callback); + + return ( +
+ My Component +
+ ); + } + + render(); + + fireEvent.click(document.body, { clientX: 50, clientY: 50 }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ clientX: 50, clientY: 50 })); + }); +}); diff --git a/src/hooks/useClickedOutside/useClickedOutside.ts b/src/hooks/useClickedOutside/useClickedOutside.ts new file mode 100644 index 0000000..342f3a5 --- /dev/null +++ b/src/hooks/useClickedOutside/useClickedOutside.ts @@ -0,0 +1,52 @@ +import { useCallback, useMemo } from 'react'; +import { unref, type Unreffable } from '../../utils/unref/unref.js'; +import { useEventListener } from '../useEventListener/useEventListener.js'; + +/** + * Registers a click event listener on the document that triggers a callback function when the + * click event occurs outside of the specified element. + * + * @param ref + * @param listener + * @param options + */ +export function useClickedOutside( + target: Unreffable, + listener: (mouseEvent: MouseEvent) => void, + options?: EventListenerOptions, +): void { + const memoizedOptions = useMemo( + () => options, + // eslint-disable-next-line react-hooks/exhaustive-deps + [...Object.keys(options ?? {}), ...Object.values(options ?? {})], + ); + + const onClick = useCallback( + (event: Event) => { + // Using element bounds because a click on a shadow element (e.g. dialog backdrop) is also + // considered to be a click outside an element + const { + top = 0, + right = 0, + bottom = 0, + left = 0, + } = unref(target)?.getBoundingClientRect() ?? {}; + + if (event instanceof MouseEvent) { + const { clientX, clientY } = event; + + if (clientX >= left && clientX <= right && clientY >= top && clientY <= bottom) { + // Clicked inside bounding box of the element + return; + } + } else { + throw new TypeError('Expected MouseEvent'); + } + + listener(event); + }, + [listener, target], + ); + + useEventListener(globalThis.document, 'click', onClick, memoizedOptions); +} diff --git a/src/index.ts b/src/index.ts index ad72bc3..de52907 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from './components/AutoFill/AutoFill.js'; export * from './hocs/ensuredForwardRef/ensuredForwardRef.js'; export * from './hooks/useBeforeMount/useBeforeMount.js'; +export * from './hooks/useClickedOutside/useClickedOutside.js'; export * from './hooks/useEventListener/useEventListener.js'; export * from './hooks/useForceRerender/useForceRerender.js'; export * from './hooks/useHasFocus/useHasFocus.js'; @@ -11,12 +12,12 @@ export * from './hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect.js'; export * from './hooks/useMediaDuration/useMediaDuration.js'; export * from './hooks/useMediaQuery/useMediaQuery.js'; export * from './hooks/useMount/useMount.js'; +export * from './hooks/useRefValue/useRefValue.js'; export * from './hooks/useRefs/useRefs.js'; export * from './hooks/useRefs/useRefs.types.js'; export * from './hooks/useRefs/utils/assertAndUnwrapRefs/assertAndUnwrapRefs.js'; export * from './hooks/useRefs/utils/unwrapRefs/unwrapRefs.js'; export * from './hooks/useRefs/utils/unwrapRefs/unwrapRefs.types.js'; -export * from './hooks/useRefValue/useRefValue.js'; export * from './hooks/useRegisterRef/useRegisterRef.js'; export * from './hooks/useResizeObserver/useResizeObserver.js'; export * from './hooks/useStaticValue/useStaticValue.js';