Skip to content

Commit

Permalink
Update useMediaQuery documentation #128
Browse files Browse the repository at this point in the history
  • Loading branch information
leroykorterink committed May 15, 2023
1 parent 3776274 commit b32eda4
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 1 deletion.
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
// 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

0 comments on commit b32eda4

Please sign in to comment.