diff --git a/packages/react/src/components/Gallery/GalleryDescription/GalleryDescription.tsx b/packages/react/src/components/Gallery/GalleryDescription/GalleryDescription.tsx index 5a5bc7eec..1a0ae6fbe 100644 --- a/packages/react/src/components/Gallery/GalleryDescription/GalleryDescription.tsx +++ b/packages/react/src/components/Gallery/GalleryDescription/GalleryDescription.tsx @@ -6,7 +6,7 @@ import { GalleryDescriptionProps } from './GalleryDescription.types'; import clsx from 'clsx'; -import { useForkRef, useWindowEventListener } from '../../../hooks'; +import { useForkRef, useOverflow } from '../../../hooks'; import { IconCloseLineW350 } from '../../../icons'; import { useDefaultProps } from '../../../theming'; import { Button } from '../../Button'; @@ -56,9 +56,12 @@ export const GalleryDescription = ({ const textRef = useRef(null); const [isExpanded, setExpanded] = useState(false); - const [isTruncated, setTruncated] = useState(false); const [maxHeight, setMaxHeight] = useState(32); + const { isOverflowX } = useOverflow(textRef); + + const isTruncated = isExpanded || isOverflowX; + const onClick = () => { if (isTruncated) { setExpanded(!isExpanded); @@ -89,25 +92,6 @@ export const GalleryDescription = ({ } }, [position, rectTop, rectBottom]); - const onResize = () => { - if (textRef.current) { - const isTruncated = textRef.current.scrollWidth > textRef.current.clientWidth; - setTruncated(isTruncated); - - if (isExpanded && !isTruncated) { - setExpanded(false); - } - } - }; - - useWindowEventListener('resize', () => { - onResize(); - }); - - useEffect(() => { - onResize(); - }, [children]); - return (
{ - const [overflow, setOverflow] = useState(false); - const ref = useRef(null); const childrenRef = useRef(null); - useResizeObserver(ref, () => { - const isOverflow = - (childrenRef.current && childrenRef.current.scrollWidth > childrenRef.current.offsetWidth) || - (ref.current && ref.current.scrollWidth > ref.current.offsetWidth) || - (childrenRef.current && childrenRef.current.scrollHeight > childrenRef.current.offsetHeight) || - (ref.current && ref.current.scrollHeight > ref.current.offsetHeight); + const root = useOverflow(ref); + const child = useOverflow(childrenRef); - setOverflow(!!isOverflow); - }); + const overflow = root.isOverflowX || root.isOverflowY || child.isOverflowX || child.isOverflowY; return ( + +# useOverflow API + +```js +import { useOverflow } from '@esfront/react'; +``` + + + +
+ +
+ +## Demos + +
    +
  • + + useOverflow + +
  • +
diff --git a/packages/react/src/hooks/useOverflow/useOverflow.stories.tsx b/packages/react/src/hooks/useOverflow/useOverflow.stories.tsx new file mode 100644 index 000000000..85789535f --- /dev/null +++ b/packages/react/src/hooks/useOverflow/useOverflow.stories.tsx @@ -0,0 +1,68 @@ +import { useRef, useState } from 'react'; + +import { Meta, StoryObj } from '@storybook/react-vite'; + +import { useOverflow } from './useOverflow'; + +type Args = { threshold?: number }; + +const meta: Meta = { + tags: ['autodocs'], + title: 'Hooks/useOverflow', + parameters: { + references: ['useOverflow'], + }, + argTypes: { + threshold: { + control: { + type: 'number', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: function Render(args) { + const [text, setText] = useState('A short line of text'); + + const ref = useRef(null); + + const { isOverflowX, isOverflowY } = useOverflow(ref, { threshold: args.threshold }); + + return ( +
+ setText(event.target.value)} + /> +
+ {text} +
+
+
isOverflowX: {isOverflowX.toString()}
+
isOverflowY: {isOverflowY.toString()}
+
+
+ ); + }, +}; diff --git a/packages/react/src/hooks/useOverflow/useOverflow.ts b/packages/react/src/hooks/useOverflow/useOverflow.ts new file mode 100644 index 000000000..2a04792f2 --- /dev/null +++ b/packages/react/src/hooks/useOverflow/useOverflow.ts @@ -0,0 +1,72 @@ +'use client'; + +import { RefObject, useEffect, useState } from 'react'; + +import { useEvent } from '../useEvent'; +import { useMutationObserver } from '../useMutationObserver'; +import { useResizeObserver } from '../useResizeObserver'; + +export type UseOverflowOptions = { + /** The tolerance in pixels used when deciding whether the content overflows. */ + threshold?: number; +}; + +export type UseOverflowResult = { + /** Whether the content is wider than the element. */ + isOverflowX: boolean; + /** Whether the content is taller than the element. */ + isOverflowY: boolean; +}; + +const MUTATION_OBSERVER_OPTIONS: MutationObserverInit = { childList: true, subtree: true, characterData: true }; + +const INITIAL_RESULT: UseOverflowResult = { + isOverflowX: false, + isOverflowY: false, +}; + +const getOverflow = (element: HTMLElement, threshold: number): UseOverflowResult => { + const { clientHeight, clientWidth, scrollHeight, scrollWidth } = element; + + return { + isOverflowX: scrollWidth - clientWidth > threshold, + isOverflowY: scrollHeight - clientHeight > threshold, + }; +}; + +/** + * The hook that tracks whether the content of an element overflows it, for example because it is clamped with an + * ellipsis. Useful for showing a tooltip or an expand button only when the content does not fit. The result is + * recalculated on resize of the element and on changes to its content. + * @param element A reference to an element to be measured. + * @param options The options object. + * @param [options.threshold] The tolerance in pixels used when deciding whether the content overflows. + * @returns The `isOverflowX` and `isOverflowY` flags. + */ +export const useOverflow = ( + element: RefObject, + options?: UseOverflowOptions +): UseOverflowResult => { + const { threshold = 1 } = options || {}; + + const [result, setResult] = useState(INITIAL_RESULT); + + const update = useEvent(() => { + if (element.current) { + const next = getOverflow(element.current, threshold); + + setResult((prev) => + prev.isOverflowX === next.isOverflowX && prev.isOverflowY === next.isOverflowY ? prev : next + ); + } + }); + + useResizeObserver(element, update); + useMutationObserver(element, update, MUTATION_OBSERVER_OPTIONS); + + useEffect(() => { + update(); + }, [element.current, threshold]); + + return result; +};