@@ -17,13 +17,13 @@ export interface ExpandableTextProps {
1717
1818 /**
1919 * Label for expand button
20- * @default 'View more'
20+ * @default 'view more'
2121 */
2222 expandLabel ?: string ;
2323
2424 /**
2525 * Label for collapse button
26- * @default 'View less'
26+ * @default 'view less'
2727 */
2828 collapseLabel ?: string ;
2929
@@ -69,28 +69,80 @@ export interface ExpandableTextProps {
6969export const ExpandableText : React . FC < ExpandableTextProps > = ( {
7070 text,
7171 maxLines = 5 ,
72- expandLabel = 'View more' ,
73- collapseLabel = 'View less' ,
72+ expandLabel = 'view more' ,
73+ collapseLabel = 'view less' ,
7474 className,
7575 expanded : controlledExpanded ,
7676 onExpandChange,
7777} ) => {
7878 const [ internalExpanded , setInternalExpanded ] = useState ( false ) ;
7979 const [ needsTruncation , setNeedsTruncation ] = useState ( false ) ;
80+ const [ truncatedText , setTruncatedText ] = useState ( text ) ;
8081 const textRef = useRef < HTMLDivElement > ( null ) ;
8182
8283 const isControlled = controlledExpanded !== undefined ;
8384 const expanded = isControlled ? controlledExpanded : internalExpanded ;
8485
85- // Detect if text needs truncation
86+ // JavaScript-based truncation to insert button inline
8687 useEffect ( ( ) => {
8788 const element = textRef . current ;
88- if ( ! element ) return ;
89+ if ( ! element || expanded ) return ;
8990
90- // Compare scroll height with client height to determine if text is truncated
91- const isOverflowing = element . scrollHeight > element . clientHeight ;
92- setNeedsTruncation ( isOverflowing ) ;
93- } , [ text , maxLines ] ) ;
91+ const checkAndTruncate = ( ) => {
92+ // Get line height
93+ const styles = getComputedStyle ( element ) ;
94+ const lineHeight = parseFloat ( styles . lineHeight ) ;
95+ const maxHeight = lineHeight * maxLines ;
96+
97+ // Create a temporary element to measure
98+ const tempElement = element . cloneNode ( true ) as HTMLDivElement ;
99+ tempElement . style . position = 'absolute' ;
100+ tempElement . style . visibility = 'hidden' ;
101+ tempElement . style . width = element . offsetWidth + 'px' ;
102+ tempElement . style . whiteSpace = 'pre-wrap' ;
103+ tempElement . style . wordWrap = 'break-word' ;
104+ document . body . appendChild ( tempElement ) ;
105+
106+ // Check if full text fits
107+ tempElement . innerHTML = '' ;
108+ tempElement . appendChild ( document . createTextNode ( text ) ) ;
109+
110+ if ( tempElement . scrollHeight <= maxHeight ) {
111+ setNeedsTruncation ( false ) ;
112+ setTruncatedText ( text ) ;
113+ document . body . removeChild ( tempElement ) ;
114+ return ;
115+ }
116+
117+ // Binary search for truncation point
118+ let low = 0 ;
119+ let high = text . length ;
120+ let bestFit = text . substring ( 0 , 50 ) ; // Default fallback
121+
122+ while ( low <= high ) {
123+ const mid = Math . floor ( ( low + high ) / 2 ) ;
124+ const testText = text . substring ( 0 , mid ) ;
125+
126+ tempElement . innerHTML = '' ;
127+ tempElement . textContent = testText + '... ' + expandLabel ;
128+
129+ if ( tempElement . scrollHeight <= maxHeight ) {
130+ bestFit = testText ;
131+ low = mid + 1 ;
132+ } else {
133+ high = mid - 1 ;
134+ }
135+ }
136+
137+ document . body . removeChild ( tempElement ) ;
138+ setTruncatedText ( bestFit ) ;
139+ setNeedsTruncation ( true ) ;
140+ } ;
141+
142+ requestAnimationFrame ( ( ) => {
143+ requestAnimationFrame ( checkAndTruncate ) ;
144+ } ) ;
145+ } , [ text , maxLines , expanded , expandLabel ] ) ;
94146
95147 const handleToggle = ( ) => {
96148 if ( isControlled ) {
@@ -103,25 +155,39 @@ export const ExpandableText: React.FC<ExpandableTextProps> = ({
103155
104156 return (
105157 < div className = { cn ( styles . expandableText , className ) } >
106- < div
107- ref = { textRef }
108- className = { cn ( styles . text , ! expanded && styles . clamped ) }
109- style = { {
110- WebkitLineClamp : expanded ? 'unset' : maxLines ,
111- } }
112- >
113- < SafeBrText text = { text } />
158+ < div ref = { textRef } className = { styles . text } >
159+ { ! expanded && needsTruncation ? (
160+ < >
161+ < SafeBrText text = { truncatedText } />
162+ ...{ ' ' }
163+ < button
164+ className = { styles . toggleButton }
165+ onClick = { handleToggle }
166+ type = "button"
167+ aria-expanded = { expanded }
168+ >
169+ { expandLabel }
170+ </ button >
171+ </ >
172+ ) : (
173+ < >
174+ < SafeBrText text = { text } />
175+ { needsTruncation && expanded && (
176+ < >
177+ { ' ' }
178+ < button
179+ className = { styles . toggleButton }
180+ onClick = { handleToggle }
181+ type = "button"
182+ aria-expanded = { expanded }
183+ >
184+ { collapseLabel }
185+ </ button >
186+ </ >
187+ ) }
188+ </ >
189+ ) }
114190 </ div >
115- { needsTruncation && (
116- < button
117- className = { styles . toggleButton }
118- onClick = { handleToggle }
119- type = "button"
120- aria-expanded = { expanded }
121- >
122- { expanded ? collapseLabel : expandLabel }
123- </ button >
124- ) }
125191 </ div >
126192 ) ;
127193} ;
0 commit comments