From 2912853ef576b0eae98c720ea93f2695f0a4bbf0 Mon Sep 17 00:00:00 2001 From: Denis Yakshov Date: Wed, 7 Oct 2026 16:53:12 +0200 Subject: [PATCH] feat(react,useElementEventListener): add new hook --- .../DateInput/useDateInputKeyboard.ts | 18 ++----- .../src/components/PhoneField/PhoneField.tsx | 48 ++++++++++--------- packages/react/src/hooks/index.ts | 1 + .../hooks/useElementEventListener/index.ts | 1 + .../useElementEventListener.api.mdx | 27 +++++++++++ .../useElementEventListener.stories.tsx | 48 +++++++++++++++++++ .../useElementEventListener.ts | 38 +++++++++++++++ .../useScrollPosition/useScrollPosition.ts | 14 ++---- 8 files changed, 147 insertions(+), 48 deletions(-) create mode 100644 packages/react/src/hooks/useElementEventListener/index.ts create mode 100644 packages/react/src/hooks/useElementEventListener/useElementEventListener.api.mdx create mode 100644 packages/react/src/hooks/useElementEventListener/useElementEventListener.stories.tsx create mode 100644 packages/react/src/hooks/useElementEventListener/useElementEventListener.ts diff --git a/packages/react/src/components/DateInput/useDateInputKeyboard.ts b/packages/react/src/components/DateInput/useDateInputKeyboard.ts index fff995299..9cf7a1f9b 100644 --- a/packages/react/src/components/DateInput/useDateInputKeyboard.ts +++ b/packages/react/src/components/DateInput/useDateInputKeyboard.ts @@ -1,6 +1,6 @@ 'use client'; -import { KeyboardEventHandler, RefObject, useEffect } from 'react'; +import { KeyboardEventHandler, RefObject } from 'react'; import { DateInputFormatter } from './DateInput.types'; @@ -8,7 +8,7 @@ import { DateInputSelection } from './useDateInputSelection'; import { DateInputState } from './useDateInputState'; import { isDigit } from './utils'; -import { useEvent } from '../../hooks'; +import { useElementEventListener, useEvent } from '../../hooks'; /** The keys the input acts on itself. Everything else is left to the browser and to the form around it. */ const CONSUMED_KEYS = new Set([ @@ -274,19 +274,7 @@ export const useDateInputKeyboard = ({ } }); - useEffect(() => { - const node = ref.current; - - if (!node) { - return; - } - - node.addEventListener('beforeinput', onBeforeInput); - - return () => { - node.removeEventListener('beforeinput', onBeforeInput); - }; - }, [onBeforeInput]); + useElementEventListener(ref, 'beforeinput', onBeforeInput); return { onKeyDown }; }; diff --git a/packages/react/src/components/PhoneField/PhoneField.tsx b/packages/react/src/components/PhoneField/PhoneField.tsx index d1ca33594..08f02623c 100644 --- a/packages/react/src/components/PhoneField/PhoneField.tsx +++ b/packages/react/src/components/PhoneField/PhoneField.tsx @@ -16,7 +16,16 @@ import { PhoneFieldPatch, } from './PhoneField.functions'; -import { useControlled, useEvent, useForkRef, useLatest, useMenu, useMenuVisibility } from '../../hooks'; +import { + useControlled, + useElementEventListener, + useEvent, + useForkRef, + useLatest, + useMenu, + useMenuVisibility, + useRefState, +} from '../../hooks'; import { IconGlobalLineW500, IconMenuDownFillW300 } from '../../icons'; import { useDefaultProps } from '../../theming'; import { AutocompleteMenu } from '../AutocompleteMenu'; @@ -93,7 +102,7 @@ export const PhoneField = memo(function PhoneField({ const ref = useRef(null); const rootRef = useForkRef(ref, inRef); - const [inputRef, setInputRefState] = useState(null); + const [inputRef, setInputRefState] = useRefState(); // The field never renders a textarea, so the control it reports back is always an input. const setInputRef = useCallback((node: FormFieldInputElement | null) => { setInputRefState(node as HTMLInputElement | null); @@ -182,11 +191,13 @@ export const PhoneField = memo(function PhoneField({ event.preventDefault(); - if (!inputRef) { + const input = inputRef.current; + + if (!input) { return; } - const { selectionStart, selectionEnd, value } = inputRef; + const { selectionStart, selectionEnd, value } = input; if (selectionStart === null || selectionEnd === null) { return; @@ -273,7 +284,7 @@ export const PhoneField = memo(function PhoneField({ onChange?.({ target: { name, value: phone } } as React.ChangeEvent); requestAnimationFrame(() => { - inputRef.setSelectionRange(newSelectionStart, newSelectionEnd); + input.setSelectionRange(newSelectionStart, newSelectionEnd); }); } }); @@ -315,17 +326,8 @@ export const PhoneField = memo(function PhoneField({ } }); - useEffect(() => { - if (inputRef) { - inputRef.addEventListener('beforeinput', onBeforeInput); - inputRef.addEventListener('input', onInput); - - return () => { - inputRef.removeEventListener('beforeinput', onBeforeInput); - inputRef.removeEventListener('input', onInput); - }; - } - }, [inputRef]); + useElementEventListener(inputRef, 'beforeinput', onBeforeInput); + useElementEventListener(inputRef, 'input', onInput); const onInputKeyDown = (e: React.KeyboardEvent) => { onKeyDownProp?.(e); @@ -362,7 +364,9 @@ export const PhoneField = memo(function PhoneField({ ).text; const onCheckCaretPosition = useEvent((e: React.SyntheticEvent) => { - if (inputRef && inputRef.selectionStart === inputRef.selectionEnd) { + const input = inputRef.current; + + if (input && input.selectionStart === input.selectionEnd) { let min = 1; if (countries.length === 1) { @@ -373,9 +377,9 @@ export const PhoneField = memo(function PhoneField({ } } - if (inputRef.selectionStart !== null && inputRef.selectionStart < min) { + if (input.selectionStart !== null && input.selectionStart < min) { e.preventDefault(); - inputRef.setSelectionRange(min, min); + input.setSelectionRange(min, min); } } }); @@ -427,9 +431,7 @@ export const PhoneField = memo(function PhoneField({ onChange?.({ target: { name, value: newValue } } as React.ChangeEvent); requestAnimationFrame(() => { - if (inputRef) { - inputRef.focus(); - } + inputRef.current?.focus(); }); } }; @@ -483,7 +485,7 @@ export const PhoneField = memo(function PhoneField({ onExited(); setTimeout(() => { - if (inputRef !== document.activeElement) { + if (inputRef.current !== document.activeElement) { onBlurProp?.({} as never); } }, 1); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index a571db68d..cb756decf 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -7,6 +7,7 @@ export { useCookie } from './useCookie'; export { useDebounce } from './useDebounce'; export { useDocumentEventListener } from './useDocumentEventListener'; export { useDragOver } from './useDragOver'; +export { useElementEventListener } from './useElementEventListener'; export { useEnhancedEffect } from './useEnhancedEffect'; export { useEvent } from './useEvent'; export { useForceUpdate } from './useForceUpdate'; diff --git a/packages/react/src/hooks/useElementEventListener/index.ts b/packages/react/src/hooks/useElementEventListener/index.ts new file mode 100644 index 000000000..e91fa8f93 --- /dev/null +++ b/packages/react/src/hooks/useElementEventListener/index.ts @@ -0,0 +1 @@ +export { useElementEventListener } from './useElementEventListener'; diff --git a/packages/react/src/hooks/useElementEventListener/useElementEventListener.api.mdx b/packages/react/src/hooks/useElementEventListener/useElementEventListener.api.mdx new file mode 100644 index 000000000..04aefd154 --- /dev/null +++ b/packages/react/src/hooks/useElementEventListener/useElementEventListener.api.mdx @@ -0,0 +1,27 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import LinkTo from '@storybook/addon-links/react'; +import { TableFunction } from '~storybook/components/TableFunction'; + + + +# useElementEventListener API + +```js +import { useElementEventListener } from '@esfront/react'; +``` + + + +
+ +
+ +## Demos + +
    +
  • + + useElementEventListener + +
  • +
diff --git a/packages/react/src/hooks/useElementEventListener/useElementEventListener.stories.tsx b/packages/react/src/hooks/useElementEventListener/useElementEventListener.stories.tsx new file mode 100644 index 000000000..d02641c75 --- /dev/null +++ b/packages/react/src/hooks/useElementEventListener/useElementEventListener.stories.tsx @@ -0,0 +1,48 @@ +import { useRef, useState } from 'react'; + +import { Meta, StoryObj } from '@storybook/react-vite'; + +import { useElementEventListener } from './useElementEventListener'; + +const meta: Meta = { + tags: ['autodocs'], + title: 'Hooks/useElementEventListener', + parameters: { + references: ['useElementEventListener'], + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: function Render() { + const [count, setCount] = useState(0); + + const ref = useRef(null); + + useElementEventListener(ref, 'click', () => { + setCount((value) => value + 1); + }); + + return ( +
+
+ Click me +
+
+ Count of clicks on the box: {count}. +
+
+ ); + }, +}; diff --git a/packages/react/src/hooks/useElementEventListener/useElementEventListener.ts b/packages/react/src/hooks/useElementEventListener/useElementEventListener.ts new file mode 100644 index 000000000..7d9111aab --- /dev/null +++ b/packages/react/src/hooks/useElementEventListener/useElementEventListener.ts @@ -0,0 +1,38 @@ +'use client'; + +import { RefObject, useEffect } from 'react'; + +import { useLatest } from '../useLatest'; + +/** + * The hook that sets up a function that will be called whenever the specified event is delivered to an element. The + * listener is reattached whenever the observed element changes. + * @param element A reference to an element to listen on. + * @param type A case-sensitive string representing the event type to listen for. + * @param callback The function that receives a notification when an event of the specified type occurs. + * @param options An object that specifies characteristics about the event listener. + */ +export const useElementEventListener = ( + element: RefObject, + type: K, + callback: (event: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions +) => { + const latestCallback = useLatest(callback); + + useEffect(() => { + const target = element.current; + + if (target) { + const onEvent = (event: HTMLElementEventMap[K]) => { + return latestCallback.current(event); + }; + + target.addEventListener(type, onEvent, options); + + return () => { + target.removeEventListener(type, onEvent); + }; + } + }, [element.current, type]); +}; diff --git a/packages/react/src/hooks/useScrollPosition/useScrollPosition.ts b/packages/react/src/hooks/useScrollPosition/useScrollPosition.ts index 7e41123ba..52d551cc9 100644 --- a/packages/react/src/hooks/useScrollPosition/useScrollPosition.ts +++ b/packages/react/src/hooks/useScrollPosition/useScrollPosition.ts @@ -2,6 +2,7 @@ import { RefObject, useEffect, useState } from 'react'; +import { useElementEventListener } from '../useElementEventListener'; import { useEvent } from '../useEvent'; import { useMutationObserver } from '../useMutationObserver'; import { useResizeObserver } from '../useResizeObserver'; @@ -28,6 +29,8 @@ export type UseScrollPositionResult = { const MUTATION_OBSERVER_OPTIONS: MutationObserverInit = { childList: true, subtree: true }; +const SCROLL_LISTENER_OPTIONS: AddEventListenerOptions = { passive: true }; + const INITIAL_RESULT: UseScrollPositionResult = { isScrollableX: false, isScrollableY: false, @@ -86,19 +89,10 @@ export const useScrollPosition = ( useResizeObserver(element, update); useMutationObserver(element, update, MUTATION_OBSERVER_OPTIONS); + useElementEventListener(element, 'scroll', update, SCROLL_LISTENER_OPTIONS); useEffect(() => { update(); - - const target = element.current; - - if (target) { - target.addEventListener('scroll', update, { passive: true }); - - return () => { - target.removeEventListener('scroll', update); - }; - } }, [element.current, threshold]); return result;