Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create useClickedOutside hook #157

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/hooks/useClickedOutside/useClickedOutside.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Meta } from '@storybook/blocks';
import { useRef } from 'react';
import { useClickedOutside } from './useClickedOutside';

<Meta title="hooks/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<Element>,
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 <div ref={ref}>Click outside of this element to trigger the callback.</div>;
}
```

Using the `useClickedOutside` hook with an element from state:

```jsx
import { useRef } from 'react';
import { useClickedOutside } from './useClickedOutside';

function MyComponent() {
const [element, setElement] = useState<HTMLDivElement>();

useClickedOutside(element, () => {
console.log('Clicked outside!');
});

return <div ref={element}>Click outside of this element to trigger the callback.</div>;
}
```
77 changes: 77 additions & 0 deletions src/hooks/useClickedOutside/useClickedOutside.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 <div ref={ref}>Click outside of this element to trigger the callback.</div>;
},
} satisfies StoryObj;

export const State = {
render(): ReactElement {
const [element, setElement] = useState<HTMLDivElement | null>(null);

useClickedOutside(element, () => {
// eslint-disable-next-line no-console
console.log('Clicked outside!');
});

return <div ref={setElement}>Click outside of this element to trigger the callback.</div>;
},
} satisfies StoryObj;

export const CustomOptions = {
render(): ReactElement {
const ref = useRef<HTMLDivElement>(null);

useClickedOutside(
ref,
() => {
// eslint-disable-next-line no-console
console.log('Clicked outside!');
},
{ capture: true },
);

return <div ref={ref}>Click outside of this element to trigger the callback.</div>;
},
} 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 (
<>
<div ref={ref1}>Click outside of this element to trigger the callback for element 1.</div>
<div ref={ref2}>Click outside of this element to trigger the callback for element 2.</div>
</>
);
},
} satisfies StoryObj;
72 changes: 72 additions & 0 deletions src/hooks/useClickedOutside/useClickedOutside.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div ref={ref} style={{ inlineSize: 100, blockSize: 100 }}>
My Component
</div>
);
}

render(<MyComponent />);

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 (
<div ref={ref} style={{ inlineSize: 100, blockSize: 100 }}>
My Component
</div>
);
}

render(<MyComponent />);

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 (
<div ref={ref} style={{ inlineSize: 100, blockSize: 100 }}>
My Component
</div>
);
}

render(<MyComponent />);

fireEvent.click(document.body, { clientX: 50, clientY: 50 });

expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ clientX: 50, clientY: 50 }));
});
});
52 changes: 52 additions & 0 deletions src/hooks/useClickedOutside/useClickedOutside.ts
Original file line number Diff line number Diff line change
@@ -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<Element | null>,
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using this way to check this would return "Inside" if you click on an element that could cover the entire element, so you're not really click the element? I don't think this is what you want.

Copy link
Member

@ThaNarie ThaNarie May 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you need to do both then.

  1. check if it's inside the expected bounds
  2. check if the clicked target is a child of the element

Could you (@leroykorterink) clarify why a backdrop would not influence the element bounding rect?

Also, both variations should have either a story or a test, currently the "backdrop" case doesn't have either?

// 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);
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down