import React, { forwardRef, useRef, useCallback, useLayoutEffect, useState, CSSProperties, ForwardRefExoticComponent, TextareaHTMLAttributes, ChangeEvent, } from "react"; import classNames from "classnames"; import { Input, InputBaseProps } from "../Input"; import { bemHOF } from "../../utilities/bem"; const cn = bemHOF("TextInput"); const DEFAULT_MAX_ROWS = 8; const parseStyleValue = (value: string) => { return parseInt(value, 10) || 0; }; type TextareaInputProps = InputBaseProps & TextareaHTMLAttributes; interface TextareaBase extends TextareaInputProps { rows?: number; } interface TextareaAutoSizing extends TextareaBase { resize?: "none"; maxRows?: number; } interface TextareaManualSizing extends TextareaBase { resize: "horizontal" | "vertical" | "both"; } export type TextareaProps = TextareaAutoSizing | TextareaManualSizing; const TextareaInput = Input as ForwardRefExoticComponent; export const Textarea: ForwardRefExoticComponent = forwardRef< HTMLTextAreaElement, TextareaProps >( ( { className, resize = "none", style, rows = 3, onChange, ...props }, ref, ) => { const isAutoSizing = resize === "none"; const textAreaRef = useRef(null); const shadowTextAreaRef = useRef(null); const [state, setState] = useState<{ isOverflowing: boolean; height: number; minHeight: number; }>({ isOverflowing: false, height: 0, minHeight: 0, }); let rest = props; let maxRows = DEFAULT_MAX_ROWS; if ("maxRows" in rest) { const { maxRows: maxRowsProp, ...propsMinusMaxRows } = rest; maxRows = maxRowsProp || DEFAULT_MAX_ROWS; rest = propsMinusMaxRows; } const classes = classNames( cn({ m: "multiline" }), !isAutoSizing && cn({ m: "multiline-resizable" }), className, ); if (ref) { if (typeof ref === "function") { ref(textAreaRef.current); } else { // eslint-disable-next-line no-param-reassign ref.current = textAreaRef.current; } } const calculateHeight = useCallback(() => { if (!isAutoSizing) { return; } const textarea = textAreaRef.current; const shadowTextarea = shadowTextAreaRef.current; if (!textarea || !shadowTextarea) { return; } const computedStyle = window.getComputedStyle(textarea); shadowTextarea.style.width = computedStyle.width; shadowTextarea.value = textarea.value || textarea.placeholder || " "; const { boxSizing } = computedStyle; const padding = parseStyleValue(computedStyle.paddingBottom) + parseStyleValue(computedStyle.paddingTop); const border = parseStyleValue(computedStyle.borderTopWidth) + parseStyleValue(computedStyle.borderBottomWidth); const innerHeight = shadowTextarea.scrollHeight - padding; shadowTextarea.value = "x"; const singleRowHeight = shadowTextarea.scrollHeight - padding; const minHeightNoPadding = rows * singleRowHeight; const minHeight = minHeightNoPadding + padding + border; const minOrInnerHeight = Math.max(minHeightNoPadding, innerHeight); const outerHeight = Math.min(maxRows * singleRowHeight, minOrInnerHeight); const updatedHeight = outerHeight + (boxSizing === "border-box" ? padding + border : 0); const isOverflowing = innerHeight > outerHeight; setState((prevState) => { if ( (updatedHeight > 0 && Math.abs((prevState.height || 0) - updatedHeight) > 1) || prevState.isOverflowing !== isOverflowing || prevState.minHeight !== minHeight ) { return { isOverflowing, height: updatedHeight, minHeight, }; } return prevState; }); }, [isAutoSizing, maxRows, rows]); useLayoutEffect(() => { calculateHeight(); }); const handleChange = (e: ChangeEvent) => { calculateHeight(); if (onChange) { onChange(e); } }; const computedStyle: CSSProperties = {}; computedStyle.height = state.height; computedStyle.overflow = state.isOverflowing ? undefined : "hidden"; computedStyle.minHeight = state.minHeight > 0 ? `${state.minHeight}px` : undefined; return ( <> {isAutoSizing && ( )} ); }, ); Textarea.defaultProps = { maxRows: DEFAULT_MAX_ROWS, };