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';