UNPKG

@yncoder/element-react

Version:
984 lines (811 loc) 25.5 kB
/* @flow */ import React from 'react'; import ReactDOM from 'react-dom'; import ClickOutside from 'react-click-outside'; import { debounce } from 'throttle-debounce'; import Popper from 'popper.js'; import StyleSheet from '../../libs/utils/style'; import { Component, PropTypes, Transition, View } from '../../libs'; import { addResizeListener, removeResizeListener } from '../../libs/utils/resize-event'; import { Scrollbar } from '../scrollbar'; import Tag from '../tag'; import Input from '../input'; import i18n from '../locale'; StyleSheet.reset(` .el-select-dropdown { position: absolute !important; } `) type State = { options: Array<Object>, isSelect: boolean, inputLength: number, inputWidth: number, inputHovering: boolean, filteredOptionsCount: number, optionsCount: number, hoverIndex: number, bottomOverflowBeforeHidden: number, cachedPlaceHolder: string, currentPlaceholder: string, selectedLabel: string, value: any, visible: boolean, query: string, selected: any, voidRemoteQuery: boolean, valueChangeBySelected: boolean, selectedInit: boolean, dropdownUl?: HTMLElement }; const sizeMap: { [size: string]: number } = { 'large': 42, 'small': 30, 'mini': 22 }; class Select extends Component { state: State; debouncedOnInputChange: Function; constructor(props: Object) { super(props); this.state = { options: [], isSelect: true, inputLength: 20, inputWidth: 0, inputHovering: false, filteredOptionsCount: 0, optionsCount: 0, hoverIndex: -1, bottomOverflowBeforeHidden: 0, cachedPlaceHolder: props.placeholder || i18n.t('el.select.placeholder'), currentPlaceholder: props.placeholder || i18n.t('el.select.placeholder'), selectedLabel: '', selectedInit: false, visible: false, selected: undefined, value: props.value, valueChangeBySelected: false, voidRemoteQuery: false, query: '' }; if (props.multiple) { this.state.selectedInit = true; this.state.selected = []; } if (props.remote) { this.state.voidRemoteQuery = true; } this.debouncedOnInputChange = debounce(this.debounce(), () => { this.onInputChange(); }); this.resetInputWidth = this._resetInputWidth.bind(this) } getChildContext(): Object { return { component: this }; } componentDidMount() { this.reference = ReactDOM.findDOMNode(this.refs.reference); this.popper = ReactDOM.findDOMNode(this.refs.popper); this.handleValueChange(); addResizeListener(this.refs.root, this.resetInputWidth); } componentWillReceiveProps(props: Object) { if (props.placeholder != this.props.placeholder) { this.setState({ currentPlaceholder: props.placeholder }); } if (props.value != this.props.value) { this.setState({ value: props.value }, () => { this.handleValueChange(); }); } } componentWillUpdate(props: Object, state: Object) { if (state.value != this.state.value) { this.onValueChange(state.value); } if (state.visible != this.state.visible) { if (this.props.onVisibleChange) { this.props.onVisibleChange(state.visible); } this.onVisibleChange(state.visible); } if (state.query != this.state.query) { this.onQueryChange(state.query); } if (Array.isArray(state.selected)) { if (state.selected.length != this.state.selected.length) { this.onSelectedChange(state.selected); } } } componentDidUpdate() { this.state.inputWidth = this.reference.getBoundingClientRect().width; } componentWillUnmount() { removeResizeListener(this.refs.root, this.resetInputWidth); } debounce(): number { return this.props.remote ? 300 : 0; } handleClickOutside() { if (this.state.visible) { this.setState({ visible: false }); } } handleValueChange() { const { multiple } = this.props; const { value, options } = this.state; if (multiple && Array.isArray(value)) { this.setState({ selected: options.reduce((prev, curr) => { return value.indexOf(curr.props.value) > -1 ? prev.concat(curr) : prev; }, []) }, () => { this.onSelectedChange(this.state.selected, false); }); } else { const selected = options.filter(option => { return option.props.value === value })[0]; if (selected) { this.state.selectedLabel = selected.props.label || selected.props.value; } } } onVisibleChange(visible: boolean) { const { multiple, filterable } = this.props; let { query, dropdownUl, selected, selectedLabel, bottomOverflowBeforeHidden } = this.state; if (!visible) { this.reference.querySelector('input').blur(); if (this.refs.root.querySelector('.el-input__icon')) { const elements = this.refs.root.querySelector('.el-input__icon'); for (let i = 0; i < elements.length; i++) { elements[i].classList.remove('is-reverse'); } } if (this.refs.input) { this.refs.input.blur(); } this.resetHoverIndex(); if (!multiple) { if (dropdownUl && selected) { const element: any = ReactDOM.findDOMNode(selected); bottomOverflowBeforeHidden = element.getBoundingClientRect().bottom - this.popper.getBoundingClientRect().bottom; } if (selected && selected.props) { if (selected.props.value) { selectedLabel = selected.currentLabel(); } } else if (filterable) { selectedLabel = ''; } this.setState({ bottomOverflowBeforeHidden, selectedLabel }); } } else { let icon = this.refs.root.querySelector('.el-input__icon'); if (icon && !icon.classList.contains('el-icon-circle-close')) { const elements = this.refs.root.querySelector('.el-input__icon'); for (let i = 0; i < elements.length; i++) { elements[i].classList.add('is-reverse'); } } if (this.popperJS) { this.popperJS.update(); } if (filterable) { query = selectedLabel; if (multiple) { this.refs.input.focus(); } else { this.refs.reference.focus(); } } if (!dropdownUl) { let dropdownChildNodes = this.popper.childNodes; dropdownUl = [].filter.call(dropdownChildNodes, item => item.tagName === 'UL')[0]; } if (!multiple && dropdownUl) { if (bottomOverflowBeforeHidden > 0) { dropdownUl.scrollTop += bottomOverflowBeforeHidden; } } this.setState({ query: query || '', dropdownUl }); } } onValueChange(val: mixed) { const { multiple } = this.props; let { options, valueChangeBySelected, selectedInit, selected, selectedLabel, currentPlaceholder, cachedPlaceHolder } = this.state; if (valueChangeBySelected) { return this.setState({ valueChangeBySelected: false }); } if (multiple && Array.isArray(val)) { this.resetInputHeight(); selectedInit = true; selected = []; currentPlaceholder = cachedPlaceHolder; val.forEach(item => { let option = options.filter(option => option.props.value === item)[0]; if (option) { this.addOptionToValue(option); } }); this.forceUpdate(); } if (!multiple) { let option = options.filter(option => option.props.value === val)[0]; if (option) { this.addOptionToValue(option); this.setState({ selectedInit, currentPlaceholder }); } else { selected = {}; selectedLabel = ''; this.setState({ selectedInit, selected, currentPlaceholder, selectedLabel }, () => { this.resetHoverIndex(); }); } } } onSelectedChange(val: any, bubble: boolean = true) { const { form } = this.context; const { multiple, filterable, onChange } = this.props; let { query, hoverIndex, inputLength, selectedInit, currentPlaceholder, cachedPlaceHolder, valueChangeBySelected } = this.state; if (multiple) { if (val.length > 0) { currentPlaceholder = ''; } else { currentPlaceholder = cachedPlaceHolder; } this.setState({ currentPlaceholder }, () => { this.resetInputHeight(); }); valueChangeBySelected = true; if (bubble) { onChange && onChange(val.map(item => item.props.value), val); form && form.onFieldChange(); } if (filterable) { query = ''; hoverIndex = -1; inputLength = 20; this.refs.input.focus(); } this.setState({ valueChangeBySelected, query, hoverIndex, inputLength }, () => { if (this.refs.input) { this.refs.input.value = ''; } }); } else { if (selectedInit) { return this.setState({ selectedInit: false }); } if (bubble) { onChange && onChange(val.props.value, val); form && form.onFieldChange(); } } } onQueryChange(query: string) { const { multiple, filterable, remote, remoteMethod, filterMethod } = this.props; let { voidRemoteQuery, hoverIndex, options, optionsCount } = this.state; if (this.popperJS) { this.popperJS.update(); } if (multiple && filterable) { this.resetInputHeight(); } if (remote && typeof remoteMethod === 'function') { hoverIndex = -1; voidRemoteQuery = query === ''; remoteMethod(query); options.forEach(option => { option.resetIndex(); }); } else if (typeof filterMethod === 'function') { filterMethod(query); } else { this.setState({ filteredOptionsCount: optionsCount }, () => { options.forEach(option => { option.queryChange(query); }); }); } this.setState({ hoverIndex, voidRemoteQuery }); } onEnter(): void { this.popperJS = new Popper(this.reference, this.popper, { modifiers: { computeStyle: { gpuAcceleration: false } } }); } onAfterLeave(): void { this.popperJS.destroy(); } iconClass(): string { return this.showCloseIcon() ? 'circle-close' : (this.props.remote && this.props.filterable ? '' : `caret-top ${this.state.visible ? 'is-reverse' : ''}`); } showCloseIcon(): boolean { let criteria = this.props.clearable && this.state.inputHovering && !this.props.multiple && this.state.options.indexOf(this.state.selected) > -1; if (!this.refs.root) return false; let icon = this.refs.root.querySelector('.el-input__icon'); if (icon) { if (criteria) { icon.addEventListener('click', this.deleteSelected.bind(this)); icon.classList.add('is-show-close'); } else { icon.removeEventListener('click', this.deleteSelected.bind(this)); icon.classList.remove('is-show-close'); } } return criteria; } emptyText(): string | boolean | null { const { loading, filterable } = this.props; const { voidRemoteQuery, options, filteredOptionsCount } = this.state; if (loading) { return i18n.t('el.select.loading'); } else { if (voidRemoteQuery) { this.state.voidRemoteQuery = false; return false; } if (filterable && filteredOptionsCount === 0) { return i18n.t('el.select.noMatch'); } if (options.length === 0) { return i18n.t('el.select.noData'); } } return null; } handleClose() { this.setState({ visible: false }); } toggleLastOptionHitState(hit?: boolean): any { const { selected } = this.state; if (!Array.isArray(selected)) return; const option = selected[selected.length - 1]; if (!option) return; if (hit === true || hit === false) { return option.hitState = hit; } option.hitState = !option.hitState; return option.hitState; } deletePrevTag(e: Object) { if (e.target.value.length <= 0 && !this.toggleLastOptionHitState()) { const { selected } = this.state; selected.pop(); this.setState({ selected }); } } addOptionToValue(option: any, init?: boolean) { const { multiple, remote } = this.props; let { selected, selectedLabel, hoverIndex, value } = this.state; if (multiple) { if (selected.indexOf(option) === -1 && (remote ? value.indexOf(option.props.value) === -1 : true)) { this.selectedInit = !!init; selected.push(option); this.resetHoverIndex(); } } else { this.selectedInit = !!init; selected = option; selectedLabel = option.currentLabel(); hoverIndex = option.index; this.setState({ selected, selectedLabel, hoverIndex }); } } managePlaceholder() { let { currentPlaceholder, cachedPlaceHolder } = this.state; if (currentPlaceholder !== '') { currentPlaceholder = this.refs.input.value ? '' : cachedPlaceHolder; } this.setState({ currentPlaceholder }); } resetInputState(e: Object) { if (e.keyCode !== 8) { this.toggleLastOptionHitState(false); } this.setState({ inputLength: this.refs.input.value.length * 15 + 20 }); } _resetInputWidth() { this.setState({ inputWidth: this.reference.getBoundingClientRect().width }) } resetInputHeight() { let inputChildNodes = this.reference.childNodes; let input = [].filter.call(inputChildNodes, item => item.tagName === 'INPUT')[0]; input.style.height = Math.max(this.refs.tags.clientHeight + 6, sizeMap[this.props.size] || 36) + 'px'; if (this.popperJS) { this.popperJS.update(); } } resetHoverIndex() { const { multiple } = this.props; let { hoverIndex, options, selected } = this.state; setTimeout(() => { if (!multiple) { hoverIndex = options.indexOf(selected); } else { if (selected.length > 0) { hoverIndex = Math.min.apply(null, selected.map(item => options.indexOf(item))); } else { hoverIndex = -1; } } this.setState({ hoverIndex }); }, 300); } toggleMenu() { const { filterable, readOnly, disabled } = this.props; const { query, visible } = this.state; if (readOnly || filterable && query === '' && visible) { return; } if (!disabled) { this.setState({ visible: !visible }); } } navigateOptions(direction: string) { let { visible, hoverIndex, options } = this.state; if (!visible) { return this.setState({ visible: true }); } let skip; if (options.length != options.filter(item => item.props.disabled === true).length) { if (direction === 'next') { hoverIndex++; if (hoverIndex === options.length) { hoverIndex = 0; } if (options[hoverIndex].props.disabled === true || options[hoverIndex].props.groupDisabled === true || !options[hoverIndex].state.visible) { skip = 'next'; } } if (direction === 'prev') { hoverIndex--; if (hoverIndex < 0) { hoverIndex = options.length - 1; } if (options[hoverIndex].props.disabled === true || options[hoverIndex].props.groupDisabled === true || !options[hoverIndex].state.visible) { skip = 'prev'; } } } this.setState({ hoverIndex, options }, () => { if (skip) { this.navigateOptions(skip); } this.resetScrollTop(); }); } resetScrollTop() { const element: any = ReactDOM.findDOMNode(this.state.options[this.state.hoverIndex]); const bottomOverflowDistance = element.getBoundingClientRect().bottom - this.popper.getBoundingClientRect().bottom; const topOverflowDistance = element.getBoundingClientRect().top - this.popper.getBoundingClientRect().top; if (this.state.dropdownUl) { if (bottomOverflowDistance > 0) { this.state.dropdownUl.scrollTop += bottomOverflowDistance; } if (topOverflowDistance < 0) { this.state.dropdownUl.scrollTop += topOverflowDistance; } } } selectOption() { let { hoverIndex, options } = this.state; if (options[hoverIndex]) { this.onOptionClick(options[hoverIndex]); } } deleteSelected(e: Object) { e.stopPropagation(); if (this.state.selectedLabel != '') { this.setState({ selected: {}, selectedLabel: '', visible: false }); this.context.form && this.context.form.onFieldChange(); if (this.props.onChange) { this.props.onChange(''); } if (this.props.onClear) { this.props.onClear(); } } } deleteTag(tag: any) { const index = this.state.selected.indexOf(tag); if (index > -1 && !this.props.disabled) { const selected = this.state.selected.slice(0); selected.splice(index, 1); this.setState({ selected }, () => { if (this.props.onRemoveTag) { this.props.onRemoveTag(tag.props.value); } }); } } handleIconClick(event) { if (this.iconClass().indexOf('circle-close') > -1) { this.deleteSelected(event); } else { this.toggleMenu(); } } onInputChange() { if (this.props.filterable && this.state.selectedLabel !== this.state.value) { this.setState({ query: this.state.selectedLabel }); } } onOptionCreate(option: any) { this.state.options.push(option); this.state.optionsCount++; this.state.filteredOptionsCount++; this.forceUpdate(); this.handleValueChange(); } onOptionDestroy(option: any) { this.state.optionsCount--; this.state.filteredOptionsCount--; const index = this.state.options.indexOf(option); if (index > -1) { this.state.options.splice(index, 1); } this.state.options.forEach(el => { if (el != option) { el.resetIndex(); } }); this.forceUpdate(); this.handleValueChange(); } onOptionClick(option: any) { const { multiple } = this.props; let { visible, selected, selectedLabel } = this.state; if (!multiple) { selected = option; selectedLabel = option.currentLabel(); visible = false; } else { let optionIndex = -1; selected = selected.slice(0); selected.forEach((item, index) => { if (item === option || item.props.value === option.props.value) { optionIndex = index; } }); if (optionIndex > -1) { selected.splice(optionIndex, 1); } else { selected.push(option); } } this.setState({ selected, selectedLabel }, () => { if (!multiple) { this.onSelectedChange(this.state.selected); } this.setState({ visible }); }); } onMouseDown(event) { event.preventDefault(); if (this.refs.input) { this.refs.input.focus(); } this.toggleMenu(); } onMouseEnter() { this.setState({ inputHovering: true }) } onMouseLeave() { this.setState({ inputHovering: false }) } render() { const { multiple, size, disabled, filterable, loading, name, readOnly } = this.props; const { selected, inputWidth, inputLength, query, selectedLabel, visible, options, filteredOptionsCount, currentPlaceholder } = this.state; return ( <div ref="root" style={this.style()} className={this.className('el-select')}> { multiple && ( <div ref="tags" className="el-select__tags" onClick={this.toggleMenu.bind(this)} style={{ maxWidth: inputWidth - 32 }}> { selected.map(el => { return ( <Tag type="primary" key={el.props.value} hit={el.hitState} closable={!disabled} closeTransition={true} onClose={this.deleteTag.bind(this, el)} > <span className="el-select__tags-text">{el.currentLabel()}</span> </Tag> ) }) } { filterable && ( <input ref="input" type="text" className={this.classNames('el-select__input', size && `is-${size}`)} style={{ width: inputLength, maxWidth: inputWidth - 42 }} disabled={disabled} defaultValue={query} onKeyUp={this.managePlaceholder.bind(this)} onKeyDown={e => { this.resetInputState(e); switch (e.keyCode) { case 27: this.setState({ visible: false }); e.preventDefault(); break; case 8: this.deletePrevTag(e); break; case 13: this.selectOption(); e.preventDefault(); break; case 38: this.navigateOptions('prev'); e.preventDefault(); break; case 40: this.navigateOptions('next'); e.preventDefault(); break; default: break; } }} onChange={e => { clearTimeout(this.timeout); this.timeout = setTimeout(() => { this.setState({ query: this.state.value }); }, this.debounce()); this.state.value = e.target.value; }} /> ) } </div> ) } <Input ref="reference" value={selectedLabel} type="text" placeholder={currentPlaceholder} name={name} size={size} disabled={disabled} readOnly={readOnly || !filterable || multiple} icon={this.iconClass() || undefined} onChange={value => this.setState({ selectedLabel: value })} onIconClick={this.handleIconClick.bind(this)} onMouseDown={this.onMouseDown.bind(this)} onMouseEnter={this.onMouseEnter.bind(this)} onMouseLeave={this.onMouseLeave.bind(this)} onKeyUp={this.debouncedOnInputChange.bind(this)} onKeyDown={e => { switch (e.keyCode) { case 9: case 27: this.setState({ visible: false }); e.preventDefault(); break; case 13: this.selectOption(); e.preventDefault(); break; case 38: this.navigateOptions('prev'); e.preventDefault(); break; case 40: this.navigateOptions('next'); e.preventDefault(); break; default: break; } }} /> <Transition name="el-zoom-in-top" onEnter={this.onEnter.bind(this)} onAfterLeave={this.onAfterLeave.bind(this)}> <View show={visible && this.emptyText() !== false}> <div ref="popper" className={this.classNames('el-select-dropdown', { 'is-multiple': multiple })} style={{ minWidth: inputWidth }} > <View show={options.length > 0 && filteredOptionsCount > 0 && !loading}> <Scrollbar viewComponent="ul" wrapClass="el-select-dropdown__wrap" viewClass="el-select-dropdown__list" > {this.props.children} </Scrollbar> </View> {this.emptyText() && <p className="el-select-dropdown__empty">{this.emptyText()}</p>} </div> </View> </Transition> </div> ) } } Select.childContextTypes = { component: PropTypes.any }; Select.contextTypes = { form: PropTypes.any }; Select.propTypes = { value: PropTypes.any, size: PropTypes.string, readOnly: PropTypes.bool, disabled: PropTypes.bool, clearable: PropTypes.bool, filterable: PropTypes.bool, loading: PropTypes.bool, remote: PropTypes.bool, remoteMethod: PropTypes.func, filterMethod: PropTypes.func, multiple: PropTypes.bool, placeholder: PropTypes.string, onChange: PropTypes.func, onVisibleChange: PropTypes.func, onRemoveTag: PropTypes.func, onClear: PropTypes.func } export default ClickOutside(Select);