import React, { ChangeEvent, Component, CSSProperties, FocusEvent, KeyboardEvent, ReactNode, TextareaHTMLAttributes, } from 'react'; import { findDOMNode } from 'react-dom'; import omit from 'lodash/omit'; import classNames from 'classnames'; import ResizeObserver from 'resize-observer-polyfill'; import { AbstractInputProps } from './Input'; import calculateNodeHeight from './calculateNodeHeight'; import { InnerRowCtx } from '../rc-components/table/TableRowContext'; import ConfigContext, { ConfigContextValue } from '../config-provider/ConfigContext'; function onNextFrame(cb: () => void) { if (window.requestAnimationFrame) { return window.requestAnimationFrame(cb); } return window.setTimeout(cb, 1); } function clearNextFrameAction(nextFrameId: number) { if (window.cancelAnimationFrame) { window.cancelAnimationFrame(nextFrameId); } else { window.clearTimeout(nextFrameId); } } export interface AutoSizeType { minRows?: number; maxRows?: number; } export interface TextAreaProps extends AbstractInputProps<HTMLTextAreaElement> { autosize?: boolean | AutoSizeType; autoFocus?: boolean; border?: boolean; } export interface TextAreaState { textareaStyles?: CSSProperties; inputLength?: number; focused?: boolean; } export type HTMLTextareaProps = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'prefix'>; export default class TextArea extends Component<TextAreaProps & HTMLTextareaProps, TextAreaState> { static displayName = 'TextArea'; static get contextType(): typeof ConfigContext { return ConfigContext; } static defaultProps = { showLengthInfo: true, border: true, labelLayout: 'float', }; context: ConfigContextValue; nextFrameActionId: number; state = { textareaStyles: {}, inputLength: 0, focused: false, }; private textAreaRef: HTMLTextAreaElement; private resizeObserver?: ResizeObserver; componentDidMount() { this.resizeTextarea(); if (this.textAreaRef.value) { this.setState({ inputLength: this.textAreaRef.value.length, }); } const { autoFocus } = this.props; if (autoFocus) { this.setState({ focused: true, }); } } componentWillReceiveProps(nextProps: TextAreaProps) { // Re-render with the new content then recalculate the height as required. if (this.textAreaRef.value !== nextProps.value) { const inputLength = nextProps.value && String(nextProps.value).length; this.setState({ inputLength: inputLength || 0, }); } if (nextProps.autoFocus) { this.setState({ focused: true, }); } const { value } = this.props; if (value !== nextProps.value) { if (this.nextFrameActionId) { clearNextFrameAction(this.nextFrameActionId); } this.nextFrameActionId = onNextFrame(this.resizeTextarea); } } componentWillUnmount() { if (this.resizeObserver) { this.resizeObserver.disconnect(); delete this.resizeObserver; } } focus() { this.textAreaRef.focus(); } blur() { this.textAreaRef.blur(); } resizeTextarea = () => { const { autosize } = this.props; if (!autosize || !this.textAreaRef) { return; } const minRows = autosize ? (autosize as AutoSizeType).minRows : null; const maxRows = autosize ? (autosize as AutoSizeType).maxRows : null; const textareaStyles = calculateNodeHeight(this.textAreaRef, false, minRows, maxRows); this.setState({ textareaStyles }); }; getPrefixCls() { const { prefixCls } = this.props; const { getPrefixCls } = this.context; return getPrefixCls('input', prefixCls); } getTextAreaClassName() { const { className, disabled } = this.props; const prefixCls = this.getPrefixCls(); return classNames(prefixCls, className, `${prefixCls}-textarea-element`, { [`${prefixCls}-disabled`]: disabled, }); } handleTextareaChange = (e: ChangeEvent<HTMLTextAreaElement>) => { if (!('value' in this.props)) { this.resizeTextarea(); } const { onChange } = this.props; if (onChange) { onChange(e); } }; handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const { onPressEnter, onKeyDown } = this.props; if (e.keyCode === 13 && typeof onPressEnter === 'function') { onPressEnter(e); } if (onKeyDown) { onKeyDown(e); } }; handleInput = () => { this.setState({ inputLength: this.textAreaRef.value.length, }); }; saveTextAreaRef = (textArea: HTMLTextAreaElement) => { this.textAreaRef = textArea; }; getWrapperClassName() { const { disabled, label, border, labelLayout } = this.props; const { inputLength, focused } = this.state; const prefixCls = this.getPrefixCls(); return classNames(`${prefixCls}-wrapper`, `${prefixCls}-textarea`, { [`${prefixCls}-has-value`]: inputLength !== 0, [`${prefixCls}-focused`]: focused, [`${prefixCls}-disabled`]: disabled, [`${prefixCls}-has-label`]: !!label, [`${prefixCls}-has-border`]: border && labelLayout === 'float', }); } handleFocus = (e: FocusEvent<HTMLTextAreaElement>) => { const { onFocus } = this.props; this.setState({ focused: true, }); if (onFocus) { onFocus(e); } }; handleBlur = (e: FocusEvent<HTMLTextAreaElement>) => { const { onBlur } = this.props; this.setState({ focused: false, }); if (onBlur) { onBlur(e); } }; getLengthInfo(prefixCls) { const { maxLength, showLengthInfo } = this.props; const { inputLength } = this.state; return showLengthInfo !== 'never' && ((maxLength && showLengthInfo) || (maxLength && maxLength > 0 && inputLength === maxLength)) ? ( <div className={`${prefixCls}-length-info`}>{`${inputLength}/${maxLength}`}</div> ) : null; } renderFloatLabel(): ReactNode { const { label } = this.props; if (label) { const prefixCls = this.getPrefixCls(); return ( <div className={`${prefixCls}-label-wrapper`}> <div className={`${prefixCls}-label`}>{label}</div> </div> ); } } render() { const props = this.props; const state = this.state; const prefixCls = this.getPrefixCls(); const omits = [ 'prefixCls', 'onPressEnter', 'autosize', 'focused', 'showLengthInfo', 'labelLayout', ]; const hasBorder = props.border && props.labelLayout === 'float'; const floatLabel = hasBorder && this.renderFloatLabel(); if (floatLabel && !state.focused) { omits.push('placeholder'); } const otherProps: TextAreaProps & HTMLTextareaProps = omit(props, omits); const style = { ...props.style, ...state.textareaStyles, }; // Make sure it could be reset when using form.getFieldDecorator if ('value' in otherProps) { otherProps.value = otherProps.value || ''; } otherProps.onInput = this.handleInput; return ( <InnerRowCtx.Consumer> {(options) => { if (options && !this.resizeObserver && this.textAreaRef) { this.resizeObserver = new ResizeObserver(options.syncRowHeight); const dom = findDOMNode(this.textAreaRef); // eslint-disable-next-line no-unused-expressions dom && this.resizeObserver.observe(dom as Element); } const textarea = ( <textarea {...otherProps} className={this.getTextAreaClassName()} style={style} onKeyDown={this.handleKeyDown} onChange={this.handleTextareaChange} ref={this.saveTextAreaRef} onInput={this.handleInput} onBlur={this.handleBlur} onFocus={this.handleFocus} /> ); const labeledTextArea = hasBorder ? ( <div className={`${prefixCls}-rendered-wrapper`}> {textarea} {floatLabel} </div> ) : textarea; const lengthInfo = this.getLengthInfo(prefixCls); return lengthInfo || hasBorder ? ( <span className={this.getWrapperClassName()}> {labeledTextArea} {lengthInfo} </span> ) : labeledTextArea; }} </InnerRowCtx.Consumer> ); } }