auto-expanding-text-area.tsx 3.8 KB

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