'use client'; import { useCallback, useEffect, useState } from 'react'; export interface UseIsTruncatedOptions { /** Measure vertical clipping (`line-clamp-N`) instead of the single-line ellipsis. */ multiline?: boolean; } /** * Tracks whether an element's text is ACTUALLY clipped — the condition for * offering a "full value" tooltip. Text that fits needs no tooltip, and showing * one anyway just repeats what the user is already reading. * * Three things can change the answer, and all three are watched: the box * resizing (`ResizeObserver`), the text itself changing, and the measured * ELEMENT changing. None of them implies the others — new text inside a * width-constrained box leaves the box exactly as it was, so the observer never * fires; and a caller that swaps between render branches (adornment vs plain) * mounts a different node under the same text, which a `useRef` would leave * measured against the old, detached node. Pass the rendered value as `value`. * * `ref` is a CALLBACK ref, not a `RefObject` — that is what makes the element * itself a dependency. Attach it exactly like a normal ref; there is no * `.current` to read. * * ```tsx * const { ref, isTruncated } = useIsTruncated(title) * *

{title}

*
* ``` */ export function useIsTruncated(value: unknown, options?: UseIsTruncatedOptions) { const multiline = options?.multiline ?? false; const [element, setElement] = useState(null); const [isTruncated, setIsTruncated] = useState(false); // Wrapped so the identity stays stable across renders: passing the raw // `useState` setter as a ref would be stable too, but this keeps the public // shape a plain `RefCallback` instead of a state dispatcher. const ref = useCallback((node: T | null) => setElement(node), []); useEffect(() => { if (!element) return undefined; // The 1px tolerance absorbs sub-pixel layout rounding, which otherwise // reports a permanent fractional "overflow" at some zoom levels / DPRs and // leaves a tooltip armed on text that is fully visible. const check = () => { setIsTruncated( multiline ? element.scrollHeight > element.clientHeight + 1 : element.scrollWidth > element.clientWidth + 1, ); }; check(); const observer = new ResizeObserver(check); observer.observe(element); return () => observer.disconnect(); }, [element, value, multiline]); // Masked rather than reset from the effect: with no element there is nothing // to clip, and deriving that during render avoids a second render pass every // time the measured node unmounts. return { ref, isTruncated: element ? isTruncated : false }; }