wix-style-react
Version:
wix-style-react
438 lines • 17.2 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 { st, 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() { }
rootAdditionalProps() {
return {};
}
/**
* 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.onClickOutside = () => {
// Hide the popover
this.hideOptions();
// Trigger the ClickOutside callback
if (this.props.onClickOutside) {
this.props.onClickOutside();
}
};
this.input = React.createRef();
this.isDropdownLayoutVisible = () => this.state.showOptions &&
(this.props.showOptionsIfEmptyInput || this.state.inputValue.length > 0);
/** Checks if focus event is related to selecting an option */
this._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;
};
/**
* Clears the input.
*
* @param event delegated to the onClear call
*/
this.clear = event => {
this.input.current && this.input.current.clear(event);
};
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 });
}
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',
'className',
], 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,
});
}
_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 (React.createElement("div", { className: `${this.uniqueId} ${this.dropdownClasses()}`, style: customStyle, "data-hook": "dropdown-layout-wrapper" },
React.createElement(HighlightContext.Provider, { value: { highlight, match: value } },
React.createElement(DropdownLayout, { ref: dropdownLayout => (this.dropdownLayout = dropdownLayout), ...dropdownProps, dataHook: "inputwithoptions-dropdownlayout", visible: true, onClose: this.hideOptions, onSelect: this._onSelect, isComposing: this.state.isComposing, inContainer: true, tabIndex: -1 }))));
}
_renderNativeSelect() {
const { options, onSelect, disabled } = this.props;
return (React.createElement("div", { className: classes.nativeSelectWrapper },
this.renderInput(),
React.createElement("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) => (React.createElement("option", { "data-hook": `native-option-${option.id}`, "data-index": index, key: option.id, value: option.value, className: classes.nativeOption }, option.value))))));
}
render() {
const { native, dataHook, popoverProps, dropDirectionUp, dropdownWidth, className, } = this.props;
const placement = dropDirectionUp ? 'top' : popoverProps.placement;
const body = popoverProps.appendTo === 'window';
const rootProps = this.rootAdditionalProps();
return !native ? (React.createElement(Popover, { className: st(classes.root, rootProps.className ?? className), ...DEFAULT_POPOVER_PROPS, dynamicWidth: body, excludeClass: this.uniqueId, ...popoverProps, width: dropdownWidth, placement: placement, dataHook: dataHook, onKeyDown: this._onKeyDown, onClickOutside: this.onClickOutside, shown: this.isDropdownLayoutVisible() },
React.createElement(Popover.Element, null,
React.createElement("div", { "data-input-parent": true, className: this.inputClasses() }, this.renderInput())),
React.createElement(Popover.Content, null, this._renderDropdownLayout()))) : (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) ||
this.shouldPerformManualSubmit(' '));
}
/**
* 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 if (!this.props.readOnly) {
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);
}
}
_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 || this.props.readOnly) {
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._onSelectListKeyDown(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: React.createElement(Input, null),
valueParser: DEFAULT_VALUE_PARSER,
dropdownWidth: null,
popoverProps: DEFAULT_POPOVER_PROPS,
dropdownOffsetLeft: '0',
showOptionsIfEmptyInput: true,
autocomplete: 'off,chrome-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;
//# sourceMappingURL=InputWithOptions.js.map