具有前缀的textarea

171 阅读2分钟

标题中的前缀指的是 输入框之前的固定内容.
它与文本占据同样的位置 但是可以设置不同的样式
考虑使用wrapper包裹textarea 用绝对定位设置前缀 使用textarea和前缀的宽高动态设置样式 使用useLayoutEffect避免闪烁\

由于js精度的问题 即使dom尺寸未发生变化 两次取得的数据也可能不一致 因此需要写函数比较尺寸而不能直接用等号判断

type Size = {
  width: number
  height: number
}
const isSizeStable = (prev: Size, current: Size) => {
  const isNumberStable = (prev: number, current: number) => {
    return Math.abs(current - prev) <= 1
  }
  return isNumberStable(prev.height, current.height) && isNumberStable(prev.width, current.width)
}

组件代码

import { useControllableValue } from 'ahooks'

export type TextAreaWithPrefix = {
  className?: string
  style?: React.CSSProperties
  innerClassName?: string
  innerStyle?: React.CSSProperties
  value?: string
  onChange?: (value: string) => void
  prefix?: ReactNode
}
export const TextAreaWithPrefix: FC<TextAreaWithPrefix> = (props) => {
  const { prefix, className, style = {}, innerClassName, innerStyle = {} } = props
  // 处理未传value和onChange的情形 确保组件受控
  const [value, onChange] = useControllableValue<string>(props)
  // 获取前缀的尺寸
  const [prefixSize, _setPrefixSize] = useState({ width: 0, height: 0 })
  const sizeContainer = useRef<HTMLDivElement>(null)
  const setPrefixSize = useCallback(() => {
    const size = sizeContainer.current?.getBoundingClientRect()
    if (!size) return
    const { width, height } = size
    _setPrefixSize((prev) => {
      if (isSizeStable(prev, { width, height })) return prev
      return { width, height }
    })
  }, [])
  useLayoutEffect(setPrefixSize)

  // 获取textarea所需的尺寸
  const [textAreaSize, _setTextAreaSize] = useState({ width: 0, height: 0 })
  const textAreaContainer = useRef<HTMLTextAreaElement>(null)
  const setTextAreaSize = useCallback(() => {
    if (!textAreaContainer.current) return
    const { scrollWidth: width, scrollHeight: height } = textAreaContainer.current
    _setTextAreaSize((prev) => {
      if (isSizeStable(prev, { width, height })) return prev
      return {
        width,
        height,
      }
    })
  }, [])
  useLayoutEffect(setTextAreaSize)

  return (
    <div
      className={cn(
        'relative flex resize-y flex-col overflow-auto focus-within:outline',
        className,
      )}
      style={{
        minHeight: prefixSize.height,
        minWidth: prefixSize.width,
        ...style,
      }}
    >
      <div className='absolute top-0 left-0' ref={sizeContainer}>
        {prefix}
      </div>
      <textarea
        value={value}
        onChange={(e) => {
          onChange?.(e.target.value)
          setPrefixSize()
          setTextAreaSize()
        }}
        // 使textarea填满容器 使用min-height确保滚动发生在容器而不是textarea
        className={cn('flex-1 resize-none overflow-hidden outline-none', innerClassName)}
        style={{
          minHeight: textAreaSize.height,
          textIndent: prefixSize.width,
          ...innerStyle,
        }}
      />
      <div className='h-0 overflow-hidden'>
        <textarea
          // 用一个不实际展示textarea计算尺寸
          // value必须受控 不然无法计算
          value={value}
          ref={textAreaContainer}
          className={cn('w-full resize-none overflow-hidden outline-none', innerClassName)}
          style={{
            textIndent: prefixSize.width,
            ...innerStyle,
          }}
        />
      </div>
    </div>
  )
}

需要注意的是 textarea不能单独设行高 因此如果前缀高度超过行高 需要进行特殊调整.\

使用例

const Page: FC = () => {
  const prefix = <span className='mx-2'>前缀</span>
  return <TextAreaWithPrefix prefix={prefix} className='m-4 w-40' />
}

动画.gif