File: /home/imensosw/www/imenso.co/dev/gravity/js/bootstrap-suggest.js
/* ===================================================
* bootstrap-suggest.js v1.0.0
* http://github.com/lodev09/bootstrap-suggest
* ===================================================
* Copyright 2014 Jovanni Lo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ========================================================== */
(function ($) {
"use strict"; // jshint ;_;
var Suggest = function(el, key, options) {
var that = this;
this.$element = $(el);
this.$items = undefined;
this.options = $.extend(true, {}, $.fn.suggest.defaults, options, this.$element.data(), this.$element.data('options'));
this.key = key;
this.isShown = false;
this.query = '';
this._queryPos = [];
this._keyPos = -1;
this.$dropdown = $('<div />', {
class: 'dropdown suggest',
html: $('<ul />', {class: 'dropdown-menu', role: 'menu'}),
'data-key': this.key
});
this.load();
};
Suggest.prototype = {
__setListener: function() {
this.$element
.on('suggest.show', $.proxy(this.options.onshow, this))
.on('suggest.select', $.proxy(this.options.onselect, this))
.on('suggest.lookup', $.proxy(this.options.onlookup, this))
.on('keypress', $.proxy(this.__keypress, this))
.on('keyup', $.proxy(this.__keyup, this));
return this;
},
__getCaretPos: function(posStart) {
// The properties that we copy into a mirrored div.
// Note that some browsers, such as Firefox,
// do not concatenate properties, i.e. padding-top, bottom etc. -> padding,
// so we have to do every single property specifically.
var properties = [
'direction', // RTL support
'boxSizing',
'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
'height',
'overflowX',
'overflowY', // copy the scrollbar for IE
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontSizeAdjust',
'lineHeight',
'fontFamily',
'textAlign',
'textTransform',
'textIndent',
'textDecoration', // might not make a difference, but better be safe
'letterSpacing',
'wordSpacing'
];
var isFirefox = !(window.mozInnerScreenX == null);
var getCaretCoordinatesFn = function (element, position, recalculate) {
// mirrored div
var div = document.createElement('div');
div.id = 'input-textarea-caret-position-mirror-div';
document.body.appendChild(div);
var style = div.style;
var computed = window.getComputedStyle? getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
// default textarea styles
style.whiteSpace = 'pre-wrap';
if (element.nodeName !== 'INPUT')
style.wordWrap = 'break-word'; // only for textarea-s
// position off-screen
style.position = 'absolute'; // required to return coordinates properly
style.visibility = 'hidden'; // not 'display: none' because we want rendering
// transfer the element's properties to the div
properties.forEach(function (prop) {
style[prop] = computed[prop];
});
if (isFirefox) {
style.width = parseInt(computed.width) - 2 + 'px' // Firefox adds 2 pixels to the padding - https://bugzilla.mozilla.org/show_bug.cgi?id=753662
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
if (element.scrollHeight > parseInt(computed.height))
style.overflowY = 'scroll';
} else {
style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
}
div.textContent = element.value.substring(0, position);
// the second special handling for input type="text" vs textarea: spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
if (element.nodeName === 'INPUT')
div.textContent = div.textContent.replace(/\s/g, "\u00a0");
var span = document.createElement('span');
// Wrapping must be replicated *exactly*, including when a long word gets
// onto the next line, with whitespace at the end of the line before (#7).
// The *only* reliable way to do that is to copy the *entire* rest of the
// textarea's content into the <span> created at the caret position.
// for inputs, just '.' would be enough, but why bother?
span.textContent = element.value.substring(position) || '.'; // || because a completely empty faux span doesn't render at all
div.appendChild(span);
var coordinates = {
top: span.offsetTop + parseInt(computed['borderTopWidth']),
left: span.offsetLeft + parseInt(computed['borderLeftWidth'])
};
document.body.removeChild(div);
return coordinates;
}
return getCaretCoordinatesFn(this.$element.get(0), posStart);
},
__keyup: function(e) {
// don't query special characters
// http://mikemurko.com/general/jquery-keycode-cheatsheet/
var specialChars = [38, 40, 37, 39, 17, 18, 9, 16, 20, 91, 93, 36, 35, 45, 33, 34, 144, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 145, 19],
$resultItems;
switch (e.keyCode) {
case 27:
this.hide();
return;
case 13:
return true;
}
if ($.inArray(e.keyCode, specialChars) !== -1) return true;
var $el = this.$element,
val = $el.val(),
currentPos = $el.get(0).selectionStart;
for (var i = currentPos; i >= 0; i--) {
var subChar = $.trim(val.substring(i-1, i));
if (!subChar) {
this.hide();
break;
}
if (subChar === this.key && $.trim(val.substring(i-2, i-1)) == '') {
this.query = val.substring(i, currentPos);
this._queryPos = [i, currentPos];
this._keyPos = i;
$resultItems = this.lookup(this.query);
if ($resultItems.length) this.show();
else this.hide();
break;
}
}
},
__getVisibleItems: function() {
return this.$items.not('.hidden');
},
__build: function() {
var elems = [], _data, $item,
$dropdown = this.$dropdown,
that = this;
if (typeof this.options.data == 'function') {
_data = this.options.data();
} else _data = this.options.data;
if (_data && _data instanceof Array) {
for (var i in _data) {
if ($item = this.__mapItem(_data[i]))
$dropdown.find('.dropdown-menu').append($item.addClass('hidden'));
}
}
var blur = function(e) {
that.hide();
}
this.$items = $dropdown.find('li:has(a)')
.on('click', function(e) {
e.preventDefault();
that.__select($(this).index());
})
.on('mouseover', function(e) {
that.$element.off('blur', blur);
})
.on('mouseout', function(e) {
that.$element.on('blur', blur);
});
this.$element.before($dropdown)
.on('blur', blur)
.on('keydown', function(e) {
var $visibleItems;
if (that.isShown) {
switch (e.keyCode) {
case 13: // enter key
$visibleItems = that.__getVisibleItems();
$visibleItems.each(function(index) {
if ($(this).is('.active'))
that.__select($(this).index());
});
return false;
break;
case 40: // arrow down
$visibleItems = that.__getVisibleItems();
if ($visibleItems.last().is('.active')) return false;
$visibleItems.each(function(index) {
var $this = $(this),
$next = $visibleItems.eq(index + 1);
//if (!$next.length) return false;
if ($this.is('.active')) {
if (!$next.is('.hidden')) {
$this.removeClass('active');
$next.addClass('active');
}
return false;
}
});
return false;
case 38: // arrow up
$visibleItems = that.__getVisibleItems();
if ($visibleItems.first().is('.active')) return false;
$visibleItems.each(function(index) {
var $this = $(this),
$prev = $visibleItems.eq(index - 1);
//if (!$prev.length) return false;
if ($this.is('.active')) {
if (!$prev.is('.hidden')) {
$this.removeClass('active');
$prev.addClass('active');
}
return false;
}
})
return false;
}
}
});
},
__mapItem: function(dataItem) {
var itemHtml, that = this,
_item = {
text: '',
value: ''
};
if (this.options.map) {
dataItem = this.options.map(dataItem);
if (!dataItem) return false;
}
if (dataItem instanceof Object) {
_item.text = dataItem.text || '';
_item.value = dataItem.value || '';
} else {
_item.text = dataItem;
_item.value = dataItem;
}
return $('<li />', {'data-value': _item.value}).html($('<a />', {
href: '#',
html: _item.text
}));
},
__select: function(index) {
var $el = this.$element,
el = $el.get(0),
val = $el.val(),
item = this.get(index),
setCaretPos = this._keyPos + item.value.toString().length + 1;
$el.val(val.slice(0, this._keyPos) + item.value + ' ' + val.slice(el.selectionStart));
if (el.setSelectionRange) {
el.setSelectionRange(setCaretPos, setCaretPos);
} else if (el.createTextRange) {
var range = el.createTextRange();
range.collapse(true);
range.moveEnd('character', setCaretPos);
range.moveStart('character', setCaretPos);
range.select();
}
$el.trigger($.extend({type: 'suggest.select'}, this), item);
this.hide();
},
get: function(index) {
var $item = this.$items.eq(index);
return {
text: $item.children('a:first').text(),
value: $item.data('value'),
index: index,
$element: $item
};
},
lookup: function(q) {
var options = this.options,
that = this,
$resultItems;
this.$items.addClass('hidden');
if (q != "") {
this.$items.filter(function (index) {
var $this = $(this),
value = $this.find('a:first').text();
if (!options.filter.casesensitive) {
value = value.toLowerCase();
q = q.toLowerCase();
}
return value.indexOf(q) != -1;
}).slice(0, options.filter.limit).removeClass('hidden active');
} else this.$items.slice(0, options.filter.limit).removeClass('hidden active');
$resultItems = this.__getVisibleItems();
this.$element.trigger($.extend({type: 'suggest.lookup'}, this), [q, $resultItems]);
return $resultItems.eq(0).addClass('active');
},
load: function() {
this.__setListener();
this.__build();
},
hide: function() {
this.$dropdown.removeClass('open');
this.isShown = false;
this.$items.removeClass('active');
this._keyPos = -1;
},
show: function() {
var $el = this.$element,
el = $el.get(0);
if (!this.isShown) {
var caretPos = this.__getCaretPos(this._keyPos);
this.$dropdown
.addClass('open')
.find('.dropdown-menu').css({
'top': caretPos.top - el.scrollTop + 'px',
'left': caretPos.left - el.scrollLeft + 'px'
});
this.isShown = true;
$el.trigger($.extend({type: 'suggest.show'}, this));
}
}
};
var old = $.fn.suggest;
// .suggest( key [, options] )
// .suggest( method [, options] )
// .suggest( suggestions )
$.fn.suggest = function(arg1) {
var arg2 = arguments[1];
var createSuggest = function(el, suggestions) {
var newData = {};
$.each(suggestions, function(keyChar, options) {
var key = keyChar.toString().charAt(0);
newData[key] = new Suggest(el, key, typeof options == 'object' && options);
});
return newData;
};
return this.each(function() {
var that = this,
$this = $(this),
data = $this.data('suggest'),
suggestion = {};
if (typeof arg1 == 'string') {
if (arg1.length > 1 && data) {
// arg1 as a method
if (typeof data[arg1] != 'undefined') data[arg1](arg2);
} else if (arg1.length == 1) {
// arg1 as key
if (arg2) {
// inline data determined if it's an array
suggestion[arg1] = arg2 instanceof Array ? {data: arg2} : arg2;
if (!data) {
$this.data('suggest', createSuggest(this, suggestion));
} else if (data && !arg1 in data) {
$this.data('suggest', $.extend(data, createSuggest(this, suggestion)));
}
}
}
} else {
// arg1 contains set of suggestions
if (!data) $this.data('suggest', createSuggest(this, arg1));
else if (data) {
$.each(arg1, function(key, value) {
if (key in data == false) {
suggestion[key] = value;
}
});
$this.data('suggest', $.extend(data, createSuggest(that, suggestion)))
}
}
});
};
$.fn.suggest.defaults = {
data: [],
map: undefined,
filter: {
casesensitive: false,
limit: 5
},
// events hook
onshow: function(e) {},
onselect: function(e, item) {},
onlookup: function(e, item) {}
}
$.fn.suggest.Constructor = Suggest;
$.fn.suggest.noConflict = function () {
$.fn.suggest = old;
return this;
}
}( jQuery ));;