kepler.gl
Version:
kepler.gl is a webgl based application to visualize large scale location data in the browser
518 lines (453 loc) • 14.4 kB
JavaScript
// Copyright (c) 2018 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import fuzzy from 'fuzzy';
import classNames from 'classnames';
import styled from 'styled-components';
import {console as Console} from 'global/window';
import Accessor from './accessor';
import KeyEvent from './keyevent';
import DropdownList, {ListItem} from './dropdown-list';
import {Search} from 'components/common/icons';
const DEFAULT_CLASS = 'typeahead';
/**
* Copied mostly from 'react-typeahead', an auto-completing text input
*
* Renders an text input that shows options nearby that you can use the
* keyboard or mouse to select.
*/
const TypeaheadWrapper = styled.div`
display: flex;
flex-direction: column;
background-color: ${props => props.theme.dropdownListBgd};
box-shadow: ${props => props.theme.dropdownListShadow};
:focus {
outline: 0;
}
`;
const InputBox = styled.div`
padding: 8px;
`;
const TypeaheadInput = styled.input`
${props => props.theme.secondaryInput}
:hover {
cursor: pointer;
background-color: ${props => props.theme.secondaryInputBgd};
}
`;
const InputIcon = styled.div`
position: absolute;
right: 15px;
top: 14px;
color: ${props => props.theme.inputPlaceholderColor};
`;
export default class Typeahead extends Component {
static propTypes = {
name: PropTypes.string,
customClasses: PropTypes.object,
maxVisible: PropTypes.number,
resultsTruncatedMessage: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.any),
fixedOptions: PropTypes.arrayOf(PropTypes.any),
allowCustomValues: PropTypes.number,
initialValue: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
textarea: PropTypes.bool,
inputProps: PropTypes.object,
onOptionSelected: PropTypes.func,
onChange: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyPress: PropTypes.func,
onKeyUp: PropTypes.func,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
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]),
defaultClassNames: PropTypes.bool,
customListComponent: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
customListItemComponent: PropTypes.oneOfType([
PropTypes.element,
PropTypes.func
]),
customListHeaderComponent: PropTypes.oneOfType([
PropTypes.element,
PropTypes.func
]),
showOptionsWhenEmpty: PropTypes.bool,
searchable: PropTypes.bool
};
static defaultProps = {
options: [],
customClasses: {},
allowCustomValues: 0,
initialValue: '',
value: '',
placeholder: '',
disabled: false,
textarea: false,
inputProps: {},
onOptionSelected(option) {},
onChange(event) {},
onKeyDown(event) {},
onKeyPress(event) {},
onKeyUp(event) {},
onFocus(event) {},
onBlur(event) {},
filterOption: null,
searchOptions: null,
inputDisplayOption: null,
defaultClassNames: true,
customListComponent: DropdownList,
customListItemComponent: ListItem,
customListHeaderComponent: null,
showOptionsWhenEmpty: true,
searchable: true,
resultsTruncatedMessage: null
};
constructor(props) {
super(props);
this.state = {
searchResults: this.getOptionsForValue(
this.props.initialValue,
this.props.options
),
// This should be called something else, 'entryValue'
entryValue: this.props.value || this.props.initialValue,
// A valid typeahead value
selection: this.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
};
}
componentDidMount() {
this.setState({
searchResults: this.getOptionsForValue('', this.props.options)
});
// call focus on entry or div to trigger key events listener
if (this.entry) {
this.entry.focus();
} else {
this.root.focus();
}
}
componentWillReceiveProps(nextProps) {
const searchResults = this.getOptionsForValue(
this.state.entryValue,
nextProps.options
);
this.setState({searchResults});
}
_shouldSkipSearch(input) {
const emptyValue = !input || input.trim().length === 0;
// this.state must be checked because it may not be defined yet if this function
// is called from within getInitialState
const isFocused = this.state && this.state.isFocused;
return !(this.props.showOptionsWhenEmpty && isFocused) && emptyValue;
}
getOptionsForValue(value, options) {
if (!this.props.searchable) {
// directly pass through options if can not be searched
return options;
}
if (this._shouldSkipSearch(value)) {
return options;
}
const searchOptions = this._generateSearchFunction();
return searchOptions(value, options);
}
focus() {
if (this.entry) {
this.entry.focus();
}
}
_hasCustomValue() {
return (
this.props.allowCustomValues > 0 &&
this.state.entryValue.length >= this.props.allowCustomValues &&
this.state.searchResults.indexOf(this.state.entryValue) < 0
);
}
_getCustomValue() {
return this._hasCustomValue() ? this.state.entryValue : null;
}
_renderIncrementalSearchResults() {
return (
<this.props.customListComponent
fixedOptions={this.props.fixedOptions}
options={
this.props.maxVisible
? this.state.searchResults.slice(0, this.props.maxVisible)
: this.state.searchResults
}
areResultsTruncated={
this.props.maxVisible &&
this.state.searchResults.length > this.props.maxVisible
}
resultsTruncatedMessage={this.props.resultsTruncatedMessage}
onOptionSelected={this._onOptionSelected}
allowCustomValues={this.props.allowCustomValues}
customValue={this._getCustomValue()}
customClasses={this.props.customClasses}
customListItemComponent={this.props.customListItemComponent}
customListHeaderComponent={this.props.customListHeaderComponent}
selectionIndex={this.state.selectionIndex}
defaultClassNames={this.props.defaultClassNames}
displayOption={this.props.displayOption}
selectedItems={this.props.selectedItems}
/>
);
}
getSelection() {
let index = this.state.selectionIndex;
if (this._hasCustomValue()) {
if (index === 0) {
return this.state.entryValue;
}
index--;
}
if (this._hasFixedOptions()) {
return index < this.props.fixedOptions.length
? this.props.fixedOptions[index]
: this.state.searchResults[index - this.props.fixedOptions.length];
}
return this.state.searchResults[index];
}
_onOptionSelected = (option, event) => {
if (this.props.searchable) {
// reset entry input
this.setState({
searchResults: this.getOptionsForValue('', this.props.options),
selection: '',
entryValue: ''
});
}
return this.props.onOptionSelected(option, event);
};
// use () => {} to avoid binding 'this'
_onTextEntryUpdated = () => {
if (this.props.searchable) {
const value = this.entry.value;
this.setState({
searchResults: this.getOptionsForValue(value, this.props.options),
selection: '',
entryValue: value
});
}
};
_onEnter = event => {
const selection = this.getSelection();
if (!selection) {
return this.props.onKeyDown(event);
}
return this._onOptionSelected(selection, event);
};
_onEscape() {
this.setState({
selectionIndex: null
});
}
_onTab(event) {
const selection = this.getSelection();
let option = selection
? selection
: this.state.searchResults.length > 0
? this.state.searchResults[0]
: null;
if (option === null && this._hasCustomValue()) {
option = this._getCustomValue();
}
if (option !== null) {
return this._onOptionSelected(option, event);
}
}
eventMap(event) {
const events = {};
events[KeyEvent.DOM_VK_UP] = this.navUp;
events[KeyEvent.DOM_VK_DOWN] = this.navDown;
events[KeyEvent.DOM_VK_RETURN] = events[
KeyEvent.DOM_VK_ENTER
] = this._onEnter;
events[KeyEvent.DOM_VK_ESCAPE] = this._onEscape;
events[KeyEvent.DOM_VK_TAB] = this._onTab;
return events;
}
_nav(delta) {
if (!this._hasHint()) {
return;
}
let newIndex =
this.state.selectionIndex === null
? delta === 1 ? 0 : delta
: 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});
}
navDown = () => {
this._nav(1);
};
navUp = () => {
this._nav(-1);
};
_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
event.preventDefault();
};
_onFocus = event => {
this.setState({isFocused: true});
if (this.props.onFocus) {
return this.props.onFocus(event);
}
};
_onBlur = event => {
this.setState({isFocused: false});
if (this.props.onBlur) {
return this.props.onBlur(event);
}
};
_renderHiddenInput() {
if (!this.props.name) {
return null;
}
return (
<input
type="hidden"
name={this.props.name}
value={this.state.selection}
/>
);
}
_generateSearchFunction() {
const searchOptionsProp = this.props.searchOptions;
const filterOptionProp = this.props.filterOption;
if (typeof searchOptionsProp === 'function') {
if (filterOptionProp !== null) {
Console.warn(
'searchOptions prop is being used, filterOption prop will be ignored'
);
}
return searchOptionsProp;
} else if (typeof filterOptionProp === 'function') {
// use custom filter option
return (value, options) =>
options.filter(o => filterOptionProp(value, o));
}
const mapper =
typeof filterOptionProp === 'string'
? Accessor.generateAccessor(filterOptionProp)
: Accessor.IDENTITY_FN;
return (value, options) =>
fuzzy
.filter(value, options, {extract: mapper})
.map(res => options[res.index]);
}
_hasHint() {
return this.state.searchResults.length > 0 || this._hasCustomValue();
}
_hasFixedOptions() {
return (
Array.isArray(this.props.fixedOptions) && this.props.fixedOptions.length
);
}
render() {
const inputClasses = {};
inputClasses[this.props.customClasses.input] = Boolean(
this.props.customClasses.input
);
const inputClassList = classNames(inputClasses);
const classes = {
[DEFAULT_CLASS]: this.props.defaultClassNames
};
classes[this.props.className] = Boolean(this.props.className);
const classList = classNames(classes);
return (
<TypeaheadWrapper
className={classList}
innerRef={comp => {
this.root = comp;
}}
tabIndex="0"
onKeyDown={this._onKeyDown}
onKeyPress={this.props.onKeyPress}
onKeyUp={this.props.onKeyUp}
onFocus={this._onFocus}
>
{this._renderHiddenInput()}
{this.props.searchable ? (
<InputBox>
<TypeaheadInput
innerRef={comp => {
this.entry = comp;
}}
type="text"
disabled={this.props.disabled}
{...this.props.inputProps}
placeholder={this.props.placeholder}
className={inputClassList}
value={this.state.entryValue}
onChange={this._onChange}
onBlur={this._onBlur}
/>
<InputIcon>
<Search height="18px"/>
</InputIcon>
</InputBox>
) : null}
{this._renderIncrementalSearchResults()}
</TypeaheadWrapper>
);
}
};