@wix/design-system
Version:
@wix/design-system
501 lines • 20.2 kB
JavaScript
import React, { Component, createRef } from 'react';
import Input from '../Input';
import omit from 'omit';
import DropdownLayout from '../DropdownLayout/DropdownLayout';
import { st, classes } from './InputWithOptions.st.css.js';
import uniqueId from 'lodash/uniqueId';
import HighlightContext from './HighlightContext';
import PopoverNext from '../PopoverNext/PopoverNext';
import { WixDesignSystemContext } from '../WixDesignSystemProvider/context';
import Drawer from '../Drawer';
export const DEFAULT_VALUE_PARSER = option => typeof option.value === 'string' ? option.value : option.label;
const INPUT_WITH_OPTIONS_DOUBLE_CLICK_THRESHOLD = 2000;
export const DEFAULT_POPOVER_PROPS = {
appendTo: 'parent',
flip: false,
fixed: true,
placement: 'bottom',
width: '100%',
minWidth: 192,
onMouseEnter: undefined,
onMouseLeave: undefined,
};
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.input = createRef();
this._onOptionMarked = (option, optionElementId) => {
const { onOptionMarked } = this.props;
this.setState({ activeDescendentId: optionElementId });
if (onOptionMarked) {
onOptionMarked(option, optionElementId);
}
};
/** 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.isDropdownLayoutVisible = this.isDropdownLayoutVisible.bind(this);
this._onInputClicked = this._onInputClicked.bind(this);
this._onOpenChange = this._onOpenChange.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(mobile) {
const inputAdditionalProps = this.inputAdditionalProps();
const inputProps = Object.assign(omit([
'onChange',
'dataHook',
'dropDirectionUp',
'focusOnSelectedOption',
'onClose',
'onSelect',
'onOptionMarked',
'overflow',
'visible',
'options',
'selectedId',
'tabIndex',
'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,
ariaExpanded: this.state.showOptions,
ariaControls: `${this.uniqueId}-listbox`,
ariaActivedescendant: this.state.activeDescendentId,
...inputProps,
onChange: this._onChange,
onInputClicked: event => this._onInputClicked(event, mobile),
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,
});
}
hasDropdownContent(additionalProps) {
const { options, fixedHeader, fixedFooter, customDropdownContent, infiniteScroll, hasMore, } = this.props;
return Boolean(customDropdownContent ||
fixedHeader ||
fixedFooter ||
[...(options ?? []), ...(additionalProps?.options ?? [])].length ||
(infiniteScroll && hasMore));
}
isDropdownLayoutVisible() {
return (this.state.showOptions &&
(this.props.showOptionsIfEmptyInput || this.state.inputValue.length > 0));
}
_renderDropdownLayout(additionalProps) {
const { highlight, value } = this.props;
const inputPropNames = [
'children',
'dataHook',
'className',
'id',
'role',
'ariaControls',
'ariaDescribedby',
'ariaLabel',
'autoFocus',
'autoSelect',
'autocomplete',
'defaultValue',
'disabled',
'status',
'statusMessage',
'statusMessageTooltipProps',
'hideStatusSuffix',
'forceFocus',
'forceHover',
'maxLength',
'menuArrow',
'clearButton',
'focusOnClearClick',
'name',
'border',
'noLeftBorderRadius',
'noRightBorderRadius',
'onBlur',
'onChange',
'onClear',
'onCompositionChange',
'onEnterPressed',
'onEscapePressed',
'onFocus',
'onInputClicked',
'onKeyDown',
'onKeyUp',
'onPaste',
'onCopy',
'placeholder',
'prefix',
'readOnly',
'disableEditing',
'rtl',
'size',
'suffix',
'textOverflow',
'tooltipPlacement',
'type',
'value',
'withSelection',
'required',
'min',
'max',
'step',
'customInput',
'pattern',
'inputRef',
'inputmode',
'ariaRoledescription',
'clearButtonTooltipContent',
'clearButtonTooltipProps',
'clearButtonAriaLabel',
'inputElementRef',
];
const inputOnlyProps = inputPropNames.filter(k => k !== 'tabIndex');
const dropdownProps = Object.assign(omit(inputOnlyProps.concat(['dataHook', 'onClickOutside']), this.props), additionalProps ?? this.dropdownAdditionalProps());
const customStyle = {
marginLeft: this.props.dropdownOffsetLeft,
width: 'inherit',
};
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, className: classes.dropdownLayout, onClose: this.hideOptions, onSelect: this._onSelect, onOptionMarked: this._onOptionMarked, isComposing: this.state.isComposing, listboxId: `${this.uniqueId}-listbox`, inContainer: true, tabIndex: -1, onDrillOut: () => this.focus() }))));
}
_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 }, option.value))))));
}
render() {
if (!!this.props.native) {
return this._renderNativeSelect();
}
const { dataHook, popoverProps: { appendTo: popoverAppendTo, fixed: popoverFixed, flip: popoverFlip, placement: popoverPlacement, dynamicWidth: popoverDynamicWidth, ...restPopoverFields }, dropDirectionUp, dropdownWidth, className, } = this.props;
const placement = dropDirectionUp ? 'top' : popoverPlacement;
const dynamicWidth = popoverDynamicWidth ?? popoverAppendTo === 'window';
const rootProps = this.rootAdditionalProps();
const additionalDropdownProps = this.dropdownAdditionalProps();
const hasContent = this.hasDropdownContent(additionalDropdownProps);
return (React.createElement(WixDesignSystemContext.Consumer, null, ({ mobile }) => mobile && this.props.showDrawerOnMobile ? (React.createElement("div", null,
React.createElement("div", { "data-input-parent": true, className: this.inputClasses() }, this.renderInput(mobile)),
React.createElement(Drawer, { dataHook: dataHook, open: this.isDropdownLayoutVisible(), onClose: this._onOpenChange, zIndex: this.props?.popoverProps?.zIndex },
React.createElement("div", { className: classes.drawerContent }, this.props.customDropdownContent ||
this._renderDropdownLayout(additionalDropdownProps))))) : (React.createElement(PopoverNext, { className: st(classes.root, { size: 'medium' }, rootProps.className ?? className), open: this.isDropdownLayoutVisible(), onOpenChange: this._onOpenChange, appendTo: popoverAppendTo ?? DEFAULT_POPOVER_PROPS.appendTo, flip: popoverFlip ?? DEFAULT_POPOVER_PROPS.flip, fixed: popoverFixed ?? DEFAULT_POPOVER_PROPS.fixed, placement: placement ?? DEFAULT_POPOVER_PROPS.placement, dynamicWidth: dynamicWidth, excludeClass: this.uniqueId, focusManagerEnabled: false, onClickOutside: this.props.onClickOutside, ...restPopoverFields, minWidth: dynamicWidth ? 'fit-content' : DEFAULT_POPOVER_PROPS.minWidth, width: dropdownWidth ??
(dynamicWidth ? null : DEFAULT_POPOVER_PROPS.width), dataHook: dataHook, onKeyDown: this._onKeyDown, autoUpdateOptions: { animationFrame: true }, contentClassName: !hasContent ? classes.emptyContent : undefined },
React.createElement(PopoverNext.Trigger, null,
React.createElement("div", { "data-input-parent": true, className: this.inputClasses() }, this.renderInput())),
React.createElement(PopoverNext.Content, null, this.props.customDropdownContent ||
this._renderDropdownLayout(additionalDropdownProps))))));
}
/**
* 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, activeDescendentId: undefined });
this.props.onOptionsHide && this.props.onOptionsHide();
this.props.onClose && this.props.onClose();
}
}
closeOnSelect() {
return this.props.closeOnSelect;
}
get isReadOnly() {
const { readOnly } = this.inputAdditionalProps() || {
readOnly: this.props.readOnly,
};
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._onOpenChange(false, 'select-option');
}
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, mobile) {
if (mobile) {
this._onOpenChange(true);
}
this.props.onInputClicked?.(event);
}
_onOpenChange(open, reason, doubleClickThreshold = INPUT_WITH_OPTIONS_DOUBLE_CLICK_THRESHOLD) {
if (this.props.disabled || this.isReadOnly) {
return;
}
if (open) {
this.showOptions();
}
else if (reason === 'outside-press' || reason === 'select-option') {
this.hideOptions();
}
else if (Date.now() - this.state.lastOptionsShow > doubleClickThreshold) {
this.hideOptions();
}
}
_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) {
const isFocusInsideDropdown = this._didSelectOption(event);
if (isFocusInsideDropdown) {
const focusedEl = event.relatedTarget;
if (focusedEl?.tabIndex >= 0) {
return;
}
this.focus();
return;
}
this._focused = false;
if (this.props.onBlur) {
this.props.onBlur(event);
}
}
_onKeyDown(event) {
if (this.props.disabled || this.isReadOnly) {
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) {
// Stop propagation when Escape is handled to prevent it from bubbling to parent elements
if (key === 'Escape' && eventWasHandled) {
event.stopPropagation();
}
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',
native: false,
showDrawerOnMobile: true,
};
InputWithOptions.displayName = 'InputWithOptions';
export default InputWithOptions;
//# sourceMappingURL=InputWithOptions.js.map