From 92cc7668e23f4f66e2de33b74bf00b17fc1963d2 Mon Sep 17 00:00:00 2001 From: Denis Yakshov Date: Wed, 7 Oct 2026 11:58:23 +0200 Subject: [PATCH] feat(react,useScrollPosition): add new hook --- .../FiltersFormGroup/FiltersFormGroup.tsx | 48 ++------ .../SidebarScrollable/SidebarScrollable.tsx | 54 ++------- packages/react/src/hooks/index.ts | 2 + .../src/hooks/useScrollPosition/index.ts | 2 + .../useScrollPosition.api.mdx | 27 +++++ .../useScrollPosition.stories.tsx | 90 +++++++++++++++ .../useScrollPosition/useScrollPosition.ts | 105 ++++++++++++++++++ 7 files changed, 243 insertions(+), 85 deletions(-) create mode 100644 packages/react/src/hooks/useScrollPosition/index.ts create mode 100644 packages/react/src/hooks/useScrollPosition/useScrollPosition.api.mdx create mode 100644 packages/react/src/hooks/useScrollPosition/useScrollPosition.stories.tsx create mode 100644 packages/react/src/hooks/useScrollPosition/useScrollPosition.ts diff --git a/packages/react/src/components/Filters/FiltersFormGroup/FiltersFormGroup.tsx b/packages/react/src/components/Filters/FiltersFormGroup/FiltersFormGroup.tsx index ceb40fd1c..acbaad819 100644 --- a/packages/react/src/components/Filters/FiltersFormGroup/FiltersFormGroup.tsx +++ b/packages/react/src/components/Filters/FiltersFormGroup/FiltersFormGroup.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Children, RefAttributes, useRef, useState } from 'react'; +import { Children, RefAttributes, useState } from 'react'; import { FiltersFormGroupProps } from './FiltersFormGroup.types'; @@ -8,7 +8,7 @@ import { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; import clsx from 'clsx'; -import { useBoolean, useResizeObserver } from '../../../hooks'; +import { useBoolean, useScrollPosition } from '../../../hooks'; import { useDefaultProps } from '../../../theming'; import { Link } from '../../Link'; import { OverlayScrollbars } from '../../OverlayScrollbars'; @@ -24,63 +24,29 @@ export const FiltersFormGroup = ({ ref: inRef, ...inProps }: FiltersFormGroupPro const [open, toggleOpen] = useBoolean(false); - const [isScrollable, setScrollable] = useState(false); - const [isBeforeScroll, setBeforeScroll] = useState(false); - const [isAfterScroll, setAfterScroll] = useState(true); - - const ref = useRef(null); - // The viewport is published from `onInitialized` rather than read off the ref during render, which // would have made the observed element depend on a value that cannot trigger a re-render. const [viewport, setViewport] = useState(null); - useResizeObserver({ current: viewport }, () => { - if (viewport) { - setScrollable(viewport.scrollHeight > viewport.clientHeight); - } - }); + const { isScrollableY, isAtTop, isAtBottom } = useScrollPosition({ current: viewport }); const onInitialized = (instance: NonNullable>) => { - const element = instance.elements().viewport; - - setViewport(element ?? null); - - if (element) { - setScrollable(element.scrollHeight > element.clientHeight); - } - }; - - const onScroll = () => { - if (!ref.current) { - return; - } - - const element = ref.current.osInstance()?.elements().viewport; - - if (!element) { - return; - } - - const { scrollTop, scrollHeight, clientHeight } = element; - setBeforeScroll(scrollTop > 0); - setAfterScroll(!(scrollTop >= scrollHeight - clientHeight)); + setViewport(instance.elements().viewport ?? null); }; return (
{!!header &&
{header}
} {!!maxLines && !open ? Children.toArray(children).slice(0, maxLines) : children} diff --git a/packages/react/src/components/Sidebar/SidebarScrollable/SidebarScrollable.tsx b/packages/react/src/components/Sidebar/SidebarScrollable/SidebarScrollable.tsx index f7df51a76..19618a655 100644 --- a/packages/react/src/components/Sidebar/SidebarScrollable/SidebarScrollable.tsx +++ b/packages/react/src/components/Sidebar/SidebarScrollable/SidebarScrollable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { RefAttributes, useRef, useState } from 'react'; +import { RefAttributes, useState } from 'react'; import { SidebarScrollableProps } from './SidebarScrollable.types'; @@ -8,7 +8,7 @@ import { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; import clsx from 'clsx'; -import { useForkRef, useResizeObserver } from '../../../hooks'; +import { useScrollPosition } from '../../../hooks'; import { useDefaultProps } from '../../../theming'; import { OverlayScrollbars } from '../..//OverlayScrollbars'; @@ -24,72 +24,38 @@ export const SidebarScrollable = ({ name: 'ESSidebarScrollable', }); - const [isScrollable, setScrollable] = useState(false); - const [isBeforeScroll, setBeforeScroll] = useState(false); - const [isAfterScroll, setAfterScroll] = useState(true); - - const ref = useRef(null); - const rootRef = useForkRef(ref, inRef); - // The viewport is published from `onInitialized` rather than read off the ref during render, which // would have made the observed element depend on a value that cannot trigger a re-render. const [viewport, setViewport] = useState(null); - useResizeObserver({ current: viewport }, () => { - if (viewport) { - setScrollable(viewport.scrollHeight > viewport.clientHeight); - } - }); + const { isScrollableY, isAtTop, isAtBottom } = useScrollPosition({ current: viewport }); const onInitialized = (instance: NonNullable>) => { - const element = instance.elements().viewport; - - setViewport(element ?? null); - - if (element) { - setScrollable(element.scrollHeight > element.clientHeight); - } - }; - - const onScroll = () => { - if (!ref.current) { - return; - } - - const element = ref.current.osInstance()?.elements().viewport; - - if (!element) { - return; - } - - const { scrollTop, scrollHeight, clientHeight } = element; - setBeforeScroll(scrollTop > 0); - setAfterScroll(!(scrollTop >= scrollHeight - clientHeight)); + setViewport(instance.elements().viewport ?? null); }; return ( <> - {isScrollable && beforeScroll} + {isScrollableY && beforeScroll} {children} - {isScrollable && afterScroll} + {isScrollableY && afterScroll} ); }; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 0d7535b16..20478b6e4 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -31,6 +31,8 @@ export { usePreviousValue } from './usePreviousValue'; export { useResizeObserver } from './useResizeObserver'; export { useScrollDirection } from './useScrollDirection'; export { useScrollLock } from './useScrollLock'; +export type { UseScrollPositionOptions, UseScrollPositionResult } from './useScrollPosition'; +export { useScrollPosition } from './useScrollPosition'; export { useScrollSpy } from './useScrollSpy'; export { useScrollSync } from './useScrollSync'; export { useSessionStorage } from './useSessionStorage'; diff --git a/packages/react/src/hooks/useScrollPosition/index.ts b/packages/react/src/hooks/useScrollPosition/index.ts new file mode 100644 index 000000000..e7bc793c0 --- /dev/null +++ b/packages/react/src/hooks/useScrollPosition/index.ts @@ -0,0 +1,2 @@ +export type { UseScrollPositionOptions, UseScrollPositionResult } from './useScrollPosition'; +export { useScrollPosition } from './useScrollPosition'; diff --git a/packages/react/src/hooks/useScrollPosition/useScrollPosition.api.mdx b/packages/react/src/hooks/useScrollPosition/useScrollPosition.api.mdx new file mode 100644 index 000000000..9cd8189b5 --- /dev/null +++ b/packages/react/src/hooks/useScrollPosition/useScrollPosition.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'; + + + +# useScrollPosition API + +```js +import { useScrollPosition } from '@esfront/react'; +``` + + + +
+ +
+ +## Demos + +
    +
  • + + useScrollPosition + +
  • +
diff --git a/packages/react/src/hooks/useScrollPosition/useScrollPosition.stories.tsx b/packages/react/src/hooks/useScrollPosition/useScrollPosition.stories.tsx new file mode 100644 index 000000000..2aad39321 --- /dev/null +++ b/packages/react/src/hooks/useScrollPosition/useScrollPosition.stories.tsx @@ -0,0 +1,90 @@ +import { CSSProperties, useRef, useState } from 'react'; + +import { Meta, StoryObj } from '@storybook/react-vite'; + +import { useScrollPosition } from './useScrollPosition'; + +import { Button } from '../../components/Button'; + +type Args = { threshold?: number }; + +const meta: Meta = { + tags: ['autodocs'], + title: 'Hooks/useScrollPosition', + parameters: { + references: ['useScrollPosition'], + }, + argTypes: { + threshold: { + control: { + type: 'number', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const SHADOW_SIZE = 24; + +const getShadow = (direction: string, isVisible: boolean): CSSProperties => ({ + position: 'absolute', + inset: 0, + pointerEvents: 'none', + opacity: isVisible ? 1 : 0, + transition: 'opacity 150ms', + background: `linear-gradient(to ${direction}, var(--es-mono-a-a150), transparent ${SHADOW_SIZE}px)`, +}); + +export const Demo: Story = { + render: function Render(args) { + const ref = useRef(null); + const [rows, setRows] = useState(8); + + const { isScrollableX, isScrollableY, isAtLeft, isAtRight, isAtTop, isAtBottom } = useScrollPosition(ref, { + threshold: args.threshold, + }); + + return ( +
+
+
+ {Array.from({ length: rows }, (_, index) => ( +
+ Row {index + 1} — scroll both axes to see the flags change +
+ ))} +
+
+
+
+
+
+
+
isScrollableX: {isScrollableX.toString()}
+
isScrollableY: {isScrollableY.toString()}
+
isAtLeft: {isAtLeft.toString()}
+
isAtRight: {isAtRight.toString()}
+
isAtTop: {isAtTop.toString()}
+
isAtBottom: {isAtBottom.toString()}
+
+
+ + +
+
+ ); + }, +}; diff --git a/packages/react/src/hooks/useScrollPosition/useScrollPosition.ts b/packages/react/src/hooks/useScrollPosition/useScrollPosition.ts new file mode 100644 index 000000000..7e41123ba --- /dev/null +++ b/packages/react/src/hooks/useScrollPosition/useScrollPosition.ts @@ -0,0 +1,105 @@ +'use client'; + +import { RefObject, useEffect, useState } from 'react'; + +import { useEvent } from '../useEvent'; +import { useMutationObserver } from '../useMutationObserver'; +import { useResizeObserver } from '../useResizeObserver'; + +export type UseScrollPositionOptions = { + /** The tolerance in pixels used when deciding whether a scroll edge has been reached. */ + threshold?: number; +}; + +export type UseScrollPositionResult = { + /** Whether the element overflows horizontally. */ + isScrollableX: boolean; + /** Whether the element overflows vertically. */ + isScrollableY: boolean; + /** Whether the element is scrolled to its left edge. `true` when the element does not overflow horizontally. */ + isAtLeft: boolean; + /** Whether the element is scrolled to its right edge. `true` when the element does not overflow horizontally. */ + isAtRight: boolean; + /** Whether the element is scrolled to its top edge. `true` when the element does not overflow vertically. */ + isAtTop: boolean; + /** Whether the element is scrolled to its bottom edge. `true` when the element does not overflow vertically. */ + isAtBottom: boolean; +}; + +const MUTATION_OBSERVER_OPTIONS: MutationObserverInit = { childList: true, subtree: true }; + +const INITIAL_RESULT: UseScrollPositionResult = { + isScrollableX: false, + isScrollableY: false, + isAtLeft: true, + isAtRight: true, + isAtTop: true, + isAtBottom: true, +}; + +const getScrollPosition = (element: HTMLElement, threshold: number): UseScrollPositionResult => { + const { clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth } = element; + + const maxScrollLeft = scrollWidth - clientWidth; + const maxScrollTop = scrollHeight - clientHeight; + + return { + isScrollableX: maxScrollLeft > threshold, + isScrollableY: maxScrollTop > threshold, + isAtLeft: scrollLeft <= threshold, + isAtRight: scrollLeft >= maxScrollLeft - threshold, + isAtTop: scrollTop <= threshold, + isAtBottom: scrollTop >= maxScrollTop - threshold, + }; +}; + +/** + * The hook that tracks whether an element overflows and whether it has reached its scroll edges. Useful for rendering + * scroll shadows or fade masks. The position is recalculated on scroll, on resize of the element and on changes to its + * content. + * @param element A reference to an element whose scroll position should be tracked. + * @param options The options object. + * @param [options.threshold] The tolerance in pixels used when deciding whether a scroll edge has been reached. + * @returns The `isScrollableX`, `isScrollableY`, `isAtLeft`, `isAtRight`, `isAtTop` and `isAtBottom` flags. + */ +export const useScrollPosition = ( + element: RefObject, + options?: UseScrollPositionOptions +): UseScrollPositionResult => { + const { threshold = 1 } = options || {}; + + const [result, setResult] = useState(INITIAL_RESULT); + + const update = useEvent(() => { + if (element.current) { + const next = getScrollPosition(element.current, threshold); + + setResult((prev) => { + const isEqual = (Object.keys(next) as Array).every( + (key) => prev[key] === next[key] + ); + + return isEqual ? prev : next; + }); + } + }); + + useResizeObserver(element, update); + useMutationObserver(element, update, MUTATION_OBSERVER_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; +};