sh-input-select
Version:
Select input box superhero theme
592 lines (520 loc) • 18.9 kB
JavaScript
import React from 'react';
import * as _ from 'lodash';
import sh from 'sh-core';
import PropTypes from 'prop-types';
import IconCheckboxSelected from './icons/icon-checkbox-selected';
import IconCheckboxUnselected from './icons/icon-checkbox-unselected';
import IconChevronDown from './icons/icon-chevron-down';
import IconChevronLeft from './icons/icon-chevron-left';
import IconChevronRight from './icons/icon-chevron-right';
import './sh-input-select.scss';
let defaultGetFunction = (option) => {
if (_.isObject(option)) {
if (option.name) {
return option.name;
} else {
return JSON.stringify(option);
}
} else {
return option;
}
};
let defaultConfig = {
getDisplay: defaultGetFunction,
getLabelDisplay: defaultGetFunction,
multiselect: false,
idField: null,
tree: false,
treeHasChildren: (options, option) => {
return !!_.find(options, {parentId: option.id});
},
treeGetChildren: (options, option) => {
return _.filter(options, {parentId: (option ? option.id : null)});
},
required: false,
};
let hotKeys = {
esc: 27,
space: 32,
up: 38,
down: 40,
};
hotKeys.list = _.values(hotKeys);
class ShInputSelect extends React.Component {
/** @namespace this.refs.inputElement */
/** @namespace this.refs.dropdownElement */
/** @namespace this.refs.mainElement */
constructor() {
super();
this.state = {
value: null,
dropdownOpen: false,
dropdownDirection: 'down',
config: _.cloneDeep(defaultConfig),
treePath: [],
treeCurrentIndex: -1,
statusValid: false,
statusTouched: false,
};
this.checkDocumentEvent = this.checkDocumentEvent.bind(this);
this.inputKeyUp = this.inputKeyUp.bind(this);
this.toggleDropdown = this.toggleDropdown.bind(this);
this.optionKeyUp = this.optionKeyUp.bind(this);
this.optionSelect = this.optionSelect.bind(this);
this.navigateTab = this.navigateTab.bind(this);
this.validate = this.validate.bind(this);
this.validateAll = this.validateAll.bind(this);
}
componentWillMount() {
if (this.props.validator) {
this.props.validator.register(this, this.validate);
}
this.setState({
config: _.assign(this.state.config, this.props.config)
});
this.updateStateValue(this.props.value);
document.addEventListener('click', this.checkDocumentEvent);
document.addEventListener('keyup', this.checkDocumentEvent);
}
componentWillReceiveProps(props) {
this.setState({
config: _.assign(this.state.config, props.config)
});
if (!_.isEqual(this.state.value, props.value)) {
this.updateStateValue(props.value);
}
}
componentWillUnmount() {
document.removeEventListener('click', this.checkDocumentEvent);
document.removeEventListener('keyup', this.checkDocumentEvent);
if (this.props.validator) {
this.props.validator.unregister(this);
}
}
checkDocumentEvent(event) {
if (this.state.dropdownOpen && !_.includes(event.path, this.refs.mainElement)) {
this.closeDropdown();
}
}
updateStateValue(value) {
if (this.isMulti()) {
let newValue = this.props.options.filter((option) => {
return !!_.includes(value, this.getIdField(option));
});
this.setState({
value: newValue
}, this.validateAll)
} else {
this.setState({
value: this.getOption(value)
}, this.validateAll);
}
}
checkRequired() {
if (this.state.config.required) {
if (this.isMulti()) {
return !_.isEmpty(this.state.value);
} else {
return this.state.value;
}
} else {
return true;
}
}
validate(onSubmit) {
let rtn = {
isValid: true
};
if (!this.checkRequired()) {
rtn = {
isValid: false,
msg: 'Required'
};
}
this.setState({
statusValid: rtn.isValid,
statusTouched: onSubmit || this.state.statusTouched
});
return rtn;
}
validateAll() {
if (this.props.validator) {
this.props.validator.validate();
} else {
this.validate();
}
}
inputKeyUp(event) {
if (_.includes(hotKeys, event.keyCode)) {
event.preventDefault();
event.stopPropagation();
switch (event.keyCode) {
case hotKeys.space: {
this.toggleDropdown();
break;
}
case hotKeys.esc: {
this.closeDropdown();
break;
}
case hotKeys.down: {
this.navigateTab(-1, -2);
}
}
}
}
inputKeyDown(event) {
if (_.includes(hotKeys, event.keyCode)) {
event.preventDefault();
event.stopPropagation();
}
}
toggleDropdown() {
if (this.state.dropdownOpen) {
this.closeDropdown();
} else {
this.openDropdown();
}
}
closeDropdown() {
this.validate(true);
this.setState({
dropdownOpen: false
});
}
openDropdown() {
let dropdownDirection = 'down';
if (this.refs.mainElement && (window.innerHeight - this.refs.mainElement.getBoundingClientRect().bottom < 300)) {
dropdownDirection = 'up';
}
this.setState({
dropdownOpen: true,
dropdownDirection: dropdownDirection
});
}
optionKeyUp(option, index) {
return (event) => {
if (_.includes(hotKeys, event.keyCode)) {
event.preventDefault();
event.stopPropagation();
switch (event.keyCode) {
case hotKeys.space: { // Space
this.optionSelect(option)();
break;
}
case hotKeys.esc: { // Esc
this.closeDropdown();
break;
}
case hotKeys.up: { // Up Arrow
this.navigateTab(1, index);
break;
}
case hotKeys.down: { // Down Arrow
this.navigateTab(-1, index);
break;
}
}
}
}
}
optionKeyDown(event) {
if (_.includes(hotKeys, event.keyCode)) {
event.preventDefault();
event.stopPropagation();
}
}
navigateTab(direction, index) {
if (!this.state.dropdownOpen) {
this.openDropdown();
}
let minIndex = 0;
if (this.state.treePath.length > 0) {
minIndex = -1;
}
let currentElement = null;
if (index < minIndex) {
currentElement = this.refs.dropdownElement.lastElementChild.lastElementChild;
} else {
currentElement = this.refs.dropdownElement.lastElementChild.children[index + (-1 * minIndex)];
}
let nextElement = null;
if (direction < 0) {
nextElement = currentElement.nextElementSibling;
if (!nextElement) {
nextElement = currentElement.parentElement.firstElementChild;
}
} else {
nextElement = currentElement.previousElementSibling;
if (!nextElement) {
nextElement = currentElement.parentElement.lastElementChild;
}
}
if (nextElement) {
nextElement.focus();
}
}
/**
* Select this option
* @param option
* @returns {function()} this function does the actual work
*/
optionSelect(option) {
return () => {
if (this.checkTree(option)) {
this.refs.inputElement.focus();
if (_.last(this.state.treePath) == option) {
this.setState({
treeCurrentIndex: this.state.treeCurrentIndex - 1
});
setTimeout(() => {
this.setState({
treePath: _.dropRight(this.state.treePath)
});
}, 550);
} else {
this.setState({
treePath: _.concat(this.state.treePath, option)
});
setTimeout(() => {
this.setState({
treeCurrentIndex: this.state.treeCurrentIndex + 1
});
}, 50);
}
} else if (this.isMulti()) {
let newValue;
if (_.includes(this.state.value, option)) {
newValue = _.without(this.state.value, option);
} else {
newValue = _.concat(this.state.value, option);
}
this.setState({
value: newValue
}, this.validateAll);
this.props.onChange(newValue.map((value) => {
return this.getIdField(value);
}));
} else {
let newValue = this.getIdField(option);
this.updateStateValue(newValue);
this.closeDropdown();
if (!_.isEqual(this.state.value, newValue)) {
this.props.onChange(newValue);
}
}
}
}
/**
* Gets the proper value used to set the value of the select
* @param option - object to get idField for
* @returns {*} the value of the idField or the entire object
*/
getIdField(option) {
if (this.state.config.idField) {
return _.get(option, this.state.config.idField);
} else {
return option;
}
}
/**
* Get an option based on value and config
* @param value - value stored and changed
* @returns {object} Option that matches passed in value
*/
getOption(value) {
if (this.state.config.idField) {
let search = {};
_.set(search, this.state.config.idField, value);
return _.find(this.props.options, search);
} else {
return value;
}
}
/**
* Get the proper displayable string for an option
* @param option - object to get the display for
* @returns {string} Visual representation of this option
*/
getDisplay(option) {
if (_.isFunction(this.state.config.getDisplay)) {
return this.state.config.getDisplay(option);
} else if (_.isString(this.state.config.getDisplay)) {
return _.get(option, this.state.config.getDisplay);
} else {
return JSON.stringify(option);
}
}
/**
* Get the proper displayable string for an label
* @param option - object to get the display for
* @returns {string} Visual representation of this option to be used as label
*/
getLabel(option) {
if (_.isFunction(this.state.config.getLabelDisplay)) {
return this.state.config.getLabelDisplay(option);
} else if (_.isString(this.state.config.getLabelDisplay)) {
return _.get(option, this.state.config.getLabelDisplay);
} else {
return JSON.stringify(option);
}
}
isMulti() {
return this.state.config.multiselect;
}
isTree() {
return this.state.config.tree;
}
checkTree(option) {
if (!this.isTree()) {
return false;
} else {
return this.state.config.treeHasChildren(this.props.options, option);
}
}
render() {
//noinspection JSUnusedLocalSymbols
let {
value,
options,
onChange,
config,
classNames,
validator,
...other
} = this.props;
let mainClasses = {
shInputSelect: true,
openDown: this.state.dropdownDirection === 'down',
openUp: this.state.dropdownDirection !== 'down',
closed: !this.state.dropdownOpen,
opened: this.state.dropdownOpen,
shValid: this.state.statusValid,
shInvalid: !this.state.statusValid,
shTouched: this.state.statusTouched,
shUntouched: !this.state.statusTouched,
other: classNames,
};
let inputSelected = 'Select';
if (this.isMulti()) {
if (this.state.value.length === 0) {
// Don't do anything
} else if (this.state.value.length === this.props.options.length) {
inputSelected = 'All Selected';
} else if (this.state.value.length === 1) {
inputSelected = this.getLabel(this.state.value[0]);
} else {
inputSelected = this.state.value.length + ' Selected';
}
} else if (this.state.value) {
inputSelected = this.getLabel(this.state.value);
}
let input = (
<div className="input" ref="inputElement" tabIndex="0" onClick={this.toggleDropdown}
onKeyUp={this.inputKeyUp} onKeyDown={this.inputKeyDown} onFocus={this.onFocus}>
<div className="input-selected">{inputSelected}</div>
<IconChevronDown />
</div>
);
let generateOptions = (tabable, parentOption) => {
let preOptions = this.props.options;
if (this.isTree()) {
preOptions = this.state.config.treeGetChildren(preOptions, parentOption);
}
return preOptions.map((current, index) => {
let showSelected = null;
if (this.isMulti()) {
if (_.includes(this.state.value, current)) {
showSelected = <IconCheckboxSelected />
} else {
showSelected = <IconCheckboxUnselected />
}
}
let showTree = null;
if (this.isTree()) {
if (this.state.config.treeHasChildren(this.props.options, current)) {
showTree = <div className="tree-forward-icon"><IconChevronRight /></div>
}
}
let optionDisplay = this.getDisplay(current);
return (
<div key={index} className="option" tabIndex={tabable && this.state.dropdownOpen ? 0 : -1}
onClick={this.optionSelect(current)} onKeyUp={this.optionKeyUp(current, index)}
onKeyDown={this.optionKeyDown}>
{showSelected}
<div className="option-details" title={optionDisplay}>{optionDisplay}</div>
{showTree}
</div>
);
});
};
let generateDropdownClasses = (index) => {
return sh.getClassNames({
dropdown: true,
multi: this.isMulti(),
tree: this.isTree(),
left: index < this.state.treeCurrentIndex,
current: index === this.state.treeCurrentIndex,
right: index > this.state.treeCurrentIndex
});
};
let dropdownPages = [];
if (this.isTree()) {
dropdownPages.push(
<div key="dropdown-main" className={generateDropdownClasses(-1)}>
{generateOptions(this.state.treePath.length === 0)}
</div>
);
for (let i = 0; i < this.state.treePath.length; i++) {
let parentOption = this.state.treePath[i];
let tabable = this.state.treePath.length - 1 === i;
let treeBack = (
<div key="back" className="option back" tabIndex={tabable && this.state.dropdownOpen ? 0 : -1}
onClick={this.optionSelect(parentOption)} onKeyUp={this.optionKeyUp(parentOption, -1)}
onKeyDown={this.optionKeyDown}>
<div className="tree-back-icon"><IconChevronLeft /></div>
<div className="option-details">{this.getDisplay(parentOption)}</div>
</div>
);
dropdownPages.push(
<div key={'dropdown-' + i} className={generateDropdownClasses(i)}>
{treeBack}
{generateOptions(tabable, parentOption)}
</div>
);
}
} else {
dropdownPages.push(
<div key="dropdown-main" className={generateDropdownClasses(-1)}>
{generateOptions(true)}
</div>
);
}
let dropdownWrapper = (
<div className="dropdown-wrapper" ref="dropdownElement">
{dropdownPages}
</div>
);
return (
<div ref="mainElement" {...other} className={sh.getClassNames(mainClasses)}>
{input}
{dropdownWrapper}
</div>
);
}
}
ShInputSelect.propTypes = {
value: PropTypes.any,
options: PropTypes.array.isRequired,
onChange: PropTypes.func,
config: PropTypes.object,
validator: PropTypes.object,
};
ShInputSelect.defaultProps = {
value: null,
options: [],
onChange: _.noop,
config: defaultConfig,
validator: null,
};
export default ShInputSelect;