1(function(factory) { 2 /* global define */ 3 /* istanbul ignore next */ 4 if ( typeof define === 'function' && define.amd ) { 5 define(['jquery'], factory); 6 } else if ( typeof module === 'object' && module.exports ) { 7 // Node/CommonJS 8 module.exports = function( root, jQuery ) { 9 if ( jQuery === undefined ) { 10 if ( typeof window !== 'undefined' ) { 11 jQuery = require('jquery'); 12 } else { 13 jQuery = require('jquery')(root); 14 } 15 } 16 factory(jQuery); 17 return jQuery; 18 }; 19 } else { 20 // Browser globals 21 factory(jQuery); 22 } 23}(function($) { 24 'use strict'; 25 26 var $doc = $(document); 27 var $win = $(window); 28 29 var pluginName = 'selectric'; 30 var classList = 'Input Items Open Disabled TempShow HideSelect Wrapper Focus Hover Responsive Above Scroll Group GroupLabel'; 31 var eventNamespaceSuffix = '.sl'; 32 33 var chars = ['a', 'e', 'i', 'o', 'u', 'n', 'c', 'y']; 34 var diacritics = [ 35 /[\xE0-\xE5]/g, // a 36 /[\xE8-\xEB]/g, // e 37 /[\xEC-\xEF]/g, // i 38 /[\xF2-\xF6]/g, // o 39 /[\xF9-\xFC]/g, // u 40 /[\xF1]/g, // n 41 /[\xE7]/g, // c 42 /[\xFD-\xFF]/g // y 43 ]; 44 45 /** 46 * Create an instance of Selectric 47 * 48 * @constructor 49 * @param {Node} element - The <select> element 50 * @param {object} opts - Options 51 */ 52 var Selectric = function(element, opts) { 53 var _this = this; 54 55 _this.element = element; 56 _this.$element = $(element); 57 58 _this.state = { 59 multiple : !!_this.$element.attr('multiple'), 60 enabled : false, 61 opened : false, 62 currValue : -1, 63 selectedIdx : -1, 64 highlightedIdx : -1 65 }; 66 67 _this.eventTriggers = { 68 open : _this.open, 69 close : _this.close, 70 destroy : _this.destroy, 71 refresh : _this.refresh, 72 init : _this.init 73 }; 74 75 _this.init(opts); 76 }; 77 78 Selectric.prototype = { 79 utils: { 80 /** 81 * Detect mobile browser 82 * 83 * @return {boolean} 84 */ 85 isMobile: function() { 86 return /android|ip(hone|od|ad)/i.test(navigator.userAgent); 87 }, 88 89 /** 90 * Escape especial characters in string (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) 91 * 92 * @param {string} str - The string to be escaped 93 * @return {string} The string with the special characters escaped 94 */ 95 escapeRegExp: function(str) { 96 return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 97 }, 98 99 /** 100 * Replace diacritics 101 * 102 * @param {string} str - The string to replace the diacritics 103 * @return {string} The string with diacritics replaced with ascii characters 104 */ 105 replaceDiacritics: function(str) { 106 var k = diacritics.length; 107 108 while (k--) { 109 str = str.toLowerCase().replace(diacritics[k], chars[k]); 110 } 111 112 return str; 113 }, 114 115 /** 116 * Format string 117 * https://gist.github.com/atesgoral/984375 118 * 119 * @param {string} f - String to be formated 120 * @return {string} String formated 121 */ 122 format: function(f) { 123 var a = arguments; // store outer arguments 124 return ('' + f) // force format specifier to String 125 .replace( // replace tokens in format specifier 126 /\{(?:(\d+)|(\w+))\}/g, // match {token} references 127 function( 128 s, // the matched string (ignored) 129 i, // an argument index 130 p // a property name 131 ) { 132 return p && a[1] // if property name and first argument exist 133 ? a[1][p] // return property from first argument 134 : a[i]; // assume argument index and return i-th argument 135 }); 136 }, 137 138 /** 139 * Get the next enabled item in the options list. 140 * 141 * @param {object} selectItems - The options object. 142 * @param {number} selected - Index of the currently selected option. 143 * @return {object} The next enabled item. 144 */ 145 nextEnabledItem: function(selectItems, selected) { 146 while ( selectItems[ selected = (selected + 1) % selectItems.length ].disabled ) { 147 // empty 148 } 149 return selected; 150 }, 151 152 /** 153 * Get the previous enabled item in the options list. 154 * 155 * @param {object} selectItems - The options object. 156 * @param {number} selected - Index of the currently selected option. 157 * @return {object} The previous enabled item. 158 */ 159 previousEnabledItem: function(selectItems, selected) { 160 while ( selectItems[ selected = (selected > 0 ? selected : selectItems.length) - 1 ].disabled ) { 161 // empty 162 } 163 return selected; 164 }, 165 166 /** 167 * Transform camelCase string to dash-case. 168 * 169 * @param {string} str - The camelCased string. 170 * @return {string} The string transformed to dash-case. 171 */ 172 toDash: function(str) { 173 return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); 174 }, 175 176 /** 177 * Calls the events registered with function name. 178 * 179 * @param {string}
179 fn - The name of the function. 180 * @param {number} scope - Scope that should be set on the function. 181 */ 182 triggerCallback: function(fn, scope) { 183 var elm = scope.element; 184 var func = scope.options['on' + fn]; 185 var args = [elm].concat([].slice.call(arguments).slice(1)); 186 187 if ( $.isFunction(func) ) { 188 func.apply(elm, args); 189 } 190 191 $(elm).trigger(pluginName + '-' + this.toDash(fn), args); 192 }, 193 194 /** 195 * Transform array list to concatenated string and remove empty values 196 * @param {array} arr - Class list 197 * @return {string} Concatenated string 198 */ 199 arrayToClassname: function(arr) { 200 var newArr = $.grep(arr, function(item) { 201 return !!item; 202 }); 203 204 return $.trim(newArr.join(' ')); 205 } 206 }, 207 208 /** Initializes */ 209 init: function(opts) { 210 var _this = this; 211 212 // Set options 213 _this.options = $.extend(true, {}, $.fn[pluginName].defaults, _this.options, opts); 214 215 _this.utils.triggerCallback('BeforeInit', _this); 216 217 // Preserve data 218 _this.destroy(true); 219 220 // Disable on mobile browsers 221 if ( _this.options.disableOnMobile && _this.utils.isMobile() ) { 222 _this.disableOnMobile = true; 223 return; 224 } 225 226 // Get classes 227 _this.classes = _this.getClassNames(); 228 229 // Create elements 230 var input = $('<input/>', { 'class': _this.classes.input, 'readonly': _this.utils.isMobile() }); 231 var items = $('<div/>', { 'class': _this.classes.items, 'tabindex': -1 }); 232 var itemsScroll = $('<div/>', { 'class': _this.classes.scroll }); 233 var wrapper = $('<div/>', { 'class': _this.classes.prefix, 'html': _this.options.arrowButtonMarkup }); 234 var label = $('<span/>', { 'class': 'label' }); 235 var outerWrapper = _this.$element.wrap('<div/>').parent().append(wrapper.prepend(label), items, input); 236 var hideSelectWrapper = $('<div/>', { 'class': _this.classes.hideselect }); 237 238 _this.elements = { 239 input : input, 240 items : items, 241 itemsScroll : itemsScroll, 242 wrapper : wrapper, 243 label : label, 244 outerWrapper : outerWrapper 245 }; 246 247 if ( _this.options.nativeOnMobile && _this.utils.isMobile() ) { 248 _this.elements.input = undefined; 249 hideSelectWrapper.addClass(_this.classes.prefix + '-is-native'); 250 251 _this.$element.on('change', function() { 252 _this.refresh(); 253 }); 254 } 255 256 _this.$element 257 .on(_this.eventTriggers) 258 .wrap(hideSelectWrapper); 259 260 _this.originalTabindex = _this.$element.prop('tabindex'); 261 _this.$element.prop('tabindex', false); 262 263 _this.populate(); 264 _this.activate(); 265 266 _this.utils.triggerCallback('Init', _this); 267 }, 268 269 /** Activates the plugin */ 270 activate: function() { 271 var _this = this; 272 var hiddenChildren = _this.elements.items.closest(':visible').children(':hidden').addClass(_this.classes.tempshow); 273 var originalWidth = _this.$element.width(); 274 275 hiddenChildren.removeClass(_this.classes.tempshow); 276 277 _this.utils.triggerCallback('BeforeActivate', _this); 278 279 _this.elements.outerWrapper.prop('class', 280 _this.utils.arrayToClassname([ 281 _this.classes.wrapper, 282 _this.$element.prop('class').replace(/\S+/g, _this.classes.prefix + '-$&'), 283 _this.options.responsive ? _this.classes.responsive : '' 284 ]) 285 ); 286 287 if ( _this.options.inheritOriginalWidth && originalWidth > 0 ) { 288 _this.elements.outerWrapper.width(originalWidth); 289 } 290 291 _this.unbindEvents(); 292 293 if ( !_this.$element.prop('disabled') ) { 294 _this.state.enabled = true; 295 296 // Not disabled, so... Removing disabled class 297 _this.elements.outerWrapper.removeClass(_this.classes.disabled); 298 299 // Remove styles from items box 300 // Fix incorrect height when refreshed is triggered with fewer options 301 _this.$li = _this.elements.items.removeAttr('style').find('li'); 302 303 _this.bindEvents(); 304 } else { 305 _this.elements.outerWrapper.addClass(_this.classes.disabled); 306 307 if ( _this.elements.input ) { 308 _this.elements.input.prop('disabled', true); 309 } 310 } 311 312 _this.utils.triggerCallback('Activate', _this); 313 }, 314 315 /** 316 * Generate classNames for elements 317 * 318 * @return {object} Classes object 319 */ 320 getClassNames: function() { 321 var _this = this; 322 var customClass = _this.options.customClass; 323 var classesObj = {}; 324 325 $.each(classList.split(' '), function(i, currClass) { 326 var c = customClass.prefix + currClass; 327 classesObj[currClass.toLowerCase()] = customClass.camelCase ? c : _this.utils.toDash(c); 328 }); 329 330 classesObj.prefix = customClass.prefix; 331 332 return classesObj; 333 }, 334 335 /** Set the label text */
336 setLabel: function() { 337 var _this = this; 338 var labelBuilder = _this.options.labelBuilder; 339 340 if ( _this.state.multiple ) { 341 // Make sure currentValues is an array 342 var currentValues = $.isArray(_this.state.currValue) ? _this.state.currValue : [_this.state.currValue]; 343 // I'm not happy with this, but currentValues can be an empty 344 // array and we need to fallback to the default option. 345 currentValues = currentValues.length === 0 ? [0] : currentValues; 346 347 var labelMarkup = $.map(currentValues, function(value) { 348 return $.grep(_this.lookupItems, function(item) { 349 return item.index === value; 350 })[0]; // we don't want nested arrays here 351 }); 352 353 labelMarkup = $.grep(labelMarkup, function(item) { 354 // Hide default (please choose) if more then one element were selected. 355 // If no option value were given value is set to option text by default 356 if ( labelMarkup.length > 1 || labelMarkup.length === 0 ) { 357 return $.trim(item.value) !== ''; 358 } 359 return item; 360 }); 361 362 labelMarkup = $.map(labelMarkup, function(item) { 363 return $.isFunction(labelBuilder) 364 ? labelBuilder(item) 365 : _this.utils.format(labelBuilder, item); 366 }); 367 368 // Limit the amount of selected values shown in label 369 if ( _this.options.multiple.maxLabelEntries ) { 370 if ( labelMarkup.length >= _this.options.multiple.maxLabelEntries + 1 ) { 371 labelMarkup = labelMarkup.slice(0, _this.options.multiple.maxLabelEntries); 372 labelMarkup.push( 373 $.isFunction(labelBuilder) 374 ? labelBuilder({ text: '...' }) 375 : _this.utils.format(labelBuilder, { text: '...' })); 376 } else { 377 labelMarkup.slice(labelMarkup.length - 1); 378 } 379 } 380 _this.elements.label.html(labelMarkup.join(_this.options.multiple.separator)); 381 382 } else { 383 var currItem = _this.lookupItems[_this.state.currValue]; 384 385 _this.elements.label.html( 386 $.isFunction(labelBuilder) 387 ? labelBuilder(currItem) 388 : _this.utils.format(labelBuilder, currItem) 389 ); 390 } 391 }, 392 393 /** Get and save the available options */ 394 populate: function() { 395 var _this = this; 396 var $options = _this.$element.children(); 397 var $justOptions = _this.$element.find('option'); 398 var $selected = $justOptions.filter(':selected'); 399 var selectedIndex = $justOptions.index($selected); 400 var currIndex = 0; 401 var emptyValue = (_this.state.multiple ? [] : 0); 402 403 if ( $selected.length > 1 && _this.state.multiple ) { 404 selectedIndex = []; 405 $selected.each(function() { 406 selectedIndex.push($(this).index()); 407 }); 408 } 409 410 _this.state.currValue = (~selectedIndex ? selectedIndex : emptyValue); 411 _this.state.selectedIdx = _this.state.currValue; 412 _this.state.highlightedIdx = _this.state.currValue; 413 _this.items = []; 414 _this.lookupItems = []; 415 416 if ( $options.length ) { 417 // Build options markup 418 $options.each(function(i) { 419 var $elm = $(this); 420 421 if ( $elm.is('optgroup') ) { 422 423 var optionsGroup = { 424 element : $elm, 425 label : $elm.prop('label'), 426 groupDisabled : $elm.prop('disabled'), 427 items : [] 428 }; 429 430 $elm.children().each(function(i) { 431 var $elm = $(this); 432 433 optionsGroup.items[i] = _this.getItemData(currIndex, $elm, optionsGroup.groupDisabled || $elm.prop('disabled')); 434 435 _this.lookupItems[currIndex] = optionsGroup.items[i]; 436 437 currIndex++; 438 }); 439 440 _this.items[i] = optionsGroup; 441 442 } else { 443 444 _this.items[i] = _this.getItemData(currIndex, $elm, $elm.prop('disabled')); 445 446 _this.lookupItems[currIndex] = _this.items[i]; 447 448 currIndex++; 449 450 } 451 }); 452 453 _this.setLabel(); 454 _this.elements.items.append( _this.elements.itemsScroll.html( _this.getItemsMarkup(_this.items) ) ); 455 } 456 }, 457 458 /** 459 * Generate items object data 460 * @param {integer} index - Current item index 461 * @param {node} $elm - Current element node 462 * @param {boolean} isDisabled - Current element disabled state 463 * @return {object} Item object 464 */ 465 getItemData: function(index, $elm, isDisabled) { 466 var _this = this; 467 468 return { 469 index : index, 470 element : $elm, 471 value : $elm.val(), 472 className : $elm.prop('class'), 473 text : $elm.html(), 474 slug : $.trim(_this.utils.replaceDiacritics($elm.html())), 475 selected : $elm.prop('selected'), 476 disabled : isDisabled 477 }; 478 }, 479 480 /** 481 * Generate options markup 482 * 483 * @param {object} items - Object containing all available options 484 * @return {string} HTML for the options box 485 */ 486 getItemsMarkup: function(items) { 487 var _this = this; 488 var markup = '<ul>'; 489 490 if ( $.isFunction(_this.options.listBuilder) && _this.options.listBuilder ) { 491 items = _this.options.listBuilder(items); 492 } 493 494 $.each(items, function(i, elm) { 495 if ( elm.label !== undefined ) { 496 497 markup += _this.utils.format('<ul class="{1}"><li class="{2}">{3}</li>', 498 _this.utils.arrayToClassname([ 499 _this.classes.group, 500 elm.groupDisabled ? 'disabled' : '', 501 elm.element.prop('class') 502 ]), 503 _this.classes.grouplabel, 504 elm.element.prop('label') 505 ); 506 507 $.each(elm.items, function(i, elm) { 508 markup += _this.getItemMarkup(elm.index, elm); 509 }); 510 511 markup += '</ul>'; 512 513 } else { 514 515 markup += _this.getItemMarkup(elm.index, elm); 516 517 } 518 }); 519 520 return markup + '</ul>'; 521 }, 522 523 /** 524 * Generate every option markup 525 * 526 * @param {number} index - Index of current item 527 * @param {object} itemData - Current item 528 * @return {string} HTML for the option 529 */ 530 getItemMarkup: function(index, itemData) { 531 var _this = this; 532 var itemBuilder = _this.options.optionsItemBuilder; 533 // limit access to item data to provide a simple interface 534 // to most relevant options. 535 var filteredItemData = { 536 value: itemData.value, 537 text : itemData.text, 538 slug : itemData.slug, 539 index: itemData.index 540 }; 541 542 return _this.utils.format('<li data-index="{1}" class="{2}">{3}</li>', 543 index, 544 _this.utils.arrayToClassname([ 545 itemData.className, 546 index === _this.items.length - 1 ? 'last' : '', 547 itemData.disabled ? 'disabled' : '', 548 itemData.selected ? 'selected' : '' 549 ]), 550 $.isFunction(itemBuilder) 551 ? _this.utils.format(itemBuilder(itemData), itemData) 552 : _this.utils.format(itemBuilder, filteredItemData) 553 ); 554 }, 555 556 /** Remove events on the elements */ 557 unbindEvents: function() { 558 var _this = this; 559 560 _this.elements.wrapper 561 .add(_this.$element) 562 .add(_this.elements.outerWrapper) 563 .add(_this.elements.input) 564 .off(eventNamespaceSuffix); 565 }, 566 567 /** Bind events on the elements */ 568 bindEvents: function() { 569 var _this = this; 570 571 _this.elements.outerWrapper.on('mouseenter' + eventNamespaceSuffix + ' mouseleave' + eventNamespaceSuffix, function(e) { 572 $(this).toggleClass(_this.classes.hover, e.type === 'mouseenter'); 573 574 // Delay close effect when openOnHover is true 575 if ( _this.options.openOnHover ) { 576 clearTimeout(_this.closeTimer); 577 578 if ( e.type === 'mouseleave' ) { 579 _this.closeTimer = setTimeout($.proxy(_this.close, _this), _this.options.hoverIntentTimeout); 580 } else { 581 _this.open(); 582 } 583 } 584 }); 585 586 // Toggle open/close
587 _this.elements.wrapper.on('click' + eventNamespaceSuffix, function(e) { 588 _this.state.opened ? _this.close() : _this.open(e); 589 }); 590 591 // Translate original element focus event to dummy input. 592 // Disabled on mobile devices because the default option list isn't 593 // shown due the fact that hidden input gets focused 594 if ( !(_this.options.nativeOnMobile && _this.utils.isMobile()) ) { 595 _this.$element.on('focus' + eventNamespaceSuffix, function() { 596 _this.elements.input.focus(); 597 }); 598 599 _this.elements.input 600 .prop({ tabindex: _this.originalTabindex, disabled: false }) 601 .on('keydown' + eventNamespaceSuffix, $.proxy(_this.handleKeys, _this)) 602 .on('focusin' + eventNamespaceSuffix, function(e) { 603 _this.elements.outerWrapper.addClass(_this.classes.focus); 604 605 // Prevent the flicker when focusing out and back again in the browser window 606 _this.elements.input.one('blur', function() { 607 _this.elements.input.blur(); 608 }); 609 610 if ( _this.options.openOnFocus && !_this.state.opened ) { 611 _this.open(e); 612 } 613 }) 614 .on('focusout' + eventNamespaceSuffix, function() { 615 _this.elements.outerWrapper.removeClass(_this.classes.focus); 616 }) 617 .on('input propertychange', function() { 618 var val = _this.elements.input.val(); 619 var searchRegExp = new RegExp('^' + _this.utils.escapeRegExp(val), 'i'); 620 621 // Clear search 622 clearTimeout(_this.resetStr); 623 _this.resetStr = setTimeout(function() { 624 _this.elements.input.val(''); 625 }, _this.options.keySearchTimeout); 626 627 if ( val.length ) { 628 // Search in select options 629 $.each(_this.items, function(i, elm) { 630 if ( !elm.disabled && searchRegExp.test(elm.text) || searchRegExp.test(elm.slug) ) { 631 _this.highlight(i); 632 return; 633 } 634 }); 635 } 636 }); 637 } 638 639 _this.$li.on({ 640 // Prevent <input> blur on Chrome 641 mousedown: function(e) { 642 e.preventDefault(); 643 e.stopPropagation(); 644 }, 645 click: function() { 646 _this.select($(this).data('index')); 647 648 // Chrome doesn't close options box if select is wrapped with a label 649 // We need to 'return false' to avoid that 650 return false; 651 } 652 }); 653 }, 654 655 /** 656 * Behavior when keyboard keys is pressed 657 * 658 * @param {object} e - Event object 659 */ 660 handleKeys: function(e) { 661 var _this = this; 662 var key = e.keyCode || e.which; 663 var keys = _this.options.keys; 664 665 var isPrevKey = $.inArray(key, keys.previous) > -1; 666 var isNextKey = $.inArray(key, keys.next) > -1; 667 var isSelectKey = $.inArray(key, keys.select) > -1; 668 var isOpenKey = $.inArray(key, keys.open) > -1; 669 var idx = _this.state.highlightedIdx; 670 var isFirstOrLastItem = (isPrevKey && idx === 0) || (isNextKey && (idx + 1) === _this.items.length); 671 var goToItem = 0; 672 673 // Enter / Space 674 if ( key === 13 || key === 32 ) { 675 e.preventDefault(); 676 } 677 678 // If it's a directional key 679 if ( isPrevKey || isNextKey ) { 680 if ( !_this.options.allowWrap && isFirstOrLastItem ) { 681 return; 682 } 683 684 if ( isPrevKey ) { 685 goToItem = _this.utils.previousEnabledItem(_this.lookupItems, idx); 686 } 687 688 if ( isNextKey ) { 689 goToItem = _this.utils.nextEnabledItem(_this.lookupItems, idx); 690 } 691 692 _this.highlight(goToItem); 693 } 694 695 // Tab / Enter / ESC 696 if ( isSelectKey && _this.state.opened ) { 697 _this.select(idx); 698 699 if ( !_this.state.multiple || !_this.options.multiple.keepMenuOpen ) { 700 _this.close(); 701 } 702 703 return; 704 } 705 706 // Space / Enter / Left / Up / Right / Down 707 if ( isOpenKey && !_this.state.opened ) { 708 _this.open(); 709 } 710 }, 711 712 /** Update the items object */ 713 refresh: function() { 714 var _this = this; 715 716 _this.populate(); 717 _this.activate(); 718 _this.utils.triggerCallback('Refresh', _this); 719 }, 720 721 /** Set options box width/height */ 722 setOptionsDimensions: function() { 723 var _this = this; 724 725 // Calculate options box height 726 // Set a temporary class on the hidden parent of the element 727 var hiddenChildren = _this.elements.items.closest(':visible').children(':hidden').addClass(_this.classes.tempshow); 728 var maxHeight = _this.options.maxHeight; 729 var itemsWidth = _this.elements.items.outerWidth();
730 var wrapperWidth = _this.elements.wrapper.outerWidth() - (itemsWidth - _this.elements.items.width()); 731 732 // Set the dimensions, minimum is wrapper width, expand for long items if option is true 733 if ( !_this.options.expandToItemText || wrapperWidth > itemsWidth ) { 734 _this.finalWidth = wrapperWidth; 735 } else { 736 // Make sure the scrollbar width is included 737 _this.elements.items.css('overflow', 'scroll'); 738 739 // Set a really long width for _this.elements.outerWrapper 740 _this.elements.outerWrapper.width(9e4); 741 _this.finalWidth = _this.elements.items.width(); 742 // Set scroll bar to auto 743 _this.elements.items.css('overflow', ''); 744 _this.elements.outerWrapper.width(''); 745 } 746 747 _this.elements.items.width(_this.finalWidth).height() > maxHeight && _this.elements.items.height(maxHeight); 748 749 // Remove the temporary class 750 hiddenChildren.removeClass(_this.classes.tempshow); 751 }, 752 753 /** Detect if the options box is inside the window */ 754 isInViewport: function() { 755 var _this = this; 756 var scrollTop = $win.scrollTop(); 757 var winHeight = $win.height(); 758 var uiPosX = _this.elements.outerWrapper.offset().top; 759 var uiHeight = _this.elements.outerWrapper.outerHeight(); 760 761 var fitsDown = (uiPosX + uiHeight + _this.itemsHeight) <= (scrollTop + winHeight); 762 var fitsAbove = (uiPosX - _this.itemsHeight) > scrollTop; 763 764 // If it does not fit below, only render it 765 // above it fit's there. 766 // It's acceptable that the user needs to 767 // scroll the viewport to see the cut off UI 768 var renderAbove = !fitsDown && fitsAbove; 769 770 _this.elements.outerWrapper.toggleClass(_this.classes.above, renderAbove); 771 }, 772 773 /** 774 * Detect if currently selected option is visible and scroll the options box to show it 775 * 776 * @param {Number|Array} index - Index of the selected items 777 */ 778 detectItemVisibility: function(index) { 779 var _this = this; 780 781 if ( _this.state.multiple ) { 782 // If index is an array, we can assume a multiple select and we 783 // want to scroll to the uppermost selected item! 784 // Math.min.apply(Math, index) returns the lowest entry in an Array. 785 index = ($.isArray(index) && index.length === 0) ? 0 : index; 786 index = $.isArray(index) ? Math.min.apply(Math, index) : index; 787 } 788 789 var liHeight = _this.$li.eq(index).outerHeight(); 790 var liTop = _this.$li[index].offsetTop; 791 var itemsScrollTop = _this.elements.itemsScroll.scrollTop(); 792 var scrollT = liTop + liHeight * 2; 793 794 _this.elements.itemsScroll.scrollTop( 795 scrollT > itemsScrollTop + _this.itemsHeight ? scrollT - _this.itemsHeight : 796 liTop - liHeight < itemsScrollTop ? liTop - liHeight : 797 itemsScrollTop 798 ); 799 }, 800 801 /** 802 * Open the select options box 803 * 804 * @param {Event} e - Event 805 */ 806 open: function(e) { 807 var _this = this; 808 809 if ( _this.options.nativeOnMobile && _this.utils.isMobile()) { 810 return false; 811 } 812 813 _this.utils.triggerCallback('BeforeOpen', _this); 814 815 if ( e ) { 816 e.preventDefault(); 817 if (_this.options.stopPropagation) { 818 e.stopPropagation(); 819 } 820 } 821 822 if ( _this.state.enabled ) { 823 _this.setOptionsDimensions(); 824 825 // Find any other opened instances of select and close it 826 $('.' + _this.classes.hideselect, '.' + _this.classes.open).children()[pluginName]('close'); 827 828 _this.state.opened = true; 829 _this.itemsHeight = _this.elements.items.outerHeight(); 830 _this.itemsInnerHeight = _this.elements.items.height(); 831 832 // Toggle options box visibility 833 _this.elements.outerWrapper.addClass(_this.classes.open); 834 835 // Give dummy input focus 836 _this.elements.input.val(''); 837 if ( e && e.type !== 'focusin' ) { 838 _this.elements.input.focus(); 839 } 840 841 // Delayed binds events on Document to make label clicks work 842 setTimeout(function() { 843 $doc
844 .on('click' + eventNamespaceSuffix, $.proxy(_this.close, _this)) 845 .on('scroll' + eventNamespaceSuffix, $.proxy(_this.isInViewport, _this)); 846 }, 1); 847 848 _this.isInViewport(); 849 850 // Prevent window scroll when using mouse wheel inside items box 851 if ( _this.options.preventWindowScroll ) { 852 /* istanbul ignore next */ 853 $doc.on('mousewheel' + eventNamespaceSuffix + ' DOMMouseScroll' + eventNamespaceSuffix, '.' + _this.classes.scroll, function(e) { 854 var orgEvent = e.originalEvent; 855 var scrollTop = $(this).scrollTop(); 856 var deltaY = 0; 857 858 if ( 'detail' in orgEvent ) { deltaY = orgEvent.detail * -1; } 859 if ( 'wheelDelta' in orgEvent ) { deltaY = orgEvent.wheelDelta; } 860 if ( 'wheelDeltaY' in orgEvent ) { deltaY = orgEvent.wheelDeltaY; } 861 if ( 'deltaY' in orgEvent ) { deltaY = orgEvent.deltaY * -1; } 862 863 if ( scrollTop === (this.scrollHeight - _this.itemsInnerHeight) && deltaY < 0 || scrollTop === 0 && deltaY > 0 ) { 864 e.preventDefault(); 865 } 866 }); 867 } 868 869 _this.detectItemVisibility(_this.state.selectedIdx); 870 871 _this.highlight(_this.state.multiple ? -1 : _this.state.selectedIdx); 872 873 _this.utils.triggerCallback('Open', _this); 874 } 875 }, 876 877 /** Close the select options box */ 878 close: function() { 879 var _this = this; 880 881 _this.utils.triggerCallback('BeforeClose', _this); 882 883 // Remove custom events on document 884 $doc.off(eventNamespaceSuffix); 885 886 // Remove visible class to hide options box 887 _this.elements.outerWrapper.removeClass(_this.classes.open); 888 889 _this.state.opened = false; 890 891 _this.utils.triggerCallback('Close', _this); 892 }, 893 894 /** Select current option and change the label */ 895 change: function() { 896 var _this = this; 897 898 _this.utils.triggerCallback('BeforeChange', _this); 899 900 if ( _this.state.multiple ) { 901 // Reset old selected 902 $.each(_this.lookupItems, function(idx) { 903 _this.lookupItems[idx].selected = false; 904 _this.$element.find('option').prop('selected', false); 905 }); 906 907 // Set new selected 908 $.each(_this.state.selectedIdx, function(idx, value) { 909 _this.lookupItems[value].selected = true; 910 _this.$element.find('option').eq(value).prop('selected', true); 911 }); 912 913 _this.state.currValue = _this.state.selectedIdx; 914 915 _this.setLabel(); 916 917 _this.utils.triggerCallback('Change', _this); 918 } else if ( _this.state.currValue !== _this.state.selectedIdx ) { 919 // Apply changed value to original select 920 _this.$element 921 .prop('selectedIndex', _this.state.currValue = _this.state.selectedIdx) 922 .data('value', _this.lookupItems[_this.state.selectedIdx].text); 923 924 // Change label text 925 _this.setLabel(); 926 927 _this.utils.triggerCallback('Change', _this); 928 } 929 }, 930 931 /** 932 * Highlight option 933 * @param {number} index - Index of the options that will be highlighted 934 */ 935 highlight: function(index) { 936 var _this = this; 937 var $filteredLi = _this.$li.filter('[data-index]').removeClass('highlighted'); 938 939 _this.utils.triggerCallback('BeforeHighlight', _this); 940 941 // Parameter index is required and should not be a disabled item 942 if ( index === undefined || index === -1 || _this.lookupItems[index].disabled ) { 943 return; 944 } 945 946 $filteredLi 947 .eq(_this.state.highlightedIdx = index) 948 .addClass('highlighted'); 949 950 _this.detectItemVisibility(index); 951 952 _this.utils.triggerCallback('Highlight', _this); 953 }, 954 955 /** 956 * Select option 957 * 958 * @param {number} index - Index of the option that will be selected 959 */ 960 select: function(index) { 961 var _this = this; 962 var $filteredLi = _this.$li.filter('[data-index]'); 963 964 _this.utils.triggerCallback('BeforeSelect', _this, index); 965 966 // Parameter index is required and should not be a disabled item 967 if ( index === undefined || index === -1 || _this.lookupItems[index].disabled ) { 968 return; 969 } 970 971 if ( _this.state.multiple ) { 972 // Make sure selectedIdx is an array 973 _this.state.selectedIdx = $.isArray(_this.state.selectedIdx) ? _this.state.selectedIdx : [_this.state.selectedIdx]; 974 975 var hasSelectedIndex = $.inArray(index, _this.state.selectedIdx); 976 if ( hasSelectedIndex !== -1 ) { 977 _this.state.selectedIdx.splice(hasSelectedIndex, 1); 978 } else { 979 _this.state.selectedIdx.push(index); 980 } 981 982 $filteredLi 983 .removeClass('selected') 984 .filter(function(index) { 985 return $.inArray(index, _this.state.selectedIdx) !== -1; 986 }) 987 .addClass('selected'); 988 } else { 989 $filteredLi 990 .removeClass('selected') 991 .eq(_this.state.selectedIdx = index) 992 .addClass('selected'); 993 } 994 995 if ( !_this.state.multiple || !_this.options.multiple.keepMenuOpen ) { 996 _this.close(); 997 } 998 999 _this.change(); 1000 1001 _this.utils.triggerCallback('Select', _this, index); 1002 }, 1003 1004 /** 1005 * Unbind and remove 1006 * 1007 * @param {boolean} preserveData - Check if the data on the element should be removed too 1008 */ 1009 destroy: function(preserveData) { 1010 var _this = this; 1011 1012 if ( _this.state && _this.state.enabled ) { 1013 _this.elements.items.add(_this.elements.wrapper).add(_this.elements.input).remove(); 1014 1015 if ( !preserveData ) { 1016 _this.$element.removeData(pluginName).removeData('value'); 1017 } 1018 1019 _this.$element.prop('tabindex', _this.originalTabindex).off(eventNamespaceSuffix).off(_this.eventTriggers).unwrap().unwrap(); 1020 1021 _this.state.enabled = false; 1022 } 1023 } 1024 }; 1025 1026 // A really lightweight plugin wrapper around the constructor, 1027 // preventing against multiple instantiations 1028 $.fn[pluginName] = function(args) { 1029 return this.each(function() { 1030 var data = $.data(this, pluginName); 1031 1032 if ( data && !data.disableOnMobile ) { 1033 (typeof args === 'string' && data[args]) ? data[args]() : data.init(args); 1034 } else { 1035 $.data(this, pluginName, new Selectric(this, args)); 1036 } 1037 });
1038 }; 1039 1040 /** 1041 * Default plugin options 1042 * 1043 * @type {object} 1044 */ 1045 $.fn[pluginName].defaults = { 1046 onChange : function(elm) { $(elm).change(); }, 1047 maxHeight : 300, 1048 keySearchTimeout : 500, 1049 arrowButtonMarkup : '<b class="button">▾</b>', 1050 disableOnMobile : false, 1051 nativeOnMobile : true, 1052 openOnFocus : true, 1053 openOnHover : false, 1054 hoverIntentTimeout : 500, 1055 expandToItemText : false, 1056 responsive : false, 1057 preventWindowScroll : true, 1058 inheritOriginalWidth : false, 1059 allowWrap : true, 1060 stopPropagation : true, 1061 optionsItemBuilder : '{text}', // function(itemData, element, index) 1062 labelBuilder : '{text}', // function(currItem) 1063 listBuilder : false, // function(items) 1064 keys : { 1065 previous : [37, 38], // Left / Up 1066 next : [39, 40], // Right / Down 1067 select : [9, 13, 27], // Tab / Enter / Escape 1068 open : [13, 32, 37, 38, 39, 40], // Enter / Space / Left / Up / Right / Down 1069 close : [9, 27] // Tab / Escape 1070 }, 1071 customClass : { 1072 prefix: pluginName, 1073 camelCase: false 1074 }, 1075 multiple : { 1076 separator: ', ', 1077 keepMenuOpen: true, 1078 maxLabelEntries: false 1079 } 1080 }; 1081}));
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.