';
},
/**
* Generate every option markup
*
* @param {number} index - Index of current item
* @param {object} itemData - Current item
* @return {string} HTML for the option
*/
getItemMarkup: function(index, itemData) {
var _this = this;
var itemBuilder = _this.options.optionsItemBuilder;
// limit access to item data to provide a simple interface
// to most relevant options.
var filteredItemData = {
value: itemData.value,
text : itemData.text,
slug : itemData.slug,
index: itemData.index
};
return _this.utils.format('
{3}
',
index,
_this.utils.arrayToClassname([
itemData.className,
index === _this.items.length - 1 ? 'last' : '',
itemData.disabled ? 'disabled' : '',
itemData.selected ? 'selected' : ''
]),
$.isFunction(itemBuilder)
? _this.utils.format(itemBuilder(itemData, this.$element, index), itemData)
: _this.utils.format(itemBuilder, filteredItemData)
);
},
/** Remove events on the elements */
unbindEvents: function() {
var _this = this;
_this.elements.wrapper
.add(_this.$element)
.add(_this.elements.outerWrapper)
.add(_this.elements.input)
.off(eventNamespaceSuffix);
},
/** Bind events on the elements */
bindEvents: function() {
var _this = this;
_this.elements.outerWrapper.on('mouseenter' + eventNamespaceSuffix + ' mouseleave' + eventNamespaceSuffix, function(e) {
$(this).toggleClass(_this.classes.hover, e.type === 'mouseenter');
// Delay close effect when openOnHover is true
if ( _this.options.openOnHover ) {
clearTimeout(_this.closeTimer);
if ( e.type === 'mouseleave' ) {
_this.closeTimer = setTimeout($.proxy(_this.close, _this), _this.options.hoverIntentTimeout);
} else {
_this.open();
}
}
});
// Toggle open/close
_this.elements.wrapper.on('click' + eventNamespaceSuffix, function(e) {
_this.state.opened ? _this.close() : _this.open(e);
});
// Translate original element focus event to dummy input.
// Disabled on mobile devices because the default option list isn't
// shown due the fact that hidden input gets focused
if ( !(_this.options.nativeOnMobile && _this.utils.isMobile()) ) {
_this.$element.on('focus' + eventNamespaceSuffix, function() {
_this.elements.input.focus();
});
_this.elements.input
.prop({ tabindex: _this.originalTabindex, disabled: false })
.on('keydown' + eventNamespaceSuffix, $.proxy(_this.handleKeys, _this))
.on('focusin' + eventNamespaceSuffix, function(e) {
_this.elements.outerWrapper.addClass(_this.classes.focus);
// Prevent the flicker when focusing out and back again in the browser window
_this.elements.input.one('blur', function() {
_this.elements.input.blur();
});
if ( _this.options.openOnFocus && !_this.state.opened ) {
_this.open(e);
}
})
.on('focusout' + eventNamespaceSuffix, function() {
_this.elements.outerWrapper.removeClass(_this.classes.focus);
})
.on('input propertychange', function() {
var val = _this.elements.input.val();
var searchRegExp = new RegExp('^' + _this.utils.escapeRegExp(val), 'i');
// Clear search
clearTimeout(_this.resetStr);
_this.resetStr = setTimeout(function() {
_this.elements.input.val('');
}, _this.options.keySearchTimeout);
if ( val.length ) {
// Search in select options
$.each(_this.items, function(i, elm) {
if (elm.disabled) {
return;
}
if (searchRegExp.test(elm.text) || searchRegExp.test(elm.slug)) {
_this.highlight(i);
return;
}
if (!elm.alt) {
return;
}
var altItems = elm.alt.split('|');
for (var ai = 0; ai < altItems.length; ai++) {
if (!altItems[ai]) {
break;
}
if (searchRegExp.test(altItems[ai].trim())) {
_this.highlight(i);
return;
}
}
});
}
});
}
_this.$li.on({
// Prevent blur on Chrome
mousedown: function(e) {
e.preventDefault();
e.stopPropagation();
},
click: function() {
_this.select($(this).data('index'));
// Chrome doesn't close options box if select is wrapped with a label
// We need to 'return false' to avoid that
return false;
}
});
},
/**
* Behavior when keyboard keys is pressed
*
* @param {object} e - Event object
*/
handleKeys: function(e) {
var _this = this;
var key = e.which;
var keys = _this.options.keys;
var isPrevKey = $.inArray(key, keys.previous) > -1;
var isNextKey = $.inArray(key, keys.next) > -1;
var isSelectKey = $.inArray(key, keys.select) > -1;
var isOpenKey = $.inArray(key, keys.open) > -1;
var idx = _this.state.highlightedIdx;
var isFirstOrLastItem = (isPrevKey && idx === 0) || (isNextKey && (idx + 1) === _this.items.length);
var goToItem = 0;
// Enter / Space
if ( key === 13 || key === 32 ) {
e.preventDefault();
}
// If it's a directional key
if ( isPrevKey || isNextKey ) {
if ( !_this.options.allowWrap && isFirstOrLastItem ) {
return;
}
if ( isPrevKey ) {
goToItem = _this.utils.previousEnabledItem(_this.lookupItems, idx);
}
if ( isNextKey ) {
goToItem = _this.utils.nextEnabledItem(_this.lookupItems, idx);
}
_this.highlight(goToItem);
}
// Tab / Enter / ESC
if ( isSelectKey && _this.state.opened ) {
_this.select(idx);
if ( !_this.state.multiple || !_this.options.multiple.keepMenuOpen ) {
_this.close();
}
return;
}
// Space / Enter / Left / Up / Right / Down
if ( isOpenKey && !_this.state.opened ) {
_this.open();
}
},
/** Update the items object */
refresh: function() {
var _this = this;
_this.populate();
_this.activate();
_this.utils.triggerCallback('Refresh', _this);
},
/** Set options box width/height */
setOptionsDimensions: function() {
var _this = this;
// Calculate options box height
// Set a temporary class on the hidden parent of the element
var hiddenChildren = _this.elements.items.closest(':visible').children(':hidden').addClass(_this.classes.tempshow);
var maxHeight = _this.options.maxHeight;
var itemsWidth = _this.elements.items.outerWidth();
var wrapperWidth = _this.elements.wrapper.outerWidth() - (itemsWidth - _this.elements.items.width());
// Set the dimensions, minimum is wrapper width, expand for long items if option is true
if ( !_this.options.expandToItemText || wrapperWidth > itemsWidth ) {
_this.finalWidth = wrapperWidth;
} else {
// Make sure the scrollbar width is included
_this.elements.items.css('overflow', 'scroll');
// Set a really long width for _this.elements.outerWrapper
_this.elements.outerWrapper.width(9e4);
_this.finalWidth = _this.elements.items.width();
// Set scroll bar to auto
_this.elements.items.css('overflow', '');
_this.elements.outerWrapper.width('');
}
_this.elements.items.width(_this.finalWidth).height() > maxHeight && _this.elements.items.height(maxHeight);
// Remove the temporary class
hiddenChildren.removeClass(_this.classes.tempshow);
},
/** Detect if the options box is inside the window */
isInViewport: function() {
var _this = this;
if (_this.options.forceRenderAbove === true) {
_this.elements.outerWrapper.addClass(_this.classes.above);
} else if (_this.options.forceRenderBelow === true) {
_this.elements.outerWrapper.addClass(_this.classes.below);
} else {
var scrollTop = $win.scrollTop();
var winHeight = $win.height();
var uiPosX = _this.elements.outerWrapper.offset().top;
var uiHeight = _this.elements.outerWrapper.outerHeight();
var fitsDown = (uiPosX + uiHeight + _this.itemsHeight) <= (scrollTop + winHeight);
var fitsAbove = (uiPosX - _this.itemsHeight) > scrollTop;
// If it does not fit below, only render it
// above it fit's there.
// It's acceptable that the user needs to
// scroll the viewport to see the cut off UI
var renderAbove = !fitsDown && fitsAbove;
var renderBelow = !renderAbove;
_this.elements.outerWrapper.toggleClass(_this.classes.above, renderAbove);
_this.elements.outerWrapper.toggleClass(_this.classes.below, renderBelow);
}
},
/**
* Detect if currently selected option is visible and scroll the options box to show it
*
* @param {Number|Array} index - Index of the selected items
*/
detectItemVisibility: function(index) {
var _this = this;
var $filteredLi = _this.$li.filter('[data-index]');
if ( _this.state.multiple ) {
// If index is an array, we can assume a multiple select and we
// want to scroll to the uppermost selected item!
// Math.min.apply(Math, index) returns the lowest entry in an Array.
index = ($.isArray(index) && index.length === 0) ? 0 : index;
index = $.isArray(index) ? Math.min.apply(Math, index) : index;
}
var liHeight = $filteredLi.eq(index).outerHeight();
var liTop = $filteredLi[index].offsetTop;
var itemsScrollTop = _this.elements.itemsScroll.scrollTop();
var scrollT = liTop + liHeight * 2;
_this.elements.itemsScroll.scrollTop(
scrollT > itemsScrollTop + _this.itemsHeight ? scrollT - _this.itemsHeight :
liTop - liHeight < itemsScrollTop ? liTop - liHeight :
itemsScrollTop
);
},
/**
* Open the select options box
*
* @param {Event} e - Event
*/
open: function(e) {
var _this = this;
if ( _this.options.nativeOnMobile && _this.utils.isMobile()) {
return false;
}
_this.utils.triggerCallback('BeforeOpen', _this);
if ( e ) {
e.preventDefault();
if (_this.options.stopPropagation) {
e.stopPropagation();
}
}
if ( _this.state.enabled ) {
_this.setOptionsDimensions();
// Find any other opened instances of select and close it
$('.' + _this.classes.hideselect, '.' + _this.classes.open).children()[pluginName]('close');
_this.state.opened = true;
_this.itemsHeight = _this.elements.items.outerHeight();
_this.itemsInnerHeight = _this.elements.items.height();
// Toggle options box visibility
_this.elements.outerWrapper.addClass(_this.classes.open);
// Give dummy input focus
_this.elements.input.val('');
if ( e && e.type !== 'focusin' ) {
_this.elements.input.focus();
}
// Delayed binds events on Document to make label clicks work
setTimeout(function() {
$doc
.on('click' + eventNamespaceSuffix, $.proxy(_this.close, _this))
.on('scroll' + eventNamespaceSuffix, $.proxy(_this.isInViewport, _this));
}, 1);
_this.isInViewport();
// Prevent window scroll when using mouse wheel inside items box
if ( _this.options.preventWindowScroll ) {
/* istanbul ignore next */
$doc.on('mousewheel' + eventNamespaceSuffix + ' DOMMouseScroll' + eventNamespaceSuffix, '.' + _this.classes.scroll, function(e) {
var orgEvent = e.originalEvent;
var scrollTop = $(this).scrollTop();
var deltaY = 0;
if ( 'detail' in orgEvent ) { deltaY = orgEvent.detail * -1; }
if ( 'wheelDelta' in orgEvent ) { deltaY = orgEvent.wheelDelta; }
if ( 'wheelDeltaY' in orgEvent ) { deltaY = orgEvent.wheelDeltaY; }
if ( 'deltaY' in orgEvent ) { deltaY = orgEvent.deltaY * -1; }
if ( scrollTop === (this.scrollHeight - _this.itemsInnerHeight) && deltaY < 0 || scrollTop === 0 && deltaY > 0 ) {
e.preventDefault();
}
});
}
_this.detectItemVisibility(_this.state.selectedIdx);
_this.highlight(_this.state.multiple ? -1 : _this.state.selectedIdx);
_this.utils.triggerCallback('Open', _this);
}
},
/** Close the select options box */
close: function() {
var _this = this;
_this.utils.triggerCallback('BeforeClose', _this);
// Remove custom events on document
$doc.off(eventNamespaceSuffix);
// Remove visible class to hide options box
_this.elements.outerWrapper.removeClass(_this.classes.open);
_this.state.opened = false;
_this.utils.triggerCallback('Close', _this);
},
/** Select current option and change the label */
change: function() {
var _this = this;
_this.utils.triggerCallback('BeforeChange', _this);
if ( _this.state.multiple ) {
// Reset old selected
$.each(_this.lookupItems, function(idx) {
_this.lookupItems[idx].selected = false;
_this.$element.find('option').prop('selected', false);
});
// Set new selected
$.each(_this.state.selectedIdx, function(idx, value) {
_this.lookupItems[value].selected = true;
_this.$element.find('option').eq(value).prop('selected', true);
});
_this.state.currValue = _this.state.selectedIdx;
_this.setLabel();
_this.utils.triggerCallback('Change', _this);
} else if ( _this.state.currValue !== _this.state.selectedIdx ) {
// Apply changed value to original select
_this.$element
.prop('selectedIndex', _this.state.currValue = _this.state.selectedIdx)
.data('value', _this.lookupItems[_this.state.selectedIdx].text);
// Change label text
_this.setLabel();
_this.utils.triggerCallback('Change', _this);
}
},
/**
* Highlight option
* @param {number} index - Index of the options that will be highlighted
*/
highlight: function(index) {
var _this = this;
var $filteredLi = _this.$li.filter('[data-index]').removeClass('highlighted');
_this.utils.triggerCallback('BeforeHighlight', _this);
// Parameter index is required and should not be a disabled item
if ( index === undefined || index === -1 || _this.lookupItems[index].disabled ) {
return;
}
$filteredLi
.eq(_this.state.highlightedIdx = index)
.addClass('highlighted');
_this.detectItemVisibility(index);
_this.utils.triggerCallback('Highlight', _this);
},
/**
* Select option
*
* @param {number} index - Index of the option that will be selected
*/
select: function(index) {
var _this = this;
var $filteredLi = _this.$li.filter('[data-index]');
_this.utils.triggerCallback('BeforeSelect', _this, index);
// Parameter index is required and should not be a disabled item
if ( index === undefined || index === -1 || _this.lookupItems[index].disabled ) {
return;
}
if ( _this.state.multiple ) {
// Make sure selectedIdx is an array
_this.state.selectedIdx = $.isArray(_this.state.selectedIdx) ? _this.state.selectedIdx : [_this.state.selectedIdx];
var hasSelectedIndex = $.inArray(index, _this.state.selectedIdx);
if ( hasSelectedIndex !== -1 ) {
_this.state.selectedIdx.splice(hasSelectedIndex, 1);
} else {
_this.state.selectedIdx.push(index);
}
$filteredLi
.removeClass('selected')
.filter(function(index) {
return $.inArray(index, _this.state.selectedIdx) !== -1;
})
.addClass('selected');
} else {
$filteredLi
.removeClass('selected')
.eq(_this.state.selectedIdx = index)
.addClass('selected');
}
if ( !_this.state.multiple || !_this.options.multiple.keepMenuOpen ) {
_this.close();
}
_this.change();
_this.utils.triggerCallback('Select', _this, index);
},
/**
* Unbind and remove
*
* @param {boolean} preserveData - Check if the data on the element should be removed too
*/
destroy: function(preserveData) {
var _this = this;
if ( _this.state && _this.state.enabled ) {
_this.elements.items.add(_this.elements.wrapper).add(_this.elements.input).remove();
if ( !preserveData ) {
_this.$element.removeData(pluginName).removeData('value');
}
_this.$element.prop('tabindex', _this.originalTabindex).off(eventNamespaceSuffix).off(_this.eventTriggers).unwrap().unwrap();
_this.state.enabled = false;
}
}
};
// A really lightweight plugin wrapper around the constructor,
// preventing against multiple instantiations
$.fn[pluginName] = function(args) {
return this.each(function() {
var data = $.data(this, pluginName);
if ( data && !data.disableOnMobile ) {
(typeof args === 'string' && data[args]) ? data[args]() : data.init(args);
} else {
$.data(this, pluginName, new Selectric(this, args));
}
});
};
/**
* Default plugin options
*
* @type {object}
*/
$.fn[pluginName].defaults = {
onChange : function(elm) { $(elm).change(); },
maxHeight : 300,
keySearchTimeout : 500,
arrowButtonMarkup : '▾',
disableOnMobile : false,
nativeOnMobile : true,
openOnFocus : true,
openOnHover : false,
hoverIntentTimeout : 500,
expandToItemText : false,
responsive : false,
preventWindowScroll : true,
inheritOriginalWidth : false,
allowWrap : true,
forceRenderAbove : false,
forceRenderBelow : false,
stopPropagation : true,
optionsItemBuilder : '{text}', // function(itemData, element, index)
labelBuilder : '{text}', // function(currItem)
listBuilder : false, // function(items)
keys : {
previous : [37, 38], // Left / Up
next : [39, 40], // Right / Down
select : [9, 13, 27], // Tab / Enter / Escape
open : [13, 32, 37, 38, 39, 40], // Enter / Space / Left / Up / Right / Down
close : [9, 27] // Tab / Escape
},
customClass : {
prefix: pluginName,
camelCase: false
},
multiple : {
separator: ', ',
keepMenuOpen: true,
maxLabelEntries: false
}
};
}));