439 lines
12 KiB
JavaScript
439 lines
12 KiB
JavaScript
/*
|
||
* typeahead.js
|
||
* https://github.com/twitter/typeahead.js
|
||
* Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
|
||
*/
|
||
|
||
var Typeahead = (function() {
|
||
'use strict';
|
||
|
||
// constructor
|
||
// -----------
|
||
|
||
function Typeahead(o, www) {
|
||
var onFocused, onBlurred, onEnterKeyed, onTabKeyed, onEscKeyed, onUpKeyed,
|
||
onDownKeyed, onLeftKeyed, onRightKeyed, onQueryChanged,
|
||
onWhitespaceChanged;
|
||
|
||
o = o || {};
|
||
|
||
if (!o.input) {
|
||
$.error('missing input');
|
||
}
|
||
|
||
if (!o.menu) {
|
||
$.error('missing menu');
|
||
}
|
||
|
||
if (!o.eventBus) {
|
||
$.error('missing event bus');
|
||
}
|
||
|
||
www.mixin(this);
|
||
|
||
this.eventBus = o.eventBus;
|
||
this.minLength = _.isNumber(o.minLength) ? o.minLength : 1;
|
||
|
||
this.input = o.input;
|
||
this.menu = o.menu;
|
||
|
||
this.enabled = true;
|
||
|
||
// activate the typeahead on init if the input has focus
|
||
this.active = false;
|
||
this.input.hasFocus() && this.activate();
|
||
|
||
// detect the initial lang direction
|
||
this.dir = this.input.getLangDir();
|
||
|
||
this._hacks();
|
||
|
||
this.menu.bind()
|
||
.onSync('selectableClicked', this._onSelectableClicked, this)
|
||
.onSync('asyncRequested', this._onAsyncRequested, this)
|
||
.onSync('asyncCanceled', this._onAsyncCanceled, this)
|
||
.onSync('asyncReceived', this._onAsyncReceived, this)
|
||
.onSync('datasetRendered', this._onDatasetRendered, this)
|
||
.onSync('datasetCleared', this._onDatasetCleared, this);
|
||
|
||
// composed event handlers for input
|
||
onFocused = c(this, 'activate', 'open', '_onFocused');
|
||
onBlurred = c(this, 'deactivate', '_onBlurred');
|
||
onEnterKeyed = c(this, 'isActive', 'isOpen', '_onEnterKeyed');
|
||
onTabKeyed = c(this, 'isActive', 'isOpen', '_onTabKeyed');
|
||
onEscKeyed = c(this, 'isActive', '_onEscKeyed');
|
||
onUpKeyed = c(this, 'isActive', 'open', '_onUpKeyed');
|
||
onDownKeyed = c(this, 'isActive', 'open', '_onDownKeyed');
|
||
onLeftKeyed = c(this, 'isActive', 'isOpen', '_onLeftKeyed');
|
||
onRightKeyed = c(this, 'isActive', 'isOpen', '_onRightKeyed');
|
||
onQueryChanged = c(this, '_openIfActive', '_onQueryChanged');
|
||
onWhitespaceChanged = c(this, '_openIfActive', '_onWhitespaceChanged');
|
||
|
||
this.input.bind()
|
||
.onSync('focused', onFocused, this)
|
||
.onSync('blurred', onBlurred, this)
|
||
.onSync('enterKeyed', onEnterKeyed, this)
|
||
.onSync('tabKeyed', onTabKeyed, this)
|
||
.onSync('escKeyed', onEscKeyed, this)
|
||
.onSync('upKeyed', onUpKeyed, this)
|
||
.onSync('downKeyed', onDownKeyed, this)
|
||
.onSync('leftKeyed', onLeftKeyed, this)
|
||
.onSync('rightKeyed', onRightKeyed, this)
|
||
.onSync('queryChanged', onQueryChanged, this)
|
||
.onSync('whitespaceChanged', onWhitespaceChanged, this)
|
||
.onSync('langDirChanged', this._onLangDirChanged, this);
|
||
}
|
||
|
||
// instance methods
|
||
// ----------------
|
||
|
||
_.mixin(Typeahead.prototype, {
|
||
|
||
// here's where hacks get applied and we don't feel bad about it
|
||
_hacks: function hacks() {
|
||
var $input, $menu;
|
||
|
||
// these default values are to make testing easier
|
||
$input = this.input.$input || $('<div>');
|
||
$menu = this.menu.$node || $('<div>');
|
||
|
||
// #705: if there's scrollable overflow, ie doesn't support
|
||
// blur cancellations when the scrollbar is clicked
|
||
//
|
||
// #351: preventDefault won't cancel blurs in ie <= 8
|
||
$input.on('blur.tt', function($e) {
|
||
var active, isActive, hasActive;
|
||
|
||
active = document.activeElement;
|
||
isActive = $menu.is(active);
|
||
hasActive = $menu.has(active).length > 0;
|
||
|
||
if (_.isMsie() && (isActive || hasActive)) {
|
||
$e.preventDefault();
|
||
// stop immediate in order to prevent Input#_onBlur from
|
||
// getting exectued
|
||
$e.stopImmediatePropagation();
|
||
_.defer(function() { $input.focus(); });
|
||
}
|
||
});
|
||
|
||
// #351: prevents input blur due to clicks within menu
|
||
$menu.on('mousedown.tt', function($e) { $e.preventDefault(); });
|
||
},
|
||
|
||
// ### event handlers
|
||
|
||
_onSelectableClicked: function onSelectableClicked(type, $el) {
|
||
this.select($el);
|
||
},
|
||
|
||
_onDatasetCleared: function onDatasetCleared() {
|
||
this._updateHint();
|
||
},
|
||
|
||
_onDatasetRendered: function onDatasetRendered(type, dataset, suggestions, async) {
|
||
this._updateHint();
|
||
this.eventBus.trigger('render', suggestions, async, dataset);
|
||
},
|
||
|
||
_onAsyncRequested: function onAsyncRequested(type, dataset, query) {
|
||
this.eventBus.trigger('asyncrequest', query, dataset);
|
||
},
|
||
|
||
_onAsyncCanceled: function onAsyncCanceled(type, dataset, query) {
|
||
this.eventBus.trigger('asynccancel', query, dataset);
|
||
},
|
||
|
||
_onAsyncReceived: function onAsyncReceived(type, dataset, query) {
|
||
this.eventBus.trigger('asyncreceive', query, dataset);
|
||
},
|
||
|
||
_onFocused: function onFocused() {
|
||
this._minLengthMet() && this.menu.update(this.input.getQuery());
|
||
},
|
||
|
||
_onBlurred: function onBlurred() {
|
||
if (this.input.hasQueryChangedSinceLastFocus()) {
|
||
this.eventBus.trigger('change', this.input.getQuery());
|
||
}
|
||
},
|
||
|
||
_onEnterKeyed: function onEnterKeyed(type, $e) {
|
||
var $selectable;
|
||
|
||
if ($selectable = this.menu.getActiveSelectable()) {
|
||
this.select($selectable) && $e.preventDefault();
|
||
}
|
||
},
|
||
|
||
_onTabKeyed: function onTabKeyed(type, $e) {
|
||
var $selectable;
|
||
|
||
if ($selectable = this.menu.getActiveSelectable()) {
|
||
this.select($selectable) && $e.preventDefault();
|
||
}
|
||
|
||
else if ($selectable = this.menu.getTopSelectable()) {
|
||
this.autocomplete($selectable) && $e.preventDefault();
|
||
}
|
||
},
|
||
|
||
_onEscKeyed: function onEscKeyed() {
|
||
this.close();
|
||
},
|
||
|
||
_onUpKeyed: function onUpKeyed() {
|
||
this.moveCursor(-1);
|
||
},
|
||
|
||
_onDownKeyed: function onDownKeyed() {
|
||
this.moveCursor(+1);
|
||
},
|
||
|
||
_onLeftKeyed: function onLeftKeyed() {
|
||
if (this.dir === 'rtl' && this.input.isCursorAtEnd()) {
|
||
this.autocomplete(this.menu.getActiveSelectable() || this.menu.getTopSelectable());
|
||
}
|
||
},
|
||
|
||
_onRightKeyed: function onRightKeyed() {
|
||
if (this.dir === 'ltr' && this.input.isCursorAtEnd()) {
|
||
this.autocomplete(this.menu.getActiveSelectable() || this.menu.getTopSelectable());
|
||
}
|
||
},
|
||
|
||
_onQueryChanged: function onQueryChanged(e, query) {
|
||
this._minLengthMet(query) ? this.menu.update(query) : this.menu.empty();
|
||
},
|
||
|
||
_onWhitespaceChanged: function onWhitespaceChanged() {
|
||
this._updateHint();
|
||
},
|
||
|
||
_onLangDirChanged: function onLangDirChanged(e, dir) {
|
||
if (this.dir !== dir) {
|
||
this.dir = dir;
|
||
this.menu.setLanguageDirection(dir);
|
||
}
|
||
},
|
||
|
||
// ### private
|
||
|
||
_openIfActive: function openIfActive() {
|
||
this.isActive() && this.open();
|
||
},
|
||
|
||
_minLengthMet: function minLengthMet(query) {
|
||
query = _.isString(query) ? query : (this.input.getQuery() || '');
|
||
|
||
return query.length >= this.minLength;
|
||
},
|
||
|
||
_updateHint: function updateHint() {
|
||
var $selectable, data, val, query, escapedQuery, frontMatchRegEx, match;
|
||
|
||
$selectable = this.menu.getTopSelectable();
|
||
data = this.menu.getSelectableData($selectable);
|
||
val = this.input.getInputValue();
|
||
|
||
if (data && !_.isBlankString(val) && !this.input.hasOverflow()) {
|
||
query = Input.normalizeQuery(val);
|
||
escapedQuery = _.escapeRegExChars(query);
|
||
|
||
// match input value, then capture trailing text
|
||
frontMatchRegEx = new RegExp('^(?:' + escapedQuery + ')(.+$)', 'i');
|
||
match = frontMatchRegEx.exec(data.val);
|
||
|
||
// clear hint if there's no trailing text
|
||
match && this.input.setHint(val + match[1]);
|
||
}
|
||
|
||
else {
|
||
this.input.clearHint();
|
||
}
|
||
},
|
||
|
||
// ### public
|
||
|
||
isEnabled: function isEnabled() {
|
||
return this.enabled;
|
||
},
|
||
|
||
enable: function enable() {
|
||
this.enabled = true;
|
||
},
|
||
|
||
disable: function disable() {
|
||
this.enabled = false;
|
||
},
|
||
|
||
isActive: function isActive() {
|
||
return this.active;
|
||
},
|
||
|
||
activate: function activate() {
|
||
// already active
|
||
if (this.isActive()) {
|
||
return true;
|
||
}
|
||
|
||
// unable to activate either due to the typeahead being disabled
|
||
// or due to the active event being prevented
|
||
else if (!this.isEnabled() || this.eventBus.before('active')) {
|
||
return false;
|
||
}
|
||
|
||
// activate
|
||
else {
|
||
this.active = true;
|
||
this.eventBus.trigger('active');
|
||
return true;
|
||
}
|
||
},
|
||
|
||
deactivate: function deactivate() {
|
||
// already idle
|
||
if (!this.isActive()) {
|
||
return true;
|
||
}
|
||
|
||
// unable to deactivate due to the idle event being prevented
|
||
else if (this.eventBus.before('idle')) {
|
||
return false;
|
||
}
|
||
|
||
// deactivate
|
||
else {
|
||
this.active = false;
|
||
this.close();
|
||
this.eventBus.trigger('idle');
|
||
return true;
|
||
}
|
||
},
|
||
|
||
isOpen: function isOpen() {
|
||
return this.menu.isOpen();
|
||
},
|
||
|
||
open: function open() {
|
||
if (!this.isOpen() && !this.eventBus.before('open')) {
|
||
this.menu.open();
|
||
this._updateHint();
|
||
this.eventBus.trigger('open');
|
||
}
|
||
|
||
return this.isOpen();
|
||
},
|
||
|
||
close: function close() {
|
||
if (this.isOpen() && !this.eventBus.before('close')) {
|
||
this.menu.close();
|
||
this.input.clearHint();
|
||
this.input.resetInputValue();
|
||
this.eventBus.trigger('close');
|
||
}
|
||
return !this.isOpen();
|
||
},
|
||
|
||
setVal: function setVal(val) {
|
||
// expect val to be a string, so be safe, and coerce
|
||
this.input.setQuery(_.toStr(val));
|
||
},
|
||
|
||
getVal: function getVal() {
|
||
return this.input.getQuery();
|
||
},
|
||
|
||
select: function select($selectable) {
|
||
var data = this.menu.getSelectableData($selectable);
|
||
|
||
if (data && !this.eventBus.before('select', data.obj)) {
|
||
this.input.setQuery(data.val, true);
|
||
|
||
this.eventBus.trigger('select', data.obj);
|
||
this.close();
|
||
|
||
// return true if selection succeeded
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
},
|
||
|
||
autocomplete: function autocomplete($selectable) {
|
||
var query, data, isValid;
|
||
|
||
query = this.input.getQuery();
|
||
data = this.menu.getSelectableData($selectable);
|
||
isValid = data && query !== data.val;
|
||
|
||
if (isValid && !this.eventBus.before('autocomplete', data.obj)) {
|
||
this.input.setQuery(data.val);
|
||
this.eventBus.trigger('autocomplete', data.obj);
|
||
|
||
// return true if autocompletion succeeded
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
},
|
||
|
||
moveCursor: function moveCursor(delta) {
|
||
var query, $candidate, data, payload, cancelMove;
|
||
|
||
query = this.input.getQuery();
|
||
$candidate = this.menu.selectableRelativeToCursor(delta);
|
||
data = this.menu.getSelectableData($candidate);
|
||
payload = data ? data.obj : null;
|
||
|
||
// update will return true when it's a new query and new suggestions
|
||
// need to be fetched – in this case we don't want to move the cursor
|
||
cancelMove = this._minLengthMet() && this.menu.update(query);
|
||
|
||
if (!cancelMove && !this.eventBus.before('cursorchange', payload)) {
|
||
this.menu.setCursor($candidate);
|
||
|
||
// cursor moved to different selectable
|
||
if (data) {
|
||
this.input.setInputValue(data.val);
|
||
}
|
||
|
||
// cursor moved off of selectables, back to input
|
||
else {
|
||
this.input.resetInputValue();
|
||
this._updateHint();
|
||
}
|
||
|
||
this.eventBus.trigger('cursorchange', payload);
|
||
|
||
// return true if move succeeded
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
},
|
||
|
||
destroy: function destroy() {
|
||
this.input.destroy();
|
||
this.menu.destroy();
|
||
}
|
||
});
|
||
|
||
return Typeahead;
|
||
|
||
// helper functions
|
||
// ----------------
|
||
|
||
function c(ctx) {
|
||
var methods = [].slice.call(arguments, 1);
|
||
|
||
return function() {
|
||
var args = [].slice.call(arguments);
|
||
|
||
_.each(methods, function(method) {
|
||
return ctx[method].apply(ctx, args);
|
||
});
|
||
};
|
||
}
|
||
})();
|