Refactor views.

This commit is contained in:
Jake Harding
2013-07-09 14:39:45 -07:00
parent c82e097c6f
commit 6809173331
3 changed files with 406 additions and 542 deletions

View File

@@ -5,121 +5,128 @@
*/
var DropdownView = (function() {
var html = {
suggestionsList: '<span class="tt-suggestions"></span>'
},
css = {
suggestionsList: { display: 'block' },
suggestion: { whiteSpace: 'nowrap', cursor: 'pointer' },
suggestionChild: { whiteSpace: 'normal' }
};
// constructor
// -----------
function DropdownView(o) {
utils.bindAll(this);
var that = this, onMouseEnter, onMouseLeave, onSuggestionClick,
onSuggestionMouseEnter, onSuggestionMouseLeave;
this.isOpen = false;
this.isEmpty = true;
this.isMouseOverDropdown = false;
// bound functions
onMouseEnter = utils.bind(this._onMouseEnter, this);
onMouseLeave = utils.bind(this._onMouseLeave, this);
onSuggestionClick = utils.bind(this._onSuggestionClick, this);
onSuggestionMouseEnter = utils.bind(this._onSuggestionMouseEnter, this);
onSuggestionMouseLeave = utils.bind(this._onSuggestionMouseLeave, this);
this.$menu = $(o.menu)
.on('mouseenter.tt', this._handleMouseenter)
.on('mouseleave.tt', this._handleMouseleave)
.on('click.tt', '.tt-suggestion', this._handleSelection)
.on('mouseover.tt', '.tt-suggestion', this._handleSuggestionMouseover)
.on('mouseleave.tt', '.tt-suggestion', this._handleSuggestionMouseleave);
.on('mouseenter.tt', onMouseEnter)
.on('mouseleave.tt', onMouseLeave)
.on('click.tt', '.tt-suggestion', onSuggestionClick)
.on('mouseenter.tt', '.tt-suggestion', onSuggestionMouseEnter)
.on('mouseleave.tt', '.tt-suggestion', onSuggestionMouseLeave);
this.sections = utils.map(o.sections, initializeSection);
utils.each(this.sections, function(i, section) {
that.$menu.append(section.getRoot());
section.onSync('rendered', that._onRendered, that);
});
}
utils.mixin(DropdownView.prototype, EventTarget, {
// private methods
// ---------------
// instance methods
// ----------------
_handleMouseenter: function() {
utils.mixin(DropdownView.prototype, EventEmitter, {
// ### private
_onMouseEnter: function onMouseEnter($e) {
this.isMouseOverDropdown = true;
},
_handleMouseleave: function() {
_onMouseLeave: function onMouseLeave($e) {
this.isMouseOverDropdown = false;
},
_handleSelection: function($e) {
var $suggestion = $($e.currentTarget);
this.trigger('suggestionSelected', extractSuggestion($suggestion));
_onSuggestionClick: function onSuggestionClick($e) {
this.trigger('suggestionClicked', $($e.currentTarget));
},
_handleSuggestionMouseover: function($e) {
var $suggestion = $($e.currentTarget);
this._getSuggestions().removeClass('tt-is-under-cursor');
$suggestion.addClass('tt-is-under-cursor');
_onSuggestionMouseEnter: function onSuggestionMouseEnter($e) {
this._setCursor($($e.currentTarget));
},
_handleSuggestionMouseleave: function($e) {
var $suggestion = $($e.currentTarget);
$suggestion.removeClass('tt-is-under-cursor');
_onSuggestionMouseLeave: function onSuggestionMouseLeave($e) {
this._removeCursor();
},
_show: function() {
// can't use jQuery#show because $menu is a span element we want
// display: block; not dislay: inline;
this.$menu.css('display', 'block');
_onRendered: function onRendered() {
this.trigger('suggestionsRendered');
},
_hide: function() {
this.$menu.hide();
_getSuggestions: function getSuggestions() {
return this.$menu.find('.tt-suggestion');
},
_moveCursor: function(increment) {
var $suggestions, $cur, nextIndex, $underCursor;
_getCursor: function getCursor() {
return this.$menu.find('.tt-cursor');
},
// don't bother moving the cursor if the menu is closed or empty
if (!this.isVisible()) {
return;
}
_setCursor: function setCursor($el) {
$el.addClass('tt-cursor');
},
_removeCursor: function removeCursor() {
this._getCursor().removeClass('tt-cursor');
},
_moveCursor: function moveCursor(increment) {
var $suggestions, $oldCursor, newCursorIndex, $newCursor;
if (!this.isOpen) { return; }
$oldCursor = this._getCursor();
$suggestions = this._getSuggestions();
$cur = $suggestions.filter('.tt-is-under-cursor');
$cur.removeClass('tt-is-under-cursor');
this._removeCursor();
// shifting before and after modulo to deal with -1 index of search input
nextIndex = $suggestions.index($cur) + increment;
nextIndex = (nextIndex + 1) % ($suggestions.length + 1) - 1;
// shifting before and after modulo to deal with -1 index
newCursorIndex = $suggestions.index($oldCursor) + increment;
newCursorIndex = (newCursorIndex + 1) % ($suggestions.length + 1) - 1;
if (nextIndex === -1) {
if (newCursorIndex === -1) {
this.trigger('cursorRemoved');
return;
}
else if (nextIndex < -1) {
// circle to last suggestion
nextIndex = $suggestions.length - 1;
else if (newCursorIndex < -1) {
newCursorIndex = $suggestions.length - 1;
}
$underCursor = $suggestions.eq(nextIndex).addClass('tt-is-under-cursor');
this._setCursor($newCursor = $suggestions.eq(newCursorIndex));
// in the case of scrollable overflow
// make sure the cursor is visible in the menu
this._ensureVisibility($underCursor);
this._ensureVisible($newCursor);
this.trigger('cursorMoved', extractSuggestion($underCursor));
this.trigger('cursorMoved');
},
_getSuggestions: function() {
return this.$menu.find('.tt-suggestions > .tt-suggestion');
},
_ensureVisible: function ensureVisible($el) {
var elTop, elBottom, menuScrollTop, menuHeight;
_ensureVisibility: function($el) {
var menuHeight = this.$menu.height() +
parseInt(this.$menu.css('paddingTop'), 10) +
parseInt(this.$menu.css('paddingBottom'), 10),
menuScrollTop = this.$menu.scrollTop(),
elTop = $el.position().top,
elBottom = elTop + $el.outerHeight(true);
elTop = $el.position().top;
elBottom = elTop + $el.outerHeight(true);
menuScrollTop = this.$menu.scrollTop();
menuHeight = this.$menu.height() +
parseInt(this.$menu.css('paddingTop'), 10) +
parseInt(this.$menu.css('paddingBottom'), 10);
if (elTop < 0) {
this.$menu.scrollTop(menuScrollTop + elTop);
@@ -130,153 +137,65 @@ var DropdownView = (function() {
}
},
// public methods
// --------------
// ### public
destroy: function() {
this.$menu.off('.tt');
this.$menu = null;
},
isVisible: function() {
return this.isOpen && !this.isEmpty;
},
closeUnlessMouseIsOverDropdown: function() {
// this helps detect the scenario a blur event has triggered
// this function. we don't want to close the menu in that case
// because it'll prevent the probable associated click event
// from being fired
if (!this.isMouseOverDropdown) {
this.close();
}
},
close: function() {
close: function close() {
if (this.isOpen) {
this.isOpen = false;
this.isMouseOverDropdown = false;
this._hide();
this.isOpen = this.isMouseOverDropdown = false;
this.$menu
.find('.tt-suggestions > .tt-suggestion')
.removeClass('tt-is-under-cursor');
this._removeCursor();
this.$menu.hide();
this.trigger('closed');
}
},
open: function() {
open: function open() {
if (!this.isOpen) {
this.isOpen = true;
!this.isEmpty && this._show();
// can't use jQuery#show because $menu is a span element we want
// display: block; not dislay: inline;
this.$menu.css('display', 'block');
this.trigger('opened');
}
},
setLanguageDirection: function(dir) {
var ltrCss = { left: '0', right: 'auto' },
rtlCss = { left: 'auto', right:' 0' };
dir === 'ltr' ? this.$menu.css(ltrCss) : this.$menu.css(rtlCss);
setLanguageDirection: function setLanguageDirection(dir) {
this.$menu.css(dir === 'ltr' ? css.ltr : css.rtl);
},
moveCursorUp: function() {
moveCursorUp: function moveCursorUp() {
this._moveCursor(-1);
},
moveCursorDown: function() {
moveCursorDown: function moveCursorDown() {
this._moveCursor(+1);
},
getSuggestionUnderCursor: function() {
var $suggestion = this._getSuggestions()
.filter('.tt-is-under-cursor')
.first();
return $suggestion.length > 0 ? extractSuggestion($suggestion) : null;
getDatumForSuggestion: function getDatumForSuggestion($el) {
return $el.length ? SectionView.extractDatum($el) : null;
},
getFirstSuggestion: function() {
var $suggestion = this._getSuggestions().first();
return $suggestion.length > 0 ? extractSuggestion($suggestion) : null;
getDatumForCursor: function getDatumForCursor() {
return this.getDatumForSuggestion(this._getCursor().first());
},
renderSuggestions: function(dataset, suggestions) {
var datasetClassName = 'tt-dataset-' + dataset.name,
wrapper = '<div class="tt-suggestion">%body</div>',
compiledHtml,
$suggestionsList,
$dataset = this.$menu.find('.' + datasetClassName),
elBuilder,
fragment,
$el;
// first time rendering suggestions for this dataset
if ($dataset.length === 0) {
$suggestionsList = $(html.suggestionsList).css(css.suggestionsList);
$dataset = $('<div></div>')
.addClass(datasetClassName)
.append(dataset.header)
.append($suggestionsList)
.append(dataset.footer)
.appendTo(this.$menu);
}
// suggestions to be rendered
if (suggestions.length > 0) {
this.isEmpty = false;
this.isOpen && this._show();
elBuilder = document.createElement('div');
fragment = document.createDocumentFragment();
utils.each(suggestions, function(i, suggestion) {
suggestion.dataset = dataset.name;
compiledHtml = dataset.template(suggestion.datum);
elBuilder.innerHTML = wrapper.replace('%body', compiledHtml);
$el = $(elBuilder.firstChild)
.css(css.suggestion)
.data('suggestion', suggestion);
$el.children().each(function() {
$(this).css(css.suggestionChild);
});
fragment.appendChild($el[0]);
});
// show this dataset in case it was previously empty
// and render the new suggestions
$dataset.show().find('.tt-suggestions').html(fragment);
}
// no suggestions to render
else {
this.clearSuggestions(dataset.name);
}
this.trigger('suggestionsRendered');
getDatumForTopSuggestion: function getDatumForTopSuggestion() {
return this.getDatumForSuggestion(this._getSuggestions().first());
},
clearSuggestions: function(datasetName) {
var $datasets = datasetName ?
this.$menu.find('.tt-dataset-' + datasetName) :
this.$menu.find('[class^="tt-dataset-"]'),
$suggestions = $datasets.find('.tt-suggestions');
update: function update(query) {
utils.each(this.sections, updateSection);
$datasets.hide();
$suggestions.empty();
function updateSection(i, section) { section.update(query); }
},
if (this._getSuggestions().length === 0) {
this.isEmpty = true;
this._hide();
}
empty: function empty() {
utils.each(this.sections, clearSection);
function clearSection(i, section) { section.clear(); }
}
});
@@ -285,7 +204,7 @@ var DropdownView = (function() {
// helper functions
// ----------------
function extractSuggestion($el) {
return $el.data('suggestion');
function initializeSection(o) {
return new SectionView(o);
}
})();

View File

@@ -5,6 +5,17 @@
*/
var InputView = (function() {
var specialKeyCodeMap;
specialKeyCodeMap = {
9: 'tab',
27: 'esc',
37: 'left',
39: 'right',
13: 'enter',
38: 'up',
40: 'down'
};
// constructor
// -----------
@@ -12,40 +23,27 @@ var InputView = (function() {
function InputView(o) {
var that = this;
utils.bindAll(this);
this.specialKeyCodeMap = {
9: 'tab',
27: 'esc',
37: 'left',
39: 'right',
13: 'enter',
38: 'up',
40: 'down'
};
this.$hint = $(o.hint);
this.$input = $(o.input)
.on('blur.tt', this._handleBlur)
.on('focus.tt', this._handleFocus)
.on('keydown.tt', this._handleSpecialKeyEvent);
.on('blur.tt', utils.bind(this._onBlur, this))
.on('focus.tt', utils.bind(this._onFocus, this))
.on('keydown.tt', utils.bind(this._onKeydown, this));
// ie7 and ie8 don't support the input event
// ie9 doesn't fire the input event when characters are removed
// not sure if ie10 is compatible
if (!utils.isMsie()) {
this.$input.on('input.tt', this._compareQueryToInputValue);
this.$input.on('input.tt', utils.bind(this._onInput, this));
}
else {
this.$input
.on('keydown.tt keypress.tt cut.tt paste.tt', function($e) {
this.$input.on('keydown.tt keypress.tt cut.tt paste.tt', function($e) {
// if a special key triggered this, ignore it
if (that.specialKeyCodeMap[$e.which || $e.keyCode]) { return; }
if (specialKeyCodeMap[$e.which || $e.keyCode]) { return; }
// give the browser a chance to update the value of the input
// before checking to see if the query changed
utils.defer(that._compareQueryToInputValue);
utils.defer(utils.bind(that._onInput, that, $e));
});
}
@@ -57,105 +55,167 @@ var InputView = (function() {
this.$overflowHelper = buildOverflowHelper(this.$input);
}
utils.mixin(InputView.prototype, EventTarget, {
// private methods
// ---------------
// static methods
// --------------
_handleFocus: function() {
InputView.normalizeQuery = function(str) {
// strips leading whitespace and condenses all whitespace
return (str || '').replace(/^\s*/g, '').replace(/\s{2,}/g, ' ');
};
// instance methods
// ----------------
utils.mixin(InputView.prototype, EventEmitter, {
// ### private
_onBlur: function onBlur($e) {
this.resetInputValue();
this.trigger('blurred');
},
_onFocus: function onFocus($e) {
this.trigger('focused');
},
_handleBlur: function() {
this.trigger('blured');
},
_onKeydown: function onKeydown($e) {
// which is normalized and consistent (but not for ie)
var keyName = specialKeyCodeMap[$e.which || $e.keyCode];
_handleSpecialKeyEvent: function($e) {
// which is normalized and consistent (but not for IE)
var keyName = this.specialKeyCodeMap[$e.which || $e.keyCode];
keyName && this.trigger(keyName + 'Keyed', $e);
},
_compareQueryToInputValue: function() {
var inputValue = this.getInputValue(),
isSameQuery = compareQueries(this.query, inputValue),
isSameQueryExceptWhitespace = isSameQuery ?
this.query.length !== inputValue.length : false;
if (isSameQueryExceptWhitespace) {
this.trigger('whitespaceChanged', { value: this.query });
}
else if (!isSameQuery) {
this.trigger('queryChanged', { value: this.query = inputValue });
this._managePreventDefault(keyName, $e);
if (keyName && this._shouldTrigger(keyName, $e)) {
this.trigger(keyName + 'Keyed', $e);
}
},
// public methods
// --------------
destroy: function() {
this.$hint.off('.tt');
this.$input.off('.tt');
this.$hint = this.$input = this.$overflowHelper = null;
_onInput: function onInput($e) {
this._checkInputValue();
},
focus: function() {
_managePreventDefault: function managePreventDefault(keyName, $e) {
var preventDefault, hintValue, inputValue;
switch (keyName) {
case 'tab':
hintValue = this.getHintValue();
inputValue = this.getInputValue();
preventDefault = hintValue &&
hintValue !== inputValue &&
!withModifier($e);
break;
case 'up':
case 'down':
preventDefault = !withModifier($e);
break;
default:
preventDefault = false;
}
preventDefault && $e.preventDefault();
},
_shouldTrigger: function shouldTrigger(keyName, $e) {
var trigger;
switch (keyName) {
case 'tab':
trigger = !withModifier($e);
break;
default:
trigger = true;
}
return trigger;
},
_checkInputValue: function checkInputValue() {
var inputValue, areEquivalent, hasDifferentWhitespace;
inputValue = this.getInputValue();
areEquivalent = areQueriesEquivalent(inputValue, this.query);
hasDifferentWhitespace = areEquivalent ?
this.query.length !== inputValue.length : false;
if (!areEquivalent) {
this.trigger('queryChanged', this.query = inputValue);
}
else if (hasDifferentWhitespace) {
this.trigger('whitespaceChanged', this.query);
}
},
// ### public
focus: function focus() {
this.$input.focus();
},
blur: function() {
blur: function blur() {
this.$input.blur();
},
getQuery: function() {
getQuery: function getQuery() {
return this.query;
},
setQuery: function(query) {
setQuery: function setQuery(query) {
this.query = query;
},
getInputValue: function() {
getInputValue: function getInputValue() {
return this.$input.val();
},
setInputValue: function(value, silent) {
setInputValue: function setInputValue(value, silent) {
this.$input.val(value);
!silent && this._compareQueryToInputValue();
!silent && this._checkInputValue();
},
getHintValue: function() {
getHintValue: function getHintValue() {
return this.$hint.val();
},
setHintValue: function(value) {
setHintValue: function setHintValue(value) {
this.$hint.val(value);
},
getLanguageDirection: function() {
resetInputValue: function resetInputValue() {
this.$input.val(this.query);
},
clearHint: function clearHint() {
this.$hint.val('');
},
getLanguageDirection: function getLanguageDirection() {
return (this.$input.css('direction') || 'ltr').toLowerCase();
},
isOverflow: function() {
hasOverflow: function hasOverflow() {
this.$overflowHelper.text(this.getInputValue());
return this.$overflowHelper.width() > this.$input.width();
},
isCursorAtEnd: function() {
var valueLength = this.$input.val().length,
selectionStart = this.$input[0].selectionStart,
range;
var valueLength, selectionStart, range;
valueLength = this.$input.val().length;
selectionStart = this.$input[0].selectionStart;
if (utils.isNumber(selectionStart)) {
return selectionStart === valueLength;
}
else if (document.selection) {
// this won't work unless the input has focus, the good news
// NOTE: this won't work unless the input has focus, the good news
// is this code should only get called when the input has focus
range = document.selection.createRange();
range.moveStart('character', -valueLength);
@@ -169,6 +229,9 @@ var InputView = (function() {
return InputView;
// helper functions
// ----------------
function buildOverflowHelper($input) {
return $('<span></span>')
.css({
@@ -193,11 +256,11 @@ var InputView = (function() {
.insertAfter($input);
}
function compareQueries(a, b) {
// strips leading whitespace and condenses all whitespace
a = (a || '').replace(/^\s*/g, '').replace(/\s{2,}/g, ' ');
b = (b || '').replace(/^\s*/g, '').replace(/\s{2,}/g, ' ');
function areQueriesEquivalent(a, b) {
return InputView.normalizeQuery(a) === InputView.normalizeQuery(b);
}
return a === b;
function withModifier($e) {
return $e.altKey || $e.ctrlKey || $e.metaKey || $e.shiftKey;
}
})();

View File

@@ -5,54 +5,7 @@
*/
var TypeaheadView = (function() {
var html = {
wrapper: '<span class="twitter-typeahead"></span>',
hint: '<input class="tt-hint" type="text" autocomplete="off" spellcheck="off" disabled>',
dropdown: '<span class="tt-dropdown-menu"></span>'
},
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' });
}
var attrsKey = 'ttAttrs';
// constructor
// -----------
@@ -60,275 +13,213 @@ var TypeaheadView = (function() {
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');
$input = this.$node.find('.tt-input');
$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.dropdown = new DropdownView({ menu: $menu, sections: o.sections })
.onSync('suggestionClicked', this._onSuggestionClicked, this)
.onSync('cursorMoved', this._onCursorMoved, this)
.onSync('cursorRemoved', this._onCursorRemoved, this)
.onSync('suggestionsRendered', this._onSuggestionsRendered, this)
.onSync('opened', this._onOpened, this)
.onSync('closed', this._onClosed, this);
this.inputView = new InputView({ input: $input, hint: $hint })
.on('focused', this._openDropdown)
.on('blured', this._closeDropdown)
.on('blured', this._setInputValueToQuery)
.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);
this.input = new InputView({ input: $input, hint: $hint })
.onSync('focused', this._onFocused, this)
.onSync('blurred', this._onBlurred, this)
.onSync('enterKeyed', this._onEnterKeyed, this)
.onSync('tabKeyed', this._onTabKeyed, this)
.onSync('escKeyed', this._onEscKeyed, this)
.onSync('upKeyed', this._onUpKeyed, this)
.onSync('downKeyed', this._onDownKeyed, this)
.onSync('leftKeyed', this._onLeftKeyed, this)
.onSync('rightKeyed', this._onRightKeyed, this)
.onSync('queryChanged', this._onQueryChanged, this)
.onSync('whitespaceChanged', this._onWhitespaceChanged, this);
}
utils.mixin(TypeaheadView.prototype, EventTarget, {
// private methods
// ---------------
// instance methods
// ----------------
_managePreventDefault: function(e) {
var $e = e.data,
hint,
inputValue,
preventDefault = false;
utils.mixin(TypeaheadView.prototype, {
switch (e.type) {
case 'tabKeyed':
hint = this.inputView.getHintValue();
inputValue = this.inputView.getInputValue();
preventDefault = hint && hint !== inputValue;
break;
// ### private
case 'upKeyed':
case 'downKeyed':
preventDefault = !$e.shiftKey && !$e.ctrlKey && !$e.metaKey;
break;
}
_onSuggestionClicked: function onSuggestionClicked(type, $el) {
var datum;
preventDefault && $e.preventDefault();
},
if (datum = this.dropdown.getDatumForSuggestion($el)) {
this._select(datum);
_setLanguageDirection: function() {
var dir = this.inputView.getLanguageDirection();
if (dir !== this.dir) {
this.dir = dir;
this.$node.css('direction', dir);
this.dropdownView.setLanguageDirection(dir);
// the click event will cause the input to lose focus, so refocus
this.input.focus();
}
},
_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;
_onCursorMoved: function onCursorMoved() {
var datum = this.dropdown.getDatumForCursor();
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);
this.input.clearHint();
this.input.setInputValue(datum.value, true);
},
beginsWithQuery = new RegExp('^(?:' + escapedQuery + ')(.*$)', 'i');
match = beginsWithQuery.exec(hint);
_onCursorRemoved: function onCursorRemoved() {
this.input.resetInputValue();
this._updateHint();
},
this.inputView.setHintValue(inputValue + (match ? match[1] : ''));
_onSuggestionsRendered: function onSuggestionsRendered() {
this._updateHint();
},
_onOpened: function onOpened() {
this._updateHint();
},
_onClosed: function onClosed() {
this.input.clearHint();
},
_onFocused: function onFocused() {
this.dropdown.open();
},
_onBlurred: function onBlurred() {
// don't close the menu because this was triggered by a blur event
// and if the menu is closed, it'll prevent the probable associated
// click event from being fired
!this.dropdown.isMouseOverDropdown && this.dropdown.close();
},
_onEnterKeyed: function onEnterKeyed(type, $e) {
var datum;
if (datum = this.dropdown.getDatumForCursor()) {
this._select(datum);
$e.preventDefault();
}
},
_clearHint: function() {
this.inputView.setHintValue('');
},
_onTabKeyed: function onTabKeyed(type, $e) {
var datum;
_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() {
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,
modifierPressed,
$e;
if(byClick) {
suggestion = e.data;
modifierPressed = false;
if (datum = this.dropdown.getDatumForCursor()) {
this._select(datum);
$e.preventDefault();
}
else {
suggestion = this.dropdownView.getSuggestionUnderCursor();
$e = e.data;
modifierPressed = $e.shiftKey || $e.ctrlKey || $e.metaKey || $e.altKey;
}
if (suggestion && !modifierPressed) {
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);
this._autocomplete();
}
},
_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 view hasn't
// been destroyed and if the query hasn't changed
if (that.$node && query === that.inputView.getQuery()) {
that.dropdownView.renderSuggestions(dataset, suggestions);
}
});
});
_onEscKeyed: function onEscKeyed() {
this.dropdown.close();
this.input.resetInputValue();
},
_autocomplete: function(e) {
var isCursorAtEnd, ignoreEvent, query, hint, suggestion;
_onUpKeyed: function onUpKeyed() {
this.dropdown.open();
this.dropdown.moveCursorUp();
},
if (e.type === 'rightKeyed' || e.type === 'leftKeyed') {
isCursorAtEnd = this.inputView.isCursorAtEnd();
ignoreEvent = this.inputView.getLanguageDirection() === 'ltr' ?
e.type === 'leftKeyed' : e.type === 'rightKeyed';
_onDownKeyed: function onDownKeyed() {
this.dropdown.open();
this.dropdown.moveCursorDown();
},
if (!isCursorAtEnd || ignoreEvent) { return; }
}
_onLeftKeyed: function onLeftKeyed() {
this.dir === 'rtl' && this._autocomplete();
},
query = this.inputView.getQuery();
hint = this.inputView.getHintValue();
_onRightKeyed: function onRightKeyed() {
this.dir === 'ltr' && this._autocomplete();
},
if (hint !== '' && query !== hint) {
suggestion = this.dropdownView.getFirstSuggestion();
this.inputView.setInputValue(suggestion.value);
_onQueryChanged: function onQueryChanged(e, query) {
this.input.clearHint();
this.dropdown.empty();
this.dropdown.update(query);
this.dropdown.open();
this._setLanguageDirection();
},
this.eventBus.trigger(
'autocompleted',
suggestion.datum,
suggestion.dataset
);
_onWhitespaceChanged: function onWhitespaceChanged() {
this._updateHint();
this.dropdown.open();
this._setLanguageDirection();
},
_setLanguageDirection: function setLanguageDirection() {
var dir;
if (this.dir !== (dir = this.input.getLanguageDirection())) {
this.dir = dir;
this.$node.css('direction', dir);
this.dropdown.setLanguageDirection(dir);
}
},
_propagateEvent: function(e) {
this.eventBus.trigger(e.type);
_updateHint: function updateHint() {
var datum, inputValue, query, escapedQuery, frontMatchRegEx, match;
datum = this.dropdown.getDatumForTopSuggestion();
if (datum && this.dropdown.isOpen && !this.input.hasOverflow()) {
inputValue = this.input.getInputValue();
query = InputView.normalizeQuery(inputValue);
escapedQuery = utils.escapeRegExChars(query);
frontMatchRegEx = new RegExp('^(?:' + escapedQuery + ')(.*$)', 'i');
match = frontMatchRegEx.exec(datum.value);
this.input.setHintValue(inputValue + (match ? match[1] : ''));
}
},
// public methods
// --------------
_autocomplete: function autocomplete() {
var hint, query, datum;
destroy: function() {
this.inputView.destroy();
this.dropdownView.destroy();
hint = this.input.getHintValue();
query = this.input.getQuery();
destroyDomStructure(this.$node);
this.$node = null;
if (hint !== '' && query !== hint && this.input.isCursorAtEnd()) {
datum = this.dropdown.getDatumForTopSuggestion();
datum && this.input.setInputValue(datum.value);
}
},
setQuery: function(query) {
this.inputView.setQuery(query);
this.inputView.setInputValue(query);
_select: function select(datum) {
this.input.setInputValue(datum.value);
this._clearHint();
this._clearSuggestions();
this._getSuggestions();
// in ie, focus is not a synchronous event, so when a selection
// is triggered by a click within the dropdown menu, we need to
// defer the closing of the dropdown otherwise it'll stay open
utils.defer(utils.bind(this.dropdown.close, this.dropdown));
// TODO: trigger an event
}
// ### public
});
return TypeaheadView;
function buildDomStructure(input) {
var $wrapper = $(html.wrapper),
$dropdown = $(html.dropdown),
$input = $(input),
$hint = $(html.hint);
var $input, $wrapper, $dropdown, $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')
});
$input = $(input);
$wrapper = $(html.wrapper).css(css.wrapper);
$dropdown = $(html.dropdown).css(css.dropdown);
$hint = $(html.hint).css(css.hint).css(getBackgroundStyles($input));
// store the original values of the attrs that get modified
// so modifications can be reverted on destroy
$input.data('ttAttrs', {
$input.data(attrsKey, {
dir: $input.attr('dir'),
autocomplete: $input.attr('autocomplete'),
spellcheck: $input.attr('spellcheck'),
@@ -336,36 +227,27 @@ var TypeaheadView = (function() {
});
$input
.addClass('tt-query')
.addClass('tt-input')
.attr({ autocomplete: 'off', spellcheck: false })
.css(css.query);
.css(css.input);
// 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);
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();
function getBackgroundStyles($el) {
return {
backgroundAttachment: $el.css('background-attachment'),
backgroundClip: $el.css('background-clip'),
backgroundColor: $el.css('background-color'),
backgroundImage: $el.css('background-image'),
backgroundOrigin: $el.css('background-origin'),
backgroundPosition: $el.css('background-position'),
backgroundRepeat: $el.css('background-repeat'),
backgroundSize: $el.css('background-size')
};
}
})();