Skip to content
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
31 changes: 30 additions & 1 deletion hooks/use-click-outside/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
# `@byndyusoft-ui/use-click-outside`

> React hook to execute the callback when a click happens outside of component or components.
A React hook that invokes the given handler when a click (pointerdown) occurs outside the specified elements.

## Installation

```sh
npm i @byndyusoft-ui/use-click-outside
# or
yarn add @byndyusoft-ui/use-click-outside
```

## Dependencies

- `@byndyusoft-ui/use-latest-ref`

## Behavior

- Subscribes to `pointerdown` on `document`. The handler is only called when the click occurs **outside all** elements passed in `refs` (i.e. `event.target` is not contained in any of `ref.current`).
- A single global listener on the document is used for all hook instances.
- The `disabled: true` option disables invoking the handler for that instance.
- The refs array may include `null` entries; they are ignored during the check.

## Signature

```ts
useClickOutside(
handler: (event: PointerEvent) => void,
refs: (RefObject<HTMLElement | null> | null)[],
options?: { disabled?: boolean }
): void
```
2 changes: 1 addition & 1 deletion hooks/use-click-outside/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@
"access": "public"
},
"dependencies": {
"@byndyusoft-ui/use-event-listener": "^0.1.2"
"@byndyusoft-ui/use-latest-ref": "^1.0.0"
}
}
2 changes: 1 addition & 1 deletion hooks/use-click-outside/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default } from './useClickOutside';
export { default, type IUseClickOutsideOptions } from './useClickOutside';
33 changes: 33 additions & 0 deletions hooks/use-click-outside/src/useClickOutside.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Markdown, Meta, Canvas, Source } from '@storybook/blocks';
import Readme from '../README.md?raw';
import * as useClickOutsideStories from './useClickOutside.stories';

<Meta title="hooks/useClickOutside" of={useClickOutsideStories} />

<Markdown>{Readme}</Markdown>

## Usage

1. Import the hook:

<Source language="javascript" code="import useClickOutside from '@byndyusoft-ui/use-click-outside';" />

2. Call the hook with a handler and an array of refs for elements that should not trigger the handler when clicked inside. Pass options (e.g. `disabled`) if needed.

**Multiple refs** — the handler runs only when the click is outside all of the given elements:

<Canvas sourceState="shown" of={useClickOutsideStories.MultipleRefsStory} />

**Disabled option** — when `disabled: true`, the handler is not called:

<Canvas sourceState="shown" of={useClickOutsideStories.DisabledStory} />

**Dropdown** — typical use case: button + panel; clicking outside both closes the panel:

<Canvas sourceState="shown" of={useClickOutsideStories.DropdownStory} />

## Notes

- Uses the **pointerdown** event (mouse, touch, pen). The handler receives a `PointerEvent`.
- The `refs` array may be stable (e.g. via `useMemo`) or recreated on every render — the hook keeps handler and refs up to date via refs and does not require strict stabilization.
- Empty refs array: when clicking outside “nothing”, the handler is not called (no “inside” elements, so the “outside all” condition does not lead to a call).
104 changes: 92 additions & 12 deletions hooks/use-click-outside/src/useClickOutside.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { forwardRef, useRef } from 'react';
import React, { forwardRef, useCallback, useRef, useState } from 'react';
import { StoryObj } from '@storybook/react';
import useClickOutside from './useClickOutside';

