/** * The new select element * * @author Platon Fedorov * @date 2021-01-12 */ import * as React from 'react'; import {IOption, ISelectProps, Value} from './Select.types'; import { Button, DropDown, Icon, joinClassNames, PLACEMENT, POPOVER_BOUNDARIES, safeInvoke, SIZE, Skeleton, Tooltip } from '../..'; import {OptionList} from '..'; import * as styles from './select.m.scss'; import {getSizeThemeKey} from '../../utils/getSizeThemeKey'; import {SizeClassNames} from '../wrapper/Wrapper.types'; import {Wrapper, WrapperProps} from '../wrapper/Wrapper'; import debounce from 'lodash/debounce'; import {Extra} from './component/extra/Extra'; interface IState { isDropDownOpen: boolean; inputText: string; isFocused: boolean; isHovered: boolean; selectedOptions: IOption[]; dropDownWidth: number; } export {IOption, IGroupOption, Value, ISelectProps, Mode} from './Select.types'; export class Select extends React.PureComponent { inputRef = React.createRef(); dropDownTargetRef = React.createRef(); dropDownRef = React.createRef(); isFocusOnInputFromOptionList: boolean = false; constructor (props: ISelectProps) { super(props); let inputText = ''; let selectedOptions: IOption[] = []; const defaultValue = this.props.defaultValue; if (defaultValue) { if (defaultValue instanceof Array && props.mode === 'default' && defaultValue.length > 1) { // Check for bad input. If we give more then one default option to 'mode: default' select selectedOptions = this.getOptionsByValue(defaultValue[0]); } else { selectedOptions = this.getOptionsByValue(defaultValue); } if (props.mode === 'default' && selectedOptions.length > 0) { inputText = selectedOptions[0].title; } } this.state = { isDropDownOpen: Boolean(props.isDefaultOpen), inputText: inputText, isFocused: false, // will be updated in override componentDidMount isHovered: Boolean(this.props.autoFocus), // to show extra controls selectedOptions: selectedOptions, dropDownWidth: 0 }; this.calculateDropDownWidth = debounce(this.calculateDropDownWidth.bind(this), 800, { leading: true, trailing: false }); this.onKeyDown = this.onKeyDown.bind(this); } static defaultProps = { mode: 'default', size: SIZE.MIDDLE, showSearch: false, showArrow: true, defaultActiveFirstOption: false, notFoundText: 'Not found', selectAllText: 'Select all', selectNoneText: 'Select none', addText: 'Add' }; getOptionsByValue = (value: Value): IOption[] => { if (value instanceof Array) { return this.props.options.filter((option) => { return value.indexOf(option.value) > -1; }); } for (let i = 0; i < this.props.options.length; i++) { if (this.props.options[i].value === value) { return [this.props.options[i]]; } } return []; } getValueList = (options: IOption[]): Array => { return options.map((option) => { return option.value; }); } getSelectedOptions = (): IOption[] => { if (this.props.value !== undefined) { return this.getOptionsByValue(this.props.value); } return this.state.selectedOptions; } openDropDown = () => { this.setState({ isDropDownOpen: true }); safeInvoke(this.props.onDropdownVisibleChange, true); }; closeDropDown = () => { this.setState({ isDropDownOpen: false, inputText: '' }); safeInvoke(this.props.onDropdownVisibleChange, false); }; addSelectedOption = (option: IOption): IOption[] => { const newOptions = this.getSelectedOptions().slice(); newOptions.push(option); this.setState({ selectedOptions: newOptions }); return newOptions; } deleteOption = (option: IOption): IOption[] => { const newOptions = this.getSelectedOptions().slice(); const index = newOptions.findIndex((optionItem) => option.value === optionItem.value); newOptions.splice(index, 1); this.setState({ selectedOptions: newOptions }); return newOptions; } deleteLastOption = (): IOption[] => { if (this.getSelectedOptions().length === 0) { return []; } const newOptions = this.getSelectedOptions().slice(); newOptions.splice(newOptions.length - 1, 1); this.setState({ selectedOptions: newOptions }); return newOptions; } deleteAllOptions = (): IOption[] => { this.setState({ selectedOptions: [] }); return []; } filterOptions = (text: string): IOption[] => { let filteredOptions: IOption[] = this.props.options; const searchText = text.toLowerCase(); if (text.length > 0) { filteredOptions = this.props.options.filter((option) => { return option.title.toLowerCase().indexOf(searchText) > -1 || option.value.toString().toLowerCase().indexOf(searchText) > -1; }); } if (text.length > 0 && this.props.canCreate === true) { const valuesList = this.props.options.map((option) => option.value.toString()); if (!valuesList.includes(this.state.inputText)) { filteredOptions.push({ value: this.state.inputText, title: `+ ${this.state.inputText}` }); } } return filteredOptions; } handleWrapperClick = (e: React.MouseEvent) => { if (!this.state.isDropDownOpen || this.props.isOpen === false) { if (this.inputRef.current !== null && e.target !== this.inputRef.current) { this.inputRef.current.focus(); } else if (this.inputRef.current === null) { this.setState({ isFocused: true }); safeInvoke(this.props.onFocus); } this.openDropDown(); } else if (this.state.inputText === '') { this.closeDropDown(); } } handleWrapperClickOutside = (e: MouseEvent) => { let isBlur = true; if (this.dropDownRef.current && e.target instanceof Node) { isBlur = !this.dropDownRef.current.contains(e.target); } if (isBlur) { this.blur(); } } handleInputChange = (e: React.FormEvent) => { this.doInputChange(e.currentTarget.value); } doInputChange = (value: string) => { this.setState({ inputText: value }); if (!this.state.isDropDownOpen) { this.openDropDown(); } safeInvoke(this.props.onSearch, value); } handleInputKeyUp = (e: React.KeyboardEvent) => { if (e.key === 'Backspace' && this.state.inputText === '' && this.props.mode !== 'default' && (this.props.showSearch === true || this.props.canCreate === true) && this.getSelectedOptions().length > 0 ) { this.onChange(this.deleteLastOption()); } if (e.key === 'Enter' && this.state.inputText !== '' && this.props.mode === 'multiple' && this.props.canCreate === true ) { const newOptionValue = e.currentTarget.value; const hasSame = this.props.options.map((option) => option.value).includes(newOptionValue); if (!hasSame) { this.onSelect({title: newOptionValue, value: newOptionValue}); } } } handleInputFocus = () => { if (!this.isFocusOnInputFromOptionList) { this.setState({ isFocused: true }); safeInvoke(this.props.onFocus); } } handleWrapperFocus = (e: FocusEvent) => { this.focus(); this.openDropDown(); } handleWrapperMouseEnter = () => { this.setState({ isHovered: true }); } handleWrapperMouseLeave = () => { this.setState({ isHovered: false }); } focus = () => { if (this.inputRef.current !== null) { this.inputRef.current.focus(); } else { this.setState({ isFocused: true }); safeInvoke(this.props.onFocus); } } blur = () => { this.setState({ inputText: '', isFocused: false }); this.closeDropDown(); safeInvoke(this.props.onBlur); } onSelect = (option: IOption | null) => { if (option) { const selectedOptions = this.getSelectedOptions(); if (this.props.mode === 'default') { const isChanged = (selectedOptions.length > 0) ? (option.value !== selectedOptions[0].value) : true; this.setState({ selectedOptions: [option] }); safeInvoke(this.props.onSelect, option.value); if (isChanged) { this.onChange([option]); } this.closeDropDown(); } else { let newOptions = selectedOptions; if (newOptions.some((optionItem) => optionItem.value === option.value)) { newOptions = this.deleteOption(option); safeInvoke(this.props.onDeselect, option.value); } else { newOptions = this.addSelectedOption(option); safeInvoke(this.props.onSelect, option.value); } this.onChange(newOptions); } this.setState({ inputText: '' }); this.isFocusOnInputFromOptionList = true; this.inputRef.current?.focus(); this.isFocusOnInputFromOptionList = false; } } onChange = (newOptions: IOption[]) => { const value: Value | undefined = this.props.mode === 'default' ? this.getValueList(newOptions)[0] : this.getValueList(newOptions); safeInvoke( this.props.onChange, value, newOptions.slice() ); } override componentDidMount (): void { this.calculateDropDownWidth(); window.addEventListener('resize', this.calculateDropDownWidth); window.addEventListener('keydown', this.onKeyDown); if (this.props.autoFocus) { this.focus(); if (this.props.isDefaultOpen !== false) { this.openDropDown(); } } } override componentWillUnmount (): void { window.removeEventListener('resize', this.calculateDropDownWidth); window.removeEventListener('keydown', this.onKeyDown); } calculateDropDownWidth () { if (this.dropDownTargetRef.current) { const width = this.dropDownTargetRef.current.clientWidth; this.setState({ dropDownWidth: width }); } } onKeyDown (e: KeyboardEvent) { if (this.state.isFocused) { if (e.key === 'Tab') { this.blur(); } else if (e.key === 'Enter' && this.state.inputText === '' && this.state.isDropDownOpen === false) { this.openDropDown(); } } } onDropDownClose = (e: React.SyntheticEvent | Event) => { if (e instanceof KeyboardEvent) { this.setState({ inputText: '', isDropDownOpen: false }); safeInvoke(this.props.onDropdownVisibleChange, false); } } renderSelect () { const isOpen = (this.props.isOpen !== undefined) ? this.props.isOpen : this.state.isDropDownOpen; const style = { width: 'auto' }; if (this.state.dropDownWidth && this.props.dropdownMatchSelectWidth === undefined) { style.width = `${this.state.dropDownWidth}px`; } if (this.state.dropDownWidth && this.props.dropdownMatchSelectWidth === '1.5:1') { style.width = `${this.state.dropDownWidth * (3 / 2)}px`; } const udSelectClassName = joinClassNames( 'ud-select', styles.udSelect ); return ( {this.props.targetRenderer !== undefined ? this.props.targetRenderer(this.handleWrapperClick, this.props.value) : this.renderWrapper()} )} placement={PLACEMENT.BOTTOM_START} hasArrow={false} hasPaddings={false} boundaries={POPOVER_BOUNDARIES.DOCUMENT} isDisabled={this.props.isDisabled || this.props.isReadOnly} > {isOpen ? (
{this.renderDropDownContent()}
) : null}
); } onExtraClear = (e: React.MouseEvent) => { e.stopPropagation(); if (!(this.props.isDisabled || this.props.isReadOnly)) { if (this.state.inputText.trim()) { this.doInputChange(''); return; } if (this.getSelectedOptions().length !== 0) { this.onChange(this.deleteAllOptions()); } } } onDeleteSelectedOption = (option: IOption) => (e: React.MouseEvent) => { e.stopPropagation(); if (!(this.props.isDisabled || this.props.isReadOnly)) { const newOptions = this.deleteOption(option); safeInvoke(this.props.onDeselect, option.value); this.onChange(newOptions); } } renderCustomValueRenderer = (value: Value) => { return (
{this.props.valueRenderer !== undefined ? this.props.valueRenderer(value) : null}
); } renderWrapper = () => { const isLogicDisabled = this.props.isDisabled || this.props.isReadOnly; const handleWrapperClick = isLogicDisabled ? undefined : this.handleWrapperClick; const handleWrapperClickOutside = isLogicDisabled ? undefined : this.handleWrapperClickOutside; const wrapperProps: WrapperProps = { size: this.props.size, prefix: this.props.prefix, suffix: this.props.suffix, hasError: this.props.hasError, isDisabled: this.props.isDisabled, isReadOnly: this.props.isReadOnly, errorMessage: this.props.errorMessage, onPrefixClick: this.props.onPrefixClick, onSuffixClick: this.props.onSuffixClick, isFocused: this.state.isFocused, forwardRef: this.dropDownTargetRef, onClick: handleWrapperClick, onClickOutside: handleWrapperClickOutside, tabIndex: isLogicDisabled ? -1 : 0, onWrapperFocus: this.handleWrapperFocus, onMouseEnter: this.handleWrapperMouseEnter, onMouseLeave: this.handleWrapperMouseLeave }; const size = this.props.size ? this.props.size : SIZE.MIDDLE; const sizeThemeKey = getSizeThemeKey('', size); const selectedOptions = this.getSelectedOptions(); const searchText = this.props.showSearch || this.props.canCreate ? this.state.inputText : ''; let isInputReadonly = true; if (this.props.showSearch === true || this.props.canCreate === true) { isInputReadonly = false; } const containerClassName = joinClassNames( styles.selectContainer, styles[sizeThemeKey], [styles.inputRowsModeScroll, Boolean(this.props.inputRowsMode === 'scroll')], [styles.isReadOnly, Boolean(this.props.isReadOnly)], [styles.isDisabled, Boolean(this.props.isDisabled)] ); return (
{ (this.props.valueRenderer !== undefined && this.props.value) ? ( this.renderCustomValueRenderer(this.props.value) ) : (
{this.props.mode !== 'default' ? ( selectedOptions.map((option) => { return this.renderSelectedMultipleValue(option, size); }) ) : null}
{this.renderValueDefault()}
) }
); } renderValueDefault = () => { const mode = this.props.mode; const selectedOptions = this.getSelectedOptions(); const isOpen = (this.props.isOpen !== undefined) ? this.props.isOpen : this.state.isDropDownOpen; const isPlaceholder = selectedOptions.length === 0 || mode === 'default' && this.state.isFocused && isOpen; const isShowValueDefault = this.state.inputText === '' && (mode === 'default' || selectedOptions.length === 0); const valueDefaultClassName = joinClassNames( styles.displayValue, [styles.placeholder, isPlaceholder] ); let valueDefault = ''; if (mode === 'default' && selectedOptions.length !== 0) { valueDefault = selectedOptions[0].title; } else if (this.props.placeholder !== undefined) { valueDefault = this.props.placeholder; } return isShowValueDefault ? (
{valueDefault}
) : null; } renderDropDownContent = () => { const searchText = this.state.inputText; let dropDownContent: React.ReactNode | null; let showLastButton = true; if (this.props.dropdownRender) { dropDownContent = this.props.dropdownRender(this.getSelectedOptions(), this.closeDropDown); } else if (this.props.isWaiting) { showLastButton = false; dropDownContent = ; } else { const options = this.filterOptions(searchText); if (this.props.sortOrder !== undefined) { options.sort((option1, option2) => { if (this.props.sortOrder === 'ASC') { return option1.title.localeCompare(option2.title); } return option2.title.localeCompare(option1.title); }); } if (options.length === 0) { showLastButton = Boolean(this.props.canAdd); if (this.props.notFoundContent) { return this.props.notFoundContent; } const style = { width: `${this.state.dropDownWidth}px` }; dropDownContent = (
{this.props.notFoundText}
); } else { dropDownContent = (
option.value)} onSelect={this.onSelect} maxHeight={this.props.optionsMaxHeight} />
); } } return ( <> {dropDownContent} {showLastButton && (this.props.canSelectAll || this.props.canAdd) && ( <> {this.props.canSelectAll && this.renderSelectAllButton()} {this.props.canAdd && this.renderAddButton()} )} ); } renderSelectAllButton = () => { const isAllSelected = this.props.isAllSelected; const onSelectAllNoneClick = isAllSelected ? this.props.onDeselectAll : this.props.onSelectAll; const selectAllNoneText = isAllSelected ? this.props.selectNoneText : this.props.selectAllText; return ( ); } renderAddButton = () => { return ( ); } renderSelectedMultipleValue = (option: IOption, size: SIZE) => { return (
{ this.props.multipleValueRenderer ? ( this.props.multipleValueRenderer(option, this.onDeleteSelectedOption) ) : ( <> {option.title} {!this.props.isReadOnly && ( )} ) }
); } override render () { const title = this.props.title; const tooltipPlacement = this.props.tooltipPlacement || PLACEMENT.TOP; if (!title) { return this.renderSelect(); } return ( {this.renderSelect()} ); } }