react-widgets
Version:
396 lines (325 loc) • 11.8 kB
JavaScript
/** React.DOM */
var React = require('react')
, cx = require('../util/cx')
, _ = require('lodash')
, caretPos = require('../util/caret')
, filter = require('../util/filter')
, mergeIntoProps = require('../util/transferProps').mergeIntoProps
, directions = require('../util/constants').directions
, Input = require('./combo-input')
, Popup = require('../popup/popup')
, List = require('../common/list')
, $ = require('../util/dom');
var btn = require('../common/btn')
, propTypes = {
value: React.PropTypes.any,
onChange: React.PropTypes.func,
itemComponent: React.PropTypes.func,
data: React.PropTypes.array,
valueField: React.PropTypes.string,
textField: React.PropTypes.string,
disabled: React.PropTypes.oneOfType([
React.PropTypes.bool,
React.PropTypes.oneOf(['disabled'])
]),
readOnly: React.PropTypes.oneOfType([
React.PropTypes.bool,
React.PropTypes.oneOf(['readOnly'])
]),
suggest: React.PropTypes.bool,
busy: React.PropTypes.bool,
duration: React.PropTypes.number, //popup
placeholder: React.PropTypes.string,
messages: React.PropTypes.shape({
open: React.PropTypes.string,
emptyList: React.PropTypes.string,
emptyFilter: React.PropTypes.string
})
};
module.exports = React.createClass({
displayName: 'ComboBox',
mixins: [
require('../mixins/DataHelpersMixin'),
require('../mixins/TextSearchMixin'),
require('../mixins/DataFilterMixin'),
require('../mixins/RtlParentContextMixin'),
require('../mixins/DataIndexStateMixin')('focusedIndex'),
require('../mixins/DataIndexStateMixin')('selectedIndex')
],
propTypes: propTypes,
getInitialState: function(){
var items = this.process(
this.props.data
, this.props.value)
, idx = this._dataIndexOf(items, this.props.value);
return {
selectedIndex: idx,
focusedIndex: idx === -1 ? 0 : idx,
processedData: items,
open: false
}
},
getDefaultProps: function(){
return {
data: [],
suggest: false,
filter: false,
delay: 500,
messages: {
open: 'open combobox',
emptyList: "There are no items in this list",
emptyFilter: "The filter returned no results"
}
}
},
shouldComponentUpdate: function(nextProps, nextState){
var isSuggesting = this.refs.input && this.refs.input.isSuggesting()
, stateChanged = !shallowEqual(nextState, this.state)
, valueChanged = !shallowEqual(nextProps, this.props)
return isSuggesting || stateChanged || valueChanged
},
componentWillReceiveProps: function(nextProps) {
var rawIdx = this._dataIndexOf(nextProps.data, nextProps.value)
, valueItem = rawIdx == -1 ? nextProps.value : nextProps.data[rawIdx]
, isSuggesting = this.refs.input.isSuggesting()
, items = this.process(
nextProps.data
, nextProps.value
, (rawIdx === -1 || isSuggesting) && this._dataText(valueItem) )
, idx = this._dataIndexOf(items, nextProps.value)
, focused = this.filterIndexOf(items, this._dataText(valueItem));
this._searchTerm = '';
this.setState({
processedData: items,
selectedIndex: idx,
focusedIndex: idx === -1
? focused !== -1 ? focused : 0 // focus the closest match
: idx
})
},
render: function(){
var DropdownValue = this.props.valueComponent
, valueItem = this._dataItem( this._data(), this.props.value )
, items = this._data()
, listID = this._id('_listbox')
, optID = this._id( '_option')
, completeType = this.props.suggest
? this.props.filter ? 'both' : 'inline'
: this.props.filter ? 'list' : '';
return mergeIntoProps(
_.omit(this.props, _.keys(propTypes)),
React.DOM.div({ref: "element",
onKeyDown: this._maybeHandle(this._keyDown),
onFocus: this._maybeHandle(_.partial(this._focus, true), true),
onBlur: this._focus.bind(null, false),
tabIndex: "-1",
className: cx({
'rw-combobox': true,
'rw-widget': true,
'rw-state-focus': this.state.focused,
'rw-open': this.state.open,
'rw-state-disabled': this.props.disabled,
'rw-state-readonly': this.props.readOnly,
'rw-rtl': this.isRtl()
})},
btn({
tabIndex: "-1",
className: "rw-select",
onClick: this._maybeHandle(this.toggle),
disabled: !!(this.props.disabled || this.props.readOnly)},
React.DOM.i({className: "rw-i rw-i-caret-down" + (this.props.busy ? ' rw-loading' : "")},
React.DOM.span({className: "rw-sr"}, this.props.messages.open)
)
),
Input({
ref: "input",
type: "text",
role: "combobox",
suggest: this.props.suggest,
'aria-owns': listID,
'aria-busy': !!this.props.busy,
'aria-autocomplete': completeType,
'aria-activedescendent': this.state.open ? optID : undefined,
'aria-expanded': this.state.open,
'aria-haspopup': true,
placeholder: this.props.placeholder,
disabled: this.props.disabled,
readOnly: this.props.readOnly,
className: "rw-input",
value: this._dataText(valueItem),
onChange: this._inputTyping,
onKeyDown: this._inputKeyDown}),
Popup({open: this.state.open, onRequestClose: this.close, duration: this.props.duration},
React.DOM.div(null,
List({ref: "list",
id: listID,
optID: optID,
'aria-hidden': !this.state.open,
'aria-live': completeType && 'polite',
style: { maxHeight: 200, height: 'auto'},
data: items,
selectedIndex: this.state.selectedIndex,
focusedIndex: this.state.focusedIndex,
textField: this.props.textField,
valueField: this.props.valueField,
onSelect: this._maybeHandle(this._onSelect),
listItem: this.props.itemComponent,
messages: {
emptyList: this.props.data.length
? this.props.messages.emptyFilter
: this.props.messages.emptyList
}})
)
)
)
)
},
setWidth: function() {
var width = $.width(this.getDOMNode())
, changed = width !== this.state.width;
if ( changed )
this.setState({ width: width })
},
_onSelect: function(data, idx, elem){
this.close()
this.change(data)
this._focus(true);
},
_inputKeyDown: function(e){
this._deleting = e.key === 'Backspace' || e.key === 'Delete'
this._isTyping = true
},
_inputTyping: function(e){
var self = this
, shouldSuggest = !!this.props.suggest
, strVal = e.target.value
, suggestion, data;
suggestion = this._deleting || !shouldSuggest
? strVal : this.suggest(this._data(), strVal)
suggestion = suggestion || strVal
data = _.find(self.props.data, function(item) {
return self._dataText(item).toLowerCase() === suggestion.toLowerCase()
})
this.change(!this._deleting && data
? data
: strVal, true)
this.open()
},
_focus: function(focused, e){
var self = this;
clearTimeout(self.timer)
!focused && self.refs.input.accept() //not suggesting anymore
self.timer = setTimeout(function(){
if(focused) self.refs.input.focus()
else self.close()
if( focused !== self.state.focused)
self.setState({ focused: focused })
}, 0)
},
_keyDown: function(e){
var self = this
, key = e.key
, alt = e.altKey
, character = String.fromCharCode(e.keyCode)
, selectedIdx = this.state.selectedIndex
, focusedIdx = this.state.focusedIndex
, isOpen = this.state.open
, noselection = selectedIdx == null || selectedIdx === -1;
if ( key === 'End' )
select(this._data().length - 1)
else if ( key === 'Home' )
select(0)
else if ( key === 'Escape' && isOpen )
this.close()
else if ( key === 'Enter' && isOpen ) {
select(focusedIdx)
this.close()
}
else if ( key === 'ArrowDown' ) {
if ( alt )
this.open()
else {
if ( isOpen ) this.setFocusedIndex(this.nextFocusedIndex())
else select(this.nextSelectedIndex())
}
}
else if ( key === 'ArrowUp' ) {
if ( alt )
this.close()
else {
if ( isOpen ) this.setFocusedIndex(this.prevFocusedIndex())
else select(this.prevSelectedIndex())
}
}
function select(idx) {
if( idx === -1 || self._data().length === 0)
return self.change(self.refs.input.getDOMNode().value, false)
self.refs.input.accept(true); //removes caret
self.change(self._data()[idx], false)
}
},
change: function(data, typing){
var change = this.props.onChange
this._typedChange = !!typing
if ( change ) change(data)
},
open: function(){
if ( !this.state.open )
this.setState({ open: true })
},
close: function(){
if ( this.state.open )
this.setState({ open: false })
},
toggle: function(e){
this._focus(true)
this.state.open
? this.close()
: this.open()
},
suggest: function(data, value){
var word = this._dataText(value)
, matcher = filter.startsWith
, suggestion = typeof value === 'string'
? _.find(data, finder, this)
: value
if ( suggestion && (!this.state || !this.state.deleting))
return this._dataText(suggestion)
return ''
function finder(item){
return matcher(
this._dataText(item).toLowerCase()
, word.toLowerCase())
}
},
_maybeHandle: function(handler, disabledOnly){
//console.log(!(this.props.disabled || (!disabledOnly &&this.props.readOnly)))
if ( !(this.props.disabled || (!disabledOnly &&this.props.readOnly)))
return handler
},
_data: function(){
return this.state.processedData
},
_id: function(suffix){
this._id_ || (this._id_ = _.uniqueId('rw_'))
return (this.props.id || this._id_) + suffix
},
process: function(data, values, searchTerm){
if( this.props.filter && searchTerm)
data = this.filter(data, searchTerm)
return data
}
})
function shallowEqual(objA, objB) {
var key;
if (objA === objB) return true;
// Test for A's keys different from B.
for (key in objA)
if (objA.hasOwnProperty(key) && (!objB.hasOwnProperty(key) || objA[key] !== objB[key]))
return false;
// Test for B'a keys missing from A.
for (key in objB)
if (objB.hasOwnProperty(key) && !objA.hasOwnProperty(key))
return false;
return true;
}