react-select-plus
Version:
A fork of react-select with support for option groups
1,270 lines (1,150 loc) • 41.1 kB
JavaScript
/*!
Copyright (c) 2016 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/react-select
*/
import React from 'react';
import ReactDOM from 'react-dom';
import AutosizeInput from 'react-input-autosize';
import classNames from 'classnames';
import defaultArrowRenderer from './utils/defaultArrowRenderer';
import defaultFilterOptions from './utils/defaultFilterOptions';
import defaultMenuRenderer from './utils/defaultMenuRenderer';
import defaultClearRenderer from './utils/defaultClearRenderer';
import stripDiacritics from './utils/stripDiacritics';
import Async from './Async';
import AsyncCreatable from './AsyncCreatable';
import Creatable from './Creatable';
import Dropdown from './Dropdown';
import Option from './Option';
import OptionGroup from './OptionGroup';
import Value from './Value';
function clone(obj) {
const copy = {};
for (let attr in obj) {
if (obj.hasOwnProperty(attr)) {
copy[attr] = obj[attr];
};
}
return copy;
}
function isGroup (option) {
return option && Array.isArray(option.options);
}
function stringifyValue (value) {
const valueType = typeof value;
if (valueType === 'string') {
return value;
} else if (valueType === 'object') {
return JSON.stringify(value);
} else if (valueType === 'number' || valueType === 'boolean') {
return String(value);
} else {
return '';
}
}
const stringOrNode = React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.node
]);
let instanceId = 1;
const invalidOptions = {};
const Select = React.createClass({
displayName: 'Select',
propTypes: {
addLabelText: React.PropTypes.string, // placeholder displayed when you want to add a label on a multi-value input
'aria-describedby': React.PropTypes.string, // HTML ID(s) of element(s) that should be used to describe this input (for assistive tech)
'aria-label': React.PropTypes.string, // Aria label (for assistive tech)
'aria-labelledby': React.PropTypes.string, // HTML ID of an element that should be used as the label (for assistive tech)
arrowRenderer: React.PropTypes.func, // Create drop-down caret element
autoBlur: React.PropTypes.bool, // automatically blur the component when an option is selected
autofocus: React.PropTypes.bool, // autofocus the component on mount
autosize: React.PropTypes.bool, // whether to enable autosizing or not
backspaceRemoves: React.PropTypes.bool, // whether backspace removes an item if there is no text input
backspaceToRemoveMessage: React.PropTypes.string, // Message to use for screenreaders to press backspace to remove the current item - {label} is replaced with the item label
className: React.PropTypes.string, // className for the outer element
clearAllText: stringOrNode, // title for the "clear" control when multi: true
clearRenderer: React.PropTypes.func, // create clearable x element
clearValueText: stringOrNode, // title for the "clear" control
clearable: React.PropTypes.bool, // should it be possible to reset value
deleteRemoves: React.PropTypes.bool, // whether backspace removes an item if there is no text input
delimiter: React.PropTypes.string, // delimiter to use to join multiple values for the hidden field value
disabled: React.PropTypes.bool, // whether the Select is disabled or not
dropdownComponent: React.PropTypes.func, // dropdown component to render the menu in
escapeClearsValue: React.PropTypes.bool, // whether escape clears the value when the menu is closed
filterOption: React.PropTypes.func, // method to filter a single option (option, filterString)
filterOptions: React.PropTypes.any, // boolean to enable default filtering or function to filter the options array ([options], filterString, [values])
ignoreAccents: React.PropTypes.bool, // whether to strip diacritics when filtering
ignoreCase: React.PropTypes.bool, // whether to perform case-insensitive filtering
inputProps: React.PropTypes.object, // custom attributes for the Input
inputRenderer: React.PropTypes.func, // returns a custom input component
instanceId: React.PropTypes.string, // set the components instanceId
isLoading: React.PropTypes.bool, // whether the Select is loading externally or not (such as options being loaded)
isOpen: React.PropTypes.bool, // whether the Select dropdown menu is open or not
joinValues: React.PropTypes.bool, // joins multiple values into a single form field with the delimiter (legacy mode)
labelKey: React.PropTypes.string, // path of the label value in option objects
matchPos: React.PropTypes.string, // (any|start) match the start or entire string when filtering
matchProp: React.PropTypes.string, // (any|label|value) which option property to filter on
menuBuffer: React.PropTypes.number, // optional buffer (in px) between the bottom of the viewport and the bottom of the menu
menuContainerStyle: React.PropTypes.object, // optional style to apply to the menu container
menuRenderer: React.PropTypes.func, // renders a custom menu with options
menuStyle: React.PropTypes.object, // optional style to apply to the menu
multi: React.PropTypes.bool, // multi-value input
name: React.PropTypes.string, // generates a hidden <input /> tag with this field name for html forms
noResultsText: stringOrNode, // placeholder displayed when there are no matching search results
onBlur: React.PropTypes.func, // onBlur handler: function (event) {}
onBlurResetsInput: React.PropTypes.bool, // whether input is cleared on blur
onChange: React.PropTypes.func, // onChange handler: function (newValue) {}
onClose: React.PropTypes.func, // fires when the menu is closed
onCloseResetsInput: React.PropTypes.bool, // whether input is cleared when menu is closed through the arrow
onFocus: React.PropTypes.func, // onFocus handler: function (event) {}
onInputChange: React.PropTypes.func, // onInputChange handler: function (inputValue) {}
onInputKeyDown: React.PropTypes.func, // input keyDown handler: function (event) {}
onMenuScrollToBottom: React.PropTypes.func, // fires when the menu is scrolled to the bottom; can be used to paginate options
onOpen: React.PropTypes.func, // fires when the menu is opened
onValueClick: React.PropTypes.func, // onClick handler for value labels: function (value, event) {}
openAfterFocus: React.PropTypes.bool, // boolean to enable opening dropdown when focused
openOnFocus: React.PropTypes.bool, // always open options menu on focus
optionClassName: React.PropTypes.string, // additional class(es) to apply to the <Option /> elements
optionComponent: React.PropTypes.func, // option component to render in dropdown
optionGroupComponent: React.PropTypes.func, // option group component to render in dropdown
optionRenderer: React.PropTypes.func, // optionRenderer: function (option) {}
options: React.PropTypes.array, // array of options
pageSize: React.PropTypes.number, // number of entries to page when using page up/down keys
placeholder: stringOrNode, // field placeholder, displayed when there's no value
renderInvalidValues: React.PropTypes.bool, // boolean to enable rendering values that do not match any options
required: React.PropTypes.bool, // applies HTML5 required attribute when needed
resetValue: React.PropTypes.any, // value to use when you clear the control
scrollMenuIntoView: React.PropTypes.bool, // boolean to enable the viewport to shift so that the full menu fully visible when engaged
searchable: React.PropTypes.bool, // whether to enable searching feature or not
simpleValue: React.PropTypes.bool, // pass the value to onChange as a simple value (legacy pre 1.0 mode), defaults to false
style: React.PropTypes.object, // optional style to apply to the control
tabIndex: React.PropTypes.string, // optional tab index of the control
tabSelectsValue: React.PropTypes.bool, // whether to treat tabbing out while focused to be value selection
value: React.PropTypes.any, // initial field value
valueComponent: React.PropTypes.func, // value component to render
valueKey: React.PropTypes.string, // path of the label value in option objects
valueRenderer: React.PropTypes.func, // valueRenderer: function (option) {}
wrapperStyle: React.PropTypes.object, // optional style to apply to the component wrapper
},
statics: { Async, AsyncCreatable, Creatable },
getDefaultProps () {
return {
addLabelText: 'Add "{label}"?',
arrowRenderer: defaultArrowRenderer,
autosize: true,
backspaceRemoves: true,
backspaceToRemoveMessage: 'Press backspace to remove {label}',
clearable: true,
clearAllText: 'Clear all',
clearRenderer: defaultClearRenderer,
clearValueText: 'Clear value',
deleteRemoves: true,
delimiter: ',',
disabled: false,
dropdownComponent: Dropdown,
escapeClearsValue: true,
filterOptions: defaultFilterOptions,
ignoreAccents: true,
ignoreCase: true,
inputProps: {},
isLoading: false,
joinValues: false,
labelKey: 'label',
matchPos: 'any',
matchProp: 'any',
menuBuffer: 0,
menuRenderer: defaultMenuRenderer,
multi: false,
noResultsText: 'No results found',
onBlurResetsInput: true,
onCloseResetsInput: true,
openAfterFocus: false,
optionComponent: Option,
optionGroupComponent: OptionGroup,
pageSize: 5,
placeholder: 'Select...',
renderInvalidValues: false,
required: false,
scrollMenuIntoView: true,
searchable: true,
simpleValue: false,
tabSelectsValue: true,
valueComponent: Value,
valueKey: 'value',
};
},
getInitialState () {
return {
inputValue: '',
isFocused: false,
isOpen: this.props.isOpen != null ? this.props.isOpen : false,
isPseudoFocused: false,
required: false,
};
},
componentWillMount () {
this._flatOptions = this.flattenOptions(this.props.options);
this._instancePrefix = 'react-select-' + (this.props.instanceId || ++instanceId) + '-';
const valueArray = this.getValueArray(this.props.value);
if (this.props.required) {
this.setState({
required: this.handleRequired(valueArray[0], this.props.multi),
});
}
},
componentDidMount () {
if (this.props.autofocus) {
this.focus();
}
},
componentWillReceiveProps (nextProps) {
if (nextProps.options !== this.props.options) {
this._flatOptions = this.flattenOptions(nextProps.options);
}
const valueArray = this.getValueArray(nextProps.value, nextProps);
if (!nextProps.isOpen && this.props.isOpen) {
this.closeMenu();
}
if (nextProps.required) {
this.setState({
required: this.handleRequired(valueArray[0], nextProps.multi),
});
}
},
componentWillUpdate (nextProps, nextState) {
if (nextState.isOpen !== this.state.isOpen) {
this.toggleTouchOutsideEvent(nextState.isOpen);
const handler = nextState.isOpen ? nextProps.onOpen : nextProps.onClose;
handler && handler();
}
},
componentDidUpdate (prevProps, prevState) {
// focus to the selected option
if (this.menu && this.focused && this.state.isOpen && !this.hasScrolledToOption) {
let focusedOptionNode = ReactDOM.findDOMNode(this.focused);
let focusedOptionPreviousSibling = focusedOptionNode.previousSibling;
let focusedOptionParent = focusedOptionNode.parentElement;
let menuNode = ReactDOM.findDOMNode(this.menu);
if (focusedOptionPreviousSibling) {
menuNode.scrollTop = focusedOptionPreviousSibling.offsetTop;
} else if (focusedOptionParent && focusedOptionParent === 'Select-menu') {
menuNode.scrollTop = focusedOptionParent.offsetTop;
} else {
menuNode.scrollTop = focusedOptionNode.offsetTop;
}
const paddingTop = parseInt(window.getComputedStyle(menuNode, null).paddingTop, 10);
if (menuNode.scrollTop <= paddingTop) menuNode.scrollTop = 0;
this.hasScrolledToOption = true;
} else if (!this.state.isOpen) {
this.hasScrolledToOption = false;
}
if (this._scrollToFocusedOptionOnUpdate && this.focused && this.menu) {
this._scrollToFocusedOptionOnUpdate = false;
var focusedDOM = ReactDOM.findDOMNode(this.focused);
var menuDOM = ReactDOM.findDOMNode(this.menu);
var focusedRect = focusedDOM.getBoundingClientRect();
var menuRect = menuDOM.getBoundingClientRect();
if (focusedRect.bottom > menuRect.bottom || focusedRect.top < menuRect.top) {
menuDOM.scrollTop = (focusedDOM.offsetTop + focusedDOM.clientHeight - menuDOM.offsetHeight);
}
}
if (this.props.scrollMenuIntoView && this.menuContainer) {
var menuContainerRect = this.menuContainer.getBoundingClientRect();
if (window.innerHeight < menuContainerRect.bottom + this.props.menuBuffer) {
window.scrollBy(0, menuContainerRect.bottom + this.props.menuBuffer - window.innerHeight);
}
}
if (prevProps.disabled !== this.props.disabled) {
this.setState({ isFocused: false }); // eslint-disable-line react/no-did-update-set-state
this.closeMenu();
}
},
componentWillUnmount () {
if (!document.removeEventListener && document.detachEvent) {
document.detachEvent('ontouchstart', this.handleTouchOutside);
} else {
document.removeEventListener('touchstart', this.handleTouchOutside);
}
},
toggleTouchOutsideEvent (enabled) {
if (enabled) {
if (!document.addEventListener && document.attachEvent) {
document.attachEvent('ontouchstart', this.handleTouchOutside);
} else {
document.addEventListener('touchstart', this.handleTouchOutside);
}
} else {
if (!document.removeEventListener && document.detachEvent) {
document.detachEvent('ontouchstart', this.handleTouchOutside);
} else {
document.removeEventListener('touchstart', this.handleTouchOutside);
}
}
},
handleTouchOutside (event) {
// handle touch outside on ios to dismiss menu
if (this.wrapper && !this.wrapper.contains(event.target) &&
this.menuContainer && !this.menuContainer.contains(event.target)) {
this.closeMenu();
}
},
focus () {
if (!this.input) return;
this.input.focus();
if (this.props.openAfterFocus) {
this.setState({
isOpen: true,
});
}
},
blurInput () {
if (!this.input) return;
this.input.blur();
},
handleTouchMove (event) {
// Set a flag that the view is being dragged
this.dragging = true;
},
handleTouchStart (event) {
// Set a flag that the view is not being dragged
this.dragging = false;
},
handleTouchEnd (event) {
// Check if the view is being dragged, In this case
// we don't want to fire the click event (because the user only wants to scroll)
if (this.dragging) return;
// Fire the mouse events
this.handleMouseDown(event);
},
handleTouchEndClearValue (event) {
// Check if the view is being dragged, In this case
// we don't want to fire the click event (because the user only wants to scroll)
if (this.dragging) return;
// Clear the value
this.clearValue(event);
},
handleMouseDown (event) {
// if the event was triggered by a mousedown and not the primary
// button, or if the component is disabled, ignore it.
if (this.props.disabled || (event.type === 'mousedown' && event.button !== 0)) {
return;
}
if (event.target.tagName === 'INPUT') {
return;
}
// prevent default event handlers
event.stopPropagation();
event.preventDefault();
// for the non-searchable select, toggle the menu
if (!this.props.searchable) {
this.focus();
return this.setState({
isOpen: !this.state.isOpen,
});
}
if (this.state.isFocused) {
// On iOS, we can get into a state where we think the input is focused but it isn't really,
// since iOS ignores programmatic calls to input.focus() that weren't triggered by a click event.
// Call focus() again here to be safe.
this.focus();
let input = this.input;
if (typeof input.getInput === 'function') {
// Get the actual DOM input if the ref is an <AutosizeInput /> component
input = input.getInput();
}
// clears the value so that the cursor will be at the end of input when the component re-renders
input.value = '';
// if the input is focused, ensure the menu is open
this.setState({
isOpen: true,
isPseudoFocused: false,
});
} else {
// otherwise, focus the input and open the menu
this._openAfterFocus = this.props.openOnFocus;
this.focus();
}
},
handleMouseDownOnArrow (event) {
// if the event was triggered by a mousedown and not the primary
// button, or if the component is disabled, ignore it.
if (this.props.disabled || (event.type === 'mousedown' && event.button !== 0)) {
return;
}
// If the menu isn't open, let the event bubble to the main handleMouseDown
if (!this.state.isOpen) {
return;
}
// prevent default event handlers
event.stopPropagation();
event.preventDefault();
// close the menu
this.closeMenu();
},
handleMouseDownOnMenu (event) {
// if the event was triggered by a mousedown and not the primary
// button, or if the component is disabled, ignore it.
if (this.props.disabled || (event.type === 'mousedown' && event.button !== 0)) {
return;
}
event.stopPropagation();
event.preventDefault();
this._openAfterFocus = true;
this.focus();
},
closeMenu () {
if(this.props.onCloseResetsInput) {
this.setState({
isOpen: false,
isPseudoFocused: this.state.isFocused && !this.props.multi,
inputValue: ''
}, () => {
if (this.props.onInputChange) this.props.onInputChange('');
});
} else {
this.setState({
isOpen: false,
isPseudoFocused: this.state.isFocused && !this.props.multi,
inputValue: this.state.inputValue
});
}
this.hasScrolledToOption = false;
},
handleInputFocus (event) {
if (this.props.disabled) return;
var isOpen = this.state.isOpen || this._openAfterFocus || this.props.openOnFocus;
if (this.props.onFocus) {
this.props.onFocus(event);
}
this.setState({
isFocused: true,
isOpen: isOpen
});
this._openAfterFocus = false;
},
handleInputBlur (event) {
// The check for menu.contains(activeElement) is necessary to prevent IE11's scrollbar from closing the menu in certain contexts.
if (this.menu && (this.menu === document.activeElement || this.menu.contains(document.activeElement))) {
this.focus();
return;
}
if (this.props.onBlur) {
this.props.onBlur(event);
}
var onBlurredState = {
isFocused: false,
isOpen: false,
isPseudoFocused: false,
};
if (this.props.onBlurResetsInput) {
onBlurredState.inputValue = '';
}
this.setState(onBlurredState);
},
handleInputChange (event) {
let newInputValue = event.target.value;
if (this.state.inputValue !== event.target.value && this.props.onInputChange) {
let nextState = this.props.onInputChange(newInputValue);
// Note: != used deliberately here to catch undefined and null
if (nextState != null && typeof nextState !== 'object') {
newInputValue = '' + nextState;
}
}
this.setState({
isOpen: true,
isPseudoFocused: false,
inputValue: newInputValue,
});
},
handleKeyDown (event) {
if (this.props.disabled) return;
if (typeof this.props.onInputKeyDown === 'function') {
this.props.onInputKeyDown(event);
if (event.defaultPrevented) {
return;
}
}
switch (event.keyCode) {
case 8: // backspace
if (!this.state.inputValue && this.props.backspaceRemoves) {
event.preventDefault();
this.popValue();
}
return;
case 9: // tab
if (event.shiftKey || !this.state.isOpen || !this.props.tabSelectsValue) {
return;
}
this.selectFocusedOption();
return;
case 13: // enter
if (!this.state.isOpen) {
this.setState({
isOpen: true
});
return;
};
event.stopPropagation();
this.selectFocusedOption();
break;
case 27: // escape
if (this.state.isOpen) {
this.closeMenu();
event.stopPropagation();
} else if (this.props.clearable && this.props.escapeClearsValue) {
this.clearValue(event);
event.stopPropagation();
}
break;
case 38: // up
this.focusPreviousOption();
break;
case 40: // down
this.focusNextOption();
break;
case 33: // page up
this.focusPageUpOption();
break;
case 34: // page down
this.focusPageDownOption();
break;
case 35: // end key
if (event.shiftKey) {
return;
}
this.focusEndOption();
break;
case 36: // home key
if (event.shiftKey) {
return;
}
this.focusStartOption();
break;
case 46: // backspace
if (!this.state.inputValue && this.props.deleteRemoves) {
event.preventDefault();
this.popValue();
}
return;
default: return;
}
event.preventDefault();
},
handleValueClick (option, event) {
if (!this.props.onValueClick) return;
this.props.onValueClick(option, event);
},
handleMenuScroll (event) {
if (!this.props.onMenuScrollToBottom) return;
let { target } = event;
if (target.scrollHeight > target.offsetHeight && !(target.scrollHeight - target.offsetHeight - target.scrollTop)) {
this.props.onMenuScrollToBottom();
}
},
handleRequired (value, multi) {
if (!value) return true;
return (multi ? value.length === 0 : Object.keys(value).length === 0);
},
getOptionLabel (op) {
return op[this.props.labelKey];
},
/**
* Turns a value into an array from the given options
* @param {String|Number|Array} value - the value of the select input
* @param {Object} nextProps - optionally specify the nextProps so the returned array uses the latest configuration
* @returns {Array} the value of the select represented in an array
*/
getValueArray (value, nextProps) {
/** support optionally passing in the `nextProps` so `componentWillReceiveProps` updates will function as expected */
const props = typeof nextProps === 'object' ? nextProps : this.props;
if (props.multi) {
if (typeof value === 'string') value = value.split(props.delimiter);
if (!Array.isArray(value)) {
if (value === null || value === undefined) return [];
value = [value];
}
return value.map(value => this.expandValue(value, props)).filter(i => i);
}
var expandedValue = this.expandValue(value, props);
return expandedValue ? [expandedValue] : [];
},
/**
* Retrieve a value from the given options and valueKey
* @param {String|Number|Array} value - the selected value(s)
* @param {Object} props - the Select component's props (or nextProps)
*/
expandValue (value, props) {
const valueType = typeof value;
if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') return value;
let { labelKey, valueKey, renderInvalidValues } = this.props;
let options = this._flatOptions;
if (!options || value === '') return;
for (var i = 0; i < options.length; i++) {
if (options[i][valueKey] === value) return options[i];
}
// no matching option, return an invalid option if renderInvalidValues is enabled
if (renderInvalidValues) {
invalidOptions[value] = invalidOptions[value] || {
invalid: true,
[labelKey]: value,
[valueKey]: value
};
return invalidOptions[value];
}
},
setValue (value) {
if (this.props.autoBlur){
this.blurInput();
}
if (!this.props.onChange) return;
if (this.props.required) {
const required = this.handleRequired(value, this.props.multi);
this.setState({ required });
}
if (this.props.simpleValue && value) {
value = this.props.multi ? value.map(i => i[this.props.valueKey]).join(this.props.delimiter) : value[this.props.valueKey];
}
this.props.onChange(value);
},
selectValue (value) {
//NOTE: update value in the callback to make sure the input value is empty so that there are no styling issues (Chrome had issue otherwise)
this.hasScrolledToOption = false;
if (this.props.multi) {
this.setState({
inputValue: '',
focusedIndex: null
}, () => {
this.addValue(value);
if (this.props.onInputChange) this.props.onInputChange('');
});
} else {
this.setState({
isOpen: false,
inputValue: '',
isPseudoFocused: this.state.isFocused,
}, () => {
this.setValue(value);
if (this.props.onInputChange) this.props.onInputChange('');
});
}
},
addValue (value) {
var valueArray = this.getValueArray(this.props.value);
const visibleOptions = this._visibleOptions.filter(val => !val.disabled);
const lastValueIndex = visibleOptions.indexOf(value);
this.setValue(valueArray.concat(value));
if (visibleOptions.length - 1 === lastValueIndex) {
// the last option was selected; focus the second-last one
this.focusOption(visibleOptions[lastValueIndex - 1]);
} else if (visibleOptions.length > lastValueIndex) {
// focus the option below the selected one
this.focusOption(visibleOptions[lastValueIndex + 1]);
}
},
popValue () {
var valueArray = this.getValueArray(this.props.value);
if (!valueArray.length) return;
if (valueArray[valueArray.length-1].clearableValue === false) return;
this.setValue(valueArray.slice(0, valueArray.length - 1));
},
removeValue (value) {
var valueArray = this.getValueArray(this.props.value);
this.setValue(valueArray.filter(i => i !== value));
},
clearValue (event) {
// if the event was triggered by a mousedown and not the primary
// button, ignore it.
if (event && event.type === 'mousedown' && event.button !== 0) {
return;
}
event.stopPropagation();
event.preventDefault();
this.setValue(this.getResetValue());
this.setState({
isOpen: false,
inputValue: '',
}, () => {
this.focus();
if (this.props.onInputChange) this.props.onInputChange('');
});
},
getResetValue () {
if (this.props.resetValue !== undefined) {
return this.props.resetValue;
} else if (this.props.multi) {
return [];
} else {
return null;
}
},
focusOption (option) {
this.setState({
focusedOption: option
});
},
focusNextOption () {
this.focusAdjacentOption('next');
},
focusPreviousOption () {
this.focusAdjacentOption('previous');
},
focusPageUpOption () {
this.focusAdjacentOption('page_up');
},
focusPageDownOption () {
this.focusAdjacentOption('page_down');
},
focusStartOption () {
this.focusAdjacentOption('start');
},
focusEndOption () {
this.focusAdjacentOption('end');
},
focusAdjacentOption (dir) {
var options = this._visibleOptions
.map((option, index) => ({ option, index }))
.filter(option => !option.option.disabled);
this._scrollToFocusedOptionOnUpdate = true;
if (!this.state.isOpen) {
this.setState({
isOpen: true,
inputValue: '',
focusedOption: this._focusedOption || (options.length ? options[dir === 'next' ? 0 : options.length - 1].option : null)
}, () => {
if (this.props.onInputChange) this.props.onInputChange('');
});
return;
}
if (!options.length) return;
var focusedIndex = -1;
for (var i = 0; i < options.length; i++) {
if (this._focusedOption === options[i].option) {
focusedIndex = i;
break;
}
}
if (dir === 'next' && focusedIndex !== -1 ) {
focusedIndex = (focusedIndex + 1) % options.length;
} else if (dir === 'previous') {
if (focusedIndex > 0) {
focusedIndex = focusedIndex - 1;
} else {
focusedIndex = options.length - 1;
}
} else if (dir === 'start') {
focusedIndex = 0;
} else if (dir === 'end') {
focusedIndex = options.length - 1;
} else if (dir === 'page_up') {
var potentialIndex = focusedIndex - this.props.pageSize;
if (potentialIndex < 0) {
focusedIndex = 0;
} else {
focusedIndex = potentialIndex;
}
} else if (dir === 'page_down') {
var potentialIndex = focusedIndex + this.props.pageSize;
if (potentialIndex > options.length - 1) {
focusedIndex = options.length - 1;
} else {
focusedIndex = potentialIndex;
}
}
if (focusedIndex === -1) {
focusedIndex = 0;
}
this.setState({
focusedIndex: options[focusedIndex].index,
focusedOption: options[focusedIndex].option
});
},
getFocusedOption () {
return this._focusedOption;
},
getInputValue () {
return this.state.inputValue;
},
selectFocusedOption () {
if (this._focusedOption) {
return this.selectValue(this._focusedOption);
}
},
renderLoading () {
if (!this.props.isLoading) return;
return (
<span className="Select-loading-zone" aria-hidden="true">
<span className="Select-loading" />
</span>
);
},
renderValue (valueArray, isOpen) {
let renderLabel = this.props.valueRenderer || this.getOptionLabel;
let ValueComponent = this.props.valueComponent;
if (!valueArray.length) {
return !this.state.inputValue ? <div className="Select-placeholder">{this.props.placeholder}</div> : null;
}
let onClick = this.props.onValueClick ? this.handleValueClick : null;
if (this.props.multi) {
return valueArray.map((value, i) => {
return (
<ValueComponent
id={this._instancePrefix + '-value-' + i}
instancePrefix={this._instancePrefix}
disabled={this.props.disabled || value.clearableValue === false}
key={`value-${i}-${value[this.props.valueKey]}`}
onClick={onClick}
onRemove={this.removeValue}
value={value}
>
{renderLabel(value, i)}
<span className="Select-aria-only"> </span>
</ValueComponent>
);
});
} else if (!this.state.inputValue) {
if (isOpen) onClick = null;
return (
<ValueComponent
id={this._instancePrefix + '-value-item'}
disabled={this.props.disabled}
instancePrefix={this._instancePrefix}
onClick={onClick}
value={valueArray[0]}
>
{renderLabel(valueArray[0])}
</ValueComponent>
);
}
},
renderInput (valueArray, focusedOptionIndex) {
var className = classNames('Select-input', this.props.inputProps.className);
const isOpen = !!this.state.isOpen;
const ariaOwns = classNames({
[this._instancePrefix + '-list']: isOpen,
[this._instancePrefix + '-backspace-remove-message']: this.props.multi
&& !this.props.disabled
&& this.state.isFocused
&& !this.state.inputValue
});
// TODO: Check how this project includes Object.assign()
const inputProps = Object.assign({}, this.props.inputProps, {
role: 'combobox',
'aria-expanded': '' + isOpen,
'aria-owns': ariaOwns,
'aria-haspopup': '' + isOpen,
'aria-activedescendant': isOpen ? this._instancePrefix + '-option-' + focusedOptionIndex : this._instancePrefix + '-value',
'aria-describedby': this.props['aria-describedby'],
'aria-labelledby': this.props['aria-labelledby'],
'aria-label': this.props['aria-label'],
className: className,
tabIndex: this.props.tabIndex,
onBlur: this.handleInputBlur,
onChange: this.handleInputChange,
onFocus: this.handleInputFocus,
ref: ref => this.input = ref,
required: this.state.required,
value: this.state.inputValue
});
if (this.props.inputRenderer) {
return this.props.inputRenderer(inputProps);
}
if (this.props.disabled || !this.props.searchable) {
const { inputClassName, ...divProps } = this.props.inputProps;
return (
<div
{...divProps}
role="combobox"
aria-expanded={isOpen}
aria-owns={isOpen ? this._instancePrefix + '-list' : this._instancePrefix + '-value'}
aria-activedescendant={isOpen ? this._instancePrefix + '-option-' + focusedOptionIndex : this._instancePrefix + '-value'}
className={className}
tabIndex={this.props.tabIndex || 0}
onBlur={this.handleInputBlur}
onFocus={this.handleInputFocus}
ref={ref => this.input = ref}
aria-readonly={'' + !!this.props.disabled}
style={{ border: 0, width: 1, display:'inline-block' }}/>
);
}
if (this.props.autosize) {
return (
<AutosizeInput {...inputProps} minWidth="5" />
);
}
return (
<div className={ className }>
<input {...inputProps} />
</div>
);
},
renderClear () {
if (!this.props.clearable || (!this.props.value || this.props.value === 0) || (this.props.multi && !this.props.value.length) || this.props.disabled || this.props.isLoading) return;
const clear = this.props.clearRenderer();
return (
<span className="Select-clear-zone" title={this.props.multi ? this.props.clearAllText : this.props.clearValueText}
aria-label={this.props.multi ? this.props.clearAllText : this.props.clearValueText}
onMouseDown={this.clearValue}
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
onTouchEnd={this.handleTouchEndClearValue}
>
{clear}
</span>
);
},
renderArrow () {
const onMouseDown = this.handleMouseDownOnArrow;
const isOpen = this.state.isOpen;
const arrow = this.props.arrowRenderer({ onMouseDown, isOpen });
return (
<span
className="Select-arrow-zone"
onMouseDown={onMouseDown}
>
{arrow}
</span>
);
},
filterFlatOptions (excludeOptions) {
const filterValue = this.state.inputValue;
const flatOptions = this._flatOptions;
if (this.props.filterOptions) {
// Maintain backwards compatibility with boolean attribute
const filterOptions = typeof this.props.filterOptions === 'function'
? this.props.filterOptions
: defaultFilterOptions;
return filterOptions(
flatOptions,
filterValue,
excludeOptions,
{
filterOption: this.props.filterOption,
ignoreAccents: this.props.ignoreAccents,
ignoreCase: this.props.ignoreCase,
labelKey: this.props.labelKey,
matchPos: this.props.matchPos,
matchProp: this.props.matchProp,
valueKey: this.props.valueKey,
}
);
} else {
return flatOptions;
}
},
flattenOptions (options, group) {
if (!options) return [];
let flatOptions = [];
for (let i = 0; i < options.length; i ++) {
// We clone each option with a pointer to its parent group for efficient unflattening
const optionCopy = clone(options[i]);
optionCopy.isInTree = false;
if (group) {
optionCopy.group = group;
}
if (isGroup(optionCopy)) {
flatOptions = flatOptions.concat(this.flattenOptions(optionCopy.options, optionCopy));
optionCopy.options = [];
} else {
flatOptions.push(optionCopy);
}
}
return flatOptions;
},
unflattenOptions (flatOptions) {
const groupedOptions = [];
let parent, child;
// Remove all ancestor groups from the tree
flatOptions.forEach((option) => {
option.isInTree = false;
parent = option.group;
while (parent) {
if (parent.isInTree) {
parent.options = [];
parent.isInTree = false;
}
parent = parent.group;
}
});
// Now reconstruct the options tree
flatOptions.forEach((option) => {
child = option;
parent = child.group;
while (parent) {
if (!child.isInTree) {
parent.options.push(child);
child.isInTree = true;
}
child = parent;
parent = child.group;
}
if (!child.isInTree) {
groupedOptions.push(child);
child.isInTree = true;
}
});
return groupedOptions;
},
onOptionRef(ref, isFocused) {
if (isFocused) {
this.focused = ref;
}
},
renderMenu (options, valueArray, focusedOption) {
if (options && options.length) {
return this.props.menuRenderer({
focusedOption,
focusOption: this.focusOption,
instancePrefix: this._instancePrefix,
labelKey: this.props.labelKey,
onFocus: this.focusOption,
onOptionRef: this.onOptionRef,
onSelect: this.selectValue,
optionClassName: this.props.optionClassName,
optionComponent: this.props.optionComponent,
optionGroupComponent: this.props.optionGroupComponent,
optionRenderer: this.props.optionRenderer || this.getOptionLabel,
options,
selectValue: this.selectValue,
valueArray,
valueKey: this.props.valueKey,
});
} else if (this.props.noResultsText) {
return (
<div className="Select-noresults">
{this.props.noResultsText}
</div>
);
} else {
return null;
}
},
renderHiddenField (valueArray) {
if (!this.props.name) return;
if (this.props.joinValues) {
let value = valueArray.map(i => stringifyValue(i[this.props.valueKey])).join(this.props.delimiter);
return (
<input
type="hidden"
ref={ref => this.value = ref}
name={this.props.name}
value={value}
disabled={this.props.disabled} />
);
}
return valueArray.map((item, index) => (
<input key={'hidden.' + index}
type="hidden"
ref={'value' + index}
name={this.props.name}
value={stringifyValue(item[this.props.valueKey])}
disabled={this.props.disabled} />
));
},
getFocusableOptionIndex (selectedOption) {
var options = this._visibleOptions;
if (!options.length) return null;
let focusedOption = this.state.focusedOption || selectedOption;
if (focusedOption && !focusedOption.disabled) {
let focusedOptionIndex = -1;
options.some((option, index) => {
const isOptionEqual = option.value === focusedOption.value;
if (isOptionEqual) {
focusedOptionIndex = index;
}
return isOptionEqual;
});
if (focusedOptionIndex !== -1) {
return focusedOptionIndex;
}
}
for (var i = 0; i < options.length; i++) {
if (!options[i].disabled) return i;
}
return null;
},
renderOuter (options, valueArray, focusedOption) {
let Dropdown = this.props.dropdownComponent;
let menu = this.renderMenu(options, valueArray, focusedOption);
if (!menu) {
return null;
}
return (
<Dropdown>
<div ref={ref => this.menuContainer = ref} className="Select-menu-outer" style={this.props.menuContainerStyle}>
<div ref={ref => this.menu = ref} role="listbox" className="Select-menu" id={this._instancePrefix + '-list'}
style={this.props.menuStyle}
onScroll={this.handleMenuScroll}
onMouseDown={this.handleMouseDownOnMenu}>
{menu}
</div>
</div>
</Dropdown>
);
},
render () {
let valueArray = this.getValueArray(this.props.value);
this._visibleOptions = this.filterFlatOptions(this.props.multi ? valueArray : null);
let options = this.unflattenOptions(this._visibleOptions);
let isOpen = typeof this.props.isOpen === 'boolean' ? this.props.isOpen : this.state.isOpen;
const focusedOptionIndex = this.getFocusableOptionIndex(valueArray[0]);
let focusedOption = null;
if (focusedOptionIndex !== null) {
focusedOption = this._focusedOption = this._visibleOptions[focusedOptionIndex];
} else {
focusedOption = this._focusedOption = null;
}
let className = classNames('Select', this.props.className, {
'Select--multi': this.props.multi,
'Select--single': !this.props.multi,
'is-disabled': this.props.disabled,
'is-focused': this.state.isFocused,
'is-loading': this.props.isLoading,
'is-open': isOpen,
'is-pseudo-focused': this.state.isPseudoFocused,
'is-searchable': this.props.searchable,
'has-value': valueArray.length,
});
let removeMessage = null;
if (this.props.multi &&
!this.props.disabled &&
valueArray.length &&
!this.state.inputValue &&
this.state.isFocused &&
this.props.backspaceRemoves) {
removeMessage = (
<span id={this._instancePrefix + '-backspace-remove-message'} className="Select-aria-only" aria-live="assertive">
{this.props.backspaceToRemoveMessage.replace('{label}', valueArray[valueArray.length - 1][this.props.labelKey])}
</span>
);
}
return (
<div ref={ref => this.wrapper = ref}
className={className}
style={this.props.wrapperStyle}>
{this.renderHiddenField(valueArray)}
<div ref={ref => this.control = ref}
className="Select-control"
style={this.props.style}
onKeyDown={this.handleKeyDown}
onMouseDown={this.handleMouseDown}
onTouchEnd={this.handleTouchEnd}
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
>
<span className="Select-multi-value-wrapper" id={this._instancePrefix + '-value'}>
{this.renderValue(valueArray, isOpen)}
{this.renderInput(valueArray, focusedOptionIndex)}
</span>
{removeMessage}
{this.renderLoading()}
{this.renderClear()}
{this.renderArrow()}
</div>
{isOpen ? this.renderOuter(options, !this.props.multi ? valueArray : null, focusedOption) : null}
</div>
);
}
});
Select.stripDiacritics = stripDiacritics;
export default Select;