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 Below 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-z0-9])([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', -1); 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 alt : $elm.attr('data-alt'), 476 selected : $elm.prop('selected'), 477 disabled : isDisabled 478 }; 479 }, 480 481 /** 482 * Generate options markup 483 * 484 * @param {object} items - Object containing all available options 485 * @return {string} HTML for the options box 486 */ 487 getItemsMarkup: function(items) { 488 var _this = this; 489 var markup = '<ul>'; 490 491 if ( $.isFunction(_this.options.listBuilder) && _this.options.listBuilder ) { 492 items = _this.options.listBuilder(items); 493 } 494 495 $.each(items, function(i, elm) { 496 if ( elm.label !== undefined ) { 497 498 markup += _this.utils.format('<ul class="{1}"><li class="{2}">{3}</li>', 499 _this.utils.arrayToClassname([ 500 _this.classes.group, 501 elm.groupDisabled ? 'disabled' : '', 502 elm.element.prop('class') 503 ]), 504 _this.classes.grouplabel, 505 elm.element.prop('label') 506 ); 507 508 $.each(elm.items, function(i, elm) { 509 markup += _this.getItemMarkup(elm.index, elm); 510 }); 511 512 markup += '</ul>'; 513 514 } else { 515 516 markup += _this.getItemMarkup(elm.index, elm); 517 518 } 519 }); 520 521 return markup + '</ul>'; 522 }, 523 524 /** 525 * Generate every option markup 526 * 527 * @param {number} index - Index of current item 528 * @param {object} itemData - Current item 529 * @return {string} HTML for the option 530 */ 531 getItemMarkup: function(index, itemData) { 532 var _this = this; 533 var itemBuilder = _this.options.optionsItemBuilder; 534 // limit access to item data to provide a simple interface 535 // to most relevant options. 536 var filteredItemData = { 537 value: itemData.value, 538 text : itemData.text, 539 slug : itemData.slug, 540 index: itemData.index 541 }; 542 543 return _this.utils.format('<li data-index="{1}" class="{2}">{3}</li>', 544 index, 545 _this.utils.arrayToClassname([ 546 itemData.className, 547 index === _this.items.length - 1 ? 'last' : '', 548 itemData.disabled ? 'disabled' : '', 549 itemData.selected ? 'selected' : '' 550 ]), 551 $.isFunction(itemBuilder) 552 ? _this.utils.format(itemBuilder(itemData, this.$element, index), itemData) 553 : _this.utils.format(itemBuilder, filteredItemData) 554 ); 555 }, 556 557 /** Remove events on the elements */ 558 unbindEvents: function() { 559 var _this = this; 560 561 _this.elements.wrapper 562 .add(_this.$element) 563 .add(_this.elements.outerWrapper) 564 .add(_this.elements.input) 565 .off(eventNamespaceSuffix); 566 }, 567 568 /** Bind events on the elements */ 569 bindEvents: function() { 570 var _this = this; 571 572 _this.elements.outerWrapper.on('mouseenter' + eventNamespaceSuffix + ' mouseleave' + eventNamespaceSuffix, function(e) { 573 $(this).toggleClass(_this.classes.hover, e.type === 'mouseenter'); 574 575 // Delay close effect when openOnHover is true 576 if ( _this.options.openOnHover ) { 577 clearTimeout(_this.closeTimer); 578 579 if ( e.type === 'mouseleave' ) { 580 _this.closeTimer = setTimeout($.proxy(_this.close, _this), _this.options.hoverIntentTimeout); 581 } else { 582 _this.open(); 583 } 584 } 585 }); 586 587 // Toggle open/close
588 _this.elements.wrapper.on('click' + eventNamespaceSuffix, function(e) { 589 _this.state.opened ? _this.close() : _this.open(e); 590 }); 591 592 // Translate original element focus event to dummy input. 593 // Disabled on mobile devices because the default option list isn't 594 // shown due the fact that hidden input gets focused 595 if ( !(_this.options.nativeOnMobile && _this.utils.isMobile()) ) { 596 _this.$element.on('focus' + eventNamespaceSuffix, function() { 597 _this.elements.input.focus(); 598 }); 599 600 _this.elements.input 601 .prop({ tabindex: _this.originalTabindex, disabled: false }) 602 .on('keydown' + eventNamespaceSuffix, $.proxy(_this.handleKeys, _this)) 603 .on('focusin' + eventNamespaceSuffix, function(e) { 604 _this.elements.outerWrapper.addClass(_this.classes.focus); 605 606 // Prevent the flicker when focusing out and back again in the browser window 607 _this.elements.input.one('blur', function() { 608 _this.elements.input.blur(); 609 }); 610 611 if ( _this.options.openOnFocus && !_this.state.opened ) { 612 _this.open(e); 613 } 614 }) 615 .on('focusout' + eventNamespaceSuffix, function() { 616 _this.elements.outerWrapper.removeClass(_this.classes.focus); 617 }) 618 .on('input propertychange', function() { 619 var val = _this.elements.input.val(); 620 var searchRegExp = new RegExp('^' + _this.utils.escapeRegExp(val), 'i'); 621 622 // Clear search 623 clearTimeout(_this.resetStr); 624 _this.resetStr = setTimeout(function() { 625 _this.elements.input.val(''); 626 }, _this.options.keySearchTimeout); 627 628 if ( val.length ) { 629 // Search in select options 630 $.each(_this.items, function(i, elm) { 631 if (elm.disabled) { 632 return; 633 } 634 if (searchRegExp.test(elm.text) || searchRegExp.test(elm.slug)) { 635 _this.highlight(i); 636 return false; 637 } 638 if (!elm.alt) { 639 return; 640 } 641 var altItems = elm.alt.split('|'); 642 for (var ai = 0; ai < altItems.length; ai++) { 643 if (!altItems[ai]) { 644 break; 645 } 646 if (searchRegExp.test(altItems[ai].trim())) { 647 _this.highlight(i); 648 return false; 649 } 650 } 651 }); 652 } 653 }); 654 } 655 656 _this.$li.on({ 657 // Prevent <input> blur on Chrome 658 mousedown: function(e) { 659 e.preventDefault(); 660 e.stopPropagation(); 661 }, 662 click: function() { 663 _this.select($(this).data('index')); 664 665 // Chrome doesn't close options box if select is wrapped with a label 666 // We need to 'return false' to avoid that 667 return false; 668 } 669 }); 670 }, 671 672 /** 673 * Behavior when keyboard keys is pressed 674 * 675 * @param {object} e - Event object 676 */ 677 handleKeys: function(e) { 678 var _this = this; 679 var key = e.which; 680 var keys = _this.options.keys; 681 682 var isPrevKey = $.inArray(key, keys.previous) > -1; 683 var isNextKey = $.inArray(key, keys.next) > -1; 684 var isSelectKey = $.inArray(key, keys.select) > -1; 685 var isOpenKey = $.inArray(key, keys.open) > -1; 686 var idx = _this.state.highlightedIdx; 687 var isFirstOrLastItem = (isPrevKey && idx === 0) || (isNextKey && (idx + 1) === _this.items.length); 688 var goToItem = 0; 689 690 // Enter / Space 691 if ( key === 13 || key === 32 ) { 692 e.preventDefault(); 693 } 694 695 // If it's a directional key 696 if ( isPrevKey || isNextKey ) { 697 if ( !_this.options.allowWrap && isFirstOrLastItem ) { 698 return; 699 } 700 701 if ( isPrevKey ) { 702 goToItem = _this.utils.previousEnabledItem(_this.lookupItems, idx); 703 } 704 705 if ( isNextKey ) { 706 goToItem = _this.utils.nextEnabledItem(_this.lookupItems, idx); 707 } 708 709 _this.highlight(goToItem); 710 } 711 712 // Tab / Enter / ESC 713 if ( isSelectKey && _this.state.opened ) { 714 _this.select(idx); 715 716 if ( !_this.state.multiple || !_this.options.multiple.keepMenuOpen ) { 717 _this.close(); 718 } 719 720 return; 721 } 722 723 // Space / Enter / Left / Up / Right / Down 724 if ( isOpenKey && !_this.state.opened ) { 725 _this.open(); 726 } 727 }, 728 729 /** Update the items object */ 730 refresh: function() { 731 var _this = this; 732 733 _this.populate(); 734 _this.activate(); 735 _this.utils.triggerCallback('Refresh', _this); 736 }, 737 738 /** Set options box width/height */ 739 setOptionsDimensions: function() { 740 var _this = this; 741 742 // Calculate options box height 743 // Set a temporary class on the hidden parent of the element 744 var hiddenChildren = _this.elements.items.closest(':visible').children(':hidden').addClass(_this.classes.tempshow); 745 var maxHeight = _this.options.maxHeight; 746 var itemsWidth = _this.elements.items.outerWidth();
747 var wrapperWidth = _this.elements.wrapper.outerWidth() - (itemsWidth - _this.elements.items.width()); 748 749 // Set the dimensions, minimum is wrapper width, expand for long items if option is true 750 if ( !_this.options.expandToItemText || wrapperWidth > itemsWidth ) { 751 _this.finalWidth = wrapperWidth; 752 } else { 753 // Make sure the scrollbar width is included 754 _this.elements.items.css('overflow', 'scroll'); 755 756 // Set a really long width for _this.elements.outerWrapper 757 _this.elements.outerWrapper.width(9e4); 758 _this.finalWidth = _this.elements.items.width(); 759 // Set scroll bar to auto 760 _this.elements.items.css('overflow', ''); 761 _this.elements.outerWrapper.width(''); 762 } 763 764 _this.elements.items.width(_this.finalWidth).height() > maxHeight && _this.elements.items.height(maxHeight); 765 766 // Remove the temporary class 767 hiddenChildren.removeClass(_this.classes.tempshow); 768 }, 769 770 /** Detect if the options box is inside the window */ 771 isInViewport: function() { 772 var _this = this; 773 774 if (_this.options.forceRenderAbove === true) { 775 _this.elements.outerWrapper.addClass(_this.classes.above); 776 } else if (_this.options.forceRenderBelow === true) { 777 _this.elements.outerWrapper.addClass(_this.classes.below); 778 } else { 779 var scrollTop = $win.scrollTop(); 780 var winHeight = $win.height(); 781 var uiPosX = _this.elements.outerWrapper.offset().top; 782 var uiHeight = _this.elements.outerWrapper.outerHeight(); 783 784 var fitsDown = (uiPosX + uiHeight + _this.itemsHeight) <= (scrollTop + winHeight); 785 var fitsAbove = (uiPosX - _this.itemsHeight) > scrollTop; 786 787 // If it does not fit below, only render it 788 // above it fit's there. 789 // It's acceptable that the user needs to 790 // scroll the viewport to see the cut off UI 791 var renderAbove = !fitsDown && fitsAbove; 792 var renderBelow = !renderAbove; 793 794 _this.elements.outerWrapper.toggleClass(_this.classes.above, renderAbove); 795 _this.elements.outerWrapper.toggleClass(_this.classes.below, renderBelow); 796 } 797 }, 798 799 /** 800 * Detect if currently selected option is visible and scroll the options box to show it 801 * 802 * @param {Number|Array} index - Index of the selected items 803 */ 804 detectItemVisibility: function(index) { 805 var _this = this; 806 var $filteredLi = _this.$li.filter('[data-index]'); 807 808 if ( _this.state.multiple ) { 809 // If index is an array, we can assume a multiple select and we 810 // want to scroll to the uppermost selected item! 811 // Math.min.apply(Math, index) returns the lowest entry in an Array. 812 index = ($.isArray(index) && index.length === 0) ? 0 : index; 813 index = $.isArray(index) ? Math.min.apply(Math, index) : index; 814 } 815 816 var liHeight = $filteredLi.eq(index).outerHeight(); 817 var liTop = $filteredLi[index].offsetTop; 818 var itemsScrollTop = _this.elements.itemsScroll.scrollTop(); 819 var scrollT = liTop + liHeight * 2; 820 821 _this.elements.itemsScroll.scrollTop( 822 scrollT > itemsScrollTop + _this.itemsHeight ? scrollT - _this.itemsHeight : 823 liTop - liHeight < itemsScrollTop ? liTop - liHeight : 824 itemsScrollTop 825 ); 826 }, 827 828 /** 829 * Open the select options box 830 * 831 * @param {Event} e - Event 832 */ 833 open: function(e) { 834 var _this = this; 835 836 if ( _this.options.nativeOnMobile && _this.utils.isMobile()) { 837 return false; 838 } 839 840 _this.utils.triggerCallback('BeforeOpen', _this); 841 842 if ( e ) { 843 e.preventDefault(); 844 if (_this.options.stopPropagation) { 845 e.stopPropagation(); 846 } 847 } 848 849 if ( _this.state.enabled ) { 850 _this.setOptionsDimensions(); 851 852 // Find any other opened instances of select and close it 853 $('.' + _this.classes.hideselect, '.' + _this.classes.open).children()[pluginName]('close'); 854 855 _this.state.opened = true; 856 _this.itemsHeight = _this.elements.items.outerHeight();
857 _this.itemsInnerHeight = _this.elements.items.height(); 858 859 // Toggle options box visibility 860 _this.elements.outerWrapper.addClass(_this.classes.open); 861 862 // Give dummy input focus 863 _this.elements.input.val(''); 864 if ( e && e.type !== 'focusin' ) { 865 _this.elements.input.focus(); 866 } 867 868 // Delayed binds events on Document to make label clicks work 869 setTimeout(function() { 870 $doc 871 .on('click' + eventNamespaceSuffix, $.proxy(_this.close, _this)) 872 .on('scroll' + eventNamespaceSuffix, $.proxy(_this.isInViewport, _this)); 873 }, 1); 874 875 _this.isInViewport(); 876 877 // Prevent window scroll when using mouse wheel inside items box 878 if ( _this.options.preventWindowScroll ) { 879 /* istanbul ignore next */ 880 $doc.on('mousewheel' + eventNamespaceSuffix + ' DOMMouseScroll' + eventNamespaceSuffix, '.' + _this.classes.scroll, function(e) { 881 var orgEvent = e.originalEvent; 882 var scrollTop = $(this).scrollTop(); 883 var deltaY = 0; 884 885 if ( 'detail' in orgEvent ) { deltaY = orgEvent.detail * -1; } 886 if ( 'wheelDelta' in orgEvent ) { deltaY = orgEvent.wheelDelta; } 887 if ( 'wheelDeltaY' in orgEvent ) { deltaY = orgEvent.wheelDeltaY; } 888 if ( 'deltaY' in orgEvent ) { deltaY = orgEvent.deltaY * -1; } 889 890 if ( scrollTop === (this.scrollHeight - _this.itemsInnerHeight) && deltaY < 0 || scrollTop === 0 && deltaY > 0 ) { 891 e.preventDefault(); 892 } 893 }); 894 } 895 896 _this.detectItemVisibility(_this.state.selectedIdx); 897 898 _this.highlight(_this.state.multiple ? -1 : _this.state.selectedIdx); 899 900 _this.utils.triggerCallback('Open', _this); 901 } 902 }, 903 904 /** Close the select options box */ 905 close: function() { 906 var _this = this; 907 908 _this.utils.triggerCallback('BeforeClose', _this); 909 910 // Remove custom events on document 911 $doc.off(eventNamespaceSuffix); 912 913 // Remove visible class to hide options box 914 _this.elements.outerWrapper.removeClass(_this.classes.open); 915 916 _this.state.opened = false; 917 918 _this.utils.triggerCallback('Close', _this); 919 }, 920 921 /** Select current option and change the label */ 922 change: function() { 923 var _this = this; 924 925 _this.utils.triggerCallback('BeforeChange', _this); 926 927 if ( _this.state.multiple ) { 928 // Reset old selected 929 $.each(_this.lookupItems, function(idx) { 930 _this.lookupItems[idx].selected = false; 931 _this.$element.find('option').prop('selected', false); 932 }); 933 934 // Set new selected 935 $.each(_this.state.selectedIdx, function(idx, value) { 936 _this.lookupItems[value].selected = true; 937 _this.$element.find('option').eq(value).prop('selected', true); 938 }); 939 940 _this.state.currValue = _this.state.selectedIdx; 941 942 _this.setLabel(); 943 944 _this.utils.triggerCallback('Change', _this); 945 } else if ( _this.state.currValue !== _this.state.selectedIdx ) { 946 // Apply changed value to original select 947 _this.$element 948 .prop('selectedIndex', _this.state.currValue = _this.state.selectedIdx) 949 .data('value', _this.lookupItems[_this.state.selectedIdx].text); 950 951 // Change label text 952 _this.setLabel(); 953 954 _this.utils.triggerCallback('Change', _this); 955 } 956 }, 957 958 /** 959 * Highlight option 960 * @param {number} index - Index of the options that will be highlighted 961 */ 962 highlight: function(index) { 963 var _this = this; 964 var $filteredLi = _this.$li.filter('[data-index]').removeClass('highlighted'); 965 966 _this.utils.triggerCallback('BeforeHighlight', _this); 967 968 // Parameter index is required and should not be a disabled item 969 if ( index === undefined || index === -1 || _this.lookupItems[index].disabled ) { 970 return; 971 } 972 973 $filteredLi 974 .eq(_this.state.highlightedIdx = index) 975 .addClass('highlighted'); 976 977 _this.detectItemVisibility(index); 978 979 _this.utils.triggerCallback('Highlight', _this); 980 }, 981 982 /** 983 * Select option 984 * 985 * @param {number} index - Index of the option that will be selected 986 */ 987 select: function(index) { 988 var _this = this; 989 var $filteredLi = _this.$li.filter('[data-index]'); 990 991 _this.utils.triggerCallback('BeforeSelect', _this, index); 992 993 // Parameter index is required and should not be a disabled item 994 if ( index === undefined || index === -1 || _this.lookupItems[index].disabled ) { 995 return; 996 } 997 998 if ( _this.state.multiple ) { 999 // Make sure selectedIdx is an array 1000 _this.state.selectedIdx = $.isArray(_this.state.selectedIdx) ? _this.state.selectedIdx : [_this.state.selectedIdx]; 1001 1002 var hasSelectedIndex = $.inArray(index, _this.state.selectedIdx); 1003 if ( hasSelectedIndex !== -1 ) { 1004 _this.state.selectedIdx.splice(hasSelectedIndex, 1); 1005 } else { 1006 _this.state.selectedIdx.push(index); 1007 } 1008 1009 $filteredLi 1010 .removeClass('selected') 1011 .filter(function(index) { 1012 return $.inArray(index, _this.state.selectedIdx) !== -1; 1013 }) 1014 .addClass('selected'); 1015 } else { 1016 $filteredLi 1017 .removeClass('selected') 1018 .eq(_this.state.selectedIdx = index) 1019 .addClass('selected'); 1020 } 1021 1022 if ( !_this.state.multiple || !_this.options.multiple.keepMenuOpen ) { 1023 _this.close(); 1024 } 1025 1026 _this.change(); 1027 1028 _this.utils.triggerCallback('Select', _this, index); 1029 }, 1030 1031 /** 1032 * Unbind and remove 1033 * 1034 * @param {boolean} preserveData - Check if the data on the element should be removed too 1035 */ 1036 destroy: function(preserveData) { 1037 var _this = this; 1038 1039 if ( _this.state && _this.state.enabled ) { 1040 _this.elements.items.add(_this.elements.wrapper).add(_this.elements.input).remove(); 1041 1042 if ( !preserveData ) { 1043 _this.$element.removeData(pluginName).removeData('value'); 1044 } 1045 1046 _this.$element.prop('tabindex', _this.originalTabindex).off(eventNamespaceSuffix).off(_this.eventTriggers).unwrap().unwrap(); 1047 1048 _this.state.enabled = false; 1049 } 1050 } 1051 }; 1052 1053 // A really lightweight plugin wrapper around the constructor, 1054 // preventing against multiple instantiations 1055 $.fn[pluginName] = function(args) { 1056 return this.each(function() { 1057 var data = $.data(this, pluginName); 1058 1059 if ( data && !data.disableOnMobile ) { 1060 (typeof args === 'string' && data[args]) ? data[args]() : data.init(args); 1061 } else { 1062 $.data(this, pluginName, new Selectric(this, args)); 1063 } 1064 });
1065 }; 1066 1067 /** 1068 * Default plugin options 1069 * 1070 * @type {object} 1071 */ 1072 $.fn[pluginName].defaults = { 1073 onChange : function(elm) { $(elm).change(); }, 1074 maxHeight : 300, 1075 keySearchTimeout : 500, 1076 arrowButtonMarkup : '<b class="button">▾</b>', 1077 disableOnMobile : false, 1078 nativeOnMobile : true, 1079 openOnFocus : true, 1080 openOnHover : false, 1081 hoverIntentTimeout : 500, 1082 expandToItemText : false, 1083 responsive : false, 1084 preventWindowScroll : true, 1085 inheritOriginalWidth : false, 1086 allowWrap : true, 1087 forceRenderAbove : false, 1088 forceRenderBelow : false, 1089 stopPropagation : true, 1090 optionsItemBuilder : '{text}', // function(itemData, element, index) 1091 labelBuilder : '{text}', // function(currItem) 1092 listBuilder : false, // function(items) 1093 keys : { 1094 previous : [37, 38], // Left / Up 1095 next : [39, 40], // Right / Down 1096 select : [9, 13, 27], // Tab / Enter / Escape 1097 open : [13, 32, 37, 38, 39, 40], // Enter / Space / Left / Up / Right / Down 1098 close : [9, 27] // Tab / Escape 1099 }, 1100 customClass : { 1101 prefix: pluginName, 1102 camelCase: false 1103 }, 1104 multiple : { 1105 separator: ', ', 1106 keepMenuOpen: true, 1107 maxLabelEntries: false 1108 } 1109 }; 1110}));
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.