|
| 1 | +import React, { useEffect, useRef, CSSProperties } from 'react' |
| 2 | + |
| 3 | +export interface AdjustLabelFitProps { |
| 4 | + /** |
| 5 | + * The text label to display and adjust |
| 6 | + */ |
| 7 | + label: string |
| 8 | + |
| 9 | + /** |
| 10 | + * The available width for the text in any valid CSS width format (px, vw, %, etc.) |
| 11 | + * If not specified, it will use the parent container's width |
| 12 | + */ |
| 13 | + width?: string | number |
| 14 | + |
| 15 | + /** |
| 16 | + * Optional font family (defaults to the parent element's font) |
| 17 | + */ |
| 18 | + fontFamily?: string |
| 19 | + |
| 20 | + /** |
| 21 | + * Initial font size in any valid CSS unit (px, pt, rem, etc.) |
| 22 | + * Default is inherited from parent |
| 23 | + */ |
| 24 | + fontSize?: string | number |
| 25 | + |
| 26 | + /** |
| 27 | + * Minimum font size in pixels (for auto-scaling) |
| 28 | + * Default is 10px |
| 29 | + */ |
| 30 | + minFontSize?: number |
| 31 | + |
| 32 | + /** |
| 33 | + * Maximum font size in pixels (for auto-scaling) |
| 34 | + * Default is 100px |
| 35 | + */ |
| 36 | + maxFontSize?: number |
| 37 | + |
| 38 | + /** |
| 39 | + * Minimum letter spacing in pixels |
| 40 | + * Default is -1px |
| 41 | + */ |
| 42 | + minLetterSpacing?: number |
| 43 | + |
| 44 | + /** |
| 45 | + * Additional CSS styles for the container |
| 46 | + */ |
| 47 | + containerStyle?: CSSProperties |
| 48 | + |
| 49 | + /** |
| 50 | + * Additional CSS styles for the label |
| 51 | + */ |
| 52 | + labelStyle?: CSSProperties |
| 53 | + |
| 54 | + /** |
| 55 | + * Additional class name for the container |
| 56 | + */ |
| 57 | + className?: string |
| 58 | + |
| 59 | + /** |
| 60 | + * Whether to use font variation settings for adjustment (requires variable font) |
| 61 | + * If false, will only use letter-spacing |
| 62 | + */ |
| 63 | + useVariableFont?: boolean |
| 64 | + |
| 65 | + /** |
| 66 | + * Whether to adjust font size to fill the container width |
| 67 | + * Default is true |
| 68 | + */ |
| 69 | + adjustFontSize?: boolean |
| 70 | + |
| 71 | + /** |
| 72 | + * Hard cut length of the text if it doesn't fit |
| 73 | + */ |
| 74 | + hardCutText?: boolean |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * A component that automatically adjusts text to fit within a specified width |
| 79 | + * using font size scaling, variable font width adjustment, and letter spacing. |
| 80 | + */ |
| 81 | +export const AdjustLabelFit: React.FC<AdjustLabelFitProps> = ({ |
| 82 | + label, |
| 83 | + width, |
| 84 | + fontFamily, |
| 85 | + fontSize, |
| 86 | + minFontSize = 10, |
| 87 | + maxFontSize = 100, |
| 88 | + minLetterSpacing = -1, |
| 89 | + containerStyle = {}, |
| 90 | + labelStyle = {}, |
| 91 | + className = '', |
| 92 | + useVariableFont = true, |
| 93 | + adjustFontSize = true, |
| 94 | + hardCutText = false, |
| 95 | +}) => { |
| 96 | + const labelRef = useRef<HTMLSpanElement>(null) |
| 97 | + const containerRef = useRef<HTMLDivElement>(null) |
| 98 | + |
| 99 | + // Convert to CSS values: |
| 100 | + const widthValue = typeof width === 'number' ? `${width}px` : width |
| 101 | + const fontSizeValue = typeof fontSize === 'number' ? `${fontSize}px` : fontSize |
| 102 | + const finalContainerStyle: CSSProperties = { |
| 103 | + display: 'block', |
| 104 | + overflow: 'hidden', |
| 105 | + ...containerStyle, |
| 106 | + ...(widthValue ? { width: widthValue } : {}), |
| 107 | + } |
| 108 | + |
| 109 | + // Label style - add optional font settings |
| 110 | + const finalLabelStyle: CSSProperties = { |
| 111 | + display: 'inline-block', |
| 112 | + ...labelStyle, |
| 113 | + ...(fontFamily ? { fontFamily } : {}), |
| 114 | + ...(fontSizeValue ? { fontSize: fontSizeValue } : {}), |
| 115 | + } |
| 116 | + |
| 117 | + const adjustTextToFit = () => { |
| 118 | + const labelElement = labelRef.current |
| 119 | + const containerElement = containerRef.current |
| 120 | + |
| 121 | + if (!labelElement || !containerElement) return |
| 122 | + |
| 123 | + const DEFAULT_WIDTH = 100 |
| 124 | + labelElement.style.letterSpacing = '0px' |
| 125 | + |
| 126 | + if (useVariableFont) { |
| 127 | + labelElement.style.fontVariationSettings = `'wdth' ${DEFAULT_WIDTH}` |
| 128 | + } |
| 129 | + |
| 130 | + // Reset label content if it was cut |
| 131 | + labelElement.textContent = label |
| 132 | + |
| 133 | + // Reset font size to initial value if specified, or to computed style if not |
| 134 | + if (adjustFontSize) { |
| 135 | + if (fontSizeValue) { |
| 136 | + labelElement.style.fontSize = fontSizeValue |
| 137 | + } else { |
| 138 | + // Use computed style if no fontSize was specified |
| 139 | + const computedStyle = window.getComputedStyle(labelElement) |
| 140 | + const initialFontSize = computedStyle.fontSize |
| 141 | + labelElement.style.fontSize = initialFontSize |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + // Force reflow to ensure measurements are accurate |
| 146 | + void labelElement.offsetWidth |
| 147 | + |
| 148 | + // Measure the container and text widths |
| 149 | + const containerWidth = containerElement.clientWidth |
| 150 | + const textWidth = labelElement.getBoundingClientRect().width |
| 151 | + |
| 152 | + if (textWidth <= containerWidth) { |
| 153 | + // If text fits but we want to expand it to fill the width |
| 154 | + if (adjustFontSize) { |
| 155 | + const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) |
| 156 | + const scaleFactor = containerWidth / textWidth |
| 157 | + const newFontSize = Math.min(currentFontSize * scaleFactor, maxFontSize) |
| 158 | + |
| 159 | + labelElement.style.fontSize = `${newFontSize}px` |
| 160 | + |
| 161 | + // Re-center text vertically if needed |
| 162 | + labelElement.style.lineHeight = '1' |
| 163 | + } |
| 164 | + return |
| 165 | + } |
| 166 | + |
| 167 | + // Text doesn't fit - adjust size first if enabled |
| 168 | + if (adjustFontSize) { |
| 169 | + const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) |
| 170 | + const scaleFactor = containerWidth / textWidth |
| 171 | + const newFontSize = Math.max(currentFontSize * scaleFactor, minFontSize) |
| 172 | + |
| 173 | + labelElement.style.fontSize = `${newFontSize}px` |
| 174 | + |
| 175 | + // Remeasure after font size adjustment |
| 176 | + void labelElement.offsetWidth |
| 177 | + const newTextWidth = labelElement.getBoundingClientRect().width |
| 178 | + |
| 179 | + // If text now fits with font size adjustment alone, we're done |
| 180 | + if (newTextWidth <= containerWidth) return |
| 181 | + } |
| 182 | + |
| 183 | + // Further adjustments if still needed |
| 184 | + if (useVariableFont) { |
| 185 | + const textWidth = labelElement.getBoundingClientRect().width |
| 186 | + const widthRatio = containerWidth / textWidth |
| 187 | + let currentWidth = DEFAULT_WIDTH * widthRatio |
| 188 | + |
| 189 | + // Use a reasonable range for width variation |
| 190 | + currentWidth = Math.max(currentWidth, 75) // minimum 75% |
| 191 | + currentWidth = Math.min(currentWidth, 110) // maximum 110% |
| 192 | + |
| 193 | + labelElement.style.fontVariationSettings = `'wdth' ${currentWidth}` |
| 194 | + |
| 195 | + // Remeasure text width after adjustment: |
| 196 | + void labelElement.offsetWidth |
| 197 | + const adjustedTextWidth = labelElement.getBoundingClientRect().width |
| 198 | + |
| 199 | + // Letter spacing if text still overflows |
| 200 | + if (adjustedTextWidth > containerWidth) { |
| 201 | + const overflow = adjustedTextWidth - containerWidth |
| 202 | + const letterCount = label.length - 1 // Spaces between letters |
| 203 | + let letterSpacing = letterCount > 0 ? -overflow / letterCount : 0 |
| 204 | + |
| 205 | + letterSpacing = Math.max(letterSpacing, minLetterSpacing) |
| 206 | + labelElement.style.letterSpacing = `${letterSpacing}px` |
| 207 | + |
| 208 | + // Hard cut text if enabled and letterspacing is not enough: |
| 209 | + if (hardCutText) { |
| 210 | + void labelElement.offsetWidth |
| 211 | + const finalTextWidth = labelElement.getBoundingClientRect().width |
| 212 | + if (finalTextWidth > containerWidth) { |
| 213 | + const ratio = containerWidth / finalTextWidth |
| 214 | + const visibleChars = Math.floor(label.length * ratio) - 1 |
| 215 | + labelElement.textContent = label.slice(0, Math.max(visibleChars, 1)) |
| 216 | + } |
| 217 | + } |
| 218 | + } |
| 219 | + } else { |
| 220 | + // No variable font type |
| 221 | + const textWidth = labelElement.getBoundingClientRect().width |
| 222 | + const overflow = textWidth - containerWidth |
| 223 | + const letterCount = label.length - 1 |
| 224 | + let letterSpacing = letterCount > 0 ? -overflow / letterCount : 0 |
| 225 | + |
| 226 | + // Limit by minLetterSpacing |
| 227 | + letterSpacing = Math.max(letterSpacing, minLetterSpacing) |
| 228 | + labelElement.style.letterSpacing = `${letterSpacing}px` |
| 229 | + |
| 230 | + // Hard cut text if enabled and letterspacing is not enough: |
| 231 | + if (hardCutText) { |
| 232 | + void labelElement.offsetWidth |
| 233 | + const finalTextWidth = labelElement.getBoundingClientRect().width |
| 234 | + if (finalTextWidth > containerWidth) { |
| 235 | + const ratio = containerWidth / finalTextWidth |
| 236 | + const visibleChars = Math.floor(label.length * ratio) - 1 |
| 237 | + labelElement.textContent = label.slice(0, Math.max(visibleChars, 1)) |
| 238 | + } |
| 239 | + } |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + useEffect(() => { |
| 244 | + adjustTextToFit() |
| 245 | + |
| 246 | + // Adjust on window resize |
| 247 | + window.addEventListener('resize', adjustTextToFit) |
| 248 | + return () => { |
| 249 | + window.removeEventListener('resize', adjustTextToFit) |
| 250 | + } |
| 251 | + }, [label, width, fontFamily, fontSize, minFontSize, maxFontSize, minLetterSpacing, useVariableFont, adjustFontSize]) |
| 252 | + |
| 253 | + return ( |
| 254 | + <div ref={containerRef} className={`adjust-label-fit ${className}`} style={finalContainerStyle}> |
| 255 | + <span ref={labelRef} style={finalLabelStyle}> |
| 256 | + {label} |
| 257 | + </span> |
| 258 | + </div> |
| 259 | + ) |
| 260 | +} |
0 commit comments