// Copyright (c) 2022 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import React, {Component, ComponentType, MouseEventHandler} from 'react'; import classnames from 'classnames'; import uniqBy from 'lodash/uniqBy'; import listensToClickOutside from 'react-onclickoutside'; import styled from 'styled-components'; import Accessor from './accessor'; import ChickletedInput from './chickleted-input'; import Typeahead from './typeahead'; import {Delete, ArrowDown} from 'components/common/icons'; import LoadingSpinner from 'components/common/loading-spinner'; import DropdownList, {ListItem} from './dropdown-list'; import {toArray} from 'utils/utils'; import {injectIntl, IntlShape} from 'react-intl'; import {FormattedMessage} from 'localization'; interface StyledDropdownSelect { inputTheme?: string; size?: string; } export const StyledDropdownSelect = styled.div.attrs({ className: 'item-selector__dropdown' })` ${props => props.inputTheme === 'secondary' ? props.theme.secondaryInput : props.inputTheme === 'light' ? props.theme.inputLT : props.theme.input}; height: ${props => props.size === 'small' ? props.theme.inputBoxHeightSmall : props.theme.inputBoxHeight}; .list__item__anchor { ${props => props.theme.dropdownListAnchor}; } `; interface DropdownSelectValueProps { hasPlaceholder?: boolean; inputTheme?: string; } const DropdownSelectValue = styled.span` color: ${props => props.hasPlaceholder && props.inputTheme === 'light' ? props.theme.selectColorPlaceHolderLT : props.hasPlaceholder ? props.theme.selectColorPlaceHolder : props.inputTheme === 'light' ? props.theme.selectColorLT : props.theme.selectColor}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; .list__item { ${props => props.inputTheme === 'light' ? props.theme.dropdownListItemLT : props.theme.dropdownListItem}; } .list__item__anchor { ${props => props.inputTheme === 'light' ? props.theme.dropdownListAnchorLT : props.theme.dropdownListAnchor}; } `; const DropdownSelectActionRight = styled.div` margin-right: 6px; display: flex; color: ${props => props.theme.subtextColor}; :hover { color: ${props => props.theme.textColor}; } `; interface DropdownWrapperProps { placement?: string; } const DropdownWrapper = styled.div` border: 0; width: 100%; left: 0; z-index: ${props => props.theme.dropdownWrapperZ}; position: absolute; bottom: ${props => (props.placement === 'top' ? props.theme.inputBoxHeight : 'auto')}; margin-top: ${props => props.placement === 'bottom' ? `${props.theme.dropdownWapperMargin}px` : 'auto'}; margin-bottom: ${props => props.placement === 'top' ? `${props.theme.dropdownWapperMargin}px` : 'auto'}; `; // The item-selector component dismount all its children if you click outside of the component because of listensToClickOutside function. // If you want to prevent this behaviour when you click on a child add this class to it. // It is useful for example, when you add the child component on the top of tree with a Portal as dropdown-list does with item-selector-submenu export const preventCloseSelector = 'prevent__close__selector'; type ItemSelectorProps = { selectedItems?: | ReadonlyArray | string | number | boolean | object | null; options: ReadonlyArray; onChange: ( items: | ReadonlyArray | string | number | boolean | object | null ) => void; fixedOptions?: ReadonlyArray | null; erasable?: boolean; showArrow?: boolean; searchable?: boolean; displayOption?: string | ((opt: any) => any); getOptionValue?: string | ((opt: any) => any); filterOption?: string | ((opt: any) => any); placement?: string; disabled?: boolean; isError?: boolean; isLoading?: boolean; multiSelect?: boolean; inputTheme?: string; size?: string; onBlur?: () => void; onFocus?: () => void; placeholder?: string; closeOnSelect?: boolean; typeaheadPlaceholder?: string; DropdownHeaderComponent?: ComponentType | null; DropDownRenderComponent?: ComponentType; DropDownLineItemRenderComponent?: ComponentType; CustomChickletComponent?: ComponentType; intl: IntlShape; withSubOptions?: boolean; }; class ItemSelector extends Component { static defaultProps = { multiSelect: true, placeholder: 'placeholder.enterValue', closeOnSelect: true, searchable: true, DropDownRenderComponent: DropdownList, DropDownLineItemRenderComponent: ListItem, withSubOptions: false }; state = { showTypeahead: false }; handleClickOutside = e => { if (e?.target?.classList?.contains(preventCloseSelector)) { return; } this._hideTypeahead(); }; _hideTypeahead() { this.setState({showTypeahead: false}); this._onBlur(); } _onBlur = () => { // note: chickleted input is not a real form element so we call onBlur() // when we feel the events are appropriate if (this.props.onBlur) { this.props.onBlur(); } }; _removeItem = (item, e) => { // only used when multiSelect = true e.preventDefault(); e.stopPropagation(); const multiSelectedItems = toArray(this.props.selectedItems); const index = multiSelectedItems.findIndex(t => t === item); if (index < 0) { return; } const items = [ ...multiSelectedItems.slice(0, index), ...multiSelectedItems.slice(index + 1, multiSelectedItems.length) ]; this.props.onChange(items); if (this.props.closeOnSelect) { this.setState({showTypeahead: false}); this._onBlur(); } }; _selectItem = item => { const getValue = Accessor.generateOptionToStringFor( this.props.getOptionValue || this.props.displayOption ); const previousSelected = toArray(this.props.selectedItems); if (this.props.multiSelect) { const items = uniqBy(previousSelected.concat(toArray(item)), getValue); this.props.onChange(items); } else { this.props.onChange(getValue(item)); } if (this.props.closeOnSelect) { this.setState({showTypeahead: false}); this._onBlur(); } }; _onErase: MouseEventHandler = e => { e.stopPropagation(); this.props.onChange(null); }; _showTypeahead: MouseEventHandler = e => { e.stopPropagation(); if (!this.props.disabled && !this.props.isLoading) { if (!this.state.showTypeahead && this.props.onFocus) { this.props.onFocus(); } this.setState({ showTypeahead: true }); } }; _renderDropdown(intl: IntlShape) { const {placement = 'bottom'} = this.props; return ( ); } render() { const selected = toArray(this.props.selectedItems); const hasValue = selected.length; const displayOption = Accessor.generateOptionToStringFor(this.props.displayOption); const {inputTheme = 'primary', DropDownLineItemRenderComponent = ListItem} = this.props; const dropdownSelectProps = { className: classnames({ active: this.state.showTypeahead }), displayOption, disabled: this.props.disabled, onClick: this._showTypeahead, error: this.props.isError, inputTheme: inputTheme, size: this.props.size }; const intl = this.props.intl; return (
{/* this part is used to display the label */} {this.props.multiSelect ? ( ) : ( // @ts-expect-error {hasValue ? ( ) : ( )} {this.props.erasable && hasValue ? ( {this.props.isLoading ? ( ) : ( )} ) : this.props.showArrow ? ( ) : null} )} {/* this part is used to built the list */} {this.state.showTypeahead && this._renderDropdown(intl)}
); } } export const ItemSelectorListen = listensToClickOutside(ItemSelector); export default injectIntl(listensToClickOutside(ItemSelector));