auto-expanding-text-area.tsx 4.3 KB

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