/* Copyright (c) 2018-2020 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. */ // @flow import * as React from 'react'; import {getOverrides} from '../helpers/overrides.js'; import DeleteAlt from '../icon/delete-alt.js'; import TriangleDownIcon from '../icon/triangle-down.js'; import SearchIconComponent from '../icon/search.js'; import {LocaleContext} from '../locale/index.js'; import type {LocaleT} from '../locale/types.js'; import {Popover, PLACEMENT} from '../popover/index.js'; import {Spinner} from '../spinner/index.js'; import getBuiId from '../utils/get-bui-id.js'; import AutosizeInput from './autosize-input.js'; import {TYPE, STATE_CHANGE_TYPE} from './constants.js'; import defaultProps from './default-props.js'; import SelectDropdown from './dropdown.js'; import { StyledRoot, StyledControlContainer, StyledPlaceholder, StyledValueContainer, StyledInputContainer, StyledIconsContainer, StyledSelectArrow, StyledClearIcon, getLoadingIconStyles, StyledSearchIconContainer, } from './styled-components.js'; import type { PropsT, SelectStateT, ValueT, OptionT, ChangeActionT, } from './types.js'; import {expandValue, normalizeOptions} from './utils/index.js'; function Noop() { return null; } const isClick = event => event.type === 'click'; const isLeftClick = event => event.button !== null && event.button !== undefined && event.button === 0; const containsNode = (parent, child) => { if (__BROWSER__) { // eslint-disable-next-line flowtype/no-weak-types return child && parent && parent.contains((child: any)); } }; export function isInteractive(rootTarget: EventTarget, rootElement: Element) { if (rootTarget instanceof Element) { let target: ?Element = rootTarget; while (target && target !== rootElement) { const role = target.getAttribute('role'); if (role === 'button' || role === 'link') { return true; } if (target.tagName) target = target.parentElement; } } return false; } // eslint-disable-next-line flowtype/no-weak-types class Select extends React.Component { static defaultProps = defaultProps; // anchor is a ref that refers to the outermost element rendered when the dropdown menu is not // open. This is required so that we can check if clicks are on/off the anchor element. anchor: {current: HTMLElement | null} = React.createRef(); // dropdown is a ref that refers to the popover element. This is required so that we can check if // clicks are on/off the dropdown element. dropdown: {current: HTMLElement | null} = React.createRef(); input: React.ElementRef<*>; // dragging is a flag to track whether a mobile device is currently scrolling versus clicking. dragging: boolean; // focusAfterClear is a flag to indicate that the dropdowm menu should open after a selected // option has been cleared. focusAfterClear: boolean; // openAfterFocus is a flag to indicate that the dropdown menu should open when the component is // focused. Developers have the option to disable initial clicks opening the dropdown menu. If not // disabled, clicks will set this flag to true. Upon focusing, look to this to see if the menu should // be opened, or only focus. openAfterFocus: boolean; // When an item is selected, it also triggers handleClickOutside and since the selected item is // already out of the menu (DOM), it will not recognize it as a subnode and triggers handleBlur // that sets isOpen to false. That's a faulty logic causing visible problems when // closeOnSelect is false. This flag helps to detect that selection was just made. justSelected: boolean; // the select components can accept an array of options or an object where properties are optgroups // and values are arrays of options. this class property is constructed and updated in a normalized // shape where optgroup titles are stored on the option in the __optgroup field. options: ValueT = []; // id generated for the listbox. used by screenreaders to associate the input with the menu it controls listboxId: string = getBuiId(); constructor(props: PropsT) { super(props); this.options = normalizeOptions(props.options); } state = { activeDescendant: null, inputValue: '', isFocused: false, isOpen: this.props.startOpen, isPseudoFocused: false, }; isMounted: boolean = false; componentDidMount() { if (this.props.autoFocus) { this.focus(); } this.isMounted = true; } componentDidUpdate(prevProps: PropsT, prevState: SelectStateT) { if (__BROWSER__) { if (prevState.isOpen !== this.state.isOpen) { if (this.state.isOpen) { this.props.onOpen && this.props.onOpen(); document.addEventListener('touchstart', this.handleTouchOutside); } else { this.props.onClose && this.props.onClose(); document.removeEventListener('touchstart', this.handleTouchOutside); } } if (!prevState.isFocused && this.state.isFocused) { document.addEventListener('click', this.handleClickOutside); } } } componentWillUnmount() { if (__BROWSER__) { document.removeEventListener('touchstart', this.handleTouchOutside); document.removeEventListener('click', this.handleClickOutside); } this.isMounted = false; } focus() { if (!this.input) return; this.input.focus(); } // Handle touch outside on mobile to dismiss menu, ensures that the // touch target is not within the anchor DOM node. handleTouchOutside = (event: TouchEvent) => { if (containsNode(this.dropdown.current, event.target)) return; if (!containsNode(this.anchor.current, event.target)) { this.closeMenu(); } }; // Track dragging state to filter false-positive actions where a user // intends to drag/scroll the page. handleTouchMove = () => (this.dragging = true); handleTouchStart = () => (this.dragging = false); handleTouchEnd = (event: TouchEvent) => { if (this.dragging) return; this.handleClick(event); }; handleTouchEndClearValue = (event: TouchEvent) => { if (this.dragging) return; this.clearValue(event); }; handleClick = (event: MouseEvent | TouchEvent) => { if (this.props.disabled || (!isClick(event) && !isLeftClick(event))) { return; } // Case comes up when text has been typed into the input field. If no text provided, // the 'input' element will have essentially 0 width therefore will not be clickable. // When click outside does not reset input, text provided will stay rendered after clicks away // from the select component. Upon subsequent clicks on the provided text, open the dropdown // menu, in addition to text edit operations. if (event.target === this.input) { // CHASE: not sure why this condition is here. I cannot replicate a situation where clicks // on provided text break into here. if (!this.state.isFocused) { this.openAfterFocus = this.props.openOnClick; this.focus(); return; } if (!this.state.isOpen) { this.setState({ isOpen: true, isPseudoFocused: false, }); return; } } // Ensures that interactive elements within the Select component do not trigger the outer click // handler. For example, after an option is selected clicks on the 'clear' icon call here. We // should ignore those events. This comes after case where click is on input element, so that // those are handled on their own. if (this.input && isInteractive(event.target, this.input)) { return; } // For the simple case where clicking on the Select does not allow for providing // text input to filter the dropdown options. if (!this.props.searchable) { this.focus(); this.setState(prev => ({isOpen: !prev.isOpen})); return; } // Cases below only apply to searchable Select component. if (this.state.isFocused) { // iOS ignores programmatic calls to input.focus() that were not triggered by a click event. // This component can get into a state where isFocused is true, but the DOM node is not // focused. Call focus here again to ensure. this.focus(); // Case comes up when click outside does not reset input - once text has been provided to // the input, and the user closes the dropdown menu the provided text is maintained. After // this, if the user focuses back into the select component then clicks on the component, // the provided text highlights rather than position's the cursor at the end of the input. if (this.input) this.input.value = ''; this.setState(prev => ({ isOpen: !this.focusAfterClear && !prev.isOpen, isPseudoFocused: false, })); this.focusAfterClear = false; } else { this.openAfterFocus = this.props.openOnClick; this.focus(); } }; closeMenu() { if (this.props.onCloseResetsInput) { this.setState({ inputValue: '', isOpen: false, isPseudoFocused: this.state.isFocused && !this.props.multi, }); } else { this.setState({ isOpen: false, isPseudoFocused: this.state.isFocused && !this.props.multi, }); } } handleInputFocus = (event: SyntheticEvent) => { if (this.props.disabled) return; if (this.props.onFocus) this.props.onFocus(event); let toOpen = this.state.isOpen || this.openAfterFocus; // if focus happens after clear values, don't open dropdown yet. toOpen = !this.focusAfterClear && toOpen; this.setState({ isFocused: true, isOpen: !!toOpen, }); this.focusAfterClear = false; this.openAfterFocus = false; }; handleBlur = (event: FocusEvent | MouseEvent) => { if (event.relatedTarget) { if ( containsNode(this.anchor.current, event.relatedTarget) || containsNode(this.dropdown.current, event.relatedTarget) ) { return; } } else if (containsNode(this.anchor.current, event.target)) { return; } if (this.props.onBlur) { this.props.onBlur(event); } if (this.isMounted) { this.setState({ isFocused: false, isOpen: false, isPseudoFocused: false, inputValue: this.props.onBlurResetsInput ? '' : this.state.inputValue, }); } }; handleClickOutside = (event: MouseEvent) => { if (this.justSelected) { this.justSelected = false; return; } if (containsNode(this.dropdown.current, event.target)) return; const isFocused = this.state.isFocused || this.state.isPseudoFocused; if (isFocused && !containsNode(this.anchor.current, event.target)) { this.handleBlur(event); } }; handleInputChange = (event: SyntheticInputEvent) => { let newInputValue = event.target.value; this.setState({ inputValue: newInputValue, isOpen: true, isPseudoFocused: false, }); if (this.props.onInputChange) { this.props.onInputChange(event); } }; handleKeyDown = (event: KeyboardEvent) => { if (this.props.disabled) return; switch (event.keyCode) { case 8: // backspace if (!this.state.inputValue && this.props.backspaceRemoves) { event.preventDefault(); this.backspaceValue(); } break; case 9: // tab this.setState(prevState => ({ isPseudoFocused: false, isFocused: false, isOpen: false, inputValue: !this.props.onCloseResetsInput || !this.props.onBlurResetsInput ? prevState.inputValue : '', })); break; case 27: // escape if ( !this.state.isOpen && this.props.clearable && this.props.escapeClearsValue ) { this.clearValue(event); this.setState({isFocused: false, isPseudoFocused: false}); } break; case 32: // space if (this.props.searchable) { break; } event.preventDefault(); if (!this.state.isOpen) { this.setState({isOpen: true}); } break; case 38: // up event.preventDefault(); if (!this.state.isOpen) { this.setState({isOpen: true}); } break; case 40: // down event.preventDefault(); if (!this.state.isOpen) { this.setState({isOpen: true}); } break; case 33: // page up event.preventDefault(); if (!this.state.isOpen) { this.setState({isOpen: true}); } break; case 34: // page down event.preventDefault(); if (!this.state.isOpen) { this.setState({isOpen: true}); } break; case 35: // end key if (event.shiftKey) { break; } event.preventDefault(); if (!this.state.isOpen) { this.setState({isOpen: true}); } break; case 36: // home key if (event.shiftKey) { break; } event.preventDefault(); if (!this.state.isOpen) { this.setState({isOpen: true}); } break; case 46: // delete if (!this.state.inputValue && this.props.deleteRemoves) { event.preventDefault(); this.popValue(); } break; } }; getOptionLabel = ( locale: LocaleT, { option, }: { option: OptionT, optionState: { $selected: boolean, $disabled: boolean, $isHighlighted: boolean, }, }, ): React.Node => option.isCreatable ? `${locale.select.create} “${option[this.props.labelKey]}”` : option[this.props.labelKey]; getValueLabel = ({option}: {option: OptionT}): React.Node => { return option[this.props.labelKey]; }; /** * Extends the value into an array from the given options */ getValueArray(value: ValueT): Array { if (!Array.isArray(value)) { if (value === null || value === undefined) return []; value = [value]; } return value.map(value => expandValue(value, this.props)); } setValue(value: ValueT, option: ?OptionT, type: ChangeActionT) { if (this.props.onChange) { this.props.onChange({ value, option, type, }); } } handleActiveDescendantChange = (id?: string) => { if (id) { this.setState({activeDescendant: id}); } else { this.setState({activeDescendant: null}); } }; handleInputRef = (input: React.ElementRef<*>) => { this.input = input; if (this.props.controlRef) { if (typeof this.props.controlRef === 'function') { this.props.controlRef(input); } else { this.props.controlRef.current = input; } } }; selectValue = ({item}: {item: OptionT}) => { if (item.disabled) { return; } this.justSelected = true; // NOTE: we add/set the value in a callback to make sure the // input value is empty to avoid styling issues in Chrome const updatedValue = this.props.onSelectResetsInput ? '' : this.state.inputValue; if (this.props.multi) { this.setState( { inputValue: updatedValue, isOpen: !this.props.closeOnSelect, }, () => { const valueArray = this.props.value; if ( valueArray.some( i => i[this.props.valueKey] === item[this.props.valueKey], ) ) { this.removeValue(item); } else { this.addValue(item); } }, ); } else { this.focus(); this.setState( { inputValue: updatedValue, isOpen: !this.props.closeOnSelect, isFocused: true, isPseudoFocused: false, }, () => { this.setValue([item], item, STATE_CHANGE_TYPE.select); }, ); } }; addValue = (item: OptionT) => { const valueArray = [...this.props.value]; this.setValue(valueArray.concat(item), item, STATE_CHANGE_TYPE.select); }; backspaceValue = () => { const item = this.popValue(); if (!item) { return; } const valueLength = this.props.value.length; const renderLabel = this.props.getValueLabel || this.getValueLabel; const labelForInput = renderLabel({option: item, index: valueLength - 1}); // label might not be a string, it might be a Node of another kind. if ( !this.props.backspaceClearsInputValue && typeof labelForInput === 'string' ) { const remainingInput = labelForInput.slice(0, -1); this.setState({ inputValue: remainingInput, isOpen: true, }); } }; popValue = () => { const valueArray = [...this.props.value]; const valueLength = valueArray.length; if (!valueLength) return; if (valueArray[valueLength - 1].clearableValue === false) return; const item = valueArray.pop(); this.setValue(valueArray, item, STATE_CHANGE_TYPE.remove); return item; }; removeValue = (item: OptionT) => { const valueArray = [...this.props.value]; this.setValue( valueArray.filter( i => i[this.props.valueKey] !== item[this.props.valueKey], ), item, STATE_CHANGE_TYPE.remove, ); this.focus(); }; clearValue = (event: KeyboardEvent | MouseEvent | TouchEvent) => { if (isClick(event) && !isLeftClick(event)) return; if (this.props.value) { const resetValue = this.props.value.filter( item => item.clearableValue === false, ); this.setValue(resetValue, null, STATE_CHANGE_TYPE.clear); } this.setState({ inputValue: '', isOpen: false, }); this.focus(); this.focusAfterClear = true; }; shouldShowPlaceholder = () => { return !( this.state.inputValue || (this.props.value && this.props.value.length) ); }; shouldShowValue = () => { return !this.state.inputValue; }; renderLoading() { if (!this.props.isLoading) return; const sharedProps = this.getSharedProps(); const {overrides = {}} = this.props; const [LoadingIndicator, loadingIndicatorProps] = getOverrides( overrides.LoadingIndicator, Spinner, ); return ( ); } renderValue( valueArray: ValueT, isOpen: boolean, locale: LocaleT, ): ?React.Node | Array { const {overrides = {}} = this.props; const sharedProps = this.getSharedProps(); const renderLabel = this.props.getValueLabel || this.getValueLabel; const Value = this.props.valueComponent || Noop; if (!valueArray.length) { return null; } if (this.props.multi) { return valueArray.map((value, i) => { const disabled = sharedProps.$disabled || value.clearableValue === false; return ( this.removeValue(value)} disabled={disabled} overrides={{Tag: overrides.Tag, MultiValue: overrides.MultiValue}} {...sharedProps} $disabled={disabled} > {renderLabel({option: value, index: i})} ); }); } else if (this.shouldShowValue()) { return ( {renderLabel({option: valueArray[0]})} ); } } renderInput() { const {overrides = {}} = this.props; const [InputContainer, inputContainerProps] = getOverrides( overrides.InputContainer, StyledInputContainer, ); const sharedProps = this.getSharedProps(); const isOpen = this.state.isOpen; const selected = this.getValueArray(this.props.value) .map(v => v[this.props.labelKey]) .join(', '); const selectedLabel = selected.length ? `Selected ${selected}. ` : ''; const label = `${selectedLabel}${this.props['aria-label'] || ''}`; if (!this.props.searchable) { return ( ); } return ( ); } renderClear() { const isValueEntered = Boolean( (this.props.value && this.props.value.length) || this.state.inputValue, ); if ( !this.props.clearable || this.props.disabled || this.props.isLoading || !isValueEntered ) { return; } const sharedProps = this.getSharedProps(); const {overrides = {}} = this.props; const [ClearIcon, clearIconProps] = getOverrides( overrides.ClearIcon, DeleteAlt, ); const ariaLabel = this.props.multi ? 'Clear all' : 'Clear value'; return ( ); } renderArrow() { if (this.props.type !== TYPE.select) { return null; } const {overrides = {}} = this.props; const [SelectArrow, selectArrowProps] = getOverrides( overrides.SelectArrow, TriangleDownIcon, ); const sharedProps = this.getSharedProps(); return ( ); } renderSearch() { if (this.props.type !== TYPE.search) { return null; } const {overrides = {}} = this.props; const [SearchIconContainer, searchIconContainerProps] = getOverrides( overrides.SearchIconContainer, StyledSearchIconContainer, ); const [SearchIcon, searchIconProps] = getOverrides( overrides.SearchIcon, SearchIconComponent, ); const sharedProps = this.getSharedProps(); return ( // TODO(v11): remove searchIconProps from SearchIconContainer ); } filterOptions(excludeOptions: ?ValueT) { const filterValue = this.state.inputValue; // apply filter function if (this.props.filterOptions) { this.options = this.props.filterOptions( this.options, filterValue, excludeOptions, { valueKey: this.props.valueKey, labelKey: this.props.labelKey, }, ); } // can user create a new option + there's no exact match already if ( filterValue && this.props.creatable && this.options .concat(this.props.value) .every( opt => opt[this.props.labelKey].toLowerCase() !== filterValue.toLowerCase().trim(), ) ) { // $FlowFixMe - this.options is typed as a read-only array this.options.push({ id: filterValue, [this.props.labelKey]: filterValue, [this.props.valueKey]: filterValue, isCreatable: true, }); } return this.options; } getSharedProps() { const { clearable, creatable, disabled, error, positive, isLoading, multi, required, size, searchable, type, value, } = this.props; const {isOpen, isFocused, isPseudoFocused} = this.state; return { $clearable: clearable, $creatable: creatable, $disabled: disabled, $error: error, $positive: positive, $isFocused: isFocused, $isLoading: isLoading, $isOpen: isOpen, $isPseudoFocused: isPseudoFocused, $multi: multi, $required: required, $searchable: searchable, $size: size, $type: type, $isEmpty: !this.getValueArray(value).length, }; } render() { this.options = normalizeOptions(this.props.options); const { overrides = {}, type, multi, noResultsMsg, value, filterOutSelected, } = this.props; if (__DEV__) { // value may be nullish, only warn if value is defined if (value && !Array.isArray(value)) { console.warn( 'The Select component expects an array as the value prop. For more information, please visit the docs at https://baseweb.design/components/select/', ); } } const [Root, rootProps] = getOverrides(overrides.Root, StyledRoot); const [ControlContainer, controlContainerProps] = getOverrides( overrides.ControlContainer, StyledControlContainer, ); const [ValueContainer, valueContainerProps] = getOverrides( overrides.ValueContainer, StyledValueContainer, ); const [IconsContainer, iconsContainerProps] = getOverrides( overrides.IconsContainer, StyledIconsContainer, ); const [PopoverOverride, popoverProps] = getOverrides( overrides.Popover, Popover, ); const [Placeholder, placeholderProps] = getOverrides( overrides.Placeholder, StyledPlaceholder, ); const sharedProps = this.getSharedProps(); const valueArray = this.getValueArray(value); const options = this.filterOptions( multi && filterOutSelected ? valueArray : null, ); const isOpen = this.state.isOpen; sharedProps.$isOpen = isOpen; if (__DEV__) { if (this.props.error && this.props.positive) { // eslint-disable-next-line no-console console.warn( `[Select] \`error\` and \`positive\` are both set to \`true\`. \`error\` will take precedence but this may not be what you want.`, ); } } return ( {locale => ( { if (!ref) return; this.anchor = ref.anchorRef; }} focusLock={false} mountNode={this.props.mountNode} onEsc={() => this.closeMenu()} isOpen={isOpen} popoverMargin={0} content={() => { const dropdownProps = { error: this.props.error, positive: this.props.positive, getOptionLabel: this.props.getOptionLabel || this.getOptionLabel.bind(this, locale), id: this.listboxId, isLoading: this.props.isLoading, labelKey: this.props.labelKey, maxDropdownHeight: this.props.maxDropdownHeight, multi, noResultsMsg, onActiveDescendantChange: this.handleActiveDescendantChange, onItemSelect: this.selectValue, options, overrides, required: this.props.required, searchable: this.props.searchable, size: this.props.size, type, value: valueArray, valueKey: this.props.valueKey, width: this.anchor.current ? this.anchor.current.clientWidth : null, keyboardControlNode: this.anchor, }; return ( ); }} placement={PLACEMENT.bottom} {...popoverProps} > {type === TYPE.search ? this.renderSearch() : null} {this.renderValue(valueArray, isOpen, locale)} {this.renderInput()} {this.shouldShowPlaceholder() ? ( {typeof this.props.placeholder !== 'undefined' ? this.props.placeholder : locale.select.placeholder} ) : null} {this.renderLoading()} {this.renderClear()} {type === TYPE.select ? this.renderArrow() : null} )} ); } } export default Select; declare var __DEV__: boolean; declare var __NODE__: boolean; declare var __BROWSER__: boolean;