react-typeahead-component
Version:
Typeahead, written using the React.js library.
511 lines (442 loc) • 16.9 kB
JavaScript
'use strict';
var React = require('react'),
Input = require('./input'),
AriaStatus = require('./aria_status'),
getTextDirection = require('../utils/get_text_direction'),
noop = function() {};
module.exports = React.createClass({
displayName: 'Typeahead',
statics: {
getInstanceCount: (function() {
var count = 0;
return function() {
return ++count;
};
}())
},
propTypes: process.env.NODE_ENV === 'production' ? {} : {
inputId: React.PropTypes.string,
inputName: React.PropTypes.string,
className: React.PropTypes.string,
autoFocus: React.PropTypes.bool,
hoverSelect: React.PropTypes.bool,
inputValue: React.PropTypes.string,
options: React.PropTypes.array,
placeholder: React.PropTypes.string,
onChange: React.PropTypes.func,
onKeyDown: React.PropTypes.func,
onKeyPress: React.PropTypes.func,
onKeyUp: React.PropTypes.func,
onFocus: React.PropTypes.func,
onBlur: React.PropTypes.func,
onSelect: React.PropTypes.func,
onInputClick: React.PropTypes.func,
handleHint: React.PropTypes.func,
onComplete: React.PropTypes.func,
onOptionClick: React.PropTypes.func,
onOptionChange: React.PropTypes.func,
onDropdownOpen: React.PropTypes.func,
onDropdownClose: React.PropTypes.func,
optionTemplate: React.PropTypes.func.isRequired,
getMessageForOption: React.PropTypes.func,
getMessageForIncomingOptions: React.PropTypes.func
},
getDefaultProps: function() {
return {
className: '',
inputValue: '',
options: [],
hoverSelect: true,
onFocus: noop,
onKeyDown: noop,
onChange: noop,
onInputClick: noop,
handleHint: function() {
return '';
},
onOptionClick: noop,
onOptionChange: noop,
onComplete: noop,
onDropdownOpen: noop,
onDropdownClose: noop,
getMessageForOption: function() {
return '';
},
getMessageForIncomingOptions: function(number) {
return (
number + ' suggestions are available. Use up and down arrows to select.'
);
}
};
},
getInitialState: function() {
return {
selectedIndex: -1,
isHintVisible: false,
isDropdownVisible: false
};
},
componentWillMount: function() {
var _this = this,
uniqueId = this.constructor.getInstanceCount();
_this.userInputValue = null;
_this.previousInputValue = null;
_this.activeDescendantId = 'react-typeahead-activedescendant-' + uniqueId;
_this.optionsId = 'react-typeahead-options-' + uniqueId;
},
componentDidMount: function() {
var addEvent = window.addEventListener,
handleWindowClose = this.handleWindowClose;
// The `focus` event does not bubble, so we must capture it instead.
// This closes Typeahead's dropdown whenever something else gains focus.
addEvent('focus', handleWindowClose, true);
// If we click anywhere outside of Typeahead, close the dropdown.
addEvent('click', handleWindowClose, false);
},
componentWillUnmount: function() {
var removeEvent = window.removeEventListener,
handleWindowClose = this.handleWindowClose;
removeEvent('focus', handleWindowClose, true);
removeEvent('click', handleWindowClose, false);
},
componentWillReceiveProps: function(nextProps) {
var nextValue = nextProps.inputValue,
nextOptions = nextProps.options,
valueLength = nextValue.length,
isHintVisible = valueLength > 0 &&
// A visible part of the hint must be
// available for us to complete it.
nextProps.handleHint(nextValue, nextOptions).slice(valueLength).length > 0;
this.setState({
isHintVisible: isHintVisible
});
},
render: function() {
var _this = this;
return (
React.createElement("div", {
style: {
position: 'relative'
},
className: 'react-typeahead-container ' + _this.props.className},
_this.renderInput(),
_this.renderDropdown(),
_this.renderAriaMessageForOptions(),
_this.renderAriaMessageForIncomingOptions()
)
);
},
renderInput: function() {
var _this = this,
state = _this.state,
props = _this.props,
inputValue = props.inputValue,
className = 'react-typeahead-input',
inputDirection = getTextDirection(inputValue);
return (
React.createElement("div", {
style: {
position: 'relative'
},
className: "react-typeahead-input-container"},
React.createElement(Input, {
disabled: true,
role: "presentation",
"aria-hidden": true,
dir: inputDirection,
className: className + ' react-typeahead-hint',
style: {
color: 'silver',
WebkitTextFillColor: 'silver',
position: 'absolute'
},
value: state.isHintVisible ? props.handleHint(inputValue, props.options) : null}
),
React.createElement(Input, {
ref: "input",
role: "combobox",
"aria-owns": _this.optionsId,
"aria-expanded": state.isDropdownVisible,
"aria-autocomplete": "both",
"aria-activedescendant": _this.activeDescendantId,
value: inputValue,
spellCheck: false,
autoComplete: false,
autoCorrect: false,
dir: inputDirection,
onClick: _this.handleClick,
onFocus: _this.handleFocus,
onBlur: props.onBlur,
onChange: _this.handleChange,
onKeyDown: _this.handleKeyDown,
id: props.inputId,
name: props.inputName,
autoFocus: props.autoFocus,
placeholder: props.placeholder,
onSelect: props.onSelect,
onKeyUp: props.onKeyUp,
onKeyPress: props.onKeyPress,
className: className + ' react-typeahead-usertext',
style: {
position: 'relative',
background: 'transparent'
}}
)
)
);
},
renderDropdown: function() {
var _this = this,
state = _this.state,
props = _this.props,
OptionTemplate = props.optionTemplate,
selectedIndex = state.selectedIndex,
isDropdownVisible = state.isDropdownVisible,
activeDescendantId = _this.activeDescendantId;
if (this.props.options.length < 1) {
return null;
}
return (
React.createElement("ul", {id: _this.optionsId,
ref: "dropdown",
role: "listbox",
"aria-hidden": !isDropdownVisible,
style: {
width: '100%',
background: '#fff',
position: 'absolute',
boxSizing: 'border-box',
display: isDropdownVisible ? 'block' : 'none'
},
className: "react-typeahead-options",
onMouseOut: this.handleMouseOut},
props.options.map(function(data, index) {
var isSelected = selectedIndex === index;
return (
React.createElement("li", {id: isSelected ? activeDescendantId : null,
"aria-selected": isSelected,
role: "option",
key: index,
onClick: _this.handleOptionClick.bind(_this, index),
onMouseOver: _this.handleOptionMouseOver.bind(_this, index)},
React.createElement(OptionTemplate, {
data: data,
index: index,
userInputValue: _this.userInputValue,
inputValue: props.inputValue,
isSelected: isSelected}
)
)
);
})
)
);
},
renderAriaMessageForOptions: function() {
var _this = this,
props = _this.props,
inputValue = props.inputValue,
option = props.options[_this.state.selectedIndex] || inputValue;
return (
React.createElement(AriaStatus, {
message: props.getMessageForOption(option) || inputValue}
)
);
},
renderAriaMessageForIncomingOptions: function() {
var props = this.props;
return (
React.createElement(AriaStatus, {
message: props.getMessageForIncomingOptions(props.options.length)}
)
);
},
showDropdown: function() {
var _this = this;
if (!_this.state.isDropdownVisible) {
_this.setState({
isDropdownVisible: true
}, function() {
_this.props.onDropdownOpen();
});
}
},
hideDropdown: function() {
var _this = this;
if (_this.state.isDropdownVisible) {
_this.setState({
isDropdownVisible: false
}, function() {
_this.props.onDropdownClose();
});
}
},
showHint: function() {
var _this = this,
props = _this.props,
inputValue = props.inputValue,
inputValueLength = inputValue.length,
isHintVisible = inputValueLength > 0 &&
// A visible part of the hint must be
// available for us to complete it.
props.handleHint(inputValue, props.options).slice(inputValueLength).length > 0;
_this.setState({
isHintVisible: isHintVisible
});
},
hideHint: function() {
this.setState({
isHintVisible: false
});
},
setSelectedIndex: function(index, callback) {
this.setState({
selectedIndex: index
}, callback);
},
handleChange: function(event) {
var _this = this;
_this.showHint();
_this.showDropdown();
_this.setSelectedIndex(-1);
_this.props.onChange(event);
_this.userInputValue = event.target.value;
},
focus: function() {
this.refs.input.getDOMNode().focus();
},
handleFocus: function(event) {
var _this = this;
_this.showDropdown();
_this.props.onFocus(event);
},
handleClick: function(event) {
var _this = this;
_this.showHint();
_this.props.onInputClick(event);
},
navigate: function(direction, callback) {
var _this = this,
minIndex = -1,
maxIndex = _this.props.options.length - 1,
index = _this.state.selectedIndex + direction;
if (index > maxIndex) {
index = minIndex;
} else if (index < minIndex) {
index = maxIndex;
}
_this.setSelectedIndex(index, callback);
},
handleKeyDown: function(event) {
var _this = this,
key = event.key,
props = _this.props,
input = _this.refs.input,
isDropdownVisible = _this.state.isDropdownVisible,
isHintVisible = _this.state.isHintVisible,
hasHandledKeyDown = false,
index,
optionData,
dir;
switch (key) {
case 'End':
case 'Tab':
if (isHintVisible && !event.shiftKey) {
event.preventDefault();
props.onComplete(event, props.handleHint(props.inputValue, props.options));
}
break;
case 'ArrowLeft':
case 'ArrowRight':
if (isHintVisible && !event.shiftKey && input.isCursorAtEnd()) {
dir = getTextDirection(props.inputValue);
if ((dir === 'ltr' && key === 'ArrowRight') || (dir === 'rtl' && key === 'ArrowLeft')) {
props.onComplete(event, props.handleHint(props.inputValue, props.options));
}
}
break;
case 'Enter':
_this.focus();
_this.hideHint();
_this.hideDropdown();
break;
case 'Escape':
_this.hideHint();
_this.hideDropdown();
break;
case 'ArrowUp':
case 'ArrowDown':
if (props.options.length > 0) {
event.preventDefault();
_this.showHint();
_this.showDropdown();
if (isDropdownVisible) {
dir = key === 'ArrowUp' ? -1: 1;
hasHandledKeyDown = true;
_this.navigate(dir, function() {
var selectedIndex = _this.state.selectedIndex,
previousInputValue = _this.previousInputValue,
optionData = previousInputValue,
optionOffsetTop = 0,
selectedOption,
dropdown;
// We're currently on an option.
if (selectedIndex >= 0) {
// Save the current `input` value,
// as we might arrow back to it later.
if (previousInputValue === null) {
_this.previousInputValue = props.inputValue;
}
optionData = props.options[selectedIndex];
// Make selected option always scroll to visible
dropdown = React.findDOMNode(_this.refs.dropdown);
selectedOption = dropdown.children[selectedIndex];
optionOffsetTop = selectedOption.offsetTop;
if(optionOffsetTop + selectedOption.clientHeight > dropdown.clientHeight ||
optionOffsetTop < dropdown.scrollTop) {
dropdown.scrollTop = optionOffsetTop;
}
}
props.onOptionChange(event, optionData, selectedIndex);
props.onKeyDown(event, optionData, selectedIndex);
});
}
}
break;
}
if (!hasHandledKeyDown) {
index = this.state.selectedIndex;
optionData = index < 0 ? props.inputValue : props.options[index];
props.onKeyDown(event, optionData, index);
}
},
handleOptionClick: function(selectedIndex, event) {
var _this = this,
props = _this.props;
_this.focus();
_this.hideHint();
_this.hideDropdown();
_this.setSelectedIndex(selectedIndex);
props.onOptionClick(event, props.options[selectedIndex], selectedIndex);
},
handleOptionMouseOver: function(selectedIndex) {
var _this = this;
if (_this.props.hoverSelect) {
_this.setSelectedIndex(selectedIndex);
}
},
handleMouseOut: function() {
var _this = this;
if (_this.props.hoverSelect) {
_this.setSelectedIndex(-1);
}
},
handleWindowClose: function(event) {
var _this = this,
target = event.target;
if (target !== window && !this.getDOMNode().contains(target)) {
_this.hideHint();
_this.hideDropdown();
}
}
});