Expand Down Expand Up @@ -26,16 +26,16 @@ const Block = forwardRef<HTMLDivElement>(

Block.displayName = 'Block';

const Template = (): JSX.Element => {
const ref1 = useRef(null);
const ref2 = useRef(null);
const ref3 = useRef(null);
const MultipleRefsTemplate = (): JSX.Element => {
const ref1 = useRef<HTMLDivElement>(null);
const ref2 = useRef<HTMLDivElement>(null);
const ref3 = useRef<HTMLDivElement>(null);

const handleClickOutside = (): void => {
const handleClickOutside = useCallback((): void => {
alert('clickOutside');
};
}, []);

useClickOutside(handleClickOutside, ref1, ref2, ref3);
useClickOutside(handleClickOutside, [ref1, ref2, ref3]);

return (
<>
Expand All @@ -46,9 +46,89 @@ const Template = (): JSX.Element => {
);
};

type TStory = StoryObj<typeof Template>;
export const MultipleRefsStory: StoryObj<typeof MultipleRefsTemplate> = {
name: 'Multiple refs',
render: () => <MultipleRefsTemplate />
};

const DisabledTemplate = (): JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [disabled, setDisabled] = useState(false);
const [lastAction, setLastAction] = useState<string>('none');

const handleClickOutside = useCallback((): void => {
setLastAction('clickOutside');
}, []);

useClickOutside(handleClickOutside, [ref, buttonRef], { disabled });

return (
<div style={{ padding: '1rem' }}>
<p>Disabled: {String(disabled)}</p>
<button ref={buttonRef} type="button" onClick={(): void => setDisabled(v => !v)}>
Toggle disabled
</button>
<div
ref={ref}
style={{
marginTop: '1rem',
padding: '1rem',
background: 'lightblue',
width: '20rem'
}}
>
Click outside this block to trigger handler (when not disabled)
</div>
<p>Last action: {lastAction}</p>
</div>
);
};

export const DisabledStory: StoryObj<typeof DisabledTemplate> = {
name: 'Disabled option',
render: () => <DisabledTemplate />
};

const DropdownTemplate = (): JSX.Element => {
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);

const handleClickOutside = useCallback((): void => {
setOpen(false);
}, []);

useClickOutside(handleClickOutside, [triggerRef, panelRef]);

return (
<div style={{ padding: '1rem' }}>
<button
ref={triggerRef}
type="button"
onClick={(): void => setOpen(v => !v)}
style={{ marginBottom: '0.5rem' }}
>
{open ? 'Close' : 'Open'} dropdown
</button>
{open && (
<div
ref={panelRef}
style={{
padding: '0.75rem',
border: '1px solid #ccc',
background: 'white',
width: '12rem'
}}
>
Dropdown content. Click outside to close.
</div>
)}
</div>
);
};

export const HookStory: TStory = {
name: 'useCLickOutside',
render: Template
export const DropdownStory: StoryObj<typeof DropdownTemplate> = {
name: 'Dropdown (close on outside click)',
render: () => <DropdownTemplate />
};
80 changes: 71 additions & 9 deletions hooks/use-click-outside/src/useClickOutside.tests.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import useClickOutside from './useClickOutside';

const Setup = (props: { onClick: () => void }): JSX.Element => {
const ref1 = useRef(null);
const ref2 = useRef(null);
const ref1 = useRef<HTMLButtonElement>(null);
const ref2 = useRef<HTMLButtonElement>(null);

useClickOutside(props.onClick, ref1, ref2);
useClickOutside(props.onClick, [ref1, ref2]);

return (
<div aria-label="container">
Expand All @@ -21,21 +21,83 @@ const Setup = (props: { onClick: () => void }): JSX.Element => {
);
};

const SetupDisabled = (props: { onClick: () => void; disabled: boolean }): JSX.Element => {
const ref = useRef<HTMLDivElement>(null);

useClickOutside(props.onClick, [ref], { disabled: props.disabled });

return (
<div aria-label="container">
<div ref={ref} aria-label="inner">
inner
</div>
</div>
);
};

const SetupMultipleInstances = (): JSX.Element => {
const refA = useRef<HTMLDivElement>(null);
const refB = useRef<HTMLDivElement>(null);
const [clicked, setClicked] = useState<null | 'A' | 'B'>(null);

useClickOutside(() => setClicked('A'), [refA]);
useClickOutside(() => setClicked('B'), [refB]);

return (
<div aria-label="page">
<div ref={refA} aria-label="block-a">
Block A
</div>
<div ref={refB} aria-label="block-b">
Block B
</div>
<div aria-label="outside">Outside</div>
{clicked !== null && <span data-testid="clicked">{clicked}</span>}
</div>
);
};

describe('hooks/useClickOutside', () => {
test('add two refs', async () => {
test('calls handler when click is outside all refs', async () => {
const onClick = vi.fn();
render(<Setup onClick={onClick} />);

await userEvent.click(screen.getByLabelText('button-1'));

expect(onClick).toBeCalledTimes(0);
expect(onClick).not.toHaveBeenCalled();

await userEvent.click(screen.getByLabelText('button-2'));
expect(onClick).not.toHaveBeenCalled();

await userEvent.click(screen.getByLabelText('container'));
expect(onClick).toHaveBeenCalledTimes(1);
});

test('does not call handler when disabled is true', async () => {
const onClick = vi.fn();
render(<SetupDisabled onClick={onClick} disabled />);

await userEvent.click(screen.getByLabelText('container'));
expect(onClick).not.toHaveBeenCalled();
});

expect(onClick).toBeCalledTimes(0);
test('calls handler when disabled is false', async () => {
const onClick = vi.fn();
render(<SetupDisabled onClick={onClick} disabled={false} />);

await userEvent.click(screen.getByLabelText('container'));
expect(onClick).toHaveBeenCalledTimes(1);
});

test('multiple instances: only handler whose refs do not contain target is called', async () => {
render(<SetupMultipleInstances />);

await userEvent.click(screen.getByLabelText('block-b'));
expect(screen.getByTestId('clicked')).toHaveTextContent('A');

await userEvent.click(screen.getByLabelText('block-a'));
expect(screen.getByTestId('clicked')).toHaveTextContent('B');

expect(onClick).toBeCalledTimes(1);
await userEvent.click(screen.getByLabelText('outside'));
expect(screen.getByTestId('clicked')).toHaveTextContent('B');
});
});
Loading
Loading