src/input/base-input.tsx (321 lines of code) (raw):
/*
Copyright (c) Uber Technologies, Inc.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
/* global window */
import * as React from 'react';
import { getOverrides } from '../helpers/overrides';
import { ADJOINED, SIZE, CUSTOM_INPUT_TYPE } from './constants';
import {
InputContainer as StyledInputContainer,
Input as StyledInput,
StyledClearIcon,
StyledClearIconContainer,
StyledMaskToggleButton,
} from './styled-components';
import type { BaseInputProps, InternalState } from './types';
import { getSharedProps } from './utils';
import Hide from '../icon/hide';
import Show from '../icon/show';
import createEvent from '../utils/create-event';
import { isFocusVisible, forkFocus, forkBlur } from '../utils/focusVisible';
import type { SyntheticEvent, FocusEvent } from 'react';
// @ts-ignore
const NullComponent = () => null;
class BaseInput<T extends HTMLInputElement | HTMLTextAreaElement> extends React.Component<
BaseInputProps<T>,
InternalState
> {
static defaultProps = {
// @ts-ignore
'aria-activedescendant': null,
// @ts-ignore
'aria-autocomplete': null,
// @ts-ignore
'aria-controls': null,
// @ts-ignore
'aria-errormessage': null,
// @ts-ignore
'aria-haspopup': null,
// @ts-ignore
'aria-label': null,
// @ts-ignore
'aria-labelledby': null,
// @ts-ignore
'aria-describedby': null,
adjoined: ADJOINED.none,
autoComplete: 'on',
autoFocus: false,
disabled: false,
error: false,
positive: false,
name: '',
inputMode: 'text',
onBlur: () => {},
onChange: () => {},
onKeyDown: () => {},
onKeyPress: () => {},
onKeyUp: () => {},
onFocus: () => {},
onClear: () => {},
clearable: false,
clearOnEscape: true,
overrides: {},
// @ts-ignore
pattern: null,
placeholder: '',
required: false,
// @ts-ignore
role: null,
size: SIZE.default,
type: 'text',
readOnly: false,
};
inputRef = this.props.inputRef || React.createRef<T>();
state = {
isFocused: this.props.autoFocus || false,
isMasked: this.props.type === 'password',
initialType: this.props.type,
isFocusVisibleForClear: false,
isFocusVisibleForMaskToggle: false,
};
componentDidMount() {
const { autoFocus, clearable } = this.props;
if (this.inputRef.current) {
if (autoFocus) {
this.inputRef.current.focus();
}
if (clearable) {
// @ts-ignore
this.inputRef.current.addEventListener('keydown', this.onInputKeyDown);
}
}
}
componentWillUnmount() {
const { clearable } = this.props;
if (clearable && this.inputRef.current) {
// @ts-ignore
this.inputRef.current.removeEventListener('keydown', this.onInputKeyDown);
}
}
clearValue() {
// trigger a fake input change event (as if all text was deleted)
const input = this.inputRef.current;
if (input) {
const nativeInputValue = Object.getOwnPropertyDescriptor(
this.props.type === CUSTOM_INPUT_TYPE.textarea
? // todo(flow->ts): globals, not props of window object
HTMLTextAreaElement.prototype
: HTMLInputElement.prototype,
'value'
);
if (nativeInputValue) {
const nativeInputValueSetter = nativeInputValue.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(input, '');
const event = createEvent('input');
input.dispatchEvent(event);
}
}
}
}
onInputKeyDown = (e: KeyboardEvent) => {
if (
this.props.clearOnEscape &&
e.key === 'Escape' &&
this.inputRef.current &&
!this.props.readOnly
) {
this.clearValue();
// prevent event from closing modal or doing something unexpected
e.stopPropagation();
}
};
onClearIconClick = () => {
if (this.inputRef.current) this.clearValue();
// return focus to the input after click
if (this.inputRef.current) this.inputRef.current.focus();
};
onFocus = (e: FocusEvent<T>) => {
this.setState({ isFocused: true });
// @ts-ignore
this.props.onFocus(e);
};
onBlur = (e: FocusEvent<T>) => {
this.setState({ isFocused: false });
// @ts-ignore
this.props.onBlur(e);
};
getInputType() {
// If the type prop is equal to "password" we allow the user to toggle between
// masked and non masked text. Internally, we toggle between type "password"
// and "text".
if (this.props.type === 'password') {
return this.state.isMasked ? 'password' : 'text';
} else {
return this.props.type;
}
}
handleFocusForMaskToggle = (event: SyntheticEvent) => {
if (isFocusVisible(event)) {
this.setState({ isFocusVisibleForMaskToggle: true });
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handleBlurForMaskToggle = (event: SyntheticEvent) => {
if (this.state.isFocusVisibleForMaskToggle !== false) {
this.setState({ isFocusVisibleForMaskToggle: false });
}
};
renderMaskToggle() {
if (this.props.type !== 'password') return null;
const [MaskToggleButton, maskToggleButtonProps] = getOverrides(
// @ts-ignore
this.props.overrides.MaskToggleButton,
StyledMaskToggleButton
);
const [MaskToggleShowIcon, maskToggleIconShowProps] = getOverrides(
// @ts-ignore
this.props.overrides.MaskToggleShowIcon,
Show
);
const [MaskToggleHideIcon, maskToggleIconHideProps] = getOverrides(
// @ts-ignore
this.props.overrides.MaskToggleHideIcon,
Hide
);
const label = this.state.isMasked ? 'Show password text' : 'Hide password text';
const iconSize = {
[SIZE.mini]: '12px',
[SIZE.compact]: '16px',
[SIZE.default]: '20px',
[SIZE.large]: '24px',
// @ts-ignore
}[this.props.size];
return (
<MaskToggleButton
$size={this.props.size}
$isFocusVisible={this.state.isFocusVisibleForMaskToggle}
aria-label={label}
onClick={() => this.setState((state) => ({ isMasked: !state.isMasked }))}
title={label}
type="button"
{...maskToggleButtonProps}
onFocus={forkFocus(maskToggleButtonProps, this.handleFocusForMaskToggle)}
onBlur={forkBlur(maskToggleButtonProps, this.handleBlurForMaskToggle)}
>
{this.state.isMasked ? (
<MaskToggleShowIcon size={iconSize} title={label} {...maskToggleIconShowProps} />
) : (
<MaskToggleHideIcon size={iconSize} title={label} {...maskToggleIconHideProps} />
)}
</MaskToggleButton>
);
}
handleFocusForClear = (event: SyntheticEvent) => {
if (isFocusVisible(event)) {
this.setState({ isFocusVisibleForClear: true });
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handleBlurForClear = (event: SyntheticEvent) => {
if (this.state.isFocusVisibleForClear !== false) {
this.setState({ isFocusVisibleForClear: false });
}
};
renderClear() {
const { clearable, value, disabled, readOnly, overrides = {} } = this.props;
if (
disabled ||
readOnly ||
!clearable ||
value == null ||
(typeof value === 'string' && value.length === 0)
) {
return null;
}
const [ClearIconContainer, clearIconContainerProps] = getOverrides(
overrides.ClearIconContainer,
StyledClearIconContainer
);
const [ClearIcon, clearIconProps] = getOverrides(overrides.ClearIcon, StyledClearIcon);
const ariaLabel = 'Clear value';
const sharedProps = getSharedProps(this.props, this.state);
const iconSize = {
[SIZE.mini]: '14px',
[SIZE.compact]: '14px',
[SIZE.default]: '16px',
[SIZE.large]: '22px',
// @ts-ignore
}[this.props.size];
return (
<ClearIconContainer
$alignTop={this.props.type === CUSTOM_INPUT_TYPE.textarea}
{...sharedProps}
{...clearIconContainerProps}
>
<ClearIcon
size={iconSize}
tabIndex={0}
title={ariaLabel}
aria-label={ariaLabel}
onClick={this.onClearIconClick}
// @ts-ignore
onKeyDown={(event) => {
if (event.key && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
this.onClearIconClick();
}
}}
role="button"
$isFocusVisible={this.state.isFocusVisibleForClear}
{...sharedProps}
{...clearIconProps}
onFocus={forkFocus(clearIconProps, this.handleFocusForClear)}
onBlur={forkBlur(clearIconProps, this.handleBlurForClear)}
/>
</ClearIconContainer>
);
}
render() {
const {
overrides: {
// @ts-ignore
InputContainer: InputContainerOverride,
// @ts-ignore
Input: InputOverride,
// @ts-ignore
Before: BeforeOverride,
// @ts-ignore
After: AfterOverride,
},
} = this.props;
// more here https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion#Preventing_autofilling_with_autocompletenew-password
const autoComplete =
this.state.initialType === 'password' &&
this.props.autoComplete === BaseInput.defaultProps.autoComplete
? 'new-password'
: this.props.autoComplete;
const sharedProps = getSharedProps(this.props, this.state);
const [InputContainer, inputContainerProps] = getOverrides(
InputContainerOverride,
StyledInputContainer
);
const [Input, inputProps] = getOverrides(InputOverride, StyledInput);
const [Before, beforeProps] = getOverrides(BeforeOverride, NullComponent);
const [After, afterProps] = getOverrides(AfterOverride, NullComponent);
return (
<InputContainer
data-baseweb={this.props['data-baseweb'] || 'base-input'}
{...sharedProps}
{...inputContainerProps}
>
<Before {...sharedProps} {...beforeProps} />
<Input
ref={this.inputRef}
aria-activedescendant={this.props['aria-activedescendant']}
aria-autocomplete={this.props['aria-autocomplete']}
aria-controls={this.props['aria-controls']}
aria-errormessage={this.props['aria-errormessage']}
aria-haspopup={this.props['aria-haspopup']}
aria-label={this.props['aria-label']}
aria-labelledby={this.props['aria-labelledby']}
aria-describedby={this.props['aria-describedby']}
aria-invalid={this.props.error}
aria-required={this.props.required}
autoComplete={autoComplete}
disabled={this.props.disabled}
readOnly={this.props.readOnly}
id={this.props.id}
inputMode={this.props.inputMode}
maxLength={this.props.maxLength}
name={this.props.name}
onBlur={this.onBlur}
onChange={this.props.onChange}
onFocus={this.onFocus}
onKeyDown={this.props.onKeyDown}
onKeyPress={this.props.onKeyPress}
onKeyUp={this.props.onKeyUp}
pattern={this.props.pattern}
placeholder={this.props.placeholder}
type={this.getInputType()}
required={this.props.required}
role={this.props.role}
value={this.props.value}
min={this.props.min}
max={this.props.max}
step={this.props.step}
rows={this.props.type === CUSTOM_INPUT_TYPE.textarea ? this.props.rows : null}
{...sharedProps}
{...inputProps}
/>
{this.renderClear()}
{this.renderMaskToggle()}
<After {...sharedProps} {...afterProps} />
</InputContainer>
);
}
}
export default BaseInput;