wix-style-react
Version:
482 lines (400 loc) • 13.5 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import deprecationLog from '../utils/deprecationLog';
import StatusIndicator from '../StatusIndicator';
import debounce from 'lodash/debounce';
import isNaN from 'lodash/isNaN';
import { st, classes } from './InputArea.st.css';
import { dataAttr, dataHooks } from './constants';
import { filterObject } from '../utils/filterObject';
import { FontUpgradeContext } from '../FontUpgrade/context';
/**
* General inputArea container
*/
class InputArea extends React.PureComponent {
_computedStyle = null;
state = {
focus: false,
counter: (this.props.value || this.props.defaultValue || '').length,
computedRows: this.props.minRowsAutoGrow,
};
// For autoGrow prop min rows is 2 so the textarea does not look like an input
static MIN_ROWS = 2;
constructor(props) {
super(props);
if (props.size === 'normal') {
deprecationLog(
'<InputArea/> - change prop size="normal" to size="medium"',
);
}
}
// For testing purposes only
_getDataAttr = () => {
const {
size,
status,
disabled,
resizable,
forceHover,
forceFocus,
} = this.props;
return filterObject(
{
[dataAttr.SIZE]: size,
[dataAttr.STATUS]: !!status && !disabled,
[dataAttr.DISABLED]: !!disabled,
[dataAttr.RESIZABLE]: !!resizable && !disabled,
[dataAttr.HOVER]: !!forceHover,
[dataAttr.FOCUS]: !!(forceFocus || this.state.focus),
},
(key, value) => !!value,
);
};
componentDidMount() {
const { autoFocus, autoGrow, value } = this.props;
autoFocus && this._onFocus();
if (autoGrow) {
this._calculateComputedRows();
}
/*
* autoFocus doesn't automatically selects text like focus do.
* Therefore we set the selection range, but in order to support prior implementation we set the start position as the end in order to place the cursor there.
*/
if (autoFocus && !!value) {
this.textArea.setSelectionRange(value.length, value.length);
}
}
componentDidUpdate(prevProps) {
const {
minRowsAutoGrow,
value,
defaultValue,
autoGrow,
hasCounter,
} = this.props;
if (autoGrow && prevProps.minRowsAutoGrow !== minRowsAutoGrow) {
this._calculateComputedRows();
}
if (hasCounter && prevProps.value !== value) {
this.setState({
counter: (value || defaultValue || '').length,
});
}
}
componentWillUnmount() {
this._updateComputedStyle.cancel();
}
render() {
const {
dataHook,
className,
autoFocus,
defaultValue,
disabled,
forceFocus,
forceHover,
id,
name,
onKeyUp,
placeholder,
readOnly,
tabIndex,
rows,
autoGrow,
value,
required,
minHeight,
maxHeight,
maxLength,
resizable,
hasCounter,
size,
tooltipPlacement,
status,
statusMessage,
children,
} = this.props;
const inlineStyle = {};
const rowsAttr = rows
? rows
: autoGrow
? this.state.computedRows
: undefined;
const onInput = !rows && autoGrow ? this._onInput : undefined;
if (minHeight) {
inlineStyle.minHeight = minHeight;
}
if (maxHeight) {
inlineStyle.maxHeight = maxHeight;
}
const ariaAttribute = {};
Object.keys(this.props)
.filter(key => key.startsWith('aria'))
.map(
key =>
(ariaAttribute['aria-' + key.substr(4).toLowerCase()] = this.props[
key
]),
);
return (
<FontUpgradeContext.Consumer>
{({ active: isMadefor }) => (
<div
data-hook={dataHook}
className={st(
classes.root,
{
isMadefor,
disabled,
size,
status,
hasFocus: forceFocus || this.state.focus,
forceHover,
resizable,
readOnly,
},
className,
)}
{...this._getDataAttr()}
>
{/* Input Area */}
<div className={classes.inputArea}>
{typeof children === 'function' ? (
children({
rows: rowsAttr,
ref: ref => (this.textArea = ref),
onFocus: this._onFocus,
onBlur: this._onBlur,
onKeyDown: this._onKeyDown,
onInput: onInput,
})
) : (
<textarea
rows={rowsAttr}
maxLength={maxLength}
ref={ref => (this.textArea = ref)}
id={id}
name={name}
style={inlineStyle}
defaultValue={defaultValue}
disabled={disabled}
value={value}
required={required}
onFocus={this._onFocus}
onBlur={this._onBlur}
onKeyDown={this._onKeyDown}
onChange={this._onChange}
onInput={onInput}
placeholder={placeholder}
tabIndex={tabIndex}
autoFocus={autoFocus}
onKeyUp={onKeyUp}
{...ariaAttribute}
readOnly={readOnly}
/>
)}
{/* Counter */}
{hasCounter && maxLength && (
<span className={classes.counter} data-hook="counter">
{this.state.counter}/{maxLength}
</span>
)}
</div>
{/* Status Indicator */}
<div className={classes.status}>
{!!status && !disabled && (
<StatusIndicator
dataHook={dataHooks.tooltip}
status={status}
message={statusMessage}
tooltipPlacement={tooltipPlacement}
/>
)}
</div>
</div>
)}
</FontUpgradeContext.Consumer>
);
}
focus = () => {
this.textArea && this.textArea.focus();
};
blur = () => {
this.textArea && this.textArea.blur();
};
select = () => {
this.textArea && this.textArea.select();
};
_onFocus = e => {
this.setState({ focus: true });
this.props.onFocus && this.props.onFocus(e);
if (this.props.autoSelect) {
// Set timeout is needed here since onFocus is called before react
// gets the reference for the input (specifically when autoFocus
// is on. So setTimeout ensures we have the ref.input needed in select)
setTimeout(() => this.select(), 0);
}
};
_onBlur = e => {
this.setState({ focus: false });
this.props.onBlur && this.props.onBlur(e);
};
_onKeyDown = e => {
this.props.onKeyDown && this.props.onKeyDown(e);
if (e.key === 'Enter') {
this.props.onEnterPressed && this.props.onEnterPressed(e);
} else if (e.key === 'Escape') {
this.props.onEscapePressed && this.props.onEscapePressed();
}
};
_onChange = e => {
this.props.onChange && this.props.onChange(e);
};
_onInput = () => {
this._calculateComputedRows();
};
_calculateComputedRows = () => {
const { minRowsAutoGrow } = this.props;
this.setState({ computedRows: 1 }, () => {
const rowsCount = this._getRowsCount();
const computedRows = Math.max(minRowsAutoGrow, rowsCount);
this.setState({
computedRows,
});
});
};
_updateComputedStyle = debounce(
() => {
this._computedStyle = window.getComputedStyle(this.textArea);
},
500,
{ leading: true },
);
_getComputedStyle = () => {
this._updateComputedStyle();
return this._computedStyle;
};
_getRowsCount = () => {
const computedStyle = this._getComputedStyle();
const fontSize = parseInt(computedStyle.getPropertyValue('font-size'), 10);
const lineHeight = parseInt(
computedStyle.getPropertyValue('line-height'),
10,
);
let lineHeightValue;
if (isNaN(lineHeight)) {
if (isNaN(fontSize)) {
return InputArea.MIN_ROWS;
}
lineHeightValue = this._getDefaultLineHeight() * fontSize;
} else {
lineHeightValue = lineHeight;
}
return Math.floor(this.textArea.scrollHeight / lineHeightValue);
};
_getDefaultLineHeight = () => {
if (!this._defaultLineHeight) {
const { parentNode } = this.textArea;
const computedStyles = this._getComputedStyle();
const fontFamily = computedStyles.getPropertyValue('font-family');
const fontSize = computedStyles.getPropertyValue('font-size');
const tempElement = document.createElement('span');
const defaultStyles =
'position:absolute;display:inline;border:0;margin:0;padding:0;line-height:normal;';
tempElement.setAttribute(
'style',
`${defaultStyles}font-family:${fontFamily};font-size:${fontSize};`,
);
tempElement.innerText = 'M';
parentNode.appendChild(tempElement);
this._defaultLineHeight =
parseInt(tempElement.clientHeight, 10) / parseInt(fontSize, 10);
tempElement.parentNode.removeChild(tempElement);
}
return this._defaultLineHeight;
};
}
InputArea.displayName = 'InputArea';
InputArea.defaultProps = {
minRowsAutoGrow: InputArea.MIN_ROWS,
size: 'medium',
};
InputArea.propTypes = {
/** Applies a data-hook HTML attribute that can be used in the tests. */
dataHook: PropTypes.string,
/** Specifies a CSS class name to be appended to the component’s root element. */
className: PropTypes.string,
/** Specifies custom textarea render function */
children: PropTypes.func,
/** Associate a control with the regions that it controls. */
ariaControls: PropTypes.string,
/** Associate a region with its descriptions. Similar to aria-controls but instead associating descriptions to the region and description identifiers are separated with a space. */
ariaDescribedby: PropTypes.string,
/** Define a string that labels the current element in case where a text label is not visible on the screen. */
ariaLabel: PropTypes.string,
/** Focus the element on mount (standard React input autoFocus). */
autoFocus: PropTypes.bool,
/** Select the entire text of the element on focus (standard React input autoSelect). */
autoSelect: PropTypes.bool,
/** Controls the size of the input. */
size: PropTypes.oneOf(['small', 'medium']),
/** Sets a default value for those who want to use this component un-controlled. */
defaultValue: PropTypes.string,
/** Specifies whether input should be disabled. */
disabled: PropTypes.bool,
/** Specifies the status of a field. */
status: PropTypes.oneOf(['error', 'warning', 'loading']),
/** Defines the message to display on status icon hover. If not given or empty there will be no tooltip. */
statusMessage: PropTypes.node,
/** USED FOR TESTING. Forces focus state on the input. */
forceFocus: PropTypes.bool,
/** USED FOR TESTING. Forces hover state on the input. */
forceHover: PropTypes.bool,
/** Specifies whether character count is enabled. */
hasCounter: PropTypes.bool,
/** Assigns an unique identifier for the root element. */
id: PropTypes.string,
/** Reference element data when a form is submitted. */
name: PropTypes.string,
/** Sets the maximum height of an area in pixels. */
maxHeight: PropTypes.string,
/** Defines the maximum text length in number of characters. */
maxLength: PropTypes.number,
/** Sets the minimum height of an area in pixels. */
minHeight: PropTypes.string,
/** Defines a standard input onBlur callback */
onBlur: PropTypes.func,
/** Defines a standard input onChange callback. */
onChange: PropTypes.func,
/** Defines a callback handler that is called when user presses enter. */
onEnterPressed: PropTypes.func,
/** Defines a callback handler that is called when user presses escape. */
onEscapePressed: PropTypes.func,
/** Defines a standard input onFocus callback. */
onFocus: PropTypes.func,
/** Defines a standard input onKeyDown callback. */
onKeyDown: PropTypes.func,
/** Defines a standard input onKeyUp callback. */
onKeyUp: PropTypes.func,
/** Sets a placeholder message to display. */
placeholder: PropTypes.string,
/** Specifies whether input is read only. */
readOnly: PropTypes.bool,
/** Specifies whether area can be manually resized by the user. */
resizable: PropTypes.bool,
/** Sets initial height of an area to fit a specified number of rows. */
rows: PropTypes.number,
/** Specifies whether area should grow and shrink according to user input. */
autoGrow: PropTypes.bool,
/** Sets the minimum amount of rows the component can have in `autoGrow` mode */
minRowsAutoGrow: PropTypes.number,
/** Indicates that element can be focused and where it participates in sequential keyboard navigation. */
tabIndex: PropTypes.number,
/** Controls placement of a status tooltip. */
tooltipPlacement: PropTypes.string,
/** Defines input value. */
value: PropTypes.string,
/** Specifies whether the input area is a mandatory field. */
required: PropTypes.bool,
};
export default InputArea;