/*jshint asi:true, expr:true */
/**
* plugin name: combo select
* author : vinay@pebbleroad
* date: 23/11/2014
* description:
* converts a select box into a searchable and keyboard friendly interface. fallbacks to native select on mobile and tablets
*/
// expose plugin as an amd module if amd loader is present:
(function (factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// amd. register as an anonymous module.
define(['jquery'], factory);
} else if (typeof exports === 'object' && typeof require === 'function') {
// browserify
factory(require('jquery'));
} else {
// browser globals
factory(jquery);
}
}(function ( $, undefined ) {
var pluginname = "comboselect",
datakey = 'comboselect';
var defaults = {
comboclass : 'combo-select',
comboarrowclass : 'combo-arrow',
combodropdownclass : 'combo-dropdown',
inputclass : 'combo-input text-input',
disabledclass : 'option-disabled',
hoverclass : 'option-hover',
selectedclass : 'option-selected',
markerclass : 'combo-marker',
themeclass : '',
maxheight : 200,
extendstyle : true,
focusinput : true
};
/**
* utility functions
*/
var keys = {
esc: 27,
tab: 9,
return: 13,
left: 37,
up: 38,
right: 39,
down: 40,
enter: 13,
shift: 16
},
ismobile = (/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(navigator.useragent.tolowercase()));
/**
* constructor
* @param {[node]} element [select element]
* @param {[object]} options [option object]
*/
function plugin ( element, options ) {
/* name of the plugin */
this._name = pluginname;
/* reverse lookup */
this.el = element
/* element */
this.$el = $(element)
/* if multiple select: stop */
if(this.$el.prop('multiple')) return;
/* settings */
this.settings = $.extend( {}, defaults, options, this.$el.data() );
/* defaults */
this._defaults = defaults;
/* options */
this.$options = this.$el.find('option, optgroup')
/* initialize */
this.init();
/* instances */
$.fn[ pluginname ].instances.push(this);
}
$.extend(plugin.prototype, {
init: function () {
/* construct the comboselect */
this._construct();
/* add event bindings */
this._events();
},
_construct: function(){
var self = this
/**
* add negative tabindex to `select`
* preserves previous tabindex
*/
this.$el.data('plugin_'+ datakey + '_tabindex', this.$el.prop('tabindex'))
/* add a tab index for desktop browsers */
!ismobile && this.$el.prop("tabindex", -1)
/**
* wrap the select
*/
this.$container = this.$el.wrapall('
').parent();
/**
* check if select has a width attribute
*/
if(this.settings.extendstyle && this.$el.attr('style')){
this.$container.attr('style', this.$el.attr("style"))
}
/**
* append dropdown arrow
*/
this.$arrow = $('').appendto(this.$container)
/**
* append dropdown
*/
this.$dropdown = $('').appendto(this.$container)
/**
* create dropdown options
*/
var o = '', k = 0, p = '';
this.selectedindex = this.$el.prop('selectedindex')
this.$options.each(function(i, e){
if(e.nodename.tolowercase() == 'optgroup'){
return o+=''+this.label+''
}
if(!e.value) p = e.innerhtml
o+=''+ (this.innerhtml) + ''
k++;
})
this.$dropdown.html(o)
/**
* items
*/
this.$items = this.$dropdown.children();
/**
* append input
*/
this.$input = $('').appendto(this.$container)
/* update input text */
this._updateinput()
},
_events: function(){
/* input: focus */
this.$container.on('focus.input', 'input', $.proxy(this._focus, this))
/**
* input: mouseup
* for input select() event to function correctly
*/
this.$container.on('mouseup.input', 'input', function(e){
e.preventdefault()
})
/* input: blur */
this.$container.on('blur.input', 'input', $.proxy(this._blur, this))
/* select: change */
this.$el.on('change.select', $.proxy(this._change, this))
/* select: focus */
this.$el.on('focus.select', $.proxy(this._focus, this))
/* select: blur */
this.$el.on('blur.select', $.proxy(this._blurselect, this))
/* dropdown arrow: click */
this.$container.on('click.arrow', '.'+this.settings.comboarrowclass , $.proxy(this._toggle, this))
/* dropdown: close */
this.$container.on('comboselect:close', $.proxy(this._close, this))
/* dropdown: open */
this.$container.on('comboselect:open', $.proxy(this._open, this))
/* html click */
$('html').off('click.comboselect').on('click.comboselect', function(){
$.each($.fn[ pluginname ].instances, function(i, plugin){
plugin.$container.trigger('comboselect:close')
})
});
/* stop `event:click` bubbling */
this.$container.on('click.comboselect', function(e){
e.stoppropagation();
})
/* input: keydown */
this.$container.on('keydown', 'input', $.proxy(this._keydown, this))
/* input: keyup */
this.$container.on('keyup', 'input', $.proxy(this._keyup, this))
/* dropdown item: click */
this.$container.on('click.item', '.option-item', $.proxy(this._select, this))
},
_keydown: function(event){
switch(event.which){
case keys.up:
this._move('up', event)
break;
case keys.down:
this._move('down', event)
break;
case keys.tab:
this._enter(event)
break;
case keys.right:
this._autofill(event);
break;
case keys.enter:
this._enter(event);
break;
default:
break;
}
},
_keyup: function(event){
switch(event.which){
case keys.esc:
this.$container.trigger('comboselect:close')
break;
case keys.enter:
case keys.up:
case keys.down:
case keys.left:
case keys.right:
case keys.tab:
case keys.shift:
break;
default:
this._filter(event.target.value)
break;
}
},
_enter: function(event){
var item = this._gethovered()
item.length && this._select(item);
/* check if it enter key */
if(event && event.which == keys.enter){
if(!item.length) {
/* check if its illegal value */
this._blur();
return true;
}
event.preventdefault();
}
},
_move: function(dir){
var items = this._getvisible(),
current = this._gethovered(),
index = current.prevall('.option-item').filter(':visible').length,
total = items.length
switch(dir){
case 'up':
index--;
(index < 0) && (index = (total - 1));
break;
case 'down':
index++;
(index >= total) && (index = 0);
break;
}
items
.removeclass(this.settings.hoverclass)
.eq(index)
.addclass(this.settings.hoverclass)
if(!this.opened) this.$container.trigger('comboselect:open');
this._fixscroll()
},
_select: function(event){
var item = event.currenttarget? $(event.currenttarget) : $(event);
if(!item.length) return;
/**
* 1. get index
*/
var index = item.data('index');
this._selectbyindex(index);
this.$container.trigger('comboselect:close')
},
_selectbyindex: function(index){
/**
* set selected index and trigger change
* @type {[type]}
*/
if(typeof index == 'undefined'){
index = 0
}
if(this.$el.prop('selectedindex') != index){
this.$el.prop('selectedindex', index).trigger('change');
}
},
_autofill: function(){
var item = this._gethovered();
if(item.length){
var index = item.data('index')
this._selectbyindex(index)
}
},
_filter: function(search){
var self = this,
items = this._getall();
needle = $.trim(search).tolowercase(),
reescape = new regexp('(\\' + ['/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\'].join('|\\') + ')', 'g'),
pattern = '(' + search.replace(reescape, '\\$1') + ')';
/**
* unwrap all markers
*/
$('.'+self.settings.markerclass, items).contents().unwrap();
/* search */
if(needle){
/* hide disabled and optgroups */
this.$items.filter('.option-group, .option-disabled').hide();
items
.hide()
.filter(function(){
var $this = $(this),
text = $.trim($this.text()).tolowercase();
/* found */
if(text.tostring().indexof(needle) != -1){
/**
* wrap the selection
*/
$this
.html(function(index, oldhtml){
return oldhtml.replace(new regexp(pattern, 'gi'), '$1')
})
return true
}
})
.show()
}else{
this.$items.show();
}
/* open the comboselect */
this.$container.trigger('comboselect:open')
},
_highlight: function(){
/*
1. check if there is a selected item
2. add hover class to it
3. if not add hover class to first item
*/
var visible = this._getvisible().removeclass(this.settings.hoverclass),
$selected = visible.filter('.'+this.settings.selectedclass)
if($selected.length){
$selected.addclass(this.settings.hoverclass);
}else{
visible
.removeclass(this.settings.hoverclass)
.first()
.addclass(this.settings.hoverclass)
}
},
_updateinput: function(){
var selected = this.$el.prop('selectedindex')
if(this.$el.val()){
text = this.$el.find('option').eq(selected).text()
this.$input.val(text)
}else{
this.$input.val('')
}
return this._getall()
.removeclass(this.settings.selectedclass)
.filter(function(){
return $(this).data('index') == selected
})
.addclass(this.settings.selectedclass)
},
_blurselect: function(){
this.$container.removeclass('combo-focus');
},
_focus: function(event){
/* toggle focus class */
this.$container.toggleclass('combo-focus', !this.opened);
/* if mobile: stop */
if(ismobile) return;
/* open combo */
if(!this.opened) this.$container.trigger('comboselect:open');
/* select the input */
this.settings.focusinput && event && event.currenttarget && event.currenttarget.nodename == 'input' && event.currenttarget.select()
},
_blur: function(){
/**
* 1. get hovered item
* 2. if not check if input value == select option
* 3. if none
*/
var val = $.trim(this.$input.val().tolowercase()),
isnumber = !isnan(val);
var index = this.$options.filter(function(){
if(isnumber){
return parseint($.trim(this.innerhtml).tolowercase()) == val
}
return $.trim(this.innerhtml).tolowercase() == val
}).prop('index')
/* select by index */
this._selectbyindex(index)
},
_change: function(){
this._updateinput();
},
_getall: function(){
return this.$items.filter('.option-item')
},
_getvisible: function(){
return this.$items.filter('.option-item').filter(':visible')
},
_gethovered: function(){
return this._getvisible().filter('.' + this.settings.hoverclass);
},
_open: function(){
var self = this
this.$container.addclass('combo-open')
this.opened = true
/* focus input field */
this.settings.focusinput && settimeout(function(){ !self.$input.is(':focus') && self.$input.focus(); });
/* highligh the items */
this._highlight()
/* fix scroll */
this._fixscroll()
/* close all others */
$.each($.fn[ pluginname ].instances, function(i, plugin){
if(plugin != self && plugin.opened) plugin.$container.trigger('comboselect:close')
})
},
_toggle: function(){
this.opened? this._close.call(this) : this._open.call(this)
},
_close: function(){
this.$container.removeclass('combo-open combo-focus')
this.$container.trigger('comboselect:closed')
this.opened = false
/* show all items */
this.$items.show();
},
_fixscroll: function(){
/**
* if dropdown is hidden
*/
if(this.$dropdown.is(':hidden')) return;
/**
* else
*/
var item = this._gethovered();
if(!item.length) return;
/**
* scroll
*/
var offsettop,
upperbound,
lowerbound,
heightdelta = item.outerheight()
offsettop = item[0].offsettop;
upperbound = this.$dropdown.scrolltop();
lowerbound = upperbound + this.settings.maxheight - heightdelta;
if (offsettop < upperbound) {
this.$dropdown.scrolltop(offsettop);
} else if (offsettop > lowerbound) {
this.$dropdown.scrolltop(offsettop - this.settings.maxheight + heightdelta);
}
},
/**
* destroy api
*/
dispose: function(){
/* remove combo arrow, input, dropdown */
this.$arrow.remove()
this.$input.remove()
this.$dropdown.remove()
/* remove tabindex property */
this.$el
.removeattr("tabindex")
/* check if there is a tabindex set before */
if(!!this.$el.data('plugin_'+ datakey + '_tabindex')){
this.$el.prop('tabindex', this.$el.data('plugin_'+ datakey + '_tabindex'))
}
/* unwrap */
this.$el.unwrap()
/* remove data */
this.$el.removedata('plugin_'+datakey)
/* remove tabindex data */
this.$el.removedata('plugin_'+datakey + '_tabindex')
/* remove change event on select */
this.$el.off('change.select focus.select blur.select');
}
});
// a really lightweight plugin wrapper around the constructor,
// preventing against multiple instantiations
$.fn[ pluginname ] = function ( options, args ) {
this.each(function() {
var $e = $(this),
instance = $e.data('plugin_'+datakey)
if (typeof options === 'string') {
if (instance && typeof instance[options] === 'function') {
instance[options](args);
}
}else{
if (instance && instance.dispose) {
instance.dispose();
}
$.data( this, "plugin_" + datakey, new plugin( this, options ) );
}
});
// chain jquery functions
return this;
};
$.fn[ pluginname ].instances = [];
}));