From 31a2a431cd1d6de90fcd1ab46cb018a58cdbcf1c Mon Sep 17 00:00:00 2001 From: Viktor Smorodin Date: Wed, 25 Feb 2026 22:45:35 +0300 Subject: [PATCH 1/3] feat(use-click-outside): rebuild hook, add docs, update tests and stories --- hooks/use-click-outside/README.md | 31 ++++- hooks/use-click-outside/package.json | 2 +- hooks/use-click-outside/src/index.ts | 2 +- .../src/useClickOutside.docs.mdx | 33 +++++ .../src/useClickOutside.stories.tsx | 103 +++++++++++++-- .../src/useClickOutside.tests.tsx | 80 ++++++++++-- .../use-click-outside/src/useClickOutside.ts | 120 ++++++++++++++---- package-lock.json | 6 +- 8 files changed, 322 insertions(+), 55 deletions(-) create mode 100644 hooks/use-click-outside/src/useClickOutside.docs.mdx diff --git a/hooks/use-click-outside/README.md b/hooks/use-click-outside/README.md index de09e840..f93b87ff 100644 --- a/hooks/use-click-outside/README.md +++ b/hooks/use-click-outside/README.md @@ -1,3 +1,32 @@ # `@byndyusoft-ui/use-click-outside` -> React hook to execute the callback when a click happens outside of component or components. \ No newline at end of file +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 | null)[], + options?: { disabled?: boolean } +): void +``` diff --git a/hooks/use-click-outside/package.json b/hooks/use-click-outside/package.json index fb27ce18..e63aa353 100644 --- a/hooks/use-click-outside/package.json +++ b/hooks/use-click-outside/package.json @@ -38,6 +38,6 @@ "access": "public" }, "dependencies": { - "@byndyusoft-ui/use-event-listener": "^0.1.2" + "@byndyusoft-ui/use-latest-ref": "^1.0.0" } } diff --git a/hooks/use-click-outside/src/index.ts b/hooks/use-click-outside/src/index.ts index f4ade927..2a2d28d6 100644 --- a/hooks/use-click-outside/src/index.ts +++ b/hooks/use-click-outside/src/index.ts @@ -1 +1 @@ -export { default } from './useClickOutside'; +export { default, type IUseClickOutsideOptions } from './useClickOutside'; diff --git a/hooks/use-click-outside/src/useClickOutside.docs.mdx b/hooks/use-click-outside/src/useClickOutside.docs.mdx new file mode 100644 index 00000000..b5fc8ffb --- /dev/null +++ b/hooks/use-click-outside/src/useClickOutside.docs.mdx @@ -0,0 +1,33 @@ +import { Markdown, Meta, Canvas, Source } from '@storybook/blocks'; +import Readme from '../README.md?raw'; +import * as useClickOutsideStories from './useClickOutside.stories'; + + + +{Readme} + +## Usage + +1. Import the hook: + + + +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: + + + +**Disabled option** — when `disabled: true`, the handler is not called: + + + +**Dropdown** — typical use case: button + panel; clicking outside both closes the panel: + + + +## 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). diff --git a/hooks/use-click-outside/src/useClickOutside.stories.tsx b/hooks/use-click-outside/src/useClickOutside.stories.tsx index 2c7f9746..5aab5d4c 100644 --- a/hooks/use-click-outside/src/useClickOutside.stories.tsx +++ b/hooks/use-click-outside/src/useClickOutside.stories.tsx @@ -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'; @@ -26,16 +26,16 @@ const Block = forwardRef( 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(null); + const ref2 = useRef(null); + const ref3 = useRef(null); - const handleClickOutside = (): void => { + const handleClickOutside = useCallback((): void => { alert('clickOutside'); - }; + }, []); - useClickOutside(handleClickOutside, ref1, ref2, ref3); + useClickOutside(handleClickOutside, [ref1, ref2, ref3]); return ( <> @@ -46,9 +46,88 @@ const Template = (): JSX.Element => { ); }; -type TStory = StoryObj; +export const MultipleRefsStory: StoryObj = { + name: 'Multiple refs', + render: () => +}; + +const DisabledTemplate = (): JSX.Element => { + const ref = useRef(null); + const [disabled, setDisabled] = useState(false); + const [lastAction, setLastAction] = useState('none'); + + const handleClickOutside = useCallback((): void => { + setLastAction('clickOutside'); + }, []); + + useClickOutside(handleClickOutside, [ref], { disabled }); + + return ( +
+

Disabled: {String(disabled)}

+ +
+ Click outside this block to trigger handler (when not disabled) +
+

Last action: {lastAction}

