UNPKG

@wix/design-system

Version:

@wix/design-system

501 lines 20.2 kB
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