UNPKG

@douyinfe/semi-ui

Version:

A modern, comprehensive, flexible design system and UI library. Connect DesignOps & DevOps. Quickly build beautiful React apps. Maintained by Douyin-fe team.

550 lines (549 loc) 22.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _throttle2 = _interopRequireDefault(require("lodash/throttle")); var _isObject2 = _interopRequireDefault(require("lodash/isObject")); var _isUndefined2 = _interopRequireDefault(require("lodash/isUndefined")); var _isFunction2 = _interopRequireDefault(require("lodash/isFunction")); var _omit2 = _interopRequireDefault(require("lodash/omit")); var _noop2 = _interopRequireDefault(require("lodash/noop")); var _react = _interopRequireDefault(require("react")); var _classnames = _interopRequireDefault(require("classnames")); var _propTypes = _interopRequireDefault(require("prop-types")); var _textareaFoundation = _interopRequireDefault(require("@douyinfe/semi-foundation/lib/cjs/input/textareaFoundation")); var _constants = require("@douyinfe/semi-foundation/lib/cjs/input/constants"); var _baseComponent = _interopRequireDefault(require("../_base/baseComponent")); require("@douyinfe/semi-foundation/lib/cjs/input/textarea.css"); var _semiIcons = require("@douyinfe/semi-icons"); var _resizeObserver = _interopRequireDefault(require("../resizeObserver")); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } var __rest = void 0 && (void 0).__rest || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; const prefixCls = _constants.cssClasses.PREFIX; class TextArea extends _baseComponent.default { constructor(props) { super(props); this.handleNativeResize = entries => { var _a, _b; // Only used for native `resize` (non-autosize). Guard anyway. if (this.props.autosize) { return; } const entry = entries && entries[0]; const rect = entry && entry.contentRect; if (!rect) { return; } const width = rect.width; const height = rect.height; // ResizeObserver will fire immediately on observe; skip the first one // to avoid triggering `onResize` on initial mount. if (!this.nativeResizeObservedOnce) { this.nativeResizeObservedOnce = true; this.lastNativeSize = { width, height }; return; } const last = this.lastNativeSize; if (last && last.width === width && last.height === height) { return; } this.lastNativeSize = { width, height }; (_b = (_a = this.props).onResize) === null || _b === void 0 ? void 0 : _b.call(_a, { height, width }); }; this.handleClear = e => { this.foundation.handleClear(e); }; this.handleClick = e => { this.foundation.handleClick(e); }; this.handleCounterClick = e => { this.foundation.handleCounterClick(e); }; this.setRef = node => { this.libRef.current = node; const { forwardRef } = this.props; if (typeof forwardRef === 'function') { forwardRef(node); } else if (forwardRef && typeof forwardRef === 'object') { forwardRef.current = node; } }; this.handleTextAreaScroll = e => { const { showLineNumber } = this.props; if (showLineNumber && this.lineNumberRef.current) { // Use rAF to avoid layout thrash requestAnimationFrame(() => { const panel = this.lineNumberRef.current; if (panel) { panel.scrollTop = e.target.scrollTop; } }); } }; this.getTextareaLineHeightPx = textarea => { const computedStyle = window.getComputedStyle(textarea); const lineHeightStr = computedStyle.lineHeight; const fontSize = parseFloat(computedStyle.fontSize) || 14; if (!lineHeightStr || lineHeightStr === 'normal') { // Browsers typically use ~1.2, but Semi textarea visually closer to 1.5 return fontSize * 1.5; } const parsed = parseFloat(lineHeightStr); return Number.isFinite(parsed) && parsed > 0 ? parsed : fontSize * 1.5; }; // Calculate the number of wrapped lines for a given text line this.calculateWrappedLines = (line, textarea) => { if (!line) return 1; const computedStyle = window.getComputedStyle(textarea); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return 1; // Set font to match textarea const fontSize = computedStyle.fontSize; const fontFamily = computedStyle.fontFamily; ctx.font = `${fontSize} ${fontFamily}`; // Calculate available width (excluding padding and scrollbar) const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; const paddingRight = parseFloat(computedStyle.paddingRight) || 0; const textareaWidth = textarea.clientWidth - paddingLeft - paddingRight; if (textareaWidth <= 0) return 1; // Measure text width const metrics = ctx.measureText(line); const textWidth = metrics.width; // Calculate wrapped lines const wrappedLines = Math.ceil(textWidth / textareaWidth); return Math.max(1, wrappedLines); }; const initValue = 'value' in props ? props.value : props.defaultValue; this.state = { value: initValue, isFocus: false, isHover: false, height: 0, minLength: props.minLength, cachedValue: props.value, textareaWidth: 0, textareaHeight: 0 }; this.focusing = false; this.foundation = new _textareaFoundation.default(this.adapter); this.lineNumberResizeObserver = null; this.libRef = /*#__PURE__*/_react.default.createRef(); this.lineNumberRef = /*#__PURE__*/_react.default.createRef(); this.throttledResizeTextarea = (0, _throttle2.default)(this.foundation.resizeTextarea, 10); this.throttledNotifyNativeResize = (0, _throttle2.default)(this.handleNativeResize, 10); this.nativeResizeObservedOnce = false; this.lastNativeSize = null; } get adapter() { return Object.assign(Object.assign({}, super.adapter), { setValue: value => this.setState({ value }, () => { if (this.props.autosize) { this.foundation.resizeTextarea(); } }), getRef: () => this.libRef.current, toggleFocusing: focusing => this.setState({ isFocus: focusing }), toggleHovering: hovering => this.setState({ isHover: hovering }), notifyChange: (val, e) => { this.props.onChange(val, e); }, notifyClear: e => this.props.onClear(e), notifyBlur: (val, e) => this.props.onBlur(e), notifyFocus: (val, e) => this.props.onFocus(e), notifyKeyDown: e => { this.props.onKeyDown(e); }, notifyHeightUpdate: height => { this.setState({ height }); this.props.onResize({ height }); }, notifyPressEnter: e => { this.props.onEnterPress && this.props.onEnterPress(e); }, notifyCompositionStart: e => this.props.onCompositionStart(e), notifyCompositionEnd: e => this.props.onCompositionEnd(e), notifyCompositionUpdate: e => this.props.onCompositionUpdate(e), setMinLength: minLength => this.setState({ minLength }), focusInput: () => { const textarea = this.libRef && this.libRef.current; textarea && textarea.focus(); }, isEventTarget: e => e && e.target === e.currentTarget }); } static getDerivedStateFromProps(props, state) { const willUpdateStates = {}; if (props.value !== state.cachedValue) { willUpdateStates.value = props.value; willUpdateStates.cachedValue = props.value; } return willUpdateStates; } componentDidMount() { // Setup resize observer for line number recalculation if (this.props.showLineNumber && this.libRef.current && typeof globalThis.ResizeObserver !== 'undefined') { const textarea = this.libRef.current; this.setState({ textareaWidth: textarea.clientWidth, textareaHeight: textarea.clientHeight }); this.lineNumberResizeObserver = new globalThis.ResizeObserver(entries => { for (const entry of entries) { // contentRect does not include borders; align with textarea.clientHeight const nextWidth = entry.contentRect.width; const nextHeight = entry.contentRect.height; this.setState({ textareaWidth: nextWidth, textareaHeight: nextHeight }); } }); this.lineNumberResizeObserver.observe(textarea); } } componentWillUnmount() { var _a, _b, _c, _d; if (this.throttledResizeTextarea) { (_b = (_a = this.throttledResizeTextarea) === null || _a === void 0 ? void 0 : _a.cancel) === null || _b === void 0 ? void 0 : _b.call(_a); this.throttledResizeTextarea = null; } if (this.throttledNotifyNativeResize) { (_d = (_c = this.throttledNotifyNativeResize) === null || _c === void 0 ? void 0 : _c.cancel) === null || _d === void 0 ? void 0 : _d.call(_c); this.throttledNotifyNativeResize = null; } if (this.lineNumberResizeObserver) { this.lineNumberResizeObserver.disconnect(); this.lineNumberResizeObserver = null; } } componentDidUpdate(prevProps, prevState) { if ((this.props.value !== prevProps.value || this.props.placeholder !== prevProps.placeholder) && this.props.autosize) { this.foundation.resizeTextarea(); } // Setup/cleanup resize observer when showLineNumber changes if (this.props.showLineNumber !== prevProps.showLineNumber) { if (this.props.showLineNumber && this.libRef.current && typeof globalThis.ResizeObserver !== 'undefined') { const textarea = this.libRef.current; this.setState({ textareaWidth: textarea.clientWidth, textareaHeight: textarea.clientHeight }); if (!this.lineNumberResizeObserver) { this.lineNumberResizeObserver = new globalThis.ResizeObserver(entries => { for (const entry of entries) { const nextWidth = entry.contentRect.width; const nextHeight = entry.contentRect.height; this.setState({ textareaWidth: nextWidth, textareaHeight: nextHeight }); } }); } this.lineNumberResizeObserver.observe(textarea); } else if (this.lineNumberResizeObserver) { this.lineNumberResizeObserver.disconnect(); this.lineNumberResizeObserver = null; } } } renderClearBtn() { const { showClear } = this.props; const displayClearBtn = this.foundation.isAllowClear(); const clearCls = (0, _classnames.default)(`${prefixCls}-clearbtn`, { [`${prefixCls}-clearbtn-hidden`]: !displayClearBtn }); if (showClear) { return ( /*#__PURE__*/ // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions _react.default.createElement("div", { className: clearCls, onClick: this.handleClear }, /*#__PURE__*/_react.default.createElement(_semiIcons.IconClear, null)) ); } return null; } renderCounter() { let counter, current, total, countCls; const { showCounter, maxCount, getValueLength } = this.props; if (showCounter || maxCount) { const { value } = this.state; // eslint-disable-next-line no-nested-ternary current = value ? (0, _isFunction2.default)(getValueLength) ? getValueLength(value) : value.length : 0; total = maxCount || null; countCls = (0, _classnames.default)(`${prefixCls}-textarea-counter`, { [`${prefixCls}-textarea-counter-exceed`]: current > total }); counter = /*#__PURE__*/_react.default.createElement("div", { className: countCls, onClick: this.handleCounterClick }, current, total ? '/' : null, total); } else { counter = null; } return counter; } renderLineNumbers() { const { showLineNumber, lineNumberStart = 1, lineNumberClassName, lineNumberStyle } = this.props; if (!showLineNumber) { return null; } // Reference textareaWidth to trigger re-render when textarea resizes const { value, textareaWidth, textareaHeight } = this.state; const textarea = this.libRef.current; const lines = value ? value.split('\n') : ['']; const lineNumberCls = (0, _classnames.default)(`${prefixCls}-textarea-lineNumber`, lineNumberClassName); const lineHeightPx = textarea ? this.getTextareaLineHeightPx(textarea) : 21; // Constrain panel height to textarea viewport height to prevent expanding textarea const mergedStyle = Object.assign(Object.assign({}, lineNumberStyle || {}), { height: textareaHeight ? `${textareaHeight}px` : undefined, maxHeight: textareaHeight ? `${textareaHeight}px` : undefined }); return /*#__PURE__*/_react.default.createElement("div", { ref: this.lineNumberRef, className: lineNumberCls, style: mergedStyle }, lines.map((line, i) => { // Calculate wrapped lines for this line const wrappedLineCount = textarea ? this.calculateWrappedLines(line, textarea) : 1; return /*#__PURE__*/_react.default.createElement("div", { key: i, className: `${prefixCls}-textarea-lineNumber-item`, style: { minHeight: `${wrappedLineCount * lineHeightPx}px`, lineHeight: `${lineHeightPx}px` } }, lineNumberStart + i); })); } render() { const _a = this.props, { autosize, placeholder, onEnterPress, onResize, resize, disabled, readonly, className, showCounter, validateStatus, maxCount, defaultValue, style, textareaStyle, forwardRef, getValueLength, maxLength, minLength, showClear, borderless, autoFocus, showLineNumber, lineNumberStart, lineNumberClassName, lineNumberStyle, composition } = _a, rest = __rest(_a, ["autosize", "placeholder", "onEnterPress", "onResize", "resize", "disabled", "readonly", "className", "showCounter", "validateStatus", "maxCount", "defaultValue", "style", "textareaStyle", "forwardRef", "getValueLength", "maxLength", "minLength", "showClear", "borderless", "autoFocus", "showLineNumber", "lineNumberStart", "lineNumberClassName", "lineNumberStyle", "composition"]); const { isFocus, value, minLength: stateMinLength } = this.state; // Only opt-in to the new resize behavior when `resize` prop is explicitly provided. // This guarantees the default width behavior remains identical to previous versions. const hasResizeProp = !(0, _isUndefined2.default)(resize); // Native CSS resize only changes the textarea box, but wrapper is `width: 100%` by default. // For horizontal resize, we need wrapper to shrink-to-fit so border/clear/counter follow. const isResizableX = !autosize && hasResizeProp && ['horizontal', 'both', 'inline'].includes(resize); const isResizableY = !autosize && hasResizeProp && ['vertical', 'both', 'block'].includes(resize); const wrapperCls = (0, _classnames.default)(className, `${prefixCls}-textarea-wrapper`, { [`${prefixCls}-textarea-borderless`]: borderless, [`${prefixCls}-textarea-wrapper-disabled`]: disabled, [`${prefixCls}-textarea-wrapper-readonly`]: readonly, [`${prefixCls}-textarea-wrapper-${validateStatus}`]: Boolean(validateStatus), [`${prefixCls}-textarea-wrapper-focus`]: isFocus, [`${prefixCls}-textarea-wrapper-withLineNumber`]: showLineNumber, [`${prefixCls}-textarea-wrapper-resizeX`]: isResizableX, [`${prefixCls}-textarea-wrapper-resizeY`]: isResizableY }); // const ref = this.props.forwardRef || this.textAreaRef; const itemCls = (0, _classnames.default)(`${prefixCls}-textarea`, { [`${prefixCls}-textarea-disabled`]: disabled, [`${prefixCls}-textarea-readonly`]: readonly, [`${prefixCls}-textarea-autosize`]: (0, _isObject2.default)(autosize) ? (0, _isUndefined2.default)(autosize === null || autosize === void 0 ? void 0 : autosize.maxRows) : autosize, [`${prefixCls}-textarea-showClear`]: showClear }); // Merge textarea style: // - autosize: force resize to none // - explicit resize prop: apply it // - otherwise: keep old behavior (do not touch `resize` inline style) const mergedTextareaStyle = Object.assign({}, textareaStyle || {}); if (autosize) { mergedTextareaStyle.resize = 'none'; } else if (hasResizeProp) { mergedTextareaStyle.resize = resize; } const shouldObserveNativeResize = !autosize && hasResizeProp && resize && resize !== 'none'; const itemProps = Object.assign(Object.assign({}, (0, _omit2.default)(rest, 'insetLabel', 'insetLabelId', 'getValueLength', 'onClear', 'showClear', 'disabledEnterStartNewLine', 'composition')), { style: mergedTextareaStyle, autoFocus: autoFocus || this.props['autofocus'], className: itemCls, disabled, readOnly: readonly, placeholder: !placeholder ? null : placeholder, onChange: e => this.foundation.handleChange(e.target.value, e), onFocus: e => this.foundation.handleFocus(e), onBlur: e => this.foundation.handleBlur(e.nativeEvent), onKeyDown: e => this.foundation.handleKeyDown(e), onScroll: this.handleTextAreaScroll, value: value === null || value === undefined ? '' : value, onCompositionStart: this.foundation.handleCompositionStart, onCompositionEnd: this.foundation.handleCompositionEnd, onCompositionUpdate: this.foundation.handleCompositionUpdate }); if (!(0, _isFunction2.default)(getValueLength)) { itemProps.maxLength = maxLength; } if (stateMinLength) { itemProps.minLength = stateMinLength; } return /*#__PURE__*/_react.default.createElement("div", { className: wrapperCls, style: style, onMouseEnter: e => this.foundation.handleMouseEnter(e), onMouseLeave: e => this.foundation.handleMouseLeave(e), onClick: e => this.handleClick(e) }, this.renderLineNumbers(), showLineNumber ? (/*#__PURE__*/_react.default.createElement("div", { className: `${prefixCls}-textarea-content` }, autosize ? (/*#__PURE__*/_react.default.createElement(_resizeObserver.default, { onResize: this.throttledResizeTextarea }, /*#__PURE__*/_react.default.createElement("textarea", Object.assign({}, itemProps, { ref: this.setRef })))) : shouldObserveNativeResize ? (/*#__PURE__*/_react.default.createElement(_resizeObserver.default, { onResize: this.throttledNotifyNativeResize }, /*#__PURE__*/_react.default.createElement("textarea", Object.assign({}, itemProps, { ref: this.setRef })))) : (/*#__PURE__*/_react.default.createElement("textarea", Object.assign({}, itemProps, { ref: this.setRef }))))) : autosize ? (/*#__PURE__*/_react.default.createElement(_resizeObserver.default, { onResize: this.throttledResizeTextarea }, /*#__PURE__*/_react.default.createElement("textarea", Object.assign({}, itemProps, { ref: this.setRef })))) : shouldObserveNativeResize ? (/*#__PURE__*/_react.default.createElement(_resizeObserver.default, { onResize: this.throttledNotifyNativeResize }, /*#__PURE__*/_react.default.createElement("textarea", Object.assign({}, itemProps, { ref: this.setRef })))) : (/*#__PURE__*/_react.default.createElement("textarea", Object.assign({}, itemProps, { ref: this.setRef }))), this.renderClearBtn(), this.renderCounter()); } } TextArea.propTypes = { autosize: _propTypes.default.oneOfType([_propTypes.default.bool, _propTypes.default.object]), borderless: _propTypes.default.bool, placeholder: _propTypes.default.string, value: _propTypes.default.string, rows: _propTypes.default.number, cols: _propTypes.default.number, maxCount: _propTypes.default.number, onEnterPress: _propTypes.default.func, validateStatus: _propTypes.default.string, className: _propTypes.default.string, style: _propTypes.default.object, textareaStyle: _propTypes.default.object, showClear: _propTypes.default.bool, onClear: _propTypes.default.func, onResize: _propTypes.default.func, onCompositionStart: _propTypes.default.func, onCompositionEnd: _propTypes.default.func, onCompositionUpdate: _propTypes.default.func, getValueLength: _propTypes.default.func, disabledEnterStartNewLine: _propTypes.default.bool, composition: _propTypes.default.bool, showLineNumber: _propTypes.default.bool, lineNumberStart: _propTypes.default.number, lineNumberClassName: _propTypes.default.string, lineNumberStyle: _propTypes.default.object, resize: _propTypes.default.oneOf(['none', 'both', 'horizontal', 'vertical', 'block', 'inline']) }; TextArea.defaultProps = { autosize: false, borderless: false, rows: 4, cols: 20, showCounter: false, showClear: false, onEnterPress: _noop2.default, onChange: _noop2.default, onBlur: _noop2.default, onFocus: _noop2.default, onKeyDown: _noop2.default, onResize: _noop2.default, onClear: _noop2.default, onCompositionStart: _noop2.default, onCompositionEnd: _noop2.default, onCompositionUpdate: _noop2.default, composition: false, showLineNumber: false, lineNumberStart: 1 }; const ForwardTextarea = /*#__PURE__*/_react.default.forwardRef((props, ref) => (/*#__PURE__*/_react.default.createElement(TextArea, Object.assign({}, props, { forwardRef: ref })))); var _default = exports.default = ForwardTextarea;