@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
JavaScript
"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;