UNPKG

@momentum-ui/react

Version:

Cisco Momentum UI framework for ReactJs applications

340 lines (293 loc) 8.19 kB
/** @component combo-box */ import React from 'react'; import PropTypes from 'prop-types'; import { EventOverlay, Input, ListItem, InputSearch, } from '@momentum-ui/react'; import omit from 'lodash/omit'; import uniqueId from 'lodash/uniqueId'; class ComboBox extends React.Component { static displayName = 'ComboBox'; state = { filteredOptions: [], focus: -1, id: this.props.id || uniqueId('md-combo-box-'), isOpen: false, value: '', }; componentDidMount() { const { children } = this.props; this.options = (children && React.Children.toArray(children)) || this.mapOptionsToListItem(); this.setFilteredOptions(); } componentDidUpdate(prevProps) { const { options, children } = this.props; const { value } = this.state; if ( (prevProps.options !== options) || (prevProps.children !== children) ) { this.options = (children && React.Children.toArray(children)) || this.mapOptionsToListItem(); this.setFilteredOptions(value); } } mapOptionsToListItem = () => { const { options } = this.props; return options.map((option, i) => <ListItem key={i} label={option} /> ); } setFilteredOptions = filter => { const { onChange } = this.props; const filteredOptions = !onChange ? this.applyFilter(filter) : this.options; this.setState({ isOpen: !!filteredOptions.length, filteredOptions, }); } hidePopover = () => { this.setState({ isOpen: false }); }; handleToggle = () => { const { filteredOptions } = this.state; filteredOptions.length && this.setState({ isOpen: true }); }; applyFilter = value => { const { searchProp } = this.props; const isSubString = string => value && string.toLowerCase().includes(value.toLowerCase()); return this.options.filter(option => (option.props[searchProp] && isSubString(option.props[searchProp])) || ['ListItemHeader'].includes(option.type.displayName) ); }; handleChange = e => { const { onChange } = this.props; const { focus } = this.state; this.setFilteredOptions(e.target.value); this.setState({ value: e.target.value, focus: !onChange ? -1 : focus, }, () => onChange && onChange(e, e.target.value)); }; handleClick = (e, selectedOption) => { const { searchProp } = this.props; const { onSelect } = this.props; this.setFilteredOptions(selectedOption.props[searchProp]); this.setState({ value: selectedOption.props[searchProp], isOpen: false, focus: -1, }, () => onSelect && onSelect(e, selectedOption)); }; setFocus = index => { this.setState({ focus: index }); }; handleKeyDown = e => { let flag = false; let newIndex; const { filteredOptions, focus, isOpen } = this.state; const length = filteredOptions && filteredOptions.length - 1; const getNewIndex = (currentIndex, change) => { const getPossibleIndex = () => { if (currentIndex + change < 0) { return length; } else if (currentIndex + change > length) { return 0; } return currentIndex + change; }; const possibleIndex = getPossibleIndex(); const potentialTarget = React.Children.toArray(filteredOptions)[possibleIndex]; return (potentialTarget.props.disabled || potentialTarget.props.isReadOnly) ? getNewIndex(possibleIndex, change) : possibleIndex; }; switch (e.which) { case 13: isOpen && (focus !== -1) && this.handleClick(e, filteredOptions[focus]); flag = true; break; case 38: if(isOpen) { newIndex = getNewIndex(focus, -1); this.setFocus(newIndex); } flag = true; break; case 40: if(isOpen) { newIndex = getNewIndex(focus, 1); this.setFocus(newIndex); } flag = true; break; default: break; } if (flag) { e.stopPropagation(); e.preventDefault(); } }; render() { const { className, clear, disabled, hasSearchIcon, inputProps, placeholder, ...props } = this.props; const otherProps = omit({...props}, [ 'children', 'id', 'onChange', 'onSelect', 'options', 'searchProp', ]); const { filteredOptions, focus, id, isOpen, value, } = this.state; const activeDescendant = this.activeChild && this.activeChild.id; const InputComp = hasSearchIcon ? InputSearch : Input; const input = ( <InputComp aria-autocomplete='list' clear={clear} disabled={disabled} inputRef={ref => this.anchorNode = ref} onChange={this.handleChange} onClick={this.handleToggle} onKeyDown={this.handleKeyDown} placeholder={placeholder} value={value} {...activeDescendant && { 'aria-activedescendant': activeDescendant }} {...inputProps} /> ); const renderFilteredOption = filteredOptions && filteredOptions.map((option, i) => React.cloneElement(option, { active: i === focus, key: i, onClick: e => this.handleClick(e, option), refName: 'option', role: 'option', ...focus === i && { ref: ref => this.activeChild = ref }, }) ); const dropdownElement = ( this.anchorNode && isOpen && ( <EventOverlay allowClickAway anchorNode={this.anchorNode} close={this.hidePopover} isOpen={isOpen} {...otherProps} > <div className='md-combo-box__options' id={id} role='listbox' {...this.anchorNode && { style: { width: this.anchorNode.getBoundingClientRect().width } } } > {renderFilteredOption} </div> </EventOverlay> ) ); return ( <div aria-controls={id} aria-haspopup='listbox' aria-expanded={isOpen} className={ 'md-combo-box' + `${(className && ` ${className}`) || ''}` } role='combobox' > {input} {dropdownElement} </div> ); } } ComboBox.propTypes = { /** @prop Children nodes to render inside ComboBox | null */ children: PropTypes.node, /** @prop Optional css class string | '' */ className: PropTypes.string, /** @prop Sets the initial input element as empty | false */ clear: PropTypes.bool, /** @prop Sets the attribute disabled to the ComboBox | false */ disabled: PropTypes.bool, /** @prop Sets the ComboBox to have a search icon | true */ hasSearchIcon: PropTypes.bool, /** @prop Sets the ID of the ComboBox */ id: PropTypes.string, /** @prop Collection of props unique for Input element | null */ inputProps: PropTypes.shape({}), /** @prop Handler invoked when the user presses any key | null */ onChange: PropTypes.func, /** @prop Handler invoked when the user selects the ComboBox | null */ onSelect: PropTypes.func, /** @prop Array of options for the ComboBox dropdown | [] */ options: PropTypes.arrayOf(PropTypes.string), /** @prop Text that initially populates the input field for guidence | '' */ placeholder: PropTypes.string, /** @prop Sets the search prop | 'label' */ searchProp: PropTypes.string, /** @prop Sets the target offset | { horizontal: 0, vertical: 4 } */ targetOffset: PropTypes.shape({ horizontal: PropTypes.number, vertical: PropTypes.number, }), }; ComboBox.defaultProps = { children: null, className: '', clear: false, disabled: false, hasSearchIcon: true, id: null, inputProps: null, onChange: null, onSelect: null, options: [], placeholder: '', searchProp: 'label', targetOffset: { horizontal: 0, vertical: 4, }, }; export default ComboBox;