/*
* typeahead.js
* https://github.com/twitter/typeahead
* Copyright 2013 Twitter, Inc. and other contributors; Licensed MIT
*/
var TypeaheadView = (function() {
var html = {
wrapper: '',
hint: '',
dropdown: ''
},
css = {
wrapper: {
position: 'relative',
display: 'inline-block'
},
hint: {
position: 'absolute',
top: '0',
left: '0',
borderColor: 'transparent',
boxShadow: 'none'
},
query: {
position: 'relative',
verticalAlign: 'top',
backgroundColor: 'transparent'
},
dropdown: {
position: 'absolute',
top: '100%',
left: '0',
// TODO: should this be configurable?
zIndex: '100',
display: 'none'
}
};
// ie specific styling
if (utils.isMsie()) {
// ie6-8 (and 9?) doesn't fire hover and click events for elements with
// transparent backgrounds, for a workaround, use 1x1 transparent gif
utils.mixin(css.query, {
backgroundImage: 'url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)'
});
}
// ie7 and under specific styling
if (utils.isMsie() && utils.isMsie() <= 7) {
utils.mixin(css.wrapper, { display: 'inline', zoom: '1' });
// if someone can tell me why this is necessary to align
// the hint with the query in ie7, i'll send you $5 - @JakeHarding
utils.mixin(css.query, { marginTop: '-1px' });
}
// constructor
// -----------
function TypeaheadView(o) {
var $menu, $input, $hint;
utils.bindAll(this);
this.$node = buildDomStructure(o.input);
this.datasets = o.datasets;
this.dir = null;
this.eventBus = o.eventBus;
$menu = this.$node.find('.tt-dropdown-menu');
$input = this.$node.find('.tt-query');
$hint = this.$node.find('.tt-hint');
this.dropdownView = new DropdownView({ menu: $menu })
.on('suggestionSelected', this._handleSelection)
.on('cursorMoved', this._clearHint)
.on('cursorMoved', this._setInputValueToSuggestionUnderCursor)
.on('cursorRemoved', this._setInputValueToQuery)
.on('cursorRemoved', this._updateHint)
.on('suggestionsRendered', this._updateHint)
.on('opened', this._updateHint)
.on('closed', this._clearHint)
.on('opened closed', this._propagateEvent);
this.inputView = new InputView({ input: $input, hint: $hint })
.on('focused', this._openDropdown)
.on('blured', this._closeDropdown)
.on('blured', this._setInputValueToQuery)
.on('blured', this._checkMismatch)
.on('enterKeyed tabKeyed', this._handleSelection)
.on('queryChanged', this._clearHint)
.on('queryChanged', this._clearSuggestions)
.on('queryChanged', this._getSuggestions)
.on('whitespaceChanged', this._updateHint)
.on('queryChanged whitespaceChanged', this._openDropdown)
.on('queryChanged whitespaceChanged', this._setLanguageDirection)
.on('escKeyed', this._closeDropdown)
.on('escKeyed', this._setInputValueToQuery)
.on('tabKeyed upKeyed downKeyed', this._managePreventDefault)
.on('upKeyed downKeyed', this._moveDropdownCursor)
.on('upKeyed downKeyed', this._openDropdown)
.on('tabKeyed leftKeyed rightKeyed', this._autocomplete);
}
utils.mixin(TypeaheadView.prototype, EventTarget, {
// private methods
// ---------------
_managePreventDefault: function(e) {
var $e = e.data,
hint,
inputValue,
preventDefault = false;
switch (e.type) {
case 'tabKeyed':
hint = this.inputView.getHintValue();
inputValue = this.inputView.getInputValue();
preventDefault = hint && hint !== inputValue;
break;
case 'upKeyed':
case 'downKeyed':
preventDefault = !$e.shiftKey && !$e.ctrlKey && !$e.metaKey;
break;
}
preventDefault && $e.preventDefault();
},
_setLanguageDirection: function() {
var dir = this.inputView.getLanguageDirection();
if (dir !== this.dir) {
this.dir = dir;
this.$node.css('direction', dir);
this.dropdownView.setLanguageDirection(dir);
}
},
_updateHint: function() {
var suggestion = this.dropdownView.getFirstSuggestion(),
hint = suggestion ? suggestion.value : null,
dropdownIsVisible = this.dropdownView.isVisible(),
inputHasOverflow = this.inputView.isOverflow(),
inputValue,
query,
escapedQuery,
beginsWithQuery,
match;
if (hint && dropdownIsVisible && !inputHasOverflow) {
inputValue = this.inputView.getInputValue();
query = inputValue
.replace(/\s{2,}/g, ' ') // condense whitespace
.replace(/^\s+/g, ''); // strip leading whitespace
escapedQuery = utils.escapeRegExChars(query);
beginsWithQuery = new RegExp('^(?:' + escapedQuery + ')(.*$)', 'i');
match = beginsWithQuery.exec(hint);
this.inputView.setHintValue(inputValue + (match ? match[1] : ''));
}
},
_clearHint: function() {
this.inputView.setHintValue('');
},
_clearSuggestions: function() {
this.dropdownView.clearSuggestions();
},
_setInputValueToQuery: function() {
this.inputView.setInputValue(this.inputView.getQuery());
},
_setInputValueToSuggestionUnderCursor: function(e) {
var suggestion = e.data;
this.inputView.setInputValue(suggestion.value, true);
},
_openDropdown: function() {
if (this.inputView.$input.is(":focus")) {
this.dropdownView.open();
}
},
_closeDropdown: function(e) {
this.dropdownView[e.type === 'blured' ?
'closeUnlessMouseIsOverDropdown' : 'close']();
},
_moveDropdownCursor: function(e) {
var $e = e.data;
if (!$e.shiftKey && !$e.ctrlKey && !$e.metaKey) {
this.dropdownView[e.type === 'upKeyed' ?
'moveCursorUp' : 'moveCursorDown']();
}
},
_handleSelection: function(e) {
var byClick = e.type === 'suggestionSelected',
suggestion = byClick ?
e.data : this.dropdownView.getSuggestionUnderCursor();
if (suggestion) {
this.inputView.setInputValue(suggestion.value);
// if triggered by click, ensure the query input still has focus
// if triggered by keypress, prevent default browser behavior
// which is most likely the submission of a form
// note: e.data is the jquery event
byClick ? this.inputView.focus() : e.data.preventDefault();
// focus is not a synchronous event in ie, so we deal with it
byClick && utils.isMsie() ?
utils.defer(this.dropdownView.close) : this.dropdownView.close();
this.eventBus.trigger('selected', suggestion.datum, suggestion.dataset);
}
},
_checkMismatch: function() {
var that = this, query = this.inputView.getQuery(), hint = this.inputView.getHintValue();
if (utils.isBlankString(query)) {
this.eventBus.trigger('mismatched');
return;
}
if (hint !== "") return;
// TODO check cache only, otherwise only works with single datasets
utils.each(this.datasets, function(i, dataset) {
dataset.getSuggestions(query, function(suggestions) {
var matches = suggestions.length;
var selectedSuggestion;
utils.each(suggestions, function(i, suggestion) {
if (suggestion.value == query) {
selectedSuggestion = suggestion;
}
});
if (selectedSuggestion) {
that.eventBus.trigger(
'matched',
selectedSuggestion.datum,
selectedSuggestion.dataset
);
} else {
that.eventBus.trigger('mismatched');
}
});
});
},
_getSuggestions: function() {
var that = this, query = this.inputView.getQuery();
if (utils.isBlankString(query)) { return; }
utils.each(this.datasets, function(i, dataset) {
dataset.getSuggestions(query, function(suggestions) {
// only render the suggestions if the query hasn't changed
if (query === that.inputView.getQuery()) {
that.dropdownView.renderSuggestions(dataset, suggestions);
}
});
});
},
_autocomplete: function(e) {
var isCursorAtEnd, ignoreEvent, query, hint, suggestion;
if (e.type === 'rightKeyed' || e.type === 'leftKeyed') {
isCursorAtEnd = this.inputView.isCursorAtEnd();
ignoreEvent = this.inputView.getLanguageDirection() === 'ltr' ?
e.type === 'leftKeyed' : e.type === 'rightKeyed';
if (!isCursorAtEnd || ignoreEvent) { return; }
}
query = this.inputView.getQuery();
hint = this.inputView.getHintValue();
if (hint !== '' && query !== hint) {
suggestion = this.dropdownView.getFirstSuggestion();
this.inputView.setInputValue(suggestion.value);
this.eventBus.trigger(
'autocompleted',
suggestion.datum,
suggestion.dataset
);
}
},
_propagateEvent: function(e) {
this.eventBus.trigger(e.type);
},
// public methods
// --------------
destroy: function() {
this.inputView.destroy();
this.dropdownView.destroy();
destroyDomStructure(this.$node);
this.$node = null;
},
setQuery: function(query) {
this.inputView.setQuery(query);
this.inputView.setInputValue(query);
this._clearHint();
this._clearSuggestions();
this._getSuggestions();
}
});
return TypeaheadView;
function buildDomStructure(input) {
var $wrapper = $(html.wrapper),
$dropdown = $(html.dropdown),
$input = $(input),
$hint = $(html.hint);
$wrapper = $wrapper.css(css.wrapper);
$dropdown = $dropdown.css(css.dropdown);
$hint
.css(css.hint)
// copy background styles from query input to hint input
.css({
backgroundAttachment: $input.css('background-attachment'),
backgroundClip: $input.css('background-clip'),
backgroundColor: $input.css('background-color'),
backgroundImage: $input.css('background-image'),
backgroundOrigin: $input.css('background-origin'),
backgroundPosition: $input.css('background-position'),
backgroundRepeat: $input.css('background-repeat'),
backgroundSize: $input.css('background-size')
});
// store the original values of the attrs that get modified
// so modifications can be reverted on destroy
$input.data('ttAttrs', {
dir: $input.attr('dir'),
autocomplete: $input.attr('autocomplete'),
spellcheck: $input.attr('spellcheck'),
style: $input.attr('style')
});
$input
.addClass('tt-query')
.attr({ autocomplete: 'off', spellcheck: false })
.css(css.query);
// ie7 does not like it when dir is set to auto,
// it does not like it one bit
try { !$input.attr('dir') && $input.attr('dir', 'auto'); } catch (e) {}
return $input
.wrap($wrapper)
.parent()
.prepend($hint)
.append($dropdown);
}
function destroyDomStructure($node) {
var $input = $node.find('.tt-query');
// need to remove attrs that weren't previously defined and
// revert attrs that originally had a value
utils.each($input.data('ttAttrs'), function(key, val) {
utils.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val);
});
$input
.detach()
.removeData('ttAttrs')
.removeClass('tt-query')
.insertAfter($node);
$node.remove();
}
})();