wix-style-react
Version:
575 lines (500 loc) • 15.6 kB
JavaScript
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Input from '../Input';
import omit from 'omit';
import DropdownLayout from '../DropdownLayout/DropdownLayout';
import { classes } from './InputWithOptions.st.css';
import uniqueId from 'lodash/uniqueId';
import Popover from '../Popover';
import HighlightContext from './HighlightContext';
export const DEFAULT_VALUE_PARSER = option => option.value;
export const DEFAULT_POPOVER_PROPS = {
appendTo: 'parent',
flip: false,
fixed: true,
placement: 'bottom',
};
class InputWithOptions extends Component {
// Abstraction
inputClasses() {}
dropdownClasses() {}
dropdownAdditionalProps() {}
inputAdditionalProps() {}
/**
* An array of key codes that act as manual submit. Will be used within
* onKeyDown(event).
*
* @returns {KeyboardEvent.key[]}
*/
getManualSubmitKeys() {
return ['Enter', 'Tab'];
}
constructor(props) {
super(props);
this.state = {
inputValue: props.value || '',
showOptions: false,
lastOptionsShow: 0,
isEditing: false,
};
this.uniqueId = uniqueId('InputWithOptions');
this._onSelect = this._onSelect.bind(this);
this._onFocus = this._onFocus.bind(this);
this._onBlur = this._onBlur.bind(this);
this._onChange = this._onChange.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
this.focus = this.focus.bind(this);
this.blur = this.blur.bind(this);
this.select = this.select.bind(this);
this.hideOptions = this.hideOptions.bind(this);
this.showOptions = this.showOptions.bind(this);
this._onManuallyInput = this._onManuallyInput.bind(this);
this._renderDropdownLayout = this._renderDropdownLayout.bind(this);
this._onInputClicked = this._onInputClicked.bind(this);
this.closeOnSelect = this.closeOnSelect.bind(this);
this.onCompositionChange = this.onCompositionChange.bind(this);
}
componentDidUpdate(prevProps, prevState) {
if (
!this.props.showOptionsIfEmptyInput &&
((!prevProps.value && this.props.value) ||
(!prevState.inputValue && this.state.inputValue))
) {
this.showOptions();
}
// Clear value in controlled mode
if (prevProps.value !== this.props.value && this.props.value === '') {
this.setState({ inputValue: '' });
}
}
onCompositionChange(isComposing) {
this.setState({ isComposing });
}
onClickOutside = () => {
// Hide the popover
this.hideOptions();
// Trigger the ClickOutside callback
if (this.props.onClickOutside) {
this.props.onClickOutside();
}
};
input = React.createRef();
renderInput() {
const inputAdditionalProps = this.inputAdditionalProps();
const inputProps = Object.assign(
omit(
[
'onChange',
'dataHook',
'dropDirectionUp',
'focusOnSelectedOption',
'onClose',
'onSelect',
'onOptionMarked',
'overflow',
'visible',
'options',
'selectedId',
'tabIndex',
'onClickOutside',
'fixedHeader',
'fixedFooter',
'maxHeightPixels',
'minWidthPixels',
'withArrow',
'closeOnSelect',
'onMouseEnter',
'onMouseLeave',
'itemHeight',
'selectedHighlight',
'inContainer',
'infiniteScroll',
'loadMore',
'hasMore',
'markedOption',
],
this.props,
),
inputAdditionalProps,
);
const { inputElement } = inputProps;
return React.cloneElement(inputElement, {
menuArrow: true,
ref: this.input,
...inputProps,
onChange: this._onChange,
onInputClicked: this._onInputClicked,
onFocus: this._onFocus,
onBlur: this._onBlur,
onCompositionChange: this.onCompositionChange,
width: inputElement.props.width,
textOverflow: this.props.textOverflow || inputElement.props.textOverflow,
tabIndex: this.props.native ? -1 : 0,
});
}
isDropdownLayoutVisible = () =>
this.state.showOptions &&
(this.props.showOptionsIfEmptyInput || this.state.inputValue.length > 0);
_renderDropdownLayout() {
const { highlight, value } = this.props;
const inputOnlyProps = omit(['tabIndex'], Input.propTypes);
const dropdownProps = Object.assign(
omit(
Object.keys(inputOnlyProps).concat(['dataHook', 'onClickOutside']),
this.props,
),
this.dropdownAdditionalProps(),
);
const customStyle = { marginLeft: this.props.dropdownOffsetLeft };
return (
<div
className={`${this.uniqueId} ${this.dropdownClasses()}`}
style={customStyle}
data-hook="dropdown-layout-wrapper"
>
<HighlightContext.Provider value={{ highlight, match: value }}>
<DropdownLayout
ref={dropdownLayout => (this.dropdownLayout = dropdownLayout)}
{...dropdownProps}
dataHook="inputwithoptions-dropdownlayout"
visible
onClose={this.hideOptions}
onSelect={this._onSelect}
isComposing={this.state.isComposing}
inContainer
tabIndex={-1}
/>
</HighlightContext.Provider>
</div>
);
}
_renderNativeSelect() {
const { options, onSelect, disabled } = this.props;
return (
<div className={classes.nativeSelectWrapper}>
{this.renderInput()}
<select
disabled={disabled}
data-hook="native-select"
className={classes.nativeSelect}
onChange={event => {
this._onChange(event);
// In this case we don't use DropdownLayout so we need to invoke `onSelect` manually
onSelect(options[event.target.selectedIndex]);
}}
>
{options.map((option, index) => (
<option
data-hook={`native-option-${option.id}`}
data-index={index}
key={option.id}
value={option.value}
className={classes.nativeOption}
>
{option.value}
</option>
))}
</select>
</div>
);
}
render() {
const { native, dataHook, popoverProps, dropDirectionUp, dropdownWidth } =
this.props;
const placement = dropDirectionUp ? 'top' : popoverProps.placement;
const body = popoverProps.appendTo === 'window';
return !native ? (
<Popover
className={classes.root}
{...DEFAULT_POPOVER_PROPS}
dynamicWidth={body}
excludeClass={this.uniqueId}
{...popoverProps}
width={dropdownWidth}
placement={placement}
dataHook={dataHook}
onKeyDown={this._onKeyDown}
onClickOutside={this.onClickOutside}
shown={this.isDropdownLayoutVisible()}
>
<Popover.Element>
<div data-input-parent className={this.inputClasses()}>
{this.renderInput()}
</div>
</Popover.Element>
<Popover.Content>{this._renderDropdownLayout()}</Popover.Content>
</Popover>
) : (
this._renderNativeSelect()
);
}
/**
* Shows dropdown options
*/
showOptions() {
if (!this.state.showOptions) {
this.setState({ showOptions: true, lastOptionsShow: Date.now() });
this.props.onOptionsShow && this.props.onOptionsShow();
}
}
/**
* Hides dropdown options
*/
hideOptions() {
if (this.state.showOptions) {
this.setState({ showOptions: false });
this.props.onOptionsHide && this.props.onOptionsHide();
this.props.onClose && this.props.onClose();
}
}
closeOnSelect() {
return this.props.closeOnSelect;
}
get isReadOnly() {
const { readOnly } = this.inputAdditionalProps() || {};
return readOnly;
}
/**
* Determine if the provided key should cause the dropdown to be opened.
*
* @param {KeyboardEvent.key}
* @returns {boolean}
*/
shouldOpenDropdown(key) {
const openKeys = this.isReadOnly
? ['Enter', 'Spacebar', ' ', 'ArrowDown']
: ['ArrowDown'];
return openKeys.includes(key);
}
/**
* Determine if the provided key should delegate the keydown event to the
* DropdownLayout.
*
* @param {KeyboardEvent.key}
* @returns {boolean}
*/
shouldDelegateKeyDown(key) {
return this.isReadOnly || !['Spacebar', ' '].includes(key);
}
/**
* Determine if the provided key should cause manual submit.
*
* @param {KeyboardEvent.key}
* @returns {boolean}
*/
shouldPerformManualSubmit(key) {
return this.getManualSubmitKeys().includes(key);
}
_onManuallyInput(inputValue = '') {
if (this.state.isComposing) {
return;
}
inputValue = inputValue.trim();
const suggestedOption = this.props.options.find(
element => element.value === inputValue,
);
if (this.props.onManuallyInput) {
this.props.onManuallyInput(inputValue, suggestedOption);
}
}
_onSelect(option, isSelectedOption) {
const { onSelect } = this.props;
if (this.closeOnSelect() || isSelectedOption) {
this.hideOptions();
}
if (onSelect) {
onSelect(
this.props.highlight
? this.props.options.find(opt => opt.id === option.id)
: option,
);
}
}
_onChange(event) {
this.setState({ inputValue: event.target.value });
if (this.props.onChange) {
this.props.onChange(event);
}
// If the input value is not empty, should show the options
if (event.target.value.trim() && !this.props.native) {
this.showOptions();
}
}
_onInputClicked(event) {
if (this.state.showOptions) {
if (Date.now() - this.state.lastOptionsShow > 2000) {
this.hideOptions();
}
} else {
this.showOptions();
}
if (this.props.onInputClicked) {
this.props.onInputClicked(event);
}
}
_onFocus(e) {
/** Don't call onFocus if input is already focused or is disabled
* can occur when input is re-focused after selecting an option
*/
if (this._focused || this.props.disabled) {
return;
}
this._focused = true;
this.setState({ isEditing: false });
if (this.props.onFocus) {
this.props.onFocus(e);
}
}
/** Checks if focus event is related to selecting an option */
_didSelectOption = event => {
const focusedElement = event && event.relatedTarget;
const dropdownContainer =
this.dropdownLayout && this.dropdownLayout.containerRef.current;
// Check if user has focused other input component
const isInput = focusedElement instanceof HTMLInputElement;
if (!focusedElement || !dropdownContainer || isInput) {
return false;
}
const isInDropdown = dropdownContainer.contains(focusedElement);
// Returns true if element is the dropdown container or is inside of it
return isInDropdown;
};
_onBlur(event) {
// Don't blur input if selected an option
const stopBlur = this._didSelectOption(event);
if (stopBlur) {
// Restore focus to input element
this.focus();
return;
}
this._focused = false;
if (this.props.onBlur) {
this.props.onBlur(event);
}
}
_onKeyDown(event) {
if (this.props.disabled) {
return;
}
const { key } = event;
/* Enter - prevent a wrapping form from submitting when hitting Enter */
/* ArrowUp - prevent input's native behaviour from moving the text cursor to the beginning */
if (key === 'Enter' || key === 'ArrowUp') {
event.preventDefault();
}
if (key !== 'ArrowDown' && key !== 'ArrowUp') {
this.setState({ isEditing: true });
}
if (this.shouldOpenDropdown(key)) {
this.showOptions();
event.preventDefault();
}
if (this.shouldDelegateKeyDown(key)) {
// Delegate event and get result
if (this.dropdownLayout) {
const eventWasHandled = this.dropdownLayout._onKeyDown(event);
if (eventWasHandled || this.isReadOnly) {
return;
}
}
// For editing mode, we want to *submit* only for specific keys.
if (this.shouldPerformManualSubmit(key)) {
this._onManuallyInput(this.state.inputValue, event);
const inputIsEmpty = !event.target.value;
if (this.closeOnSelect() || (key === 'Tab' && inputIsEmpty)) {
this.hideOptions();
}
}
}
}
/**
* Sets focus on the input element
* @param {FocusOptions} options
*/
focus(options = {}) {
this.input.current && this.input.current.focus(options);
}
/**
* Removes focus on the input element
*/
blur() {
this.input.current && this.input.current.blur();
}
/**
* Selects all text in the input element
*/
select() {
this.input.current && this.input.current.select();
}
}
InputWithOptions.defaultProps = {
...Input.defaultProps,
...DropdownLayout.defaultProps,
onSelect: () => {},
options: [],
closeOnSelect: true,
inputElement: <Input />,
valueParser: DEFAULT_VALUE_PARSER,
dropdownWidth: null,
popoverProps: DEFAULT_POPOVER_PROPS,
dropdownOffsetLeft: '0',
showOptionsIfEmptyInput: true,
autocomplete: 'off',
native: false,
};
InputWithOptions.propTypes = {
...Input.propTypes,
...DropdownLayout.propTypes,
/** Use a customized input component instead of the default wix-style-react `<Input/>` component */
inputElement: PropTypes.element,
/** Closes DropdownLayout on option selection */
closeOnSelect: PropTypes.bool,
/** A callback which is called when the user performs a Submit-Action.
* Submit-Action triggers are: "Enter", "Tab", [typing any defined delimiters], Paste action.
* `onManuallyInput(values: Array<string>): void - The array of strings is the result of splitting the input value by the given delimiters */
onManuallyInput: PropTypes.func,
/** A callback which is called when options dropdown is shown */
onOptionsShow: PropTypes.func,
/** A callback which is called when options dropdown is hidden */
onOptionsHide: PropTypes.func,
/** Function that receives an option, and should return the value to be displayed. */
valueParser: PropTypes.func,
/** Sets the width of the dropdown */
dropdownWidth: PropTypes.string,
/** Sets the offset of the dropdown from the left */
dropdownOffsetLeft: PropTypes.string,
/** Controls whether to show options if input is empty */
showOptionsIfEmptyInput: PropTypes.bool,
/** Mark in bold word parts based on search pattern */
highlight: PropTypes.bool,
/** Indicates whether to render using the native select element */
native: PropTypes.bool,
/** common popover props */
popoverProps: PropTypes.shape({
appendTo: PropTypes.oneOf(['window', 'scrollParent', 'parent', 'viewport']),
maxWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
minWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
flip: PropTypes.bool,
fixed: PropTypes.bool,
placement: PropTypes.oneOf([
'auto-start',
'auto',
'auto-end',
'top-start',
'top',
'top-end',
'right-start',
'right',
'right-end',
'bottom-end',
'bottom',
'bottom-start',
'left-end',
'left',
'left-start',
]),
dynamicWidth: PropTypes.bool,
}),
};
InputWithOptions.displayName = 'InputWithOptions';
export default InputWithOptions;