From 1e4b48aa075e6131a7244cd4726ddb5ba75fcecf Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Wed, 1 Nov 2023 16:10:31 +0100 Subject: refactor(hooks): rewrite useOnClickOutside hook * remove `useCapture` parameter (it does not make sense to use bubbling here) * return a MutableRefObject instead of a RefObject to be able to test the hook --- .../molecules/modals/tooltip/tooltip.tsx | 10 +++-- src/utils/hooks/use-on-click-outside.tsx | 50 ---------------------- src/utils/hooks/use-on-click-outside/index.ts | 1 + .../use-on-click-outside.test.ts | 42 ++++++++++++++++++ .../use-on-click-outside/use-on-click-outside.ts | 38 ++++++++++++++++ 5 files changed, 87 insertions(+), 54 deletions(-) delete mode 100644 src/utils/hooks/use-on-click-outside.tsx create mode 100644 src/utils/hooks/use-on-click-outside/index.ts create mode 100644 src/utils/hooks/use-on-click-outside/use-on-click-outside.test.ts create mode 100644 src/utils/hooks/use-on-click-outside/use-on-click-outside.ts (limited to 'src') diff --git a/src/components/molecules/modals/tooltip/tooltip.tsx b/src/components/molecules/modals/tooltip/tooltip.tsx index b3a3f5a..525900d 100644 --- a/src/components/molecules/modals/tooltip/tooltip.tsx +++ b/src/components/molecules/modals/tooltip/tooltip.tsx @@ -61,10 +61,12 @@ export const Tooltip: FC = ({ const tooltipClass = `${styles.tooltip} ${styles[directionModifier]} ${styles[visibilityModifier]} ${className}`; const btnRef = useRef(null); - const closeModal = (target: Node) => { - if (!onClickOutside) return; - - if (btnRef.current && !btnRef.current.contains(target)) { + const closeModal = ({ target }: MouseEvent | FocusEvent) => { + if ( + onClickOutside && + btnRef.current && + !btnRef.current.contains(target as Node) + ) { onClickOutside(); } }; diff --git a/src/utils/hooks/use-on-click-outside.tsx b/src/utils/hooks/use-on-click-outside.tsx deleted file mode 100644 index b810ddc..0000000 --- a/src/utils/hooks/use-on-click-outside.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { RefObject, useCallback, useEffect, useRef } from 'react'; - -export type UseOnClickOutsideCallback = (target: Node) => void; - -/** - * Detect click/focus outside an element and fire a callback function. - * - * @param {UseOnClickOutsideCallback} callback - A callback function to fire. - * @param {boolean} useCapture - Define event propagation method. Default: true. - * @returns {RefObject} A React reference object. - */ -export const useOnClickOutside = ( - callback: UseOnClickOutsideCallback, - useCapture: boolean = true -): RefObject => { - const ref = useRef(null); - - /** - * Check if the target is outside the ref. - * - * @param {Node} target - The event target. - * @returns {boolean | null} True if the target is outside the ref. - */ - const isTargetOutside = (target: Node): boolean | null => { - return ref.current && !ref.current.contains(target); - }; - - /** - * Fire the callback if the event target is outside. - */ - const handler = useCallback( - (e: MouseEvent | FocusEvent) => { - if (e.target && isTargetOutside(e.target as Node)) - callback(e.target as Node); - }, - [callback] - ); - - useEffect(() => { - document.addEventListener('click', handler, useCapture); - document.addEventListener('focusin', handler, useCapture); - - return () => { - document.removeEventListener('click', handler); - document.removeEventListener('focusin', handler); - }; - }, [handler, useCapture]); - - return ref; -}; diff --git a/src/utils/hooks/use-on-click-outside/index.ts b/src/utils/hooks/use-on-click-outside/index.ts new file mode 100644 index 0000000..b9fff06 --- /dev/null +++ b/src/utils/hooks/use-on-click-outside/index.ts @@ -0,0 +1 @@ +export * from './use-on-click-outside'; diff --git a/src/utils/hooks/use-on-click-outside/use-on-click-outside.test.ts b/src/utils/hooks/use-on-click-outside/use-on-click-outside.test.ts new file mode 100644 index 0000000..982c0ee --- /dev/null +++ b/src/utils/hooks/use-on-click-outside/use-on-click-outside.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { renderHook } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { useOnClickOutside } from './use-on-click-outside'; + +describe('useOnClickOutside', () => { + it('can execute a function on click outside the given ref', async () => { + const user = userEvent.setup(); + const cb = jest.fn(); + const wrapper = document.createElement('div'); + const el = document.createElement('div'); + + wrapper.append(el); + document.body.append(wrapper); + + const { result } = renderHook(() => useOnClickOutside(cb)); + + result.current.current = el; + + await user.click(wrapper); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('does not execute the callback on click inside the given ref', async () => { + const user = userEvent.setup(); + const cb = jest.fn(); + const wrapper = document.createElement('div'); + const el = document.createElement('div'); + + wrapper.append(el); + document.body.append(wrapper); + + const { result } = renderHook(() => useOnClickOutside(cb)); + + result.current.current = wrapper; + + await user.click(el); + + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/hooks/use-on-click-outside/use-on-click-outside.ts b/src/utils/hooks/use-on-click-outside/use-on-click-outside.ts new file mode 100644 index 0000000..c9a1132 --- /dev/null +++ b/src/utils/hooks/use-on-click-outside/use-on-click-outside.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; + +export type useOnClickOutsideHandler = (ev: MouseEvent | FocusEvent) => void; + +/** + * Detect clicks (or focus) outside a component. + * + * @param {useOnClickOutsideHandler} [handler] - A function to handle the event. + * @returns {RefObject} A ref object. + */ +export const useOnClickOutside = ( + handler?: useOnClickOutsideHandler +): MutableRefObject => { + const ref = useRef(null); + + const listener = useCallback( + (ev: MouseEvent | FocusEvent) => { + if (!handler || !ref.current || ref.current.contains(ev.target as Node)) { + return; + } + + handler(ev); + }, + [handler] + ); + + useEffect(() => { + document.addEventListener('click', listener, true); + document.addEventListener('focusin', listener, true); + + return () => { + document.removeEventListener('click', listener, true); + document.removeEventListener('focusin', listener, true); + }; + }, [listener]); + + return ref; +}; -- cgit v1.2.3