forked from reformcollective/library
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCustomTextOverflow.tsx
More file actions
157 lines (142 loc) · 4.32 KB
/
Copy pathCustomTextOverflow.tsx
File metadata and controls
157 lines (142 loc) · 4.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import { useEffect, useRef, useState } from "react"
interface CustomTextOverflowProps {
/**
* the text to display
*/
children: string
/**
* the custom ellipsis to display,
* defaults to "..."
*/
ellipsis?: string
/**
* the number of lines you need
*/
maxLines?: number
/**
* where in the text to place the ellipsis
* as an index in characters. negative numbers count from the end of the text,
* and 0 is the very end of the text
*/
truncatePosition?: number
}
/**
* given a number of lines, this component will truncate the text to fit within
* the given number of lines.
*
* This can also adjust the overflow ellipsis to be any text you want, and can truncate at
* any position in the text (ie, the beginning, middle, or end of the line)
*/
export default function CustomTextOverflow({
children,
ellipsis = "...",
maxLines = 1,
truncatePosition = -1,
}: CustomTextOverflowProps) {
const wrapperRef = useRef<HTMLDivElement>(null)
const [refreshSignal, setRefreshSignal] = useState(0)
/**
* measure the number of lines taken up by the text, and recursively
* shorten the text until it fits within the given number of lines
*/
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshSignal is required to trigger a re-measurement in specific cases
useEffect(() => {
const wrapper = wrapperRef.current
if (!wrapper) return
const lines = getNumberOfLines(wrapper)
if (lines <= maxLines) {
wrapper.textContent = children
return () => {
wrapper.textContent = `${children}${ellipsis}`
}
}
// recursively shrink the text until it fits
const shrinkText = (numberOfCharsToRemove: number) => {
// where we shrink the text needs to change if the truncate position is pos/neg
const shrinkBefore = truncatePosition < 0
// we need to use slightly different math to put the ellipses at the very end
const ellipsisAtEnd = truncatePosition === 0
const textBeforeEllipsis = ellipsisAtEnd
? children.slice(0, children.length - numberOfCharsToRemove)
: children.slice(
0,
truncatePosition - (shrinkBefore ? numberOfCharsToRemove : 0),
)
const textAfterEllipsis = ellipsisAtEnd
? ""
: children.slice(
truncatePosition + (shrinkBefore ? 0 : numberOfCharsToRemove),
)
const newText = `${textBeforeEllipsis}${ellipsis}${textAfterEllipsis}`
wrapper.textContent = newText
if (getNumberOfLines(wrapper) <= maxLines) return
if (numberOfCharsToRemove >= children.length) return
shrinkText(numberOfCharsToRemove + 1)
}
shrinkText(1)
return () => {
wrapper.textContent = `${children}${ellipsis}`
}
}, [children, ellipsis, maxLines, truncatePosition, refreshSignal])
/**
* invalidate the text measurement when the window is resized
*/
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | undefined
const handleResize = () => {
clearTimeout(timeout)
timeout = setTimeout(() => {
setRefreshSignal((p) => p + 1)
})
}
const observer = new ResizeObserver(handleResize)
if (wrapperRef.current instanceof HTMLElement) {
for (const parent of getAllParents(wrapperRef.current, 10)) {
observer.observe(parent)
}
}
return () => {
observer.disconnect()
clearTimeout(timeout)
}
}, [])
// prompt a CSS solution if possible
if (ellipsis === "..." && truncatePosition === 0)
return (
<>
This component should only be used if you need a custom ellipsis or
custom truncation position. Otherwise, use CSS.
</>
)
return (
<div ref={wrapperRef}>
{children}
{/* include the ellipsis for measurement purposes */}
{ellipsis}
</div>
)
}
/**
* get the number of lines that fit within the given element
* @param element the element to measure
* @returns the number of lines that would fit within the element
*/
const getNumberOfLines = (element: HTMLElement) => {
const { height } = element.getBoundingClientRect()
const lineHeight = Number.parseFloat(
window.getComputedStyle(element).getPropertyValue("line-height"),
)
return Math.round(height / lineHeight)
}
/**
* given an element, get all of its parents from nearest to farthest
*/
const getAllParents = (element: HTMLElement, limit: number) => {
const parents = []
let current = element.parentElement
while (current && parents.length < limit) {
parents.push(current)
current = current.parentElement
}
return parents
}