+
+ ); +}; + +export const DisabledStory: StoryObj = { + name: 'Disabled option', + render: () => +}; + +const DropdownTemplate = (): JSX.Element => { + const triggerRef = useRef(null); + const panelRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleClickOutside = useCallback((): void => { + setOpen(false); + }, []); + + useClickOutside(handleClickOutside, [triggerRef, panelRef]); + + return ( +
+ + {open && ( +
+ Dropdown content. Click outside to close. +
+ )} +
+ ); +}; -export const HookStory: TStory = { - name: 'useCLickOutside', - render: Template +export const DropdownStory: StoryObj = { + name: 'Dropdown (close on outside click)', + render: () => }; diff --git a/hooks/use-click-outside/src/useClickOutside.tests.tsx b/hooks/use-click-outside/src/useClickOutside.tests.tsx index e839b2ab..d0dad15d 100644 --- a/hooks/use-click-outside/src/useClickOutside.tests.tsx +++ b/hooks/use-click-outside/src/useClickOutside.tests.tsx @@ -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(null); + const ref2 = useRef(null); - useClickOutside(props.onClick, ref1, ref2); + useClickOutside(props.onClick, [ref1, ref2]); return (
@@ -21,21 +21,83 @@ const Setup = (props: { onClick: () => void }): JSX.Element => { ); }; +const SetupDisabled = (props: { onClick: () => void; disabled: boolean }): JSX.Element => { + const ref = useRef(null); + + useClickOutside(props.onClick, [ref], { disabled: props.disabled }); + + return ( +
+
+ inner +
+
+ ); +}; + +const SetupMultipleInstances = (): JSX.Element => { + const refA = useRef(null); + const refB = useRef(null); + const [clicked, setClicked] = useState(null); + + useClickOutside(() => setClicked('A'), [refA]); + useClickOutside(() => setClicked('B'), [refB]); + + return ( +
+
+ Block A +
+
+ Block B +
+
Outside
+ {clicked != null && {clicked}} +
+ ); +}; + describe('hooks/useClickOutside', () => { - test('add two refs', async () => { + test('calls handler when click is outside all refs', async () => { const onClick = vi.fn(); render(); 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(); + + 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(); 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(); + + 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'); }); }); diff --git a/hooks/use-click-outside/src/useClickOutside.ts b/hooks/use-click-outside/src/useClickOutside.ts index f0d027ec..8c9eac7c 100644 --- a/hooks/use-click-outside/src/useClickOutside.ts +++ b/hooks/use-click-outside/src/useClickOutside.ts @@ -1,35 +1,99 @@ -import { useCallback, MutableRefObject, useRef, useEffect } from 'react'; -import useEventListener from '@byndyusoft-ui/use-event-listener'; - -export default function useClickOutside( - handler: () => void, - ...refs: Array | HTMLElement | null> -): void { - const documentRef = useRef(document); - const latestHandler = useRef(handler); - const latestRefs = useRef(refs); - - // Keep refs updated - useEffect(() => { - latestHandler.current = handler; - latestRefs.current = refs; - }); +import { useEffect, RefObject, useRef, MutableRefObject } from 'react'; +import useLatestRef from '@byndyusoft-ui/use-latest-ref'; - const internalHandler = useCallback((e: Event) => { - const target = e.target as Node; +type RefLike = RefObject | null; - const isInside = latestRefs.current.some(ref => { - if (!ref) return false; +// Глобальный реестр всех активных хуков useClickOutside +const globalRegistry = new Map< + symbol, + { + handlerRef: MutableRefObject<(event: PointerEvent) => void>; + refsRef: MutableRefObject; + disabled: boolean; + } +>(); - const element = ref instanceof HTMLElement ? ref : ref.current; +// Глобальный обработчик события +let globalEventHandler: ((event: PointerEvent) => void) | null = null; - return element?.contains(target); - }); +// Функция для регистрации глобального обработчика +const ensureGlobalEventHandler = (): void => { + if (!globalEventHandler) { + globalEventHandler = (event: PointerEvent) => { + const target = event.target; + if (target == null || !(target instanceof Node)) { + return; + } + + for (const [, { handlerRef, refsRef, disabled }] of globalRegistry) { + if (disabled) { + continue; + } + + const refs = refsRef.current; + if (!refs?.length) { + continue; + } - if (!isInside) { - latestHandler.current(); - } - }, []); // Empty dependency array since we use latest refs + const isClickOutside = !refs.some(ref => ref?.current != null && ref.current.contains(target)); - useEventListener('click', internalHandler, documentRef); + if (isClickOutside) { + handlerRef.current(event); + } + } + }; + + document.addEventListener('pointerdown', globalEventHandler); + } +}; + +// Функция для очистки глобального обработчика +const cleanupGlobalEventHandler = (): void => { + if (globalEventHandler != null && globalRegistry.size === 0) { + document.removeEventListener('pointerdown', globalEventHandler); + globalEventHandler = null; + } +}; + +export interface IUseClickOutsideOptions { + disabled?: boolean; } + +/** + * Подписывается на клики вне переданных refs. Handler вызывается только если клик + * произошёл вне всех переданных элементов (pointerdown на document). + * + * @param handler — вызывается с PointerEvent при клике вне всех refs + * @param refs — массив ref'ов элементов; клик считается «внутри», если target внутри любого из них + * @param options.disabled — при true handler не вызывается + */ + +const useClickOutside = ( + handler: (event: PointerEvent) => void, + refs: RefLike[], + options?: IUseClickOutsideOptions +): void => { + const hookId = useRef(Symbol()); + const isDisabled = options?.disabled ?? false; + const handlerRef = useLatestRef(handler); + const refsRef = useLatestRef(refs); + + useEffect(() => { + const id = hookId.current; + + globalRegistry.set(id, { + handlerRef, + refsRef, + disabled: isDisabled + }); + + ensureGlobalEventHandler(); + + return () => { + globalRegistry.delete(id); + cleanupGlobalEventHandler(); + }; + }, [isDisabled]); +}; + +export default useClickOutside; diff --git a/package-lock.json b/package-lock.json index cf8997c6..2e7cb76f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -157,7 +157,7 @@ "version": "0.1.1", "license": "ISC", "dependencies": { - "@byndyusoft-ui/use-event-listener": "^0.1.2" + "@byndyusoft-ui/use-latest-ref": "^1.0.0" } }, "hooks/use-event-listener": { @@ -193,7 +193,7 @@ }, "hooks/use-interval": { "name": "@byndyusoft-ui/use-interval", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@byndyusoft-ui/types": "^0.3.0", @@ -248,7 +248,7 @@ }, "hooks/use-timeout": { "name": "@byndyusoft-ui/use-timeout", - "version": "2.0.1", + "version": "2.1.0", "license": "Apache-2.0", "dependencies": { "@byndyusoft-ui/use-latest-ref": "^1.0.0" From 0c3c9d29c9bb6e72258d48eece57ec0b08614ea7 Mon Sep 17 00:00:00 2001 From: Viktor Smorodin Date: Wed, 25 Feb 2026 23:25:27 +0300 Subject: [PATCH 2/3] feat: update stories --- hooks/use-click-outside/src/useClickOutside.stories.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hooks/use-click-outside/src/useClickOutside.stories.tsx b/hooks/use-click-outside/src/useClickOutside.stories.tsx index 5aab5d4c..027e96b8 100644 --- a/hooks/use-click-outside/src/useClickOutside.stories.tsx +++ b/hooks/use-click-outside/src/useClickOutside.stories.tsx @@ -53,6 +53,7 @@ export const MultipleRefsStory: StoryObj = { const DisabledTemplate = (): JSX.Element => { const ref = useRef(null); + const buttonRef = useRef(null); const [disabled, setDisabled] = useState(false); const [lastAction, setLastAction] = useState('none'); @@ -60,12 +61,12 @@ const DisabledTemplate = (): JSX.Element => { setLastAction('clickOutside'); }, []); - useClickOutside(handleClickOutside, [ref], { disabled }); + useClickOutside(handleClickOutside, [ref, buttonRef], { disabled }); return (

Disabled: {String(disabled)}

-
Date: Wed, 11 Mar 2026 15:00:44 +0300 Subject: [PATCH 3/3] feat: fixes after review --- hooks/use-click-outside/src/useClickOutside.tests.tsx | 2 +- hooks/use-click-outside/src/useClickOutside.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/hooks/use-click-outside/src/useClickOutside.tests.tsx b/hooks/use-click-outside/src/useClickOutside.tests.tsx index d0dad15d..8b2a3bc7 100644 --- a/hooks/use-click-outside/src/useClickOutside.tests.tsx +++ b/hooks/use-click-outside/src/useClickOutside.tests.tsx @@ -52,7 +52,7 @@ const SetupMultipleInstances = (): JSX.Element => { Block B
Outside
- {clicked != null && {clicked}} + {clicked !== null && {clicked}}
); }; diff --git a/hooks/use-click-outside/src/useClickOutside.ts b/hooks/use-click-outside/src/useClickOutside.ts index 8c9eac7c..d681f0bb 100644 --- a/hooks/use-click-outside/src/useClickOutside.ts +++ b/hooks/use-click-outside/src/useClickOutside.ts @@ -35,7 +35,9 @@ const ensureGlobalEventHandler = (): void => { continue; } - const isClickOutside = !refs.some(ref => ref?.current != null && ref.current.contains(target)); + const isClickOutside = !refs.some( + ref => ref !== null && ref.current !== null && ref.current.contains(target) + ); if (isClickOutside) { handlerRef.current(event); @@ -49,7 +51,7 @@ const ensureGlobalEventHandler = (): void => { // Функция для очистки глобального обработчика const cleanupGlobalEventHandler = (): void => { - if (globalEventHandler != null && globalRegistry.size === 0) { + if (globalEventHandler !== null && globalRegistry.size === 0) { document.removeEventListener('pointerdown', globalEventHandler); globalEventHandler = null; }