react-bootstrap-autosuggest
Version:
Autosuggest component for react-bootstrap
1,389 lines (1,311 loc) • 48.6 kB
JavaScript
// @flow
import classNames from 'classnames'
import shallowEqual from 'fbjs/lib/shallowEqual'
import keycode from 'keycode'
import PropTypes from 'prop-types'
import React from 'react'
import { Dropdown } from 'react-bootstrap'
import ReactDOM from 'react-dom'
import warning from 'warning'
import Choices from './Choices'
import Suggestions from './Suggestions'
import ItemAdapter from './ItemAdapter'
export { ItemAdapter }
import ListAdapter from './ListAdapter'
export { ListAdapter }
import EmptyListAdapter from './EmptyListAdapter'
export { EmptyListAdapter }
import ArrayListAdapter from './ArrayListAdapter'
export { ArrayListAdapter }
import MapListAdapter from './MapListAdapter'
export { MapListAdapter }
import ObjectListAdapter from './ObjectListAdapter'
export { ObjectListAdapter }
import type { Node } from './types'
type Props = {
addonAfter?: Node;
addonBefore?: Node;
allowDuplicates?: boolean;
bsSize?: 'small' | 'large';
buttonAfter?: Node;
buttonBefore?: Node;
choicesClass?: React.Component<*, *, *> | string;
closeOnCompletion?: boolean;
datalist?: any;
datalistAdapter?: ListAdapter<*, *>;
datalistMessage?: Node;
datalistOnly?: boolean;
datalistPartial?: boolean;
defaultValue?: any;
disabled?: boolean;
dropup?: boolean;
groupClassName?: string;
inputSelect?: (input: HTMLInputElement, value: string, completion: string) => void;
itemAdapter?: ItemAdapter<*>;
itemReactKeyPropName?: string;
itemSortKeyPropName?: string;
itemValuePropName?: string;
multiple?: boolean;
onAdd?: (item: any) => void;
onBlur?: (value: any) => void;
onChange?: (value: any) => void;
onDatalistMessageSelect?: () => void;
onFocus?: (value: any) => void;
onRemove?: (index: number) => void;
onSearch?: (search: string) => void;
onSelect?: (item: any) => void;
onToggle?: (open: boolean) => void;
placeholder?: string;
required?: boolean;
searchDebounce?: number;
showToggle?: boolean | 'auto';
suggestionsClass?: React.Component<*, *, *> | string;
toggleId?: string | number;
type?: string;
value?: any;
valueIsItem?: boolean;
}
type State = {
open: boolean;
disableFilter: boolean;
inputValue: string;
inputValueKeyPress: number;
inputFocused: boolean;
selectedItems: any[];
searchValue: ?string;
}
/**
* Combo-box input component that combines a drop-down list and a single-line
* editable text box. The set of options for the drop-down list can be
* controlled dynamically. Selection of multiple items is supported using a
* tag/pill-style user interface within a simulated text box.
*/
export default class Autosuggest extends React.Component {
static propTypes = {
/**
* Text or component appearing in the input group after the input element
* (and before any button specified in `buttonAfter`).
*/
addonAfter: PropTypes.node,
/**
* Text or component appearing in the input group before the input element
* (and before any button specified in `buttonBefore`).
*/
addonBefore: PropTypes.node,
/**
* Indicates whether duplicate values are allowed in `multiple` mode.
*/
allowDuplicates: PropTypes.bool,
/**
* Specifies the size of the form group and its contained components.
* Leave undefined for normal/medium size.
*/
bsSize: PropTypes.oneOf(['small', 'large']),
/**
* Button component appearing in the input group after the input element
* (and after any add-on specified in `addonAfter`).
*/
buttonAfter: PropTypes.node,
/**
* Button component appearing in the input group before the input element
* (and after any add-on specified in `addonBefore`).
*/
buttonBefore: PropTypes.node,
/**
* React component class used to render the selected items in multiple mode.
*/
choicesClass: PropTypes.oneOfType([
PropTypes.func,
PropTypes.string
]),
/**
* Indicates whether the drop-down menu should be closed automatically when
* auto-completion occurs. By default, the menu will remain open, so the
* user can see any additional information about the selected item (such as
* a shorthand code that caused it to be selected).
*/
closeOnCompletion: PropTypes.bool,
/**
* A collection of items (such as an array, object, or Map) used as
* auto-complete suggestions. Each item may have any type supported by the
* `itemAdapter`. The default item adapter has basic support for any
* non-null type: it will initially try to access item properties using the
* configured property names (`itemReactKeyPropName`, `itemSortKeyPropName`,
* and `itemValuePropName`), but will fall back to using the `toString`
* method to obtain these properties to support primitives and other object
* types.
*
* If `datalist` is undefined or null and `onSearch` is not, the datalist
* is assumed to be dynamically populated, and the drop-down toggle will be
* enabled and will trigger `onSearch` the first time it is clicked.
* Conversely, an empty `datalist` or undefined/null `onSearch` indicates
* that there are no auto-complete options.
*/
datalist: PropTypes.any,
/**
* An instance of the ListAdapter class that provides datalist access
* methods required by this component.
*/
datalistAdapter: PropTypes.object,
/**
* Message to be displayed at the end of the datalist. It can be used to
* indicate that data is being fetched asynchronously, that an error
* occurred fetching data, or that additional options can be requested.
* It behaves similarly to a menu item, except that it is not filtered or
* sorted and cannot be selected (except to invoke `onDatalistMessageSelect`).
* Changing this property to a different non-null value while the component
* is focused causes the drop-down menu to be opened, which is useful for
* reporting status, such as that options are being fetched or failed to be
* fetched.
*/
datalistMessage: PropTypes.node,
/**
* Indicates that only values matching an item from the `datalist` property
* are considered valid. For search purposes, intermediate values of the
* underlying `input` element may not match while the component is focused,
* but any non-matching value will be replaced with the previous matching
* value when the component loses focus.
*
* Note that there are two cases where the current (valid) value may not
* correspond to an item in the datalist:
*
* - If the value was provided by the `value` or `defaultValue` property
* and either `datalist` is undefined/null (as opposed to empty) or
* `datalistPartial` is true, the value is assumed to be valid.
* - If `datalist` changes and `datalistPartial` is true, any previously
* valid value is assumed to remain valid. (Conversely, if `datalist`
* changes and `datalistPartial` is false, a previously valid value will
* be invalidated if not in the new `datalist`.)
*/
datalistOnly: PropTypes.bool,
/**
* Indicates that the `datalist` property should be considered incomplete
* for validation purposes. Specifically, if both `datalistPartial` and
* `datalistOnly` are true, changes to the `datalist` will not render
* invalid a value that was previously valid. This is useful in cases where
* a partial datalist is obtained dynamically in response to the `onSearch`
* callback.
*/
datalistPartial: PropTypes.bool,
/**
* Initial value to be rendered when used as an
* [uncontrolled component](https://facebook.github.io/react/docs/forms.html#uncontrolled-components)
* (i.e. no `value` property is supplied).
*/
defaultValue: PropTypes.any,
/**
* Indicates whether the form group is disabled, which causes all of its
* contained elements to ignore input and focus events and to be displayed
* grayed out.
*/
disabled: PropTypes.bool,
/**
* Indicates whether the suggestion list should drop up instead of down.
*
* Note that currently a drop-up list extending past the top of the page is
* clipped, rendering the clipped items inaccessible, whereas a drop-down
* list will extend the page and allow scrolling as necessary.
*/
dropup: PropTypes.bool,
/**
* Custom class name applied to the input group.
*/
groupClassName: PropTypes.string,
/**
* Function used to select a portion of the input value when auto-completion
* occurs. The default implementation selects just the auto-completed
* portion, which is equivalent to:
*
* ```js
* defaultInputSelect(input, value, completion) {
* input.setSelectionRange(value.length, completion.length)
* }
* ```
*/
inputSelect: PropTypes.func,
/**
* An instance of the ItemAdapter class that provides the item access
* methods required by this component.
*/
itemAdapter: PropTypes.object,
/**
* Name of the item property used for the React component key. If this
* property is not defined, `itemValuePropName` is used instead. If neither
* property is defined, `toString()` is called on the item.
*/
itemReactKeyPropName: PropTypes.string,
/**
* Name of the item property used for sorting items. If this property is not
* defined, `itemValuePropName` is used instead. If neither property is
* defined, `toString()` is called on the item.
*/
itemSortKeyPropName: PropTypes.string,
/**
* Name of item property used for the input element value. If this property
* is not defined, `toString()` is called on the item.
*/
itemValuePropName: PropTypes.string,
/**
* Enables selection of multiple items. The value property should be an
* array of items.
*/
multiple: PropTypes.bool,
/**
* Callback function called whenever a new value should be appended to the
* array of values in `multiple` mode. The sole argument is the added item.
*/
onAdd: PropTypes.func,
/**
* Callback function called whenever the input focus leaves this component.
* The sole argument is current value (see `onChange for details`).
*/
onBlur: PropTypes.func,
/**
* Callback function called whenever the input value changes to a different
* valid value. Validity depends on properties such as `datalistOnly`,
* `valueIsItem`, and `required`. The sole argument is current value:
*
* - If `multiple` is enabled, the current value is an array of selected
* items.
* - If `valueIsItem` is enabled, the current value is the selected
* datalist item.
* - Otherwise, the current value is the `input` element value. Note that
* if `datalistOnly` or `required` are enabled, only valid values trigger
* a callback.
*/
onChange: PropTypes.func,
/**
* Callback function called whenever the datalist item created for
* `datalistMessage` is selected. If this property is null, the associated
* item is displayed as disabled.
*/
onDatalistMessageSelect: PropTypes.func,
/**
* Callback function called whenever the input focus enters this component.
* The sole argument is current value (see `onChange for details`).
*/
onFocus: PropTypes.func,
/**
* Callback function called whenever a value should be removed from the
* array of values in `multiple` mode. The sole argument is the index of
* the value to remove.
*/
onRemove: PropTypes.func,
/**
* Callback function called periodically when the `input` element value has
* changed. The sole argument is the current value of the `input` element.
* This callback can be used to dynamically populate the `datalist` based on
* the input value so far, e.g. with values obtained from a remote service.
* Once changed, the value must then remain unchanged for `searchDebounce`
* milliseconds before the function will be called. No two consecutive
* invocations of the function will be passed the same value (i.e. changing
* and then restoring the value within the debounce interval is not
* considered a change). Note also that the callback can be invoked with an
* empty string, if the user clears the `input` element; this implies that
* any minimum search string length should be imposed by the function.
*/
onSearch: PropTypes.func,
/**
* Callback function called whenever an item from the suggestion list is
* selected (regardless of whether it is clicked or typed). The sole
* argument is the selected item.
*/
onSelect: PropTypes.func,
/**
* Callback function called whenever the drop-down list of suggestions is
* opened or closed. The sole argument is a boolean value indicating whether
* the list is open.
*/
onToggle: PropTypes.func,
/**
* Placeholder text propagated to the underlying `input` element (when
* `multiple` is false or no items have been selected).
*/
placeholder: PropTypes.string,
/**
* `required` property passed to the `input` element (when `multiple` is
* false or no items have been selected).
*/
required: PropTypes.bool,
/**
* The number of milliseconds that must elapse between the last change to
* the `input` element value and a call to `onSearch`. The default is 250.
*/
searchDebounce: PropTypes.number,
/**
* Indicates whether to show the drop-down toggle. If set to `auto`, the
* toggle is shown only when the `datalist` is non-empty or dynamic.
*/
showToggle: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.oneOf(['auto'])
]),
/**
* React component class used to render the drop-down list of suggestions.
*/
suggestionsClass: PropTypes.oneOfType([
PropTypes.func,
PropTypes.string
]),
/**
* ID supplied to the drop-down toggle and used by the drop-down menu to
* refer to it.
*/
toggleId: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number
]),
/**
* `type` property supplied to the contained `input` element. Only textual
* types should be specified, such as `text`, `search`, `email`, `tel`, or
* perhaps `number`. Note that the browser may supply additional UI elements
* for some types (e.g. increment/decrement buttons for `number`) that may
* need additional styling or may interfere with UI elements supplied by
* this component.
*/
type: PropTypes.string,
/**
* The value to be rendered by the component. If unspecified, the component
* behaves like an [uncontrolled component](https://facebook.github.io/react/docs/forms.html#uncontrolled-components).
*/
value: PropTypes.any,
/**
* Indicates that the `value` property should be interpreted as a datalist
* item, as opposed to the string value of the underlying `input` element.
* When false (the default), the `value` property (if specified) is
* expected to be a string and corresponds (indirectly) to the `value`
* property of the underlying `input` element. When true, the `value`
* property is expected to be a datalist item whose display value (as
* provided by the `itemAdapter`) is used as the `input` element value.
* This property also determines whether the argument to the `onChange`
* callback is the `input` value or a datalist item.
*
* Note that unless `datalistOnly` is also true, items may also be created
* dynamically using the `newFromValue` method of the `itemAdapter`.
*
* Also note that this property is ignored if `multiple` is true; in that
* case, the `value` property and `onChange` callback argument are
* implicitly an array of datalist items.
*/
valueIsItem: PropTypes.bool
};
static contextTypes = {
$bs_formGroup: PropTypes.object
};
static defaultInputSelect(input: HTMLInputElement, value: string, completion: string) {
// https://html.spec.whatwg.org/multipage/forms.html#do-not-apply
switch (input.type) {
case 'text':
case 'search':
case 'url':
case 'tel':
case 'password':
// istanbul ignore else
if (input.setSelectionRange) {
input.setSelectionRange(value.length, completion.length)
} else if (input.createTextRange) { // old IE
const range = input.createTextRange()
range.moveEnd('character', completion.length)
range.moveStart('character', value.length)
range.select()
}
}
}
static defaultProps = {
closeOnCompletion: false,
datalistOnly: false,
datalistPartial: false,
disabled: false,
dropup: false,
inputSelect: Autosuggest.defaultInputSelect,
multiple: false,
itemReactKeyPropName: 'key',
itemSortKeyPropName: 'sortKey',
itemValuePropName: 'value',
searchDebounce: 250,
showToggle: 'auto',
type: 'text',
valueIsItem: false
};
state: State;
_itemAdapter: ItemAdapter<*>;
_listAdapter: ListAdapter<*, *>;
_lastValidItem: any;
_lastValidValue: string;
_foldedInputValue: string;
_pseudofocusedItem: any;
_keyPressCount: number;
_inputItem: any;
_inputItemEphemeral: boolean;
_valueIsValid : boolean;
_valueWasValidated: boolean;
_lastOnChangeValue: any;
_lastOnSelectValue: any;
_autoCompleteAfterRender: ?boolean;
_menuFocusedBeforeUpdate: ?boolean;
_lastOpenEventType: ?string;
_focusTimeoutId: ?number;
_focused: ?boolean;
_searchTimeoutId: ?number;
constructor(props: Props, ...args: any) {
super(props, ...args)
/* istanbul ignore next: https://github.com/gotwarlost/istanbul/issues/690#issuecomment-265718617 */
this._itemAdapter = props.itemAdapter || new ItemAdapter()
this._itemAdapter.receiveProps(props)
this._listAdapter = props.datalistAdapter ||
this._getListAdapter(props.datalist)
this._listAdapter.receiveProps(props, this._itemAdapter)
const { inputValue, inputItem, inputItemEphemeral, selectedItems } =
this._getValueFromProps(props)
this._setValueMeta(inputItem, inputItemEphemeral, true, true)
this._lastValidItem = inputItem
this._lastValidValue = inputValue
this._keyPressCount = 0
this.state = {
open: false,
disableFilter: false,
inputValue,
inputValueKeyPress: 0,
inputFocused: false,
selectedItems,
searchValue: null
}
this._lastOnChangeValue = this._getCurrentValue()
this._lastOnSelectValue = inputItem
const self: any = this // https://github.com/facebook/flow/issues/1517
self._renderSelected = this._renderSelected.bind(this)
self._getItemKey = this._getItemKey.bind(this)
self._isSelectedItem = this._isSelectedItem.bind(this)
self._renderSuggested = this._renderSuggested.bind(this)
self._handleToggleClick = this._handleToggleClick.bind(this)
self._handleInputChange = this._handleInputChange.bind(this)
self._handleItemSelect = this._handleItemSelect.bind(this)
self._removeItem = this._removeItem.bind(this)
self._handleShowAll = this._handleShowAll.bind(this)
self._handleKeyDown = this._handleKeyDown.bind(this)
self._handleKeyPress = this._handleKeyPress.bind(this)
self._handleMenuClose = this._handleMenuClose.bind(this)
self._handleInputFocus = this._handleInputFocus.bind(this)
self._handleInputBlur = this._handleInputBlur.bind(this)
self._handleFocus = this._handleFocus.bind(this)
self._handleBlur = this._handleBlur.bind(this)
}
_getListAdapter<L>(list: L): ListAdapter<*, L> {
if (list == null) {
return (new EmptyListAdapter(): any)
} else if (Array.isArray(list)) {
return (new ArrayListAdapter(): any)
} else if (list instanceof Map) {
return (new MapListAdapter(): any)
} else if (typeof list === 'object') {
return (new ObjectListAdapter(): any)
} else {
throw Error('Unexpected datalist type: datalistAdapter required')
}
}
_getValueFromProps(props: Props): {
inputValue: string,
inputItem: any,
inputItemEphemeral: boolean,
selectedItems: any[]
} {
let inputValue = ''
let inputItem = null
let inputItemEphemeral = false
let selectedItems = []
const value = props.value || props.defaultValue
if (value != null) {
if (props.multiple) {
if (Array.isArray(value)) {
selectedItems = this._filterItems(value, props)
} else {
warning(!value, 'Array expected for value property')
}
} else if (props.valueIsItem) {
const itemValue = this._itemAdapter.getInputValue(value)
if (props.datalist != null) {
inputItem = this._listAdapter.findMatching(props.datalist, itemValue)
if (inputItem != null) {
inputValue = inputItem === value ? itemValue :
this._itemAdapter.getInputValue(inputItem)
} else if (props.datalistOnly && !props.datalistPartial) {
this._warnInvalidValue(value)
} else {
inputValue = itemValue
inputItem = value
}
} else {
inputValue = itemValue
inputItem = value
}
} else if (value) {
if (props.datalist != null) {
inputItem = this._listAdapter.findMatching(props.datalist, value)
if (inputItem != null) {
inputValue = this._itemAdapter.getInputValue(inputItem)
} else if (props.datalistOnly && !props.datalistPartial) {
this._warnInvalidValue(value)
} else {
inputValue = value.toString()
inputItem = this._itemAdapter.newFromValue(value)
inputItemEphemeral = true
}
} else {
inputValue = value.toString()
inputItem = this._itemAdapter.newFromValue(value)
inputItemEphemeral = true
}
}
}
return { inputValue, inputItem, inputItemEphemeral, selectedItems }
}
_filterItems(items: any[], props: Props): any[] {
if (props.datalist != null || !props.allowDuplicates) {
const result = []
const valueSet = {}
let different = false
for (let item of items) {
const value = this._itemAdapter.getInputValue(item)
if (!props.allowDuplicates && valueSet[value]) {
different = true
continue
}
const listItem = this._listAdapter.findMatching(props.datalist, value)
if (listItem != null) {
result.push(listItem)
valueSet[value] = true
different = true
} else if (props.datalistOnly && !props.datalistPartial) {
this._warnInvalidValue(value)
different = true
} else {
result.push(item)
valueSet[value] = true
}
}
if (different) {
return result
}
}
return items
}
_warnInvalidValue(value: string) {
warning(false, 'Value "%s" does not match any datalist value', value)
}
_setInputValue(value: string, callback?: () => void) {
// track keypress count in state so re-render is forced even if value is
// unchanged; this is necessary when typing over the autocompleted range
// with matching characters to properly maintain the input selection range
this.setState({
inputValue: value,
inputValueKeyPress: this._keyPressCount
}, callback)
}
_setValueMeta(
inputItem: any,
inputItemEphemeral: boolean = false,
isValid: boolean = inputItem != null,
validated: boolean = isValid) {
this._inputItem = inputItem
this._inputItemEphemeral = inputItemEphemeral
this._valueIsValid = isValid
this._valueWasValidated = validated
}
_clearInput() {
this._setValueMeta(null, false, true, true)
this._setInputValue('')
}
_getValueUsing(props: Props, inputValue: string, inputItem: any, selectedItems: any[]) {
return props.multiple ? selectedItems :
props.valueIsItem ? inputItem : inputValue
}
_getCurrentValue() {
return this._getValueUsing(
this.props, this.state.inputValue, this._inputItem, this.state.selectedItems)
}
componentDidMount() {
// IE8 can jump cursor position if not immediately updated to typed value;
// for other browsers, we can avoid re-rendering for the auto-complete
this._autoCompleteAfterRender = !this.refs.input.setSelectionRange
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.itemAdapter != this.props.itemAdapter) {
this._itemAdapter = nextProps.itemAdapter || new ItemAdapter()
}
this._itemAdapter.receiveProps(nextProps)
if (nextProps.datalist != this.props.datalist ||
nextProps.datalistAdapter != this.props.datalistAdapter) {
if (nextProps.datalistAdapter) {
this._listAdapter = nextProps.datalistAdapter
} else {
const listAdapter = this._getListAdapter(nextProps.datalist)
if (listAdapter.constructor != this._listAdapter.constructor) {
this._listAdapter = listAdapter
}
}
}
this._listAdapter.receiveProps(nextProps, this._itemAdapter)
// if props.value changes (to a value other than the current state), or
// validation changes to make state invalid, propagate props.value to state
const nextValue = nextProps.value
let { inputValue } = this.state
const valueChanged = nextValue !== this.props.value &&
nextValue !== this._getValueUsing(nextProps, inputValue, this._inputItem,
this.state.selectedItems)
let inputItem, inputValueInvalid, propsValueInvalid, validateSelected
if (!valueChanged) {
if (nextProps.datalistOnly) {
const canValidate = !nextProps.datalistPartial && nextProps.datalist != null
const validationChanged = !this.props.datalistOnly ||
(!nextProps.datalistPartial && this.props.datalistPartial) ||
(nextProps.datalist != this.props.datalist)
if (inputValue) {
inputItem = this._listAdapter.findMatching(nextProps.datalist, inputValue)
if (inputItem == null) {
if (!canValidate && !this._inputItemEphemeral) {
inputItem = this._inputItem
} else if (this._inputItemEphemeral && nextValue === inputValue) {
propsValueInvalid = true
}
}
inputValueInvalid = inputItem == null && validationChanged
// update metadata but don't reset input value if invalid but focused
if (inputValueInvalid && this._focused) {
this._setValueMeta(null, false, false, true)
if (validationChanged && canValidate && this._lastValidItem != null) {
// revalidate last valid item, which will be restored on blur
this._lastValidItem = this._listAdapter.findMatching(
nextProps.datalist, this._lastValidValue)
if (this._lastValidItem == null) {
this._lastValidValue = ''
}
}
inputValueInvalid = false
}
} else {
inputItem = null
inputValueInvalid = false
}
validateSelected = nextProps.multiple && canValidate && validationChanged
}
if (nextProps.multiple && !nextProps.allowDuplicates && this.props.allowDuplicates) {
validateSelected = true
}
}
// inputValueInvalid implies !multiple, since inputValue of multiple should
// be blank when not focused
if (valueChanged || inputValueInvalid) {
let inputItemEphemeral, selectedItems
if (propsValueInvalid) {
inputValue = ''
inputItemEphemeral = false
selectedItems = []
} else {
({ inputValue, inputItem, inputItemEphemeral, selectedItems } =
this._getValueFromProps(nextProps))
}
// if props.value change resolved to current state item, don't reset input
if (inputItem !== this._inputItem || !this._focused) {
this._setValueMeta(inputItem, inputItemEphemeral, true, true)
this._setInputValue(inputValue)
this.setState({ selectedItems })
validateSelected = false
this._lastValidItem = inputItem
this._lastValidValue = inputValue
// suppress onChange (but not onSelect) if value came from props
if (valueChanged) {
this._lastOnChangeValue = this._getValueUsing(nextProps, inputValue,
inputItem, selectedItems)
}
} else if (valueChanged && nextProps.multiple) {
this.setState({ selectedItems })
}
} else if (inputValue && nextProps.datalist != this.props.datalist && this._focused) {
// if datalist changed but value didn't, attempt to autocomplete
this._checkAutoComplete(inputValue, nextProps)
}
if (validateSelected) {
const selectedItems = this._filterItems(this.state.selectedItems, nextProps)
this.setState({ selectedItems })
}
// open dropdown if datalist message is set while focused
if (nextProps.datalistMessage &&
nextProps.datalistMessage != this.props.datalistMessage &&
this._focused) {
this._open('message', nextProps)
}
}
shouldComponentUpdate(nextProps: Props, nextState: State): boolean {
return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState)
}
componentWillUpdate(nextProps: Props, nextState: State) {
const { suggestions } = this.refs
this._menuFocusedBeforeUpdate = suggestions && suggestions.isFocused()
const nextInputValue = nextState.inputValue
if (nextInputValue != this.state.inputValue) {
let inputItem, inputItemEphemeral, isValid
if (!this._valueWasValidated) {
if (nextInputValue) {
inputItem = this._listAdapter.findMatching(nextProps.datalist, nextInputValue)
if (inputItem == null && !nextProps.datalistOnly) {
inputItem = this._itemAdapter.newFromValue(nextInputValue)
inputItemEphemeral = true
isValid = true
} else {
inputItemEphemeral = false
isValid = inputItem != null
}
} else {
inputItem = null
inputItemEphemeral = false
isValid = !nextProps.required
}
this._setValueMeta(inputItem, inputItemEphemeral, isValid)
} else {
inputItem = this._inputItem
isValid = this._valueIsValid
}
if (isValid) {
this._lastValidItem = inputItem
this._lastValidValue = inputItem && !inputItemEphemeral ?
this._itemAdapter.getInputValue(inputItem) : nextInputValue
}
if (isValid) {
const { multiple, onChange } = nextProps
if (!multiple && onChange) {
const value = this._getValueUsing(
nextProps, nextInputValue, inputItem, nextState.selectedItems)
if (value !== this._lastOnChangeValue) {
this._lastOnChangeValue = value
onChange(value)
}
}
const { onSelect } = nextProps
if (onSelect && inputItem !== this._lastOnSelectValue) {
this._lastOnSelectValue = inputItem
onSelect(inputItem)
}
}
}
const { onToggle } = nextProps
if (onToggle && nextState.open != this.state.open) {
onToggle(nextState.open)
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
if ((this.state.open && !prevState.open &&
this._lastOpenEventType === 'keydown') ||
(this.state.disableFilter && !prevState.disableFilter &&
this._menuFocusedBeforeUpdate)) {
this.refs.suggestions.focusFirst()
} else if (!this.state.open && prevState.open) { // closed
if (this._menuFocusedBeforeUpdate) {
this._menuFocusedBeforeUpdate = false
this._focusInput()
}
}
}
componentWillUnmount() {
clearTimeout(this._focusTimeoutId)
this._focusTimeoutId = null
clearTimeout(this._searchTimeoutId)
this._searchTimeoutId = null
}
_focusInput() {
const input = ReactDOM.findDOMNode(this.refs.input)
// istanbul ignore else
if (input instanceof HTMLElement) {
input.focus()
}
}
_open(eventType: string, props: Props) {
this._lastOpenEventType = eventType
const disableFilter = eventType !== 'autocomplete' && this._hasNoOrExactMatch(props)
this.setState({ open: true, disableFilter })
const { onSearch } = props
const { inputValue, searchValue } = this.state
if (onSearch && searchValue !== inputValue) {
this.setState({ searchValue: inputValue })
onSearch(inputValue)
}
}
_close() {
this.setState({ open: false })
}
_toggleOpen(eventType: string, props: Props) {
if (this.state.open) {
this._close()
} else {
this._open(eventType, props)
}
}
_canOpen(): boolean {
const { datalist } = this.props
return (datalist == null && this.props.onSearch) ||
!this._listAdapter.isEmpty(datalist) ||
!!this.props.datalistMessage
}
_hasNoOrExactMatch(props: Props): boolean {
if (this._inputItem != null && !this._inputItemEphemeral) {
return true // exact match
}
const foldedValue = this._itemAdapter.foldValue(this.state.inputValue)
return this._listAdapter.find(props.datalist,
item => this._itemAdapter.itemIncludedByInput(item, foldedValue)) == null
}
render(): React.Element<*> {
const { showToggle } = this.props
const toggleCanOpen = this._canOpen()
const toggleVisible = showToggle === 'auto' ? toggleCanOpen : showToggle
const classes = {
autosuggest: true,
open: this.state.open,
disabled: this.props.disabled,
dropdown: toggleVisible && !this.props.dropup,
dropup: toggleVisible && this.props.dropup
}
return <div
key="dropdown"
className={classNames(classes)}
onFocus={this._handleFocus}
onBlur={this._handleBlur}>
{this._renderInputGroup(toggleVisible, toggleCanOpen)}
{this._renderMenu()}
</div>
}
_renderInputGroup(toggleVisible: boolean, toggleCanOpen: boolean): Node {
const addonBefore = this.props.addonBefore ? (
<span className="input-group-addon" key="addonBefore">
{this.props.addonBefore}
</span>
) : null
const addonAfter = this.props.addonAfter ? (
<span className="input-group-addon" key="addonAfter">
{this.props.addonAfter}
</span>
) : null
const buttonBefore = this.props.buttonBefore ? (
<span className="input-group-btn">
{this.props.buttonBefore}
</span>
) : null
// Bootstrap expects the dropdown toggle to be last,
// as it does not reset the right border radius for toggles:
// .input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle)
// { @include border-right-radius(0); }
const toggle = toggleVisible && this._renderToggle(toggleCanOpen)
const buttonAfter = (toggle || this.props.buttonAfter) ? (
<span className="input-group-btn">
{this.props.buttonAfter}
{toggle}
</span>
) : null
const classes = classNames({
'input-group': addonBefore || addonAfter || buttonBefore || buttonAfter,
'input-group-sm': this.props.bsSize === 'small',
'input-group-lg': this.props.bsSize === 'large',
'input-group-toggle': !!toggle
})
return classes ? (
<div className={classes} key="input-group">
{addonBefore}
{buttonBefore}
{this._renderChoices()}
{addonAfter}
{buttonAfter}
</div>
) : this._renderChoices()
}
_renderToggle(canOpen: boolean): Node {
return (
<Dropdown.Toggle
ref="toggle"
key="toggle"
id={this.props.toggleId}
bsSize={this.props.bsSize}
disabled={this.props.disabled || !canOpen}
open={this.state.open}
onClick={this._handleToggleClick}
onKeyDown={this._handleKeyDown} />
)
}
_renderChoices(): Node {
if (this.props.multiple) {
const { choicesClass: ChoicesClass = Choices } = this.props
return (
<ChoicesClass ref="choices"
autoHeight={!this.props.showToggle &&
!this.props.addonAfter && !this.props.addonBefore &&
!this.props.buttonAfter && !this.props.buttonBefore}
disabled={this.props.disabled}
focused={this.state.inputFocused}
inputValue={this.state.inputValue}
items={this.state.selectedItems}
onKeyPress={this._handleKeyPress}
onRemove={this._removeItem}
renderItem={this._renderSelected}>
{this._renderInput()}
</ChoicesClass>
)
}
return this._renderInput()
}
// autobind
_renderSelected(item: any): Node {
return this._itemAdapter.renderSelected(item)
}
_renderInput(): Node {
const formGroup = this.context.$bs_formGroup
const controlId = formGroup && formGroup.controlId
const extraProps = {}
for (let key of Object.keys(this.props)) {
if (!Autosuggest.propTypes[key]) {
extraProps[key] = this.props[key]
}
}
const noneSelected = !this.props.multiple || !this.state.selectedItems.length
// set autoComplete off to avoid a redundant browser drop-down menu,
// but allow it to be overridden by extra props for auto-fill purposes
return <input
autoComplete="off"
{...extraProps}
className={classNames(this.props.className,
{ 'form-control': !this.props.multiple })}
ref="input"
key="input"
id={controlId}
disabled={this.props.disabled}
required={this.props.required && noneSelected}
placeholder={noneSelected ? this.props.placeholder : undefined}
type={this.props.type}
value={this.state.inputValue}
onChange={this._handleInputChange}
onKeyDown={this._handleKeyDown}
onKeyPress={this._handleKeyPress}
onFocus={this._handleInputFocus}
onBlur={this._handleInputBlur} />
}
_renderMenu(): ?Node {
this._pseudofocusedItem = null
const { open } = this.state
if (!open) {
return null
}
const { datalist } = this.props
const foldedValue = this._itemAdapter.foldValue(this.state.inputValue)
this._foldedInputValue = foldedValue
let items
if (this.state.disableFilter) {
items = this._listAdapter.toArray(datalist)
} else {
items = this._listAdapter.filter(datalist, item =>
this._itemAdapter.itemIncludedByInput(item, foldedValue) &&
this._allowItem(item))
}
items = this._itemAdapter.sortItems(items, foldedValue)
const filtered = items.length < this._listAdapter.getLength(datalist)
// visually indicate that first item will be selected if Enter is pressed
// while the input element is focused (unless multiple and not datalist-only)
let focusedIndex
if (items.length > 0 && this.state.inputFocused &&
(!this.props.multiple || this.props.datalistOnly)) {
this._pseudofocusedItem = items[focusedIndex = 0]
}
const { suggestionsClass: SuggestionsClass = Suggestions,
datalistMessage, onDatalistMessageSelect, toggleId } = this.props
return <SuggestionsClass ref="suggestions"
datalistMessage={datalistMessage}
filtered={filtered}
focusedIndex={focusedIndex}
getItemKey={this._getItemKey}
isSelectedItem={this._isSelectedItem}
items={items}
labelledBy={toggleId}
onClose={this._handleMenuClose}
onDatalistMessageSelect={onDatalistMessageSelect}
onDisableFilter={this._handleShowAll}
onSelect={this._handleItemSelect}
open={open}
renderItem={this._renderSuggested} />
}
_allowItem(item: any): boolean {
if (this.props.allowDuplicates) {
return true
}
const value = this._itemAdapter.getInputValue(item)
return !this.state.selectedItems.find(
i => this._itemAdapter.getInputValue(i) === value)
}
// autobind
_getItemKey(item: any): string | number {
return this._itemAdapter.getReactKey(item)
}
// autobind
_isSelectedItem(item: any): boolean {
return this._itemAdapter.itemMatchesInput(item, this._foldedInputValue)
}
// autobind
_renderSuggested(item: any): Node {
return this._itemAdapter.renderSuggested(item)
}
// autobind
_handleToggleClick() {
this._toggleOpen('click', this.props)
}
// autobind
_handleInputChange(event: SyntheticInputEvent) {
const { value } = (event.target: Object)
// prevent auto-complete on backspace/delete/copy/paste/etc.
const allowAutoComplete = this._keyPressCount > this.state.inputValueKeyPress
if (allowAutoComplete && value) {
if (this._autoCompleteAfterRender) {
this._setValueMeta()
this._setInputValue(value, () => {
this._checkAutoComplete(value, this.props)
})
} else if (!this._checkAutoComplete(value, this.props)) {
this._setValueMeta()
this._setInputValue(value)
}
} else {
this._setValueMeta()
this._setInputValue(value)
}
// suppress onSearch if can't auto-complete and not open
if (allowAutoComplete || this.state.open) {
const { onSearch } = this.props
if (onSearch) {
clearTimeout(this._searchTimeoutId)
this._searchTimeoutId = setTimeout(() => {
this._searchTimeoutId = null
if (value != this.state.searchValue) {
this.setState({ searchValue: value })
onSearch(value)
}
}, this.props.searchDebounce)
}
}
}
_checkAutoComplete(value: string, props: Props) {
// open dropdown if any items would be included
let valueUpdated = false
const { datalist } = props
const foldedValue = this._itemAdapter.foldValue(value)
const includedItems = this._listAdapter.filter(datalist, i =>
this._itemAdapter.itemIncludedByInput(i, foldedValue) && this._allowItem(i))
if (includedItems.length > 0) {
// if only one item is included and the value must come from the list,
// autocomplete using that item
const { datalistOnly, datalistPartial } = props
if (includedItems.length === 1 && datalistOnly && !datalistPartial) {
const found = includedItems[0]
const foundValue = this._itemAdapter.getInputValue(found)
let callback
const { inputSelect } = props
if (value != foundValue && inputSelect &&
this._itemAdapter.foldValue(foundValue).startsWith(foldedValue)) {
const input = this.refs.input
callback = () => { inputSelect(input, value, foundValue) }
}
this._setValueMeta(found)
this._setInputValue(foundValue, callback)
valueUpdated = true
if (this.state.open ? props.closeOnCompletion :
value != foundValue && !props.closeOnCompletion) {
this._toggleOpen('autocomplete', props)
}
} else {
// otherwise, just check if any values match, and select the first one
// (without modifying the input value)
const found = includedItems.find(i =>
this._itemAdapter.itemMatchesInput(i, foldedValue))
if (found) {
this._setValueMeta(found)
this._setInputValue(value)
valueUpdated = true
}
// open dropdown unless exactly one matching value was found
if (!this.state.open && (!found || includedItems.length > 1)) {
this._open('autocomplete', props)
}
}
}
return valueUpdated
}
// autobind
_handleItemSelect(item: any) {
if (this.props.multiple) {
this._addItem(item)
} else {
const itemValue = this._itemAdapter.getInputValue(item)
this._setValueMeta(item)
this._setInputValue(itemValue)
}
this._close()
}
_addItem(item: any) {
if (this._allowItem(item)) {
const selectedItems = [
...this.state.selectedItems,
item
]
this.setState({ selectedItems })
const { onAdd, onChange } = this.props
if (onAdd) {
onAdd(item)
}
if (onChange) {
onChange(selectedItems)
}
}
this._clearInput()
if (this.state.open) {
this._close()
}
}
// autobind
_removeItem(index: number) {
const previousItems = this.state.selectedItems
const selectedItems = previousItems.slice(0, index).concat(
previousItems.slice(index + 1))
this.setState({ selectedItems })
const { onRemove, onChange } = this.props
if (onRemove) {
onRemove(index)
}
if (onChange) {
onChange(selectedItems)
}
}
_addInputValue(): boolean {
if (this._inputItem) {
this._addItem(this._inputItem)
return true
}
return false
}
// autobind
_handleShowAll() {
this.setState({ disableFilter: true })
}
// autobind
_handleKeyDown(event: SyntheticKeyboardEvent) {
if (this.props.disabled) return
switch (event.keyCode || event.which) {
case keycode.codes.down:
case keycode.codes['page down']:
if (this.state.open) {
this.refs.suggestions.focusFirst()
} else if (this._canOpen()) {
this._open('keydown', this.props)
}
event.preventDefault()
break
case keycode.codes.left:
case keycode.codes.backspace:
if (this.refs.choices && this.refs.input &&
this._getCursorPosition(this.refs.input) === 0) {
this.refs.choices.focusLast()
event.preventDefault()
}
break
case keycode.codes.right:
if (this.refs.choices && this.refs.input &&
this._getCursorPosition(this.refs.input) === this.state.inputValue.length) {
this.refs.choices.focusFirst()
event.preventDefault()
}
break
case keycode.codes.enter:
if (this.props.multiple && this.state.inputValue) {
event.preventDefault()
if (this._addInputValue()) {
break
}
}
if (this.state.open && this.state.inputFocused) {
event.preventDefault()
if (this._pseudofocusedItem) {
this._handleItemSelect(this._pseudofocusedItem)
} else {
this._close()
}
}
break
case keycode.codes.esc:
case keycode.codes.tab:
this._handleMenuClose(event)
break
}
}
_getCursorPosition(input: React.Component<*, *, *>): ?number {
const inputNode = ReactDOM.findDOMNode(input)
// istanbul ignore else
if (inputNode instanceof HTMLInputElement) {
return inputNode.selectionStart
}
}
// autobind
_handleKeyPress() {
++this._keyPressCount
}
// autobind
_handleMenuClose() {
if (this.state.open) {
this._close()
}
}
// autobind
_handleInputFocus() {
this.setState({ inputFocused: true })
}
// autobind
_handleInputBlur() {
this.setState({ inputFocused: false })
}
// autobind
_handleFocus() {
if (this._focusTimeoutId) {
clearTimeout(this._focusTimeoutId)
this._focusTimeoutId = null
} else {
this._focused = true
const { onFocus } = this.props
if (onFocus) {
const value = this._getCurrentValue()
onFocus(value)
}
}
}
// autobind
_handleBlur() {
this._focusTimeoutId = setTimeout(() => {
this._focusTimeoutId = null
this._focused = false
const { inputValue } = this.state
const { onBlur } = this.props
if (this.props.multiple) {
if (inputValue && !this._addInputValue()) {
this._clearInput()
}
} else if (inputValue != this._lastValidValue) {
// invoke onBlur after state change, rather than immediately
let callback
if (onBlur) {
callback = () => {
const value = this._getCurrentValue()
onBlur(value)
}
}
// restore last valid value/item
this._setValueMeta(this._lastValidItem, false, true, true)
this._setInputValue(this._lastValidValue, callback)
return
}
if (onBlur) {
const value = this._getCurrentValue()
onBlur(value)
}
}, 1)
}
}