@yncoder/element-react
Version:
Element UI for React
409 lines (346 loc) • 10.1 kB
JSX
/* @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 { Component, PropTypes, View } from '../../libs';
import CascaderMenu from './Menu';
import Input from '../input';
import i18n from '../locale';
type State = {
currentValue: [],
menu: any,
menuVisible: boolean,
inputHover: boolean,
inputValue: any,
flatOptions: []
};
type Context = {
component: any
};
class Cascader extends Component {
state: State;
input: any;
debouncedInputChange: Function;
constructor(props: Object) {
super(props);
this.state = {
currentValue: props.value,
menu: null,
menuVisible: false,
inputHover: false,
inputValue: '',
flatOptions: this.flattenOptions(props.options)
}
this.debouncedInputChange = debounce(props.debounce, () => {
const value = this.state.inputValue;
const before = this.props.beforeFilter(value);
if (before && before.then) {
this.state.menu.setState({
options: [{
__IS__FLAT__OPTIONS: true,
label: i18n.t('el.cascader.loading'),
value: '',
disabled: true
}]
});
before.then(() => {
this.handleInputChange(value);
});
} else {
this.handleInputChange(value);
}
});
}
getChildContext(): Context {
return {
component: this
};
}
componentDidMount() {
this.input = ReactDOM.findDOMNode(this.refs.input);
}
componentWillReceiveProps(props: Object) {
this.setState({
currentValue: props.value,
flatOptions: this.flattenOptions(props.options),
});
this.state.menu.setState({
options: props.options
});
}
componentDidUpdate(props: Object, state: State) {
const { menuVisible } = this.state;
if (menuVisible !== state.menuVisible) {
if (menuVisible) {
this.showMenu();
if (this.popperJS) {
this.popperJS.update();
} else {
this.popperJS = new Popper(this.input, ReactDOM.findDOMNode(this.refs.menu), {
placement: 'bottom-start',
modifiers: {
computeStyle: {
gpuAcceleration: false
}
}
});
}
} else {
this.hideMenu();
if (this.popperJS) {
this.popperJS.destroy();
}
delete this.popperJS;
}
}
}
componentWillUnmount() {
if (this.popperJS) {
this.popperJS.destroy();
}
}
placeholder(): string {
return this.props.placeholder || i18n.t('el.cascader.placeholder');
}
updatePopper() {
if (this.popperJS) {
this.popperJS.update();
}
}
initMenu(menu: any) {
this.state.menu = menu;
}
showMenu() {
this.state.menu.setState({
value: this.state.currentValue.slice(0),
visible: true,
options: this.props.options,
inputWidth: this.input.offsetWidth - 2
});
}
hideMenu() {
this.setState({ inputValue: '' });
if (this.state.menu) {
this.state.menu.setState({ visible: false });
}
}
handleActiveItemChange(value: []) {
this.updatePopper();
if (this.props.activeItemChange) {
this.props.activeItemChange(value);
}
}
handlePick(value: [], close: boolean = true) {
this.setState({
currentValue: value
});
if (close) {
this.setState({ menuVisible: false });
}
if (this.props.onChange) {
this.props.onChange(value);
}
}
handleInputChange(value: any) {
if (!this.state.menuVisible) return;
const flatOptions = this.state.flatOptions;
if (!value) {
this.state.menu.setState({
options: this.props.options
});
return;
}
let filteredFlatOptions = flatOptions.filter(optionsStack => {
return optionsStack.some(option => new RegExp(value, 'i').test(option[this.labelKey()]));
});
if (filteredFlatOptions.length > 0) {
filteredFlatOptions = filteredFlatOptions.map(optionStack => {
return {
__IS__FLAT__OPTIONS: true,
value: optionStack.map(item => item[this.valueKey()]),
label: this.renderFilteredOptionLabel(value, optionStack)
};
});
} else {
filteredFlatOptions = [{
__IS__FLAT__OPTIONS: true,
label: i18n.t('el.cascader.noMatch'),
value: '',
disabled: true
}];
}
this.state.menu.setState({
options: filteredFlatOptions
});
}
renderFilteredOptionLabel(inputValue: any, optionsStack: any): [] {
return optionsStack.map((option, index) => {
const label = option[this.labelKey()];
const keywordIndex = label.toLowerCase().indexOf(inputValue.toLowerCase());
const labelPart = label.slice(keywordIndex, inputValue.length + keywordIndex);
const node = keywordIndex > -1 ? this.highlightKeyword(label, labelPart) : label;
return index === 0 ? node : [' / ', node];
});
}
highlightKeyword(label: string, keyword: string): any {
return label.split(keyword).map((node, index) => index === 0 ? node : [
(<span className="el-cascader-menu__item__keyword">{keyword}</span>),
node
]);
}
flattenOptions(options: any, ancestor: any = []): [] {
let flatOptions = [];
options.forEach((option) => {
const optionsStack = ancestor.concat(option);
if (!option[this.childrenKey()]) {
flatOptions.push(optionsStack);
} else {
if (this.changeOnSelect) {
flatOptions.push(optionsStack);
}
flatOptions = flatOptions.concat(this.flattenOptions(option[this.childrenKey()], optionsStack));
}
});
return flatOptions;
}
clearValue(e: SyntheticEvent<HTMLElement>) {
e.stopPropagation();
this.handlePick([], true);
}
handleClickOutside() {
if (this.state.menuVisible) {
this.setState({ menuVisible: false });
}
}
handleClick() {
if (this.props.disabled) return;
if (this.filterable) {
this.setState({
menuVisible: true
});
return;
}
this.setState({
menuVisible: !this.state.menuVisible
});
}
/* Computed Methods */
labelKey(): string {
return this.props.props.label || 'label';
}
valueKey(): string {
return this.props.props.value || 'value';
}
childrenKey(): string {
return this.props.props.children || 'children';
}
currentLabels(): [] {
let options = this.props.options;
let labels = [];
this.state.currentValue.forEach(value => {
const targetOption = options && options.filter(option => option[this.valueKey()] === value)[0];
if (targetOption) {
labels.push(targetOption[this.labelKey()]);
options = targetOption[this.childrenKey()];
}
});
return labels;
}
render() {
const { size, disabled, filterable, clearable, showAllLevels } = this.props;
const { menuVisible, inputHover, inputValue } = this.state;
const currentLabels = this.currentLabels();
return (
<span ref="reference" className={this.className('el-cascader', size ? 'el-cascader--' + size : '', {
'is-opened': menuVisible,
'is-disabled': disabled
})}>
<span
onClick={this.handleClick.bind(this)}
onMouseEnter={() => { this.setState({ inputHover: true }) }}
onMouseLeave={() => { this.setState({ inputHover: false }) }}
>
<Input
ref="input"
readOnly={!filterable}
placeholder={currentLabels.length ? undefined : this.placeholder()}
value={inputValue}
onChange={value => { this.setState({ inputValue: value }) }}
onKeyUp={this.debouncedInputChange.bind(this)}
size={size}
disabled={disabled}
icon={
clearable && inputHover && currentLabels.length ? (
<i
className="el-input__icon el-icon-circle-close el-cascader__clearIcon"
onClick={this.clearValue.bind(this)}
/>
) : (
<i
className={this.classNames('el-input__icon el-icon-caret-bottom', {
'is-reverse': menuVisible
})}
/>
)
}
/>
<View show={currentLabels.length}>
<span className="el-cascader__label">
{
showAllLevels ? currentLabels.map((label, index) => {
return (
<label key={index}>
{label}
{index < currentLabels.length - 1 && <span> / </span>}
</label>
)
}) : currentLabels[currentLabels.length - 1]
}
</span>
</View>
</span>
<CascaderMenu ref="menu" />
</span>
)
}
}
Cascader.childContextTypes = {
component: PropTypes.any
};
Cascader.propTypes = {
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string
})).isRequired,
props: PropTypes.object,
value: PropTypes.array,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
clearable: PropTypes.bool,
changeOnSelect: PropTypes.bool,
popperClass: PropTypes.string,
expandTrigger: PropTypes.string,
filterable: PropTypes.bool,
size: PropTypes.string,
showAllLevels: PropTypes.bool,
debounce: PropTypes.number,
activeItemChange: PropTypes.func,
beforeFilter: PropTypes.func,
onChange: PropTypes.func
}
Cascader.defaultProps = {
value: [],
clearable: false,
expandTrigger: 'click',
showAllLevels: true,
debounce: 300,
props: {
children: 'children',
label: 'label',
value: 'value',
disabled: 'disabled'
},
beforeFilter: () => (() => { })
}
export default ClickOutside(Cascader);