2023-09-13 05:06:30 -04:00
|
|
|
import { useCallback, useEffect, useRef } from 'react'
|
2023-06-29 08:56:05 -04:00
|
|
|
import { callFnsInSequence } from '../../utils/functions'
|
|
|
|
import { MergeAndOverride } from '../../../../types/utils'
|
|
|
|
|
|
|
|
type AutoExpandingTextAreaProps = MergeAndOverride<
|
|
|
|
React.ComponentProps<'textarea'>,
|
|
|
|
{
|
|
|
|
onResize?: () => void
|
2024-02-02 06:54:26 -05:00
|
|
|
onAutoFocus?: (textarea: HTMLTextAreaElement) => void
|
2023-06-29 08:56:05 -04:00
|
|
|
}
|
|
|
|
>
|
|
|
|
|
|
|
|
function AutoExpandingTextArea({
|
|
|
|
onChange,
|
|
|
|
onResize,
|
2023-09-13 05:06:30 -04:00
|
|
|
autoFocus,
|
2024-02-02 06:54:26 -05:00
|
|
|
onAutoFocus,
|
2023-06-29 08:56:05 -04:00
|
|
|
...rest
|
|
|
|
}: AutoExpandingTextAreaProps) {
|
|
|
|
const ref = useRef<HTMLTextAreaElement>(null)
|
2023-09-05 10:46:13 -04:00
|
|
|
const previousHeightRef = useRef<number | null>(null)
|
2023-09-13 05:06:30 -04:00
|
|
|
const previousMeasurementRef = useRef<{
|
|
|
|
heightAdjustment: number
|
|
|
|
value: string
|
|
|
|
} | null>(null)
|
|
|
|
|
|
|
|
const resetHeight = useCallback(() => {
|
|
|
|
const el = ref.current
|
|
|
|
if (!el) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const { value } = el
|
|
|
|
const previousMeasurement = previousMeasurementRef.current
|
|
|
|
|
|
|
|
// Do nothing if the textarea value hasn't changed since the last reset
|
|
|
|
if (previousMeasurement !== null && value === previousMeasurement.value) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let heightAdjustment
|
|
|
|
if (previousMeasurement === null) {
|
|
|
|
const computedStyle = window.getComputedStyle(el)
|
|
|
|
heightAdjustment =
|
|
|
|
computedStyle.boxSizing === 'border-box'
|
|
|
|
? Math.ceil(
|
|
|
|
parseFloat(computedStyle.borderTopWidth) +
|
|
|
|
parseFloat(computedStyle.borderBottomWidth)
|
|
|
|
)
|
|
|
|
: -Math.floor(
|
|
|
|
parseFloat(computedStyle.paddingTop) +
|
|
|
|
parseFloat(computedStyle.paddingBottom)
|
|
|
|
)
|
|
|
|
} else {
|
|
|
|
heightAdjustment = previousMeasurement.heightAdjustment
|
|
|
|
}
|
|
|
|
|
|
|
|
const curHeight = el.clientHeight
|
|
|
|
const fitHeight = el.scrollHeight
|
|
|
|
|
|
|
|
// Clear height if text area is empty
|
|
|
|
if (value === '') {
|
|
|
|
el.style.removeProperty('height')
|
|
|
|
}
|
|
|
|
// Otherwise, expand to fit text
|
|
|
|
else if (fitHeight > curHeight) {
|
|
|
|
el.style.height = fitHeight + heightAdjustment + 'px'
|
|
|
|
}
|
|
|
|
|
|
|
|
previousMeasurementRef.current = { heightAdjustment, value }
|
|
|
|
}, [])
|
2023-06-29 08:56:05 -04:00
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!ref.current || !onResize || !('ResizeObserver' in window)) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
2023-09-05 10:46:13 -04:00
|
|
|
if (!ref.current) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
const newHeight = ref.current.offsetHeight
|
2024-01-25 07:58:51 -05:00
|
|
|
// Ignore the resize when the height of the element is less than or equal to 0
|
|
|
|
if (newHeight <= 0) {
|
|
|
|
return
|
|
|
|
}
|
2023-09-05 10:46:13 -04:00
|
|
|
const heightChanged = newHeight !== previousHeightRef.current
|
|
|
|
previousHeightRef.current = newHeight
|
|
|
|
if (heightChanged) {
|
2023-07-18 04:35:04 -04:00
|
|
|
// Prevent errors like "ResizeObserver loop completed with undelivered
|
2023-09-05 10:46:13 -04:00
|
|
|
// notifications" that occur if onResize triggers another repaint. The
|
|
|
|
// cost of this is that onResize lags one frame behind, but it's
|
2023-07-18 04:35:04 -04:00
|
|
|
// unlikely to matter.
|
2023-07-27 05:55:10 -04:00
|
|
|
|
|
|
|
// Wrap onResize to prevent extra parameters being passed
|
|
|
|
window.requestAnimationFrame(() => onResize())
|
2023-06-29 08:56:05 -04:00
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
resizeObserver.observe(ref.current)
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
resizeObserver.disconnect()
|
|
|
|
}
|
|
|
|
}, [onResize])
|
|
|
|
|
2024-02-02 06:54:26 -05:00
|
|
|
// Maintain a copy onAutoFocus in a ref for use in the autofocus effect
|
|
|
|
// below so that the effect doesn't run when onAutoFocus changes
|
|
|
|
const onAutoFocusRef = useRef(onAutoFocus)
|
|
|
|
useEffect(() => {
|
|
|
|
onAutoFocusRef.current = onAutoFocus
|
|
|
|
}, [onAutoFocus])
|
|
|
|
|
2023-09-13 05:06:30 -04:00
|
|
|
// Implement autofocus manually so that the cursor is placed at the end of
|
|
|
|
// the textarea content
|
|
|
|
useEffect(() => {
|
|
|
|
const el = ref.current
|
|
|
|
if (!el) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
resetHeight()
|
|
|
|
if (autoFocus) {
|
|
|
|
const cursorPos = el.value.length
|
2024-02-02 06:54:26 -05:00
|
|
|
const timer = window.setTimeout(() => {
|
2023-10-24 08:59:29 -04:00
|
|
|
el.focus()
|
|
|
|
el.setSelectionRange(cursorPos, cursorPos)
|
2024-02-02 06:54:26 -05:00
|
|
|
if (onAutoFocusRef.current) {
|
|
|
|
onAutoFocusRef.current(el)
|
|
|
|
}
|
2023-10-24 08:59:29 -04:00
|
|
|
}, 100)
|
2024-02-02 06:54:26 -05:00
|
|
|
|
|
|
|
return () => {
|
|
|
|
window.clearTimeout(timer)
|
|
|
|
}
|
2023-09-13 05:06:30 -04:00
|
|
|
}
|
|
|
|
}, [autoFocus, resetHeight])
|
|
|
|
|
|
|
|
// Reset height when the value changes via the `value` prop. If the textarea
|
|
|
|
// is controlled, this means resetHeight is called twice per keypress, but
|
|
|
|
// this is mitigated by a check on whether the value has actually changed in
|
|
|
|
// resetHeight()
|
|
|
|
useEffect(() => {
|
|
|
|
resetHeight()
|
|
|
|
}, [rest.value, resetHeight])
|
|
|
|
|
2023-06-29 08:56:05 -04:00
|
|
|
return (
|
|
|
|
<textarea
|
|
|
|
onChange={callFnsInSequence(onChange, resetHeight)}
|
|
|
|
{...rest}
|
|
|
|
ref={ref}
|
|
|
|
/>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
export default AutoExpandingTextArea
|