react-bootstrap-typeahead
Version:
React typeahead with Bootstrap styling
276 lines (275 loc) • 10.7 kB
JavaScript
import isEqual from 'fast-deep-equal';
import PropTypes from 'prop-types';
import React from 'react';
import TypeaheadManager from './TypeaheadManager';
import { clearTypeahead, clickOrFocusInput, getInitialState, hideMenu, toggleMenu, } from './TypeaheadState';
import { caseSensitiveType, checkPropType, defaultInputValueType, defaultSelectedType, highlightOnlyResultType, ignoreDiacriticsType, isRequiredForA11y, labelKeyType, optionType, selectedType, } from '../propTypes';
import { addCustomOption, defaultFilterBy, getOptionLabel, getOptionProperty, getStringLabelKey, getUpdatedActiveIndex, getTruncatedOptions, isFunction, isShown, isString, noop, uniqueId, validateSelectedPropChange, } from '../utils';
import { DEFAULT_LABELKEY } from '../constants';
const propTypes = {
allowNew: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
autoFocus: PropTypes.bool,
caseSensitive: checkPropType(PropTypes.bool, caseSensitiveType),
defaultInputValue: checkPropType(PropTypes.string, defaultInputValueType),
defaultOpen: PropTypes.bool,
defaultSelected: checkPropType(PropTypes.arrayOf(optionType), defaultSelectedType),
filterBy: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.string.isRequired),
PropTypes.func,
]),
highlightOnlyResult: checkPropType(PropTypes.bool, highlightOnlyResultType),
id: checkPropType(PropTypes.oneOfType([PropTypes.number, PropTypes.string]), isRequiredForA11y),
ignoreDiacritics: checkPropType(PropTypes.bool, ignoreDiacriticsType),
labelKey: checkPropType(PropTypes.oneOfType([PropTypes.string, PropTypes.func]), labelKeyType),
maxResults: PropTypes.number,
minLength: PropTypes.number,
multiple: PropTypes.bool,
onBlur: PropTypes.func,
onChange: PropTypes.func,
onFocus: PropTypes.func,
onInputChange: PropTypes.func,
onKeyDown: PropTypes.func,
onMenuToggle: PropTypes.func,
onPaginate: PropTypes.func,
open: PropTypes.bool,
options: PropTypes.arrayOf(optionType).isRequired,
paginate: PropTypes.bool,
selected: checkPropType(PropTypes.arrayOf(optionType), selectedType),
};
const defaultProps = {
allowNew: false,
autoFocus: false,
caseSensitive: false,
defaultInputValue: '',
defaultOpen: false,
defaultSelected: [],
filterBy: [],
highlightOnlyResult: false,
ignoreDiacritics: true,
labelKey: DEFAULT_LABELKEY,
maxResults: 100,
minLength: 0,
multiple: false,
onBlur: noop,
onFocus: noop,
onInputChange: noop,
onKeyDown: noop,
onMenuToggle: noop,
onPaginate: noop,
paginate: true,
};
function triggerInputChange(input, value) {
const inputValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
inputValue && inputValue.set && inputValue.set.call(input, value);
const e = new Event('input', { bubbles: true });
input.dispatchEvent(e);
}
class Typeahead extends React.Component {
static propTypes = propTypes;
static defaultProps = defaultProps;
state = getInitialState(this.props);
inputNode = null;
isMenuShown = false;
items = [];
componentDidMount() {
this.props.autoFocus && this.focus();
}
componentDidUpdate(prevProps, prevState) {
const { labelKey, multiple, selected } = this.props;
validateSelectedPropChange(selected, prevProps.selected);
if (selected && !isEqual(selected, prevState.selected)) {
this.setState({ selected });
if (!multiple) {
this.setState({
text: selected.length ? getOptionLabel(selected[0], labelKey) : '',
});
}
}
}
render() {
const { onChange, ...props } = this.props;
const mergedPropsAndState = { ...props, ...this.state };
const { filterBy, labelKey, options, paginate, shownResults, text } = mergedPropsAndState;
this.isMenuShown = isShown(mergedPropsAndState);
this.items = [];
let results = [];
if (this.isMenuShown) {
const cb = (isFunction(filterBy) ? filterBy : defaultFilterBy);
results = options.filter((option) => cb(option, mergedPropsAndState));
const shouldPaginate = paginate && results.length > shownResults;
results = getTruncatedOptions(results, shownResults);
if (addCustomOption(results, mergedPropsAndState)) {
results.push({
customOption: true,
[getStringLabelKey(labelKey)]: text,
});
}
if (shouldPaginate) {
results.push({
[getStringLabelKey(labelKey)]: '',
paginationOption: true,
});
}
}
return (React.createElement(TypeaheadManager, { ...mergedPropsAndState, hideMenu: this.hideMenu, inputNode: this.inputNode, inputRef: this.inputRef, isMenuShown: this.isMenuShown, onActiveItemChange: this._handleActiveItemChange, onAdd: this._handleSelectionAdd, onBlur: this._handleBlur, onChange: this._handleInputChange, onClear: this._handleClear, onClick: this._handleClick, onFocus: this._handleFocus, onHide: this.hideMenu, onInitialItemChange: this._handleInitialItemChange, onKeyDown: this._handleKeyDown, onMenuItemClick: this._handleMenuItemSelect, onRemove: this._handleSelectionRemove, results: results, setItem: this.setItem, toggleMenu: this.toggleMenu }));
}
blur = () => {
this.inputNode && this.inputNode.blur();
this.hideMenu();
};
clear = () => {
this.setState(clearTypeahead);
};
focus = () => {
this.inputNode && this.inputNode.focus();
};
getInput = () => {
return this.inputNode;
};
inputRef = (inputNode) => {
this.inputNode = inputNode;
};
setItem = (item, position) => {
this.items[position] = item;
};
hideMenu = () => {
this.setState(hideMenu);
};
toggleMenu = () => {
this.setState(toggleMenu);
};
_handleActiveIndexChange = (activeIndex) => {
this.setState((state) => ({
activeIndex,
activeItem: activeIndex >= 0 ? state.activeItem : undefined,
}));
};
_handleActiveItemChange = (activeItem) => {
if (!isEqual(activeItem, this.state.activeItem)) {
this.setState({ activeItem });
}
};
_handleBlur = (e) => {
e.persist();
this.setState({ isFocused: false }, () => this.props.onBlur(e));
};
_handleChange = (selected) => {
this.props.onChange && this.props.onChange(selected);
};
_handleClear = () => {
this.inputNode && triggerInputChange(this.inputNode, '');
this.setState(clearTypeahead, () => {
if (this.props.multiple) {
this._handleChange([]);
}
});
};
_handleClick = (e) => {
e.persist();
const onClick = this.props.inputProps?.onClick;
this.setState(clickOrFocusInput, () => isFunction(onClick) && onClick(e));
};
_handleFocus = (e) => {
e.persist();
this.setState(clickOrFocusInput, () => this.props.onFocus(e));
};
_handleInitialItemChange = (initialItem) => {
if (!isEqual(initialItem, this.state.initialItem)) {
this.setState({ initialItem });
}
};
_handleInputChange = (e) => {
e.persist();
const text = e.currentTarget.value;
const { multiple, onInputChange } = this.props;
const shouldClearSelections = this.state.selected.length && !multiple;
this.setState((state, props) => {
const { activeIndex, activeItem, shownResults } = getInitialState(props);
return {
activeIndex,
activeItem,
selected: shouldClearSelections ? [] : state.selected,
showMenu: true,
shownResults,
text,
};
}, () => {
onInputChange(text, e);
shouldClearSelections && this._handleChange([]);
});
};
_handleKeyDown = (e) => {
const { activeItem } = this.state;
if (!this.isMenuShown) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
this.setState({ showMenu: true });
}
this.props.onKeyDown(e);
return;
}
switch (e.key) {
case 'ArrowUp':
case 'ArrowDown':
e.preventDefault();
this._handleActiveIndexChange(getUpdatedActiveIndex(this.state.activeIndex, e.key, this.items));
break;
case 'Enter':
e.preventDefault();
activeItem && this._handleMenuItemSelect(activeItem, e);
break;
case 'Escape':
case 'Tab':
this.hideMenu();
break;
default:
break;
}
this.props.onKeyDown(e);
};
_handleMenuItemSelect = (option, e) => {
if (getOptionProperty(option, 'paginationOption')) {
this._handlePaginate(e);
}
else {
this._handleSelectionAdd(option);
}
};
_handlePaginate = (e) => {
e.persist();
this.setState((state, props) => ({
shownResults: state.shownResults + props.maxResults,
}), () => this.props.onPaginate(e, this.state.shownResults));
};
_handleSelectionAdd = (option) => {
const { multiple, labelKey } = this.props;
let selected;
let selection = option;
let text;
if (!isString(selection) && selection.customOption) {
selection = { ...selection, id: uniqueId('new-id-') };
}
if (multiple) {
selected = this.state.selected.concat(selection);
text = '';
}
else {
selected = [selection];
text = getOptionLabel(selection, labelKey);
}
this.setState((state, props) => ({
...hideMenu(state, props),
initialItem: selection,
selected,
text,
}), () => this._handleChange(selected));
};
_handleSelectionRemove = (selection) => {
const selected = this.state.selected.filter((option) => !isEqual(option, selection));
this.focus();
this.setState((state, props) => ({
...hideMenu(state, props),
selected,
}), () => this._handleChange(selected));
};
}
export default Typeahead;