1111 lines
35 KiB
JavaScript
1111 lines
35 KiB
JavaScript
(function(factory) {
|
|
/* global define */
|
|
/* istanbul ignore next */
|
|
if ( typeof define === 'function' && define.amd ) {
|
|
define(['jquery'], factory);
|
|
} else if ( typeof module === 'object' && module.exports ) {
|
|
// Node/CommonJS
|
|
module.exports = function( root, jQuery ) {
|
|
if ( jQuery === undefined ) {
|
|
if ( typeof window !== 'undefined' ) {
|
|
jQuery = require('jquery');
|
|
} else {
|
|
jQuery = require('jquery')(root);
|
|
}
|
|
}
|
|
factory(jQuery);
|
|
return jQuery;
|
|
};
|
|
} else {
|
|
// Browser globals
|
|
factory(jQuery);
|
|
}
|
|
}(function($) {
|
|
'use strict';
|
|
|
|
var $doc = $(document);
|
|
var $win = $(window);
|
|
|
|
var pluginName = 'selectric';
|
|
var classList = 'Input Items Open Disabled TempShow HideSelect Wrapper Focus Hover Responsive Above Below Scroll Group GroupLabel';
|
|
var eventNamespaceSuffix = '.sl';
|
|
|
|
var chars = ['a', 'e', 'i', 'o', 'u', 'n', 'c', 'y'];
|
|
var diacritics = [
|
|
/[\xE0-\xE5]/g, // a
|
|
/[\xE8-\xEB]/g, // e
|
|
/[\xEC-\xEF]/g, // i
|
|
/[\xF2-\xF6]/g, // o
|
|
/[\xF9-\xFC]/g, // u
|
|
/[\xF1]/g, // n
|
|
/[\xE7]/g, // c
|
|
/[\xFD-\xFF]/g // y
|
|
];
|
|
|
|
/**
|
|
* Create an instance of Selectric
|
|
*
|
|
* @constructor
|
|
* @param {Node} element - The <select> element
|
|
* @param {object} opts - Options
|
|
*/
|
|
var Selectric = function(element, opts) {
|
|
var _this = this;
|
|
|
|
_this.element = element;
|
|
_this.$element = $(element);
|
|
|
|
_this.state = {
|
|
multiple : !!_this.$element.attr('multiple'),
|
|
enabled : false,
|
|
opened : false,
|
|
currValue : -1,
|
|
selectedIdx : -1,
|
|
highlightedIdx : -1
|
|
};
|
|
|
|
_this.eventTriggers = {
|
|
open : _this.open,
|
|
close : _this.close,
|
|
destroy : _this.destroy,
|
|
refresh : _this.refresh,
|
|
init : _this.init
|
|
};
|
|
|
|
_this.init(opts);
|
|
};
|
|
|
|
Selectric.prototype = {
|
|
utils: {
|
|
/**
|
|
* Detect mobile browser
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isMobile: function() {
|
|
return /android|ip(hone|od|ad)/i.test(navigator.userAgent);
|
|
},
|
|
|
|
/**
|
|
* Escape especial characters in string (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions)
|
|
*
|
|
* @param {string} str - The string to be escaped
|
|
* @return {string} The string with the special characters escaped
|
|
*/
|
|
escapeRegExp: function(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
|
},
|
|
|
|
/**
|
|
* Replace diacritics
|
|
*
|
|
* @param {string} str - The string to replace the diacritics
|
|
* @return {string} The string with diacritics replaced with ascii characters
|
|
*/
|
|
replaceDiacritics: function(str) {
|
|
var k = diacritics.length;
|
|
|
|
while (k--) {
|
|
str = str.toLowerCase().replace(diacritics[k], chars[k]);
|
|
}
|
|
|
|
return str;
|
|
},
|
|
|
|
/**
|
|
* Format string
|
|
* https://gist.github.com/atesgoral/984375
|
|
*
|
|
* @param {string} f - String to be formated
|
|
* @return {string} String formated
|
|
*/
|
|
format: function(f) {
|
|
var a = arguments; // store outer arguments
|
|
return ('' + f) // force format specifier to String
|
|
.replace( // replace tokens in format specifier
|
|
/\{(?:(\d+)|(\w+))\}/g, // match {token} references
|
|
function(
|
|
s, // the matched string (ignored)
|
|
i, // an argument index
|
|
p // a property name
|
|
) {
|
|
return p && a[1] // if property name and first argument exist
|
|
? a[1][p] // return property from first argument
|
|
: a[i]; // assume argument index and return i-th argument
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Get the next enabled item in the options list.
|
|
*
|
|
* @param {object} selectItems - The options object.
|
|
* @param {number} selected - Index of the currently selected option.
|
|
* @return {object} The next enabled item.
|
|
*/
|
|
nextEnabledItem: function(selectItems, selected) {
|
|
while ( selectItems[ selected = (selected + 1) % selectItems.length ].disabled ) {
|
|
// empty
|
|
}
|
|
return selected;
|
|
},
|
|
|
|
/**
|
|
* Get the previous enabled item in the options list.
|
|
*
|
|
* @param {object} selectItems - The options object.
|
|
* @param {number} selected - Index of the currently selected option.
|
|
* @return {object} The previous enabled item.
|
|
*/
|
|
previousEnabledItem: function(selectItems, selected) {
|
|
while ( selectItems[ selected = (selected > 0 ? selected : selectItems.length) - 1 ].disabled ) {
|
|
// empty
|
|
}
|
|
return selected;
|
|
},
|
|
|
|
/**
|
|
* Transform camelCase string to dash-case.
|
|
*
|
|
* @param {string} str - The camelCased string.
|
|
* @return {string} The string transformed to dash-case.
|
|
*/
|
|
toDash: function(str) {
|
|
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
},
|
|
|
|
/**
|
|
* Calls the events registered with function name.
|
|
*
|
|
* @param {string} fn - The name of the function.
|
|
* @param {number} scope - Scope that should be set on the function.
|
|
*/
|
|
triggerCallback: function(fn, scope) {
|
|
var elm = scope.element;
|
|
var func = scope.options['on' + fn];
|
|
var args = [elm].concat([].slice.call(arguments).slice(1));
|
|
|
|
if ( $.isFunction(func) ) {
|
|
func.apply(elm, args);
|
|
}
|
|
|
|
$(elm).trigger(pluginName + '-' + this.toDash(fn), args);
|
|
},
|
|
|
|
/**
|
|
* Transform array list to concatenated string and remove empty values
|
|
* @param {array} arr - Class list
|
|
* @return {string} Concatenated string
|
|
*/
|
|
arrayToClassname: function(arr) {
|
|
var newArr = $.grep(arr, function(item) {
|
|
return !!item;
|
|
});
|
|
|
|
return $.trim(newArr.join(' '));
|
|
}
|
|
},
|
|
|
|
/** Initializes */
|
|
init: function(opts) {
|
|
var _this = this;
|
|
|
|
// Set options
|
|
_this.options = $.extend(true, {}, $.fn[pluginName].defaults, _this.options, opts);
|
|
|
|
_this.utils.triggerCallback('BeforeInit', _this);
|
|
|
|
// Preserve data
|
|
_this.destroy(true);
|
|
|
|
// Disable on mobile browsers
|
|
if ( _this.options.disableOnMobile && _this.utils.isMobile() ) {
|
|
_this.disableOnMobile = true;
|
|
return;
|
|
}
|
|
|
|
// Get classes
|
|
_this.classes = _this.getClassNames();
|
|
|
|
// Create elements
|
|
var input = $('<input/>', { 'class': _this.classes.input, 'readonly': _this.utils.isMobile() });
|
|
var items = $('<div/>', { 'class': _this.classes.items, 'tabindex': -1 });
|
|
var itemsScroll = $('<div/>', { 'class': _this.classes.scroll });
|
|
var wrapper = $('<div/>', { 'class': _this.classes.prefix, 'html': _this.options.arrowButtonMarkup });
|
|
var label = $('<span/>', { 'class': 'label' });
|
|
var outerWrapper = _this.$element.wrap('<div/>').parent().append(wrapper.prepend(label), items, input);
|
|
var hideSelectWrapper = $('<div/>', { 'class': _this.classes.hideselect });
|
|
|
|
_this.elements = {
|
|
input : input,
|
|
items : items,
|
|
itemsScroll : itemsScroll,
|
|
wrapper : wrapper,
|
|
label : label,
|
|
outerWrapper : outerWrapper
|
|
};
|
|
|
|
if ( _this.options.nativeOnMobile && _this.utils.isMobile() ) {
|
|
_this.elements.input = undefined;
|
|
hideSelectWrapper.addClass(_this.classes.prefix + '-is-native');
|
|
|
|
_this.$element.on('change', function() {
|
|
_this.refresh();
|
|
});
|
|
}
|
|
|
|
_this.$element
|
|
.on(_this.eventTriggers)
|
|
.wrap(hideSelectWrapper);
|
|
|
|
_this.originalTabindex = _this.$element.prop('tabindex');
|
|
_this.$element.prop('tabindex', -1);
|
|
|
|
_this.populate();
|
|
_this.activate();
|
|
|
|
_this.utils.triggerCallback('Init', _this);
|
|
},
|
|
|
|
/** Activates the plugin */
|
|
activate: function() {
|
|
var _this = this;
|
|
var hiddenChildren = _this.elements.items.closest(':visible').children(':hidden').addClass(_this.classes.tempshow);
|
|
var originalWidth = _this.$element.width();
|
|
|
|
hiddenChildren.removeClass(_this.classes.tempshow);
|
|
|
|
_this.utils.triggerCallback('BeforeActivate', _this);
|
|
|
|
_this.elements.outerWrapper.prop('class',
|
|
_this.utils.arrayToClassname([
|
|
_this.classes.wrapper,
|
|
_this.$element.prop('class').replace(/\S+/g, _this.classes.prefix + '-$&'),
|
|
_this.options.responsive ? _this.classes.responsive : ''
|
|
])
|
|
);
|
|
|
|
if ( _this.options.inheritOriginalWidth && originalWidth > 0 ) {
|
|
_this.elements.outerWrapper.width(originalWidth);
|
|
}
|
|
|
|
_this.unbindEvents();
|
|
|
|
if ( !_this.$element.prop('disabled') ) {
|
|
_this.state.enabled = true;
|
|
|
|
// Not disabled, so... Removing disabled class
|
|
_this.elements.outerWrapper.removeClass(_this.classes.disabled);
|
|
|
|
// Remove styles from items box
|
|
// Fix incorrect height when refreshed is triggered with fewer options
|
|
_this.$li = _this.elements.items.removeAttr('style').find('li');
|
|
|
|
_this.bindEvents();
|
|
} else {
|
|
_this.elements.outerWrapper.addClass(_this.classes.disabled);
|
|
|
|
if ( _this.elements.input ) {
|
|
_this.elements.input.prop('disabled', true);
|
|
}
|
|
}
|
|
|
|
_this.utils.triggerCallback('Activate', _this);
|
|
},
|
|
|
|
/**
|
|
* Generate classNames for elements
|
|
*
|
|
* @return {object} Classes object
|
|
*/
|
|
getClassNames: function() {
|
|
var _this = this;
|
|
var customClass = _this.options.customClass;
|
|
var classesObj = {};
|
|
|
|
$.each(classList.split(' '), function(i, currClass) {
|
|
var c = customClass.prefix + currClass;
|
|
classesObj[currClass.toLowerCase()] = customClass.camelCase ? c : _this.utils.toDash(c);
|
|
});
|
|
|
|
classesObj.prefix = customClass.prefix;
|
|
|
|
return classesObj;
|
|
},
|
|
|
|
/** Set the label text */
|
|
setLabel: function() {
|
|
var _this = this;
|
|
var labelBuilder = _this.options.labelBuilder;
|
|
|
|
if ( _this.state.multiple ) {
|
|
// Make sure currentValues is an array
|
|
var currentValues = $.isArray(_this.state.currValue) ? _this.state.currValue : [_this.state.currValue];
|
|
// I'm not happy with this, but currentValues can be an empty
|
|
// array and we need to fallback to the default option.
|
|
currentValues = currentValues.length === 0 ? [0] : currentValues;
|
|
|
|
var labelMarkup = $.map(currentValues, function(value) {
|
|
return $.grep(_this.lookupItems, function(item) {
|
|
return item.index === value;
|
|
})[0]; // we don't want nested arrays here
|
|
});
|
|
|
|
labelMarkup = $.grep(labelMarkup, function(item) {
|
|
// Hide default (please choose) if more then one element were selected.
|
|
// If no option value were given value is set to option text by default
|
|
if ( labelMarkup.length > 1 || labelMarkup.length === 0 ) {
|
|
return $.trim(item.value) !== '';
|
|
}
|
|
return item;
|
|
});
|
|
|
|
labelMarkup = $.map(labelMarkup, function(item) {
|
|
return $.isFunction(labelBuilder)
|
|
? labelBuilder(item)
|
|
: _this.utils.format(labelBuilder, item);
|
|
});
|
|
|
|
// Limit the amount of selected values shown in label
|
|
if ( _this.options.multiple.maxLabelEntries ) {
|
|
if ( labelMarkup.length >= _this.options.multiple.maxLabelEntries + 1 ) {
|
|
labelMarkup = labelMarkup.slice(0, _this.options.multiple.maxLabelEntries);
|
|
labelMarkup.push(
|
|
$.isFunction(labelBuilder)
|
|
? labelBuilder({ text: '...' })
|
|
: _this.utils.format(labelBuilder, { text: '...' }));
|
|
} else {
|
|
labelMarkup.slice(labelMarkup.length - 1);
|
|
}
|
|
}
|
|
_this.elements.label.html(labelMarkup.join(_this.options.multiple.separator));
|
|
|
|
} else {
|
|
var currItem = _this.lookupItems[_this.state.currValue];
|
|
|
|
_this.elements.label.html(
|
|
$.isFunction(labelBuilder)
|
|
? labelBuilder(currItem)
|
|
: _this.utils.format(labelBuilder, currItem)
|
|
);
|
|
}
|
|
},
|
|
|
|
/** Get and save the available options */
|
|
populate: function() {
|
|
var _this = this;
|
|
var $options = _this.$element.children();
|
|
var $justOptions = _this.$element.find('option');
|
|
var $selected = $justOptions.filter(':selected');
|
|
var selectedIndex = $justOptions.index($selected);
|
|
var currIndex = 0;
|
|
var emptyValue = (_this.state.multiple ? [] : 0);
|
|
|
|
if ( $selected.length > 1 && _this.state.multiple ) {
|
|
selectedIndex = [];
|
|
$selected.each(function() {
|
|
selectedIndex.push($(this).index());
|
|
});
|
|
}
|
|
|
|
_this.state.currValue = (~selectedIndex ? selectedIndex : emptyValue);
|
|
_this.state.selectedIdx = _this.state.currValue;
|
|
_this.state.highlightedIdx = _this.state.currValue;
|
|
_this.items = [];
|
|
_this.lookupItems = [];
|
|
|
|
if ( $options.length ) {
|
|
// Build options markup
|
|
$options.each(function(i) {
|
|
var $elm = $(this);
|
|
|
|
if ( $elm.is('optgroup') ) {
|
|
|
|
var optionsGroup = {
|
|
element : $elm,
|
|
label : $elm.prop('label'),
|
|
groupDisabled : $elm.prop('disabled'),
|
|
items : []
|
|
};
|
|
|
|
$elm.children().each(function(i) {
|
|
var $elm = $(this);
|
|
|
|
optionsGroup.items[i] = _this.getItemData(currIndex, $elm, optionsGroup.groupDisabled || $elm.prop('disabled'));
|
|
|
|
_this.lookupItems[currIndex] = optionsGroup.items[i];
|
|
|
|
currIndex++;
|
|
});
|
|
|
|
_this.items[i] = optionsGroup;
|
|
|
|
} else {
|
|
|
|
_this.items[i] = _this.getItemData(currIndex, $elm, $elm.prop('disabled'));
|
|
|
|
_this.lookupItems[currIndex] = _this.items[i];
|
|
|
|
currIndex++;
|
|
|
|
}
|
|
});
|
|
|
|
_this.setLabel();
|
|
_this.elements.items.append( _this.elements.itemsScroll.html( _this.getItemsMarkup(_this.items) ) );
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Generate items object data
|
|
* @param {integer} index - Current item index
|
|
* @param {node} $elm - Current element node
|
|
* @param {boolean} isDisabled - Current element disabled state
|
|
* @return {object} Item object
|
|
*/
|
|
getItemData: function(index, $elm, isDisabled) {
|
|
var _this = this;
|
|
|
|
return {
|
|
index : index,
|
|
element : $elm,
|
|
value : $elm.val(),
|
|
className : $elm.prop('class'),
|
|
text : $elm.html(),
|
|
slug : $.trim(_this.utils.replaceDiacritics($elm.html())),
|
|
alt : $elm.attr('data-alt'),
|
|
selected : $elm.prop('selected'),
|
|
disabled : isDisabled
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Generate options markup
|
|
*
|
|
* @param {object} items - Object containing all available options
|
|
* @return {string} HTML for the options box
|
|
*/
|
|
getItemsMarkup: function(items) {
|
|
var _this = this;
|
|
var markup = '<ul>';
|
|
|
|
if ( $.isFunction(_this.options.listBuilder) && _this.options.listBuilder ) {
|
|
items = _this.options.listBuilder(items);
|
|
}
|
|
|
|
$.each(items, function(i, elm) {
|
|
if ( elm.label !== undefined ) {
|
|
|
|
markup += _this.utils.format('<ul class="{1}"><li class="{2}">{3}</li>',
|
|
_this.utils.arrayToClassname([
|
|
_this.classes.group,
|
|
elm.groupDisabled ? 'disabled' : '',
|
|
elm.element.prop('class')
|
|
]),
|
|
_this.classes.grouplabel,
|
|
elm.element.prop('label')
|
|
);
|
|
|
|
$.each(elm.items, function(i, elm) {
|
|
markup += _this.getItemMarkup(elm.index, elm);
|
|
});
|
|
|
|
markup += '</ul>';
|
|
|
|
} else {
|
|
|
|
markup += _this.getItemMarkup(elm.index, elm);
|
|
|
|
}
|
|
});
|
|
|
|
return markup + '</ul>';
|
|
},
|
|
|
|
/**
|
|
* 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('<li data-index="{1}" class="{2}">{3}</li>',
|
|
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 <input> 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 : '<b class="button">▾</b>',
|
|
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
|
|
}
|
|
};
|
|
}));
|