1- import type { Ref } from 'react' ;
1+ import type { ReactNode , Ref } from 'react' ;
22import type { TextProps as AriaTextProps , ContextValue } from 'react-aria-components' ;
33
44import { cva , cx } from 'class-variance-authority' ;
5- import { createContext } from 'react' ;
5+ import { createContext , useCallback , useEffect , useRef , useState } from 'react' ;
6+ import { useFocus , useFocusVisible , useHover } from 'react-aria' ;
67import { Text as AriaText } from 'react-aria-components' ;
78
9+ import { Focusable } from './Focusable' ;
810import styles from './styles/Text.module.css' ;
11+ import type { TooltipProps } from './Tooltip' ;
12+ import { Tooltip , TooltipTrigger } from './Tooltip' ;
913import { useLPContextProps } from './utils' ;
1014
1115const textStyles = cva ( styles . text , {
@@ -37,6 +41,14 @@ interface TextProps extends Omit<AriaTextProps, 'className' | 'elementType'> {
3741 elementType ?: AriaTextProps [ 'elementType' ] ;
3842 /** Optional CSS class name */
3943 className ?: AriaTextProps [ 'className' ] ;
44+ /** Enable tooltip on text overflow */
45+ showTooltipOnOverflow ?: boolean ;
46+ /** Custom tooltip content. If not provided, uses the text children as tooltip content */
47+ tooltipContent ?: ReactNode ;
48+ /** Tooltip placement */
49+ tooltipPlacement ?: TooltipProps [ 'placement' ] ;
50+ /** Additional CSS class name for the tooltip */
51+ tooltipClassName ?: string ;
4052}
4153
4254const TextContext = createContext < ContextValue < TextProps , HTMLElement > > ( null ) ;
@@ -48,6 +60,38 @@ const getDefaultElementType = (size: 'small' | 'medium' | 'large'): string => {
4860 return 'span' ;
4961} ;
5062
63+ /**
64+ * Custom hook to detect text overflow
65+ */
66+ const useTextOverflow = ( ) => {
67+ const ref = useRef < HTMLElement > ( null ) ;
68+ const [ hasOverflow , setHasOverflow ] = useState ( false ) ;
69+
70+ const checkOverflow = useCallback ( ( ) => {
71+ if ( ! ref . current ) return ;
72+
73+ const element = ref . current ;
74+ const isOverflowing =
75+ element . scrollHeight > element . clientHeight || element . scrollWidth > element . clientWidth ;
76+
77+ setHasOverflow ( isOverflowing ) ;
78+ } , [ ] ) ;
79+
80+ useEffect ( ( ) => {
81+ checkOverflow ( ) ;
82+
83+ // Recheck on window resize
84+ const resizeObserver = new ResizeObserver ( checkOverflow ) ;
85+ if ( ref . current ) {
86+ resizeObserver . observe ( ref . current ) ;
87+ }
88+
89+ return ( ) => resizeObserver . disconnect ( ) ;
90+ } , [ checkOverflow ] ) ;
91+
92+ return { ref, hasOverflow } ;
93+ } ;
94+
5195/**
5296 * A generic Text component for body text.
5397 *
@@ -56,21 +100,49 @@ const getDefaultElementType = (size: 'small' | 'medium' | 'large'): string => {
56100 * Built on top of [React Aria `Text` component](https://react-spectrum.adobe.com/react-spectrum/Text.html#text).
57101 */
58102const Text = ( {
59- ref,
103+ ref : externalRef ,
60104 size = 'medium' ,
61105 bold = false ,
62106 maxLines,
63107 elementType,
64108 className,
65109 style,
110+ showTooltipOnOverflow = false ,
111+ tooltipContent,
112+ tooltipPlacement = 'top' ,
113+ tooltipClassName,
66114 ...props
67115} : TextProps ) => {
68- [ props , ref ] = useLPContextProps ( props , ref , TextContext ) ;
116+ [ props , externalRef ] = useLPContextProps ( props , externalRef , TextContext ) ;
69117
70- return (
118+ const { ref : overflowRef , hasOverflow } = useTextOverflow ( ) ;
119+ const { hoverProps, isHovered } = useHover ( { } ) ;
120+ const [ isFocused , setFocused ] = useState ( false ) ;
121+ const { focusProps } = useFocus ( {
122+ onFocus : ( ) => setFocused ( true ) ,
123+ onBlur : ( ) => setFocused ( false ) ,
124+ } ) ;
125+ const { isFocusVisible } = useFocusVisible ( ) ;
126+
127+ // Merge refs
128+ const mergedRef = useCallback (
129+ ( element : HTMLElement | null ) => {
130+ overflowRef . current = element ;
131+
132+ if ( typeof externalRef === 'function' ) {
133+ externalRef ( element ) ;
134+ } else if ( externalRef && 'current' in externalRef ) {
135+ externalRef . current = element ;
136+ }
137+ } ,
138+ [ externalRef , overflowRef ] ,
139+ ) ;
140+
141+ const textElement = (
71142 < AriaText
72143 { ...props }
73- ref = { ref }
144+ { ...( showTooltipOnOverflow ? { ...hoverProps , ...focusProps } : { } ) }
145+ ref = { mergedRef }
74146 elementType = { elementType || getDefaultElementType ( size ) }
75147 className = { cx ( textStyles ( { size, bold } ) , maxLines && styles . truncate , className ) }
76148 style = { {
@@ -83,6 +155,22 @@ const Text = ({
83155 { props . children }
84156 </ AriaText >
85157 ) ;
158+
159+ if ( ! showTooltipOnOverflow ) {
160+ return textElement ;
161+ }
162+
163+ return (
164+ < TooltipTrigger
165+ isDisabled = { ! hasOverflow }
166+ isOpen = { hasOverflow && ( isHovered || ( isFocusVisible && isFocused ) ) }
167+ >
168+ < Focusable > { textElement } </ Focusable >
169+ < Tooltip placement = { tooltipPlacement } className = { tooltipClassName } >
170+ { tooltipContent ?? props . children }
171+ </ Tooltip >
172+ </ TooltipTrigger >
173+ ) ;
86174} ;
87175
88176export { Text , TextContext , textStyles } ;
0 commit comments