backpack-ui
Version:
Lonely Planet's Components
846 lines (716 loc) • 22.6 kB
JSX
import React, { Component } from "react";
import PropTypes from "prop-types";
import fuzzy from "fuzzy";
import { Style } from "radium";
import styles from "./styles";
import TypeaheadDropdown from "./typeaheadDropdown";
import TypeaheadStatus from "./typeaheadStatus";
import createClassList from "./utils/createClassList";
import Accessor from "./utils/accessor";
import KeyEvent from "./utils/keyEvent";
/**
* A "typeahead", an auto-completing text input
*
* Renders an text input that shows options nearby that you can use the
* keyboard or mouse to select. Requires CSS for MASSIVE DAMAGE.
*/
let count = 0;
class Typeahead extends Component {
static getInstanceCount() {
return ++count; // eslint-disable-line no-plusplus
}
constructor(props) {
super(props);
this.state = {
// The options matching the entry value
// searchResults: this.getOptionsForValue(props.initialValue, props.options),
searchResults: [],
// This should be called something else, "entryValue"
entryValue: props.value || props.initialValue,
// A valid typeahead value
selection: props.value,
// Index of the selection
selectionIndex: null,
// Keep track of the focus state of the input element, to determine
// whether to show options when empty (if showOptionsWhenEmpty is true)
isFocused: false,
// true when focused, false onOptionSelected
showResults: false,
isDropdownVisible: false,
};
this.previousInputValue = null;
this.onKeyUp = this.onKeyUp.bind(this);
this.onMouseOver = this.onMouseOver.bind(this);
this.onMouseOut = this.onMouseOut.bind(this);
this.onEnter = this.onEnter.bind(this);
this.onEscape = this.onEscape.bind(this);
this.onBackspace = this.onBackspace.bind(this);
this.onTab = this.onTab.bind(this);
this.onChange = this.onChange.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onOptionSelected = this.onOptionSelected.bind(this);
this.onTextEntryUpdated = this.onTextEntryUpdated.bind(this);
this.getOptionsForValue = this.getOptionsForValue.bind(this);
this.setEntryText = this.setEntryText.bind(this);
this.getSelection = this.getSelection.bind(this);
this.getCustomValue = this.getCustomValue.bind(this);
this.setSelectedIndex = this.setSelectedIndex.bind(this);
this.showDropdown = this.showDropdown.bind(this);
this.hideDropdown = this.hideDropdown.bind(this);
this.focus = this.focus.bind(this);
this.hasCustomValue = this.hasCustomValue.bind(this);
this.countTruncatedResults = this.countTruncatedResults.bind(this);
this.areResultsTruncated = this.areResultsTruncated.bind(this);
this.resultsTruncatedMessage = this.resultsTruncatedMessage.bind(this);
this.handleWindowClose = this.handleWindowClose.bind(this);
this.hasHint = this.hasHint.bind(this);
this.generateSearchFunction = this.generateSearchFunction.bind(this);
this.shouldSkipSearch = this.shouldSkipSearch.bind(this);
this.eventMap = this.eventMap.bind(this);
this.nav = this.nav.bind(this);
this.navDown = this.navDown.bind(this);
this.navUp = this.navUp.bind(this);
this.renderIncrementalSearchResults = this.renderIncrementalSearchResults.bind(this);
this.renderHiddenInput = this.renderHiddenInput.bind(this);
this.renderAriaMessageForOptions = this.renderAriaMessageForOptions.bind(this);
this.renderAriaMessageForIncomingOptions = this.renderAriaMessageForIncomingOptions.bind(this);
}
componentWillMount() {
const uniqueId = Typeahead.getInstanceCount();
this.activeDescendantId = `typeaheadActiveDescendant-${uniqueId}`;
this.optionsId = `typeaheadOptions-${uniqueId}`;
}
componentDidMount() {
if (typeof window !== "undefined") {
// The `focus` event does not bubble, so we must capture it instead.
// This closes Typeahead's dropdown whenever something else gains focus.
window.addEventListener("focus", this.handleWindowClose, true);
// If we click anywhere outside of Typeahead, close the dropdown.
window.addEventListener("click", this.handleWindowClose, false);
}
}
componentWillReceiveProps(nextProps) {
const { options } = nextProps;
const { entryValue } = this.state;
this.setState({
searchResults: this.getOptionsForValue(entryValue, options),
});
}
componentDidUpdate(prevProps, prevState) {
const { entryValue } = this.state;
if (entryValue !== prevState.entryValue && !prevState.isDropdownVisible) {
this.showDropdown();
}
}
componentWillUnmount() {
if (typeof window !== "undefined") {
window.removeEventListener("focus", this.handleWindowClose, true);
window.removeEventListener("click", this.handleWindowClose, false);
}
}
onMouseOver(event, index) {
this.setState({
selectionIndex: index,
});
}
onMouseOut() {
this.setState({
selectionIndex: null,
});
}
onKeyUp(e) {
const query = e.target.value;
clearTimeout(this.searchTimer);
if (this.props.validate) {
this.props.validate(query, this.state.searchResults);
}
if (e.key === "Backspace" && query === "") {
return;
}
if (e.key !== "Enter" && e.key !== "ArrowUp" && e.key !== "ArrowDown") {
this.searchTimer = setTimeout(() => {
this.props.dataSource(query)
.then((json) => {
let results = [];
if (this.props.filterResults) {
results = this.props.filterResults(json);
} else {
results = json.places.map(place => place.attributes.name);
}
if (this.props.validate) {
this.props.validate(query, results);
}
this.props.onKeyUp(json.places);
this.setState({ searchResults: results });
});
}, 200);
}
}
onEnter(event) {
const selection = this.getSelection();
if (!selection) {
return this.props.onKeyDown(event);
}
return this.onOptionSelected(event, selection);
}
onEscape() {
this.setState({
isDropdownVisible: false,
selectionIndex: null,
});
}
onBackspace() {
if (this.state.selectionIndex !== null) {
this.setState({
selectionIndex: null,
});
}
}
onTab(event) {
let option = this.getSelection();
if (!option && this.hasCustomValue()) {
option = this.getCustomValue();
}
if (option) {
return this.onOptionSelected(event, option);
}
return null;
}
onChange(event) {
if (this.props.onChange) {
this.props.onChange(event);
}
this.onTextEntryUpdated();
}
onKeyDown(event) {
/**
* If there are no visible elements, don't perform selector
* navigation. Just pass this up to the upstream onKeydown handler.
* Also skip if the user is pressing the shift key, since none of
* our handlers are looking for shift
*/
if (!this.hasHint() || event.shiftKey) {
return this.props.onKeyDown(event);
}
const handler = this.eventMap()[event.keyCode];
if (handler) {
handler(event);
} else {
return this.props.onKeyDown(event);
}
// Don't propagate the keystroke back to the DOM/browser
if (
event.keyCode !== KeyEvent.DOM_VK_BACKSPACE &&
event.keyCode !== KeyEvent.DOM_VK_TAB
) {
event.preventDefault();
}
return null;
}
onFocus(event) {
this.setState({
isFocused: true,
showResults: true,
isDropdownVisible: true,
}, () => {
this.onTextEntryUpdated();
});
if (this.props.onFocus) {
return this.props.onFocus(event);
}
return null;
}
async onBlur(event) {
this.setState({
isFocused: false,
}, () => {
this.onTextEntryUpdated();
});
const thereAreSearchResults = this.state.searchResults.length > 0;
const thereIsInput = event.target.value !== "";
/**
* If the user is not hovering or arrow-keying over any particular option, we may safely
* select the topmost option among the search results. It'll either be the one
* that they've already selected, or they otherwise are not disclosing any specific preference.
*/
const noSpecificSelectionIsPending = [null, -1].includes(this.state.selectionIndex);
const insertFirstSearchResult = [
this.props.forceSelection,
noSpecificSelectionIsPending,
thereAreSearchResults,
thereIsInput,
].every(Boolean);
if (insertFirstSearchResult) {
// Force selection of the first search result.
const [option] = this.state.searchResults;
const optionString = this.displayOption(option, 0);
await this.setSelectedOption(option);
if (this.props.validate) {
this.props.validate(optionString, this.state.searchResults);
}
this.props.onOptionSelected(event, option);
}
if (this.props.onBlur) {
return this.props.onBlur(event);
}
return null;
}
onOptionSelected(event, option) {
const optionString = this.displayOption(option, 0);
this.inputElement.focus();
this.setSelectedOption(option);
this.inputElement.blur();
if (this.props.validate) {
this.props.validate(optionString, this.state.searchResults);
}
return this.props.onOptionSelected(event, option);
}
onTextEntryUpdated() {
const { options } = this.props;
const value = this.inputElement.value;
this.setState({
searchResults: this.getOptionsForValue(value, options),
selection: "",
entryValue: value,
});
}
async setSelectedOption(option) {
const optionString = this.displayOption(option, 0);
const formInputOptionString = this.formInputOption(option);
this.inputElement.value = optionString;
const results = this.getOptionsForValue(optionString, this.props.options);
await new Promise((resolve) => {
this.setState({
searchResults: results,
selection: formInputOptionString,
selectionIndex: results.indexOf(optionString),
entryValue: optionString,
showResults: false,
}, resolve);
});
}
getOptionsForValue(value, options) {
if (this.shouldSkipSearch(value)) {
return [];
}
const searchOptions = this.generateSearchFunction();
return searchOptions(value, options);
}
setEntryText(value) {
this.inputElement.value = value;
this.onTextEntryUpdated();
}
getSelection() {
let index = this.state.selectionIndex;
if (this.hasCustomValue()) {
if (index === 0) {
return this.state.entryValue;
}
index -= 1;
}
return this.state.searchResults[index];
}
getCustomValue() {
const { entryValue } = this.state;
if (this.hasCustomValue()) {
return entryValue;
}
return null;
}
setSelectedIndex(index, callback) {
this.setState({
selectionIndex: index,
}, callback);
}
showDropdown() {
this.setState({
isDropdownVisible: true,
});
}
hideDropdown() {
this.setState({
isDropdownVisible: false,
});
}
focus() {
this.inputElement.focus();
}
hasCustomValue() {
const { allowCustomValues } = this.props;
const { entryValue, searchResults } = this.state;
if (
allowCustomValues > 0 &&
entryValue.length >= allowCustomValues &&
searchResults.indexOf(entryValue) < 0
) {
return true;
}
return false;
}
countTruncatedResults() {
const { maxVisible } = this.props;
const { searchResults } = this.state;
return parseInt(searchResults.length, 10) - parseInt(maxVisible, 10);
}
areResultsTruncated() {
const { maxVisible } = this.props;
const { searchResults } = this.state;
return !!maxVisible && (searchResults.length > maxVisible);
}
resultsTruncatedMessage() {
const areResultsTruncated = this.areResultsTruncated();
const countTruncatedResults = this.countTruncatedResults();
const {
resultsTruncatedMessage: message,
} = this.props;
return areResultsTruncated ? (
message || `There are ${countTruncatedResults} more results.`
) : "";
}
get displayOption() {
return Accessor.generateOptionToStringFor(
this.props.inputDisplayOption || this.props.displayOption,
);
}
get formInputOption() {
return Accessor.generateOptionToStringFor(
this.props.formInputOption || this.displayOption,
);
}
handleWindowClose(event) {
const target = event.target;
if (target !== window && !this.typeahead.contains(target)) {
this.hideDropdown();
}
}
hasHint() {
return this.state.searchResults.length > 0 || this.hasCustomValue();
}
generateSearchFunction() {
const { searchOptions, filterOption } = this.props;
if (typeof searchOptions === "function") {
return searchOptions;
} else if (typeof filterOption === "function") {
return (value, options) => options.filter((o) => filterOption(value, o));
}
let mapper;
if (typeof filterOption === "string") {
mapper = Accessor.generateAccessor(filterOption);
} else {
mapper = Accessor.identityFunction;
}
return (value, options) => fuzzy
.filter(value, options, { extract: mapper })
.map((res) => options[res.index]);
}
shouldSkipSearch(input) {
const { showOptionsWhenEmpty } = this.props;
const { isFocused } = this.state;
const emptyValue = !input || input.trim().length === 0;
return !(showOptionsWhenEmpty && isFocused) && emptyValue;
}
eventMap() {
const events = {};
events[KeyEvent.DOM_VK_UP] = this.navUp;
events[KeyEvent.DOM_VK_DOWN] = this.navDown;
events[KeyEvent.DOM_VK_RETURN] = this.onEnter;
events[KeyEvent.DOM_VK_ENTER] = this.onEnter;
events[KeyEvent.DOM_VK_ESCAPE] = this.onEscape;
events[KeyEvent.DOM_VK_BACKSPACE] = this.onBackspace;
events[KeyEvent.DOM_VK_TAB] = this.onTab;
return events;
}
nav(delta) {
if (!this.hasHint()) {
return;
}
const setIndex = delta === 1 ? 0 : delta;
let newIndex = this.state.selectionIndex === null ?
setIndex :
this.state.selectionIndex + delta;
let length = this.props.maxVisible ?
this.state.searchResults.slice(0, this.props.maxVisible).length :
this.state.searchResults.length;
if (this.hasCustomValue()) {
length += 1;
}
if (newIndex < 0) {
newIndex += length;
} else if (newIndex >= length) {
newIndex -= length;
}
this.setState({
selectionIndex: newIndex,
entryValue: this.state.searchResults[newIndex],
});
}
navDown() {
if (this.state.isDropdownVisible) {
this.nav(1);
} else {
this.showDropdown();
}
}
navUp() {
if (this.state.isDropdownVisible) {
this.nav(-1);
} else {
this.showDropdown();
}
}
renderIncrementalSearchResults() {
// Nothing has been entered into the textbox
if (this.shouldSkipSearch(this.state.entryValue)) {
return "";
}
// Something was just selected
if (this.state.selection) {
return "";
}
const ListComponent = this.props.customListComponent;
return (
<ListComponent
ref={node => (this.dropdown = node)}
id={this.optionsId}
isVisible={this.state.isDropdownVisible}
activeDescendantId={this.activeDescendantId}
options={this.props.maxVisible ?
this.state.searchResults.slice(0, this.props.maxVisible) :
this.state.searchResults
}
areResultsTruncated={this.areResultsTruncated()}
resultsTruncatedMessage={this.resultsTruncatedMessage()}
onOptionSelected={this.onOptionSelected}
allowCustomValues={this.props.allowCustomValues}
customValue={this.getCustomValue()}
customClasses={this.props.customClasses}
selectionIndex={this.state.selectionIndex}
disableDefaultClassNames={this.props.disableDefaultClassNames}
displayOption={Accessor.generateOptionToStringFor(this.props.displayOption)}
onMouseOver={this.onMouseOver}
/>
);
}
renderHiddenInput() {
const { name } = this.props;
const { selection } = this.state;
if (!name) {
return null;
}
return (
<input
type="hidden"
name={name}
value={selection}
/>
);
}
renderAriaMessageForOptions() {
const inputValue = this.state.entryValue;
const options = this.state.searchResults;
const selectedOption = options[this.state.selectionIndex];
return (
<TypeaheadStatus>
{selectedOption || inputValue}
</TypeaheadStatus>
);
}
renderAriaMessageForIncomingOptions() {
const { maxVisible } = this.props;
const { searchResults } = this.state;
const options = searchResults || [];
const totalOptions = options.length ? options.length : 0;
const numberOfSuggestions = maxVisible && (maxVisible < options.length) ?
maxVisible : totalOptions;
const suggestionText = `${numberOfSuggestions} suggestion${numberOfSuggestions !== 1 ? "s are" : " is"} available.`;
const instructionText = numberOfSuggestions > 0 ? "Use up and down arrows to select." : "";
return (
<TypeaheadStatus>
{`${suggestionText} ${instructionText}`}
</TypeaheadStatus>
);
}
render() {
const {
className,
customClasses,
disableDefaultClassNames,
textarea,
disabled,
placeholder,
onKeyPress,
autoFocus,
required,
inputId,
inputName,
} = this.props;
const InputElement = textarea ? "textarea" : "input";
const containerClassList = createClassList(
className,
"typeahead",
disableDefaultClassNames,
);
const inputClassList = createClassList(
customClasses.input,
"input",
disableDefaultClassNames,
);
let value = this.props.value;
if (this.props.forceSelection &&
this.props.value !== this.props.initialValue &&
(this.state.searchResults.indexOf(this.props.value) === -1) &&
this.state.selectionIndex === null) {
value = this.state.entryValue;
}
return (
<div
className={containerClassList}
ref={node => (this.typeahead = node)}
onMouseOut={this.onMouseOut}
style={this.props.style}
>
<Style rules={styles} />
{this.renderHiddenInput()}
<InputElement
ref={node => (this.inputElement = node)}
className={inputClassList}
type="text"
role="combobox"
aria-owns={this.optionsId}
aria-controls={this.optionsId}
aria-expanded={this.state.isDropdownVisible}
aria-autocomplete="both"
aria-activedescendant={this.activeDescendantId}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
autoFocus={autoFocus}
required={required}
spellCheck={false}
id={inputId}
name={inputName}
disabled={disabled}
placeholder={placeholder}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyPress={onKeyPress}
onKeyUp={this.onKeyUp} // eslint-disable-line react/jsx-no-bind
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
{this.state.showResults && this.renderIncrementalSearchResults()}
{this.renderAriaMessageForOptions()}
{this.renderAriaMessageForIncomingOptions()}
</div>
);
}
}
Typeahead.propTypes = {
name: PropTypes.string,
className: PropTypes.string,
customClasses: PropTypes.shape({
hover: PropTypes.string,
input: PropTypes.string,
listAnchor: PropTypes.string,
listItem: PropTypes.string,
results: PropTypes.string,
resultsTruncated: PropTypes.string,
customAdd: PropTypes.string,
}),
dataSource: PropTypes.func,
maxVisible: PropTypes.number,
resultsTruncatedMessage: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.string),
allowCustomValues: PropTypes.number,
initialValue: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
textarea: PropTypes.bool,
inputId: PropTypes.string,
inputName: PropTypes.string,
autoFocus: PropTypes.bool,
required: PropTypes.bool,
onOptionSelected: PropTypes.func,
onChange: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyPress: PropTypes.func,
onKeyUp: PropTypes.func,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
filterResults: PropTypes.func,
validate: PropTypes.func,
forceSelection: PropTypes.bool,
filterOption: PropTypes.oneOfType([
PropTypes.string,
PropTypes.func,
]),
searchOptions: PropTypes.func,
displayOption: PropTypes.oneOfType([
PropTypes.string,
PropTypes.func,
]),
inputDisplayOption: PropTypes.oneOfType([
PropTypes.string,
PropTypes.func,
]),
formInputOption: PropTypes.oneOfType([
PropTypes.string,
PropTypes.func,
]),
disableDefaultClassNames: PropTypes.bool,
customListComponent: PropTypes.oneOfType([
PropTypes.element,
PropTypes.func,
]),
showOptionsWhenEmpty: PropTypes.bool,
style: PropTypes.objectOf(
PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.object,
]),
),
};
Typeahead.defaultProps = {
options: [],
className: null,
customClasses: {},
allowCustomValues: 0,
initialValue: "",
value: "",
placeholder: "",
disabled: false,
textarea: false,
inputId: "typeaheadInput",
inputName: "typeaheadInput",
autoFocus: false,
required: false,
onOptionSelected: () => {},
onChange: () => {},
onKeyDown: () => {},
onKeyPress: () => {},
onKeyUp: () => {},
onFocus: () => {},
onBlur: () => {},
filterResults: null,
validate: null,
forceSelection: false,
filterOption: null,
searchOptions: null,
inputDisplayOption: null,
disableDefaultClassNames: false,
customListComponent: TypeaheadDropdown,
showOptionsWhenEmpty: false,
maxVisible: 0,
displayOption: null,
formInputOption: null,
name: null,
resultsTruncatedMessage: null,
style: null,
dataSource: () => new Promise((resolve) => resolve([])),
};
export default Typeahead;