auto-expanding-text-area.tsx 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import { ChangeEvent, useCallback, useEffect, useRef } from 'react'
  2. import { callFnsInSequence } from '../../utils/functions'
  3. import { MergeAndOverride } from '../../../../types/utils'
  4. type AutoExpandingTextAreaProps = MergeAndOverride<
  5. React.ComponentProps<'textarea'>,
  6. {
  7. onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void
  8. onResize?: () => void
  9. onAutoFocus?: (textarea: HTMLTextAreaElement) => void
  10. }
  11. >
  12. function AutoExpandingTextArea({
  13. onChange,
  14. onResize,
  15. autoFocus,
  16. onAutoFocus,
  17. ...rest
  18. }: AutoExpandingTextAreaProps) {
  19. const ref = useRef<HTMLTextAreaElement>(null)
  20. const previousHeightRef = useRef<number | null>(null)
  21. const previousMeasurementRef = useRef<{
  22. heightAdjustment: number
  23. value: string
  24. } | null>(null)
  25. const resetHeight = useCallback(() => {
  26. const el = ref.current
  27. if (!el) {
  28. return
  29. }
  30. const { value } = el
  31. const previousMeasurement = previousMeasurementRef.current
  32. // Do nothing if the textarea value hasn't changed since the last reset
  33. if (previousMeasurement !== null && value === previousMeasurement.value) {
  34. return
  35. }
  36. let heightAdjustment
  37. if (previousMeasurement === null) {
  38. const computedStyle = window.getComputedStyle(el)
  39. heightAdjustment =
  40. computedStyle.boxSizing === 'border-box'
  41. ? Math.ceil(
  42. parseFloat(computedStyle.borderTopWidth) +
  43. parseFloat(computedStyle.borderBottomWidth)
  44. )
  45. : -Math.floor(
  46. parseFloat(computedStyle.paddingTop) +
  47. parseFloat(computedStyle.paddingBottom)
  48. )
  49. } else {
  50. heightAdjustment = previousMeasurement.heightAdjustment
  51. }
  52. const curHeight = el.clientHeight
  53. const fitHeight = el.scrollHeight
  54. // Clear height if text area is empty
  55. if (value === '') {
  56. el.style.removeProperty('height')
  57. }
  58. // Otherwise, expand to fit text
  59. else if (fitHeight > curHeight) {
  60. el.style.height = fitHeight + heightAdjustment + 'px'
  61. }
  62. previousMeasurementRef.current = { heightAdjustment, value }
  63. }, [])
  64. useEffect(() => {
  65. if (!ref.current || !onResize || !('ResizeObserver' in window)) {
  66. return
  67. }
  68. const resizeObserver = new ResizeObserver(() => {
  69. if (!ref.current) {
  70. return
  71. }
  72. const newHeight = ref.current.offsetHeight
  73. // Ignore the resize when the height of the element is less than or equal to 0
  74. if (newHeight <= 0) {
  75. return
  76. }
  77. const heightChanged = newHeight !== previousHeightRef.current
  78. previousHeightRef.current = newHeight
  79. if (heightChanged) {
  80. // Prevent errors like "ResizeObserver loop completed with undelivered
  81. // notifications" that occur if onResize triggers another repaint. The
  82. // cost of this is that onResize lags one frame behind, but it's
  83. // unlikely to matter.
  84. // Wrap onResize to prevent extra parameters being passed
  85. window.requestAnimationFrame(() => onResize())
  86. }
  87. })
  88. resizeObserver.observe(ref.current)
  89. return () => {
  90. resizeObserver.disconnect()
  91. }
  92. }, [onResize])
  93. // Maintain a copy onAutoFocus in a ref for use in the autofocus effect
  94. // below so that the effect doesn't run when onAutoFocus changes
  95. const onAutoFocusRef = useRef(onAutoFocus)
  96. useEffect(() => {
  97. onAutoFocusRef.current = onAutoFocus
  98. }, [onAutoFocus])
  99. // Implement autofocus manually so that the cursor is placed at the end of
  100. // the textarea content
  101. useEffect(() => {
  102. const el = ref.current
  103. if (!el) {
  104. return
  105. }
  106. resetHeight()
  107. if (autoFocus) {
  108. const cursorPos = el.value.length
  109. const timer = window.setTimeout(() => {
  110. el.focus()
  111. el.setSelectionRange(cursorPos, cursorPos)
  112. if (onAutoFocusRef.current) {
  113. onAutoFocusRef.current(el)
  114. }
  115. }, 100)
  116. return () => {
  117. window.clearTimeout(timer)
  118. }
  119. }
  120. }, [autoFocus, resetHeight])
  121. // Reset height when the value changes via the `value` prop. If the textarea
  122. // is controlled, this means resetHeight is called twice per keypress, but
  123. // this is mitigated by a check on whether the value has actually changed in
  124. // resetHeight()
  125. useEffect(() => {
  126. resetHeight()
  127. }, [rest.value, resetHeight])
  128. return (
  129. <textarea
  130. onChange={callFnsInSequence(onChange, resetHeight)}
  131. {...rest}
  132. ref={ref}
  133. />
  134. )
  135. }
  136. export default AutoExpandingTextArea