1/* 2 * SOL - Searchable Option List jQuery plugin 3 * Version 2.0.2 4 * https://pbauerochse.github.io/searchable-option-list/ 5 * 6 * Copyright 2015, Patrick Bauerochse 7 * Portions Copyright (c) 2020, Chris Fraire <cfraire@me.com>. 8 * 9 * Licensed under the MIT license: 10 * http://www.opensource.org/licenses/MIT 11 * 12 */ 13 14/* 15 * Original based on SOL v2.0.2 16 * Modified by Krystof Tulinger for OpenGrok in 2016. 17 */ 18 19/*jslint nomen: true */ 20; 21(function ($, window, document) { 22 'use strict'; 23 24 // constructor 25 var SearchableOptionList = function ($element, options) { 26 this.$originalElement = $element; 27 this.options = options; 28 29 // allow setting options as data attribute 30 // e.g. <select data-sol-options="{'allowNullSelection':true}"> 31 this.metadata = this.$originalElement.data('sol-options'); 32 }; 33 34 // plugin prototype 35 SearchableOptionList.prototype = { 36 37 SOL_OPTION_FORMAT: { 38 type: 'option', // fixed 39 value: undefined, // value that will be submitted 40 selected: false, // boolean selected state 41 disabled: false, // boolean disabled state 42 label: undefined, // label string 43 tooltip: undefined, // tooltip string 44 cssClass: '' // custom css class for container 45 }, 46 SOL_OPTIONGROUP_FORMAT: { 47 type: 'optiongroup', // fixed 48 label: undefined, // label string 49 tooltip: undefined, // tooltip string 50 disabled: false, // all children disabled boolean property 51 children: undefined // array of SOL_OPTION_FORMAT objects 52 }, 53 54 DATA_KEY: 'sol-element', 55 WINDOW_EVENTS_KEY: 'sol-window-events', 56 57 // default option values 58 defaults: { 59 data: undefined, 60 name: undefined, // name attribute, can also be set as name="" attribute on original element or data-sol-name="" 61 62 texts: { 63 noItemsAvailable: 'No entries found', 64 selectAll: 'Select all', 65 selectNone: 'Select none', 66 quickDelete: '×', 67 searchplaceholder: 'Click here to search', 68 loadingData: 'Still loading data...', 69 /* 70 * Modified for OpenGrok in 2016. 71 */ 72 itemsSelected: '{$a} more items selected' 73 }, 74 75 events: { 76 onInitialized: undefined, 77 onRendered: undefined, 78 onOpen: undefined, 79 onClose: undefined, 80 onChange: undefined, 81 onScroll: function () { 82 83 var selectionContainerYPos = this.$input.offset().top - this.config.scrollTarget.scrollTop() + this.$input.outerHeight(false), 84 selectionContainerHeight = this.$selectionContainer.outerHeight(false), 85 selectionContainerBottom = selectionContainerYPos + selectionContainerHeight, 86 displayContainerAboveInput = this.config.displayContainerAboveInput || document.documentElement.clientHeight - this.config.scrollTarget.scrollTop() < selectionContainerBottom, 87 selectionContainerWidth = this.$innerContainer.outerWidth(false) - parseInt(this.$selectionContainer.css('border-left-width'), 10) - parseInt(this.$selectionContainer.css('border-right-width'), 10); 88 89 if (displayContainerAboveInput) { 90 // position the popup above the input 91 selectionContainerYPos = this.$input.offset().top - selectionContainerHeight - this.config.scrollTarget.scrollTop() + parseInt(this.$selectionContainer.css('border-bottom-width'), 10); 92 this.$container 93 .removeClass('sol-selection-bottom') 94 .addClass('sol-selection-top'); 95 } else { 96 this.$container 97 .removeClass('sol-selection-top') 98 .addClass('sol-selection-bottom'); 99 } 100 101 if (this.$innerContainer.css('display') !== 'block') { 102 // container has a certain width 103 // make selection container a bit wider 104 selectionContainerWidth = selectionContainerWidth * 1.2; 105 } else { 106 107 var borderRadiusSelector = displayContainerAboveInput ? 'border-bottom-right-radius' : 'border-top-right-radius'; 108 109 // no border radius on top 110 this.$selectionContainer 111 .css(borderRadiusSelector, 'initial'); 112 113 if (this.$actionButtons) { 114 this.$actionButtons 115 .css(borderRadiusSelector, 'initial'); 116 } 117 } 118 119 this.$selectionContainer 120 .css('top', Math.floor(selectionContainerYPos)) 121 .css('left', Math.floor(this.$container.offset().left)) 122 .css('width', selectionContainerWidth); 123 124 // remember the position 125 this.config.displayContainerAboveInput = displayContainerAboveInput; 126 } 127 }, 128 129 selectAllMaxItemsThreshold: 30, 130 showSelectAll: function () { 131 return this.config.multiple && this.config.selectAllMaxItemsThreshold && this.items && this.items.length <= this.config.selectAllMaxItemsThreshold; 132 }, 133 134 useBracketParameters: false, 135 multiple: undefined, 136 /* 137 * Modified for OpenGrok in 2016. 138 */ 139 resultsContainer: undefined, 140 closeOnClick: false, 141 showSelectionBelowList: false, 142 allowNullSelection: false, 143 scrollTarget: undefined, 144 maxHeight: undefined, 145 converter: undefined, 146 asyncBatchSize: 300, 147 searchTimeout: 300, 148 maxShow: 0 149 }, 150 151 // initialize the plugin 152 init: function () { 153 this.numSelected = 0; 154 this.valMap = null; 155 this.config = $.extend(true, {}, this.defaults, this.options, this.metadata); 156 157 var originalName = this._getNameAttribute(), 158 sol = this; 159 160 if (!originalName) { 161 this._showErrorLabel('name attribute is required'); 162 return; 163 } 164 165 // old IE does not support trim 166 if (typeof String.prototype.trim !== 'function') { 167 String.prototype.trim = function () { 168 return this.replace(/^\s+|\s+$/g, ''); 169 } 170 } 171 172 this.config.multiple = this.config.multiple || this.$originalElement.attr('multiple'); 173 174 if (!this.config.scrollTarget) { 175 this.config.scrollTarget = $(window); 176 } 177 178 this._registerWindowEventsIfNecessary(); 179 this._initializeUiElements(); 180 this._initializeInputEvents(); 181 182 setTimeout(function () { 183 sol._initializeData(); 184 185 // take original form element out of form submission 186 // by removing the name attribute 187 sol.$originalElement 188 .data(sol.DATA_KEY, sol) 189 .removeAttr('name') 190 .data('sol-name', originalName); 191 }, 0); 192 193 this.$originalElement.hide(); 194 this.$container 195 .css('visibility', 'initial') 196 .show(); 197 198 return this; 199 }, 200 201 _getNameAttribute: function () { 202 return this.config.name || this.$originalElement.data('sol-name') || this.$originalElement.attr('name'); 203 }, 204 205 // shows an error label 206 _showErrorLabel: function (message) { 207 var $errorMessage = $('<div style="color: red; font-weight: bold;" />').html(message); 208 if (!this.$container) { 209 $errorMessage.insertAfter(this.$originalElement); 210 } else { 211 this.$container.append($errorMessage); 212 } 213 }, 214 215 // register click handler to determine when to trigger the close event 216 _registerWindowEventsIfNecessary: function () { 217 if (!window[this.WINDOW_EVENTS_KEY]) { 218 $(document).click(function (event) { 219 // if clicked inside a sol element close all others 220 // else close all sol containers 221 222 var $clickedElement = $(event.target), 223 $closestSelectionContainer = $clickedElement.closest('.sol-selection-container'), 224 $closestInnerContainer = $clickedElement.closest('.sol-inner-container'), 225 $clickedWithinThisSolContainer; 226 227 if ($closestInnerContainer.length) { 228 $clickedWithinThisSolContainer = $closestInnerContainer.first().parent('.sol-container'); 229 } else if ($closestSelectionContainer.length) { 230 $clickedWithinThisSolContainer = $closestSelectionContainer.first().parent('.sol-container'); 231 } 232 233 $('.sol-active') 234 .not($clickedWithinThisSolContainer) 235 .each(function (index, item) { 236 $(item) 237 .data(SearchableOptionList.prototype.DATA_KEY) 238 .close(); 239 }); 240 }); 241 242 // remember we already registered the global events 243 window[this.WINDOW_EVENTS_KEY] = true; 244 } 245 }, 246 247 // add sol ui elements 248 _initializeUiElements: function () { 249 var self = this; 250 251 this.internalScrollWrapper = function () { 252 if ($.isFunction(self.config.events.onScroll)) { 253 self.config.events.onScroll.call(self); 254 } 255 }; 256 257 this.$input = $('<input type="text"/>') 258 .attr('placeholder', this.config.texts.searchplaceholder); 259 260 this.$noResultsItem = $('<div class="sol-no-results"/>').html(this.config.texts.noItemsAvailable).hide(); 261 this.$loadingData = $('<div class="sol-loading-data"/>').html(this.config.texts.loadingData); 262 this.$xItemsSelected = $('<div class="sol-results-count"/>'); 263 264 this.$caret = $('<div class="sol-caret-container"><b class="sol-caret"/></div>').click(function (e) { 265 self.toggle(); 266 e.preventDefault(); 267 return false; 268 }); 269 270 var $inputContainer = $('<div class="sol-input-container"/>').append(this.$input); 271 this.$innerContainer = $('<div class="sol-inner-container"/>').append($inputContainer).append(this.$caret); 272 this.$selection = $('<div class="sol-selection"/>'); 273 this.$selectionContainer = $('<div class="sol-selection-container"/>') 274 .append(this.$noResultsItem) 275 .append(this.$loadingData) 276 .append(this.$selection); 277 278 this.$container = $('<div class="sol-container"/>') 279 .hide() 280 /* 281 * Modified for OpenGrok in 2016. 282 */ 283 .keydown(function (e) { 284 if (e.keyCode == 13) { 285 var concat = ''; 286 $("#sbox #qtbl input[type='text']").each(function () { 287 concat += $.trim($(this).val()); 288 }); 289 if (e.keyCode == 13 && concat === '') { 290 // follow the project user's typed (may not exist) 291 if(self.$input.val() !== '') { 292 window.location = document.xrefPath + '/' + self.$input.val(); 293 return false; 294 } 295 var $el = $(".keyboard-selection").first().find(".sol-checkbox") 296 // follow the actual project 297 if($el.length && $el.data('sol-item') && 298 $el.data('sol-item').label) { 299 window.location = document.xrefPath + 300 '/' + 301 $el.data('sol-item').label; 302 return false; 303 } 304 // follow first selected project 305 $el = $(".sol-selected-display-item").first() 306 if($el.length && $el.data('label')) { 307 window.location = document.xrefPath + '/' + $el.data('label'); 308 return false; 309 } 310 return false; 311 } 312 return true; 313 } 314 }) 315 .data(this.DATA_KEY, this) 316 .append(this.$selectionContainer) 317 .append(this.$innerContainer) 318 .insertBefore(this.$originalElement); 319 320 // add selected items display container 321 this.$showSelectionContainer = $('<div class="sol-current-selection"/>'); 322 323 /* 324 * Modified for OpenGrok in 2016. 325 */ 326 var $el = this.config.resultsContainer || this.$innerContainer 327 if (this.config.resultsContainer) { 328 this.$showSelectionContainer.appendTo($el); 329 } else { 330 if (this.config.showSelectionBelowList) { 331 this.$showSelectionContainer.insertAfter($el); 332 } else { 333 this.$showSelectionContainer.insertBefore($el); 334 } 335 } 336 337 // dimensions 338 if (this.config.maxHeight) { 339 this.$selection.css('max-height', this.config.maxHeight); 340 } 341 342 // detect inline css classes and styles 343 var cssClassesAsString = this.$originalElement.attr('class'), 344 cssStylesAsString = this.$originalElement.attr('style'), 345 cssClassList = [], 346 stylesList = []; 347 348 if (cssClassesAsString && cssClassesAsString.length > 0) { 349 cssClassList = cssClassesAsString.split(/\s+/); 350 351 // apply css classes to $container 352 for (var i = 0; i < cssClassList.length; i++) { 353 this.$container.addClass(cssClassList[i]); 354 } 355 } 356 357 if (cssStylesAsString && cssStylesAsString.length > 0) { 358 stylesList = cssStylesAsString.split(/\;/); 359 360 // apply css inline styles to $container 361 for (var i = 0; i < stylesList.length; i++) { 362 var splitted = stylesList[i].split(/\s*\:\s*/g); 363 364 if (splitted.length === 2) { 365 366 if (splitted[0].toLowerCase().indexOf('height') >= 0) { 367 // height property, apply to innerContainer instead of outer 368 this.$innerContainer.css(splitted[0].trim(), splitted[1].trim()); 369 } else { 370 this.$container.css(splitted[0].trim(), splitted[1].trim()); 371 } 372 } 373 } 374 } 375 376 if (this.$originalElement.css('display') !== 'block') { 377 this.$container.css('width', this._getActualCssPropertyValue(this.$originalElement, 'width')); 378 } 379 380 if ($.isFunction(this.config.events.onRendered)) { 381 this.config.events.onRendered.call(this, this); 382 } 383 }, 384 385 _getActualCssPropertyValue: function ($element, property) { 386 387 var domElement = $element.get(0), 388 originalDisplayProperty = $element.css('display'); 389 390 // set invisible to get original width setting instead of translated to px 391 // see https://bugzilla.mozilla.org/show_bug.cgi?id=707691#c7 392 $element.css('display', 'none'); 393 394 if (domElement.currentStyle) { 395 return domElement.currentStyle[property]; 396 } else if (window.getComputedStyle) { 397 return document.defaultView.getComputedStyle(domElement, null).getPropertyValue(property); 398 } 399 400 $element.css('display', originalDisplayProperty); 401 402 return $element.css(property); 403 }, 404 405 _initializeInputEvents: function () { 406 // form event 407 var self = this, 408 $form = this.$input.parents('form').first(); 409 410 if ($form && $form.length === 1 && !$form.data(this.WINDOW_EVENTS_KEY)) { 411 var resetFunction = function () { 412 var $changedItems = []; 413 414 $form.find('.sol-option input').each(function (index, item) { 415 var $item = $(item), 416 initialState = $item.data('sol-item').selected; 417 418 if ($item.prop('checked') !== initialState) { 419 $item 420 .prop('checked', initialState) 421 .trigger('sol-change', true); 422 $changedItems.push($item); 423 } 424 }); 425 426 if ($changedItems.length > 0 && $.isFunction(self.config.events.onChange)) { 427 self.config.events.onChange.call(self, self, $changedItems); 428 } 429 }; 430 431 $form.on('reset', function (event) { 432 // unfortunately the reset event gets fired _before_ 433 // the inputs are actually reset. The only possibility 434 // to overcome this is to set an interval to execute 435 // own scripts some time after the actual reset event 436 437 // before fields are actually reset by the browser 438 // needed to reset newly checked fields 439 resetFunction.call(self); 440 441 // timeout for selection after form reset 442 // needed to reset previously checked fields 443 setTimeout(function () { 444 resetFunction.call(self); 445 }, 100); 446 }); 447 448 $form.data(this.WINDOW_EVENTS_KEY, true); 449 } 450 451 // text input events 452 this.$input 453 .focus(function () { 454 self.open(); 455 }) 456 .on('propertychange input', function (e) { 457 var valueChanged = true; 458 if (e.type=='propertychange') { 459 valueChanged = e.originalEvent.propertyName.toLowerCase()=='value'; 460 } 461 if (valueChanged) { 462 if ($(this).data('timeout')) { 463 clearTimeout($(this).data('timeout')); 464 } 465 $(this).data('timeout', setTimeout(function () { 466 self._applySearchTermFilter(); 467 }, self.config.searchTimeout)) 468 469 } 470 }); 471 472 // keyboard navigation 473 this.$container 474 .on('keydown', function (e) { 475 var keyCode = e.keyCode; 476 477 // event handling for keyboard navigation 478 // only when there are results to be shown 479 if (!self.$noResultsItem.is(':visible')) { 480 481 var $currentHighlightedOption, 482 $nextHighlightedOption, 483 directionValue, 484 preventDefault = false, 485 $allVisibleOptions = self.$selection.find('.sol-option:visible'); 486 487 if (keyCode === 40 || keyCode === 38) { 488 // arrow up or down to select an item 489 self._setKeyBoardNavigationMode(true); 490 /* 491 * Modified for OpenGrok in 2016. 492 */ 493 $currentHighlightedOption = self.$selection.find('.sol-option.keyboard-selection') 494 $currentHighlightedOption.find("input[type='checkbox']").blur(); 495 directionValue = (keyCode === 38) ? -1 : 1; // negative for up, positive for down 496 497 var indexOfNextHighlightedOption = $allVisibleOptions.index($currentHighlightedOption) + directionValue; 498 if (indexOfNextHighlightedOption < 0) { 499 indexOfNextHighlightedOption = $allVisibleOptions.length - 1; 500 } else if (indexOfNextHighlightedOption >= $allVisibleOptions.length) { 501 indexOfNextHighlightedOption = 0; 502 } 503 504 $currentHighlightedOption.removeClass('keyboard-selection'); 505 $nextHighlightedOption = $($allVisibleOptions[indexOfNextHighlightedOption]) 506 .addClass('keyboard-selection'); 507 /* 508 * Modified for OpenGrok in 2016. 509 */ 510 $nextHighlightedOption.find("input[type='checkbox']").focus() 511 512 /* 513 * Modified for OpenGrok in 2016. 514 */ 515 //self.$selection.scrollTop(self.$selection.scrollTop() + $nextHighlightedOption.position().top); 516 517 preventDefault = true; 518 } else if (self.keyboardNavigationMode === true && keyCode === 32) { 519 // toggle current selected item with space bar 520 $currentHighlightedOption = self.$selection.find('.sol-option.keyboard-selection input'); 521 $currentHighlightedOption 522 .prop('checked', !$currentHighlightedOption.is(':checked')) 523 .trigger('change'); 524 525 preventDefault = true; 526 } 527 528 if (preventDefault) { 529 // dont trigger any events in the input 530 e.preventDefault(); 531 return false; 532 } 533 } 534 }) 535 .on('keyup', function (e) { 536 var keyCode = e.keyCode; 537 538 if (keyCode === 27) { 539 // escape key 540 if (self.keyboardNavigationMode === true) { 541 self._setKeyBoardNavigationMode(false); 542 } else if (self.$input.val() === '') { 543 // trigger closing of container 544 self.$caret.trigger('click'); 545 self.$input.trigger('blur'); 546 } else { 547 // reset input and result filter 548 self.$input.val('').trigger('input'); 549 } 550 } else if (keyCode === 16 || keyCode === 17 || keyCode === 18 || keyCode === 20) { 551 // special events like shift and control 552 return; 553 } 554 }); 555 }, 556 557 _setKeyBoardNavigationMode: function (keyboardNavigationOn) { 558 559 if (keyboardNavigationOn) { 560 // on 561 this.keyboardNavigationMode = true; 562 this.$selection.addClass('sol-keyboard-navigation'); 563 } else { 564 // off 565 this.keyboardNavigationMode = false; 566 this.$selection.find('.sol-option.keyboard-selection') 567 this.$selection.removeClass('sol-keyboard-navigation'); 568 this.$selectionContainer.find('.sol-option.keyboard-selection').removeClass('keyboard-selection'); 569 this.$selection.scrollTop(0); 570 } 571 }, 572 573 _applySearchTermFilter: function () { 574 if (!this.items || this.items.length === 0) { 575 return; 576 } 577 578 var searchTerm = this.$input.val(), 579 lowerCased = (searchTerm || '').toLowerCase(); 580 581 // show previously filtered elements again 582 this.$selectionContainer.find('.sol-filtered-search').removeClass('sol-filtered-search'); 583 this._setNoResultsItemVisible(false); 584 585 if (lowerCased.trim().length > 0) { 586 this._findTerms(this.items, lowerCased); 587 } 588 589 // call onScroll to position the popup again 590 // important if showing popup above list 591 if ($.isFunction(this.config.events.onScroll)) { 592 this.config.events.onScroll.call(this); 593 } 594 }, 595 596 _findTerms: function (dataArray, searchTerm) { 597 if (!dataArray || !$.isArray(dataArray) || dataArray.length === 0) { 598 return; 599 } 600 601 var self = this, 602 amountOfUnfilteredItems = dataArray.length 603 604 // reset keyboard navigation mode when applying new filter 605 this._setKeyBoardNavigationMode(false); 606 607 /* 608 * Modified for OpenGrok in 2016. 609 * recursion was very slow (however good lookin') 610 */ 611 for (var itemIndex = 0; itemIndex < dataArray.length; itemIndex++) { 612 var item = dataArray[itemIndex]; 613 if (item.type === 'option') { 614 var $element = item.displayElement, 615 elementSearchableTerms = (item.label + ' ' + item.tooltip).trim().toLowerCase(); 616 617 if (elementSearchableTerms.indexOf(searchTerm) === -1) { 618 $element.addClass('sol-filtered-search'); 619 amountOfUnfilteredItems--; 620 } 621 } else { 622 var amountOfUnfilteredChildren = item.children.length 623 for (var childrenIndex = 0; childrenIndex < item.children.length; childrenIndex++) { 624 var child = item.children[childrenIndex]; 625 if (child.type === 'option') { 626 var $element = child.displayElement, 627 elementSearchableTerms = (child.label + ' ' + child.tooltip).trim().toLowerCase(); 628 629 if (elementSearchableTerms.indexOf(searchTerm) === -1) { 630 $element.addClass('sol-filtered-search'); 631 amountOfUnfilteredChildren--; 632 } 633 } 634 } 635 636 if (amountOfUnfilteredChildren === 0) { 637 item.displayElement.addClass('sol-filtered-search'); 638 amountOfUnfilteredItems--; 639 } 640 } 641 } 642 643 this._setNoResultsItemVisible(amountOfUnfilteredItems === 0); 644 }, 645 646 _initializeData: function () { 647 if (!this.config.data) { 648 this.items = this._detectDataFromOriginalElement(); 649 } else if ($.isFunction(this.config.data)) { 650 this.items = this._fetchDataFromFunction(this.config.data); 651 } else if ($.isArray(this.config.data)) { 652 this.items = this._fetchDataFromArray(this.config.data); 653 } else if (typeof this.config.data === (typeof 'a string')) { 654 this._loadItemsFromUrl(this.config.data); 655 } else { 656 this._showErrorLabel('Unknown data type'); 657 } 658 659 if (this.items) { 660 // done right away -> invoke postprocessing 661 this._processDataItems(this.items); 662 } 663 }, 664 665 _detectDataFromOriginalElement: function () { 666 if (this.$originalElement.prop('tagName').toLowerCase() === 'select') { 667 var self = this, 668 solData = []; 669 670 $.each(this.$originalElement.children(), function (index, item) { 671 var $item = $(item), 672 itemTagName = $item.prop('tagName').toLowerCase(), 673 solDataItem; 674 675 if (itemTagName === 'option') { 676 solDataItem = self._processSelectOption($item); 677 if (solDataItem) { 678 solData.push(solDataItem); 679 } 680 } else if (itemTagName === 'optgroup') { 681 solDataItem = self._processSelectOptgroup($item); 682 if (solDataItem) { 683 solData.push(solDataItem); 684 } 685 } else { 686 self._showErrorLabel('Invalid element found in select: ' + itemTagName + '. Only option and optgroup are allowed'); 687 } 688 }); 689 return this._invokeConverterIfNecessary(solData); 690 } else if (this.$originalElement.data('sol-data')) { 691 var solDataAttributeValue = this.$originalElement.data('sol-data'); 692 return this._invokeConverterIfNecessary(solDataAttributeValue); 693 } else { 694 this._showErrorLabel('Could not determine data from original element. Must be a select or data must be provided as data-sol-data="" attribute'); 695 } 696 }, 697 698 _processSelectOption: function ($option) { 699 return $.extend({}, this.SOL_OPTION_FORMAT, { 700 value: $option.val(), 701 selected: $option.prop('selected'), 702 disabled: $option.prop('disabled'), 703 cssClass: $option.attr('class'), 704 label: $option.html(), 705 tooltip: $option.attr('title'), 706 element: $option 707 }); 708 }, 709 710 _processSelectOptgroup: function ($optgroup) { 711 var self = this, 712 solOptiongroup = $.extend({}, this.SOL_OPTIONGROUP_FORMAT, { 713 label: $optgroup.attr('label'), 714 tooltip: $optgroup.attr('title'), 715 disabled: $optgroup.prop('disabled'), 716 children: [] 717 }), 718 optgroupChildren = $optgroup.children('option'); 719 720 $.each(optgroupChildren, function (index, item) { 721 var $child = $(item), 722 solOption = self._processSelectOption($child); 723 724 // explicitly disable children when optgroup is disabled 725 if (solOptiongroup.disabled) { 726 solOption.disabled = true; 727 } 728 729 solOptiongroup.children.push(solOption); 730 }); 731 732 return solOptiongroup; 733 }, 734 735 _fetchDataFromFunction: function (dataFunction) { 736 return this._invokeConverterIfNecessary(dataFunction(this)); 737 }, 738 739 _fetchDataFromArray: function (dataArray) { 740 return this._invokeConverterIfNecessary(dataArray); 741 }, 742 743 _loadItemsFromUrl: function (url) { 744 var self = this; 745 $.ajax(url, { 746 success: function (actualData) { 747 self.items = self._invokeConverterIfNecessary(actualData); 748 if (self.items) { 749 self._processDataItems(self.items); 750 } 751 }, 752 error: function (xhr, status, message) { 753 self._showErrorLabel('Error loading from url ' + url + ': ' + message); 754 }, 755 dataType: 'json' 756 }); 757 }, 758 759 _invokeConverterIfNecessary: function (dataItems) { 760 if ($.isFunction(this.config.converter)) { 761 return this.config.converter.call(this, this, dataItems); 762 } 763 return dataItems; 764 }, 765 766 _processDataItems: function (solItems) { 767 if (!solItems) { 768 this._showErrorLabel('Data items not present. Maybe the converter did not return any values'); 769 return; 770 } 771 772 if (solItems.length === 0) { 773 this._setNoResultsItemVisible(true); 774 this.$loadingData.remove(); 775 return; 776 } 777 778 var self = this, 779 nextIndex = 0, 780 dataProcessedFunction = function () { 781 // hide "loading data" 782 this.$loadingData.remove(); 783 this._initializeSelectAll(); 784 785 if ($.isFunction(this.config.events.onInitialized)) { 786 this.config.events.onInitialized.call(this, this, solItems); 787 } 788 }, 789 loopFunction = function () { 790 791 var currentBatch = 0, 792 item; 793 794 while (currentBatch++ < self.config.asyncBatchSize && nextIndex < solItems.length) { 795 item = solItems[nextIndex++]; 796 if (item.type === self.SOL_OPTION_FORMAT.type) { 797 self._renderOption(item); 798 } else if (item.type === self.SOL_OPTIONGROUP_FORMAT.type) { 799 self._renderOptiongroup(item); 800 } else { 801 self._showErrorLabel('Invalid item type found ' + item.type); 802 return; 803 } 804 } 805 806 if (nextIndex >= solItems.length) { 807 dataProcessedFunction.call(self); 808 } else { 809 setTimeout(loopFunction, 0); 810 } 811 }; 812 813 // start async rendering of html elements 814 loopFunction.call(this); 815 }, 816 817 _renderOption: function (solOption, $optionalTargetContainer) { 818 var self = this, 819 $actualTargetContainer = $optionalTargetContainer || this.$selection, 820 $inputElement, 821 /* 822 * Modified for OpenGrok in 2016. 823 */ 824 $labelText = $('<div class="sol-label-text"/>') 825 .html(solOption.label.trim().length === 0 ? ' ' : solOption.label) 826 .addClass(solOption.cssClass), 827 $label, 828 $displayElement, 829 inputName = this._getNameAttribute(); 830 /* 831 * Modified for OpenGrok in 2016, 2019. 832 */ 833 var data = $(solOption.element).data('messages'); 834 var messagesLevel = $(solOption.element).data('messages-level'); 835 var messagesAvailable = data && data.length; 836 if (messagesAvailable && messagesLevel) { 837 var cssString = 'pull-right '; 838 cssString += 'note-' + messagesLevel; 839 cssString += ' important-note important-note-rounded'; 840 841 $labelText.append( 842 $('<span>') 843 .addClass(cssString) 844 .data("messages", data) 845 .attr('data-messages', '') 846 .text('!') 847 ); 848 } 849 850 if (this.config.multiple) { 851 // use checkboxes 852 $inputElement = $('<input type="checkbox" class="sol-checkbox"/>'); 853 854 if (this.config.useBracketParameters) { 855 inputName += '[]'; 856 } 857 } else { 858 // use radio buttons 859 $inputElement = $('<input type="radio" class="sol-radio"/>') 860 .on('change', function () { 861 // when selected notify all others of being deselected 862 self.$selectionContainer.find('input[type="radio"][name="' + inputName + '"]').not($(this)).trigger('sol-deselect'); 863 }) 864 .on('sol-deselect', function () { 865 // remove display selection item 866 // TODO also better show it inline instead of above or below to save space 867 self._removeSelectionDisplayItem($(this)); 868 }); 869 } 870 871 $inputElement 872 .on('change', function (event, skipCallback) { 873 $(this).trigger('sol-change', skipCallback); 874 }) 875 .on('sol-change', function (event, skipCallback) { 876 /* 877 * Modified for OpenGrok in 2016. 878 */ 879 var $closestOption = $(this).closest('.sol-option') 880 self._setKeyBoardNavigationMode(true) 881 self.$selection 882 .find('.sol-option.keyboard-selection') 883 .removeClass("keyboard-selection") 884 885 $closestOption.addClass('keyboard-selection') 886 //self.$selection.scrollTop(self.$selection.scrollTop() + $closestOption.position().top) 887 888 self._selectionChange($(this), skipCallback); 889 }) 890 .data('sol-item', solOption) 891 .prop('checked', solOption.selected) 892 .prop('disabled', solOption.disabled) 893 .attr('name', inputName) 894 .val(solOption.value); 895 896 $label = $('<label class="sol-label"/>') 897 .attr('title', solOption.tooltip) 898 .append($inputElement) 899 .append($labelText); 900 /* 901 * Modified for OpenGrok in 2016. 902 */ 903 $displayElement = $('<div class="sol-option"/>').dblclick(function (e) { 904 var $el = $(this).find('.sol-checkbox'); 905 if ($el.length && $el.data('sol-item') && $el.data('sol-item').label) { 906 // go first project 907 window.location = document.xrefPath + '/' + $(this).find('.sol-checkbox').data('sol-item').label; 908 } 909 }).append($label); 910 /* 911 * Modified for OpenGrok in 2016, 2019. 912 */ 913 $inputElement.data('messages-available', messagesAvailable); 914 if (messagesLevel) { 915 $inputElement.data('messages-level', messagesLevel); 916 } 917 918 solOption.displayElement = $displayElement; 919 920 $actualTargetContainer.append($displayElement); 921 922 if (solOption.selected) { 923 this._addSelectionDisplayItem($inputElement); 924 } 925 }, 926 927 _renderOptiongroup: function (solOptiongroup) { 928 var self = this, 929 $groupCaption = $('<div class="sol-optiongroup-label"/>') 930 .attr('title', solOptiongroup.tooltip) 931 .html(solOptiongroup.label), 932 $groupCheckbox = $('<input class="sol-checkbox" style="display: none" type="checkbox" name="group" value="' + solOptiongroup.label+ '"/>'), 933 $groupItem = $('<div class="sol-optiongroup"/>').append($groupCaption).append($groupCheckbox); 934 935 if (solOptiongroup.disabled) { 936 $groupItem.addClass('disabled'); 937 } 938 /* 939 * Modified for OpenGrok in 2016, 2017. 940 */ 941 $groupCaption.click(function (e) { 942 // select all group 943 if (self.config.multiple) { 944 if (!e.ctrlKey) { 945 self.deselectAll(); 946 } 947 self.selectAll($(this).text()) 948 self.$selection.scrollTop(self.$selection.scrollTop() + $(this).position().top) 949 } 950 }); 951 952 /* 953 * Modified for OpenGrok in 2016. 954 */ 955 this.$selection.append($groupItem); 956 957 if ($.isArray(solOptiongroup.children)) { 958 $.each(solOptiongroup.children, function (index, item) { 959 self._renderOption(item, $groupItem); 960 }); 961 } 962 963 solOptiongroup.displayElement = $groupItem; 964 }, 965 966 _initializeSelectAll: function () { 967 // multiple values selectable 968 if (this.config.showSelectAll === true || ($.isFunction(this.config.showSelectAll) && this.config.showSelectAll.call(this))) { 969 // buttons for (de-)select all 970 var self = this, 971 $deselectAllButton = $('<a href="#" class="sol-deselect-all"/>').html(this.config.texts.selectNone).click(function (e) { 972 self.deselectAll(); 973 e.preventDefault(); 974 return false; 975 }), 976 $selectAllButton = $('<a href="#" class="sol-select-all"/>').html(this.config.texts.selectAll).click(function (e) { 977 self.selectAll(); 978 e.preventDefault(); 979 return false; 980 }); 981 982 this.$actionButtons = $('<div class="sol-action-buttons"/>').append($selectAllButton).append($deselectAllButton).append('<div class="sol-clearfix"/>'); 983 this.$selectionContainer.prepend(this.$actionButtons); 984 } 985 }, 986 987 _selectionChange: function ($changeItem, skipCallback) { 988 989 // apply state to original select if necessary 990 // helps to keep old legacy code running which depends 991 // on retrieving the value via jQuery option selectors 992 // e.g. $('#myPreviousSelectWhichNowIsSol').val() 993 if (this.$originalElement && this.$originalElement.prop('tagName').toLowerCase() === 'select') { 994 var self = this; 995 if (this.valMap == null) { 996 this.$originalElement.find('option').each(function (index, item) { 997 var $currentOriginalOption = $(item); 998 if ($currentOriginalOption.val() === $changeItem.val()) { 999 $currentOriginalOption.prop('selected', $changeItem.prop('checked')); 1000 self.$originalElement.trigger('change'); 1001 return false; // stop the loop 1002 } 1003 }); 1004 } else { 1005 var mappedVal = this.valMap.get($changeItem.val()); 1006 if (mappedVal) { 1007 mappedVal.prop('selected', $changeItem.prop('checked')); 1008 self.$originalElement.trigger('change'); 1009 } 1010 } 1011 } 1012 1013 if ($changeItem.prop('checked')) { 1014 this._addSelectionDisplayItem($changeItem); 1015 } else { 1016 this._removeSelectionDisplayItem($changeItem); 1017 } 1018 1019 if (this.config.multiple) { 1020 // update position of selection container 1021 // to allow selecting more entries 1022 this.config.scrollTarget.trigger('scroll'); 1023 } else { 1024 // only one option selectable 1025 // close selection container 1026 this.close(); 1027 } 1028 1029 if (!skipCallback && $.isFunction(this.config.events.onChange)) { 1030 this.config.events.onChange.call(this, this, $changeItem); 1031 } 1032 }, 1033 1034 _setXItemsSelected: function() { 1035 if (this.config.maxShow !== 0 && this.numSelected > this.config.maxShow) { 1036 var xItemsText = this.config.texts.itemsSelected.replace('{$a}', 1037 this.numSelected - this.config.maxShow); 1038 this.$xItemsSelected.html('<div class="sol-selected-display-item-text">' + 1039 xItemsText + '<div>'); 1040 this.$showSelectionContainer.append(this.$xItemsSelected); 1041 this.$xItemsSelected.show(); 1042 } else { 1043 this.$xItemsSelected.hide(); 1044 } 1045 }, 1046 1047 _addSelectionDisplayItem: function ($changedItem) { 1048 this.numSelected = 1 + this.numSelected; 1049 if (this.config.numSelectedItem) { 1050 this.config.numSelectedItem.val(this.numSelected); 1051 } 1052 1053 if (this.config.maxShow !== 0 && this.numSelected > this.config.maxShow) { 1054 if (this.valMap == null) { 1055 this._setXItemsSelected(); 1056 } 1057 } else { 1058 this._buildSelectionDisplayItem($changedItem); 1059 } 1060 }, 1061 1062 _buildSelectionDisplayItem: function ($changedItem) { 1063 var solOptionItem = $changedItem.data('sol-item'), 1064 self = this, 1065 $existingDisplayItem, 1066 $displayItemText; 1067 1068 /* 1069 * Modified for OpenGrok in 2016, 2019. 1070 */ 1071 var label = solOptionItem.label; 1072 if ($changedItem.data('messages-available')) { 1073 label += ' <span class="'; 1074 label += 'note-' + $changedItem.data('messages-level'); 1075 label += ' important-note important-note-rounded" '; 1076 label += 'title="Some message is present for this project.'; 1077 label += ' Find more info in the project list.">!</span>' 1078 } 1079 1080 $displayItemText = $('<span class="sol-selected-display-item-text" />').html(label); 1081 $existingDisplayItem = $('<div class="sol-selected-display-item"/>') 1082 .append($displayItemText) 1083 .attr('title', solOptionItem.tooltip) 1084 .data('label', solOptionItem.label) 1085 .appendTo(this.$showSelectionContainer) 1086 .dblclick(function () { // Modified for OpenGrok in 2017. 1087 $changedItem.dblclick(); 1088 }); 1089 1090 // show remove button on display items if not disabled and null selection allowed 1091 if ((this.config.multiple || this.config.allowNullSelection) && !$changedItem.prop('disabled')) { 1092 $('<span class="sol-quick-delete"/>') 1093 .html(this.config.texts.quickDelete) 1094 .click(function () { // deselect the project and refresh the search 1095 $changedItem 1096 .prop('checked', false) 1097 .trigger('change'); 1098 /* 1099 * Modified for OpenGrok in 2017. 1100 */ 1101 if (self.config.quickDeleteForm) { 1102 if (self.config.quickDeletePermit) { 1103 if (self.config.quickDeletePermit()) { 1104 self.config.quickDeleteForm.submit(); 1105 } 1106 } else { 1107 self.config.quickDeleteForm.submit(); 1108 } 1109 } 1110 }) 1111 .prependTo($existingDisplayItem); 1112 } 1113 1114 solOptionItem.displaySelectionItem = $existingDisplayItem; 1115 }, 1116 1117 _removeSelectionDisplayItem: function ($changedItem) { 1118 var solOptionItem = $changedItem.data('sol-item'), 1119 $myDisplayItem = solOptionItem.displaySelectionItem; 1120 1121 var wasExceeding = this.config.maxShow !== 0 && this.numSelected > this.config.maxShow; 1122 this.numSelected = this.numSelected - 1; 1123 if (this.config.numSelectedItem) { 1124 this.config.numSelectedItem.val(this.numSelected); 1125 } 1126 1127 if ($myDisplayItem) { 1128 $myDisplayItem.remove(); 1129 solOptionItem.displaySelectionItem = undefined; 1130 1131 /* 1132 * N.b. for bulk mode, wasExceeding handling is off since only 1133 * Clear or Invert-Selection would cause this function to be 1134 * called. For Clear, there won't be any selected items at the 1135 * end, so wasExceeding is irrelevant. For Invert-Selection, 1136 * checked options are unchecked first -- i.e. we go to zero 1137 * this.numSelected first -- so normal _addSelectionDisplayItem 1138 * takes care of things. 1139 */ 1140 1141 if (wasExceeding && this.valMap == null) { 1142 var self = this; 1143 this.$selectionContainer 1144 .find('.sol-option input[type="checkbox"]:not([disabled]):checked') 1145 .each(function (index, item) { 1146 var $currentOptionItem = $(item); 1147 if ($currentOptionItem.data('sol-item').displaySelectionItem == null) { 1148 self._buildSelectionDisplayItem($currentOptionItem); 1149 return false; 1150 } 1151 }); 1152 } 1153 } 1154 if (this.valMap == null) { 1155 this._setXItemsSelected(); 1156 } 1157 }, 1158 1159 _setNoResultsItemVisible: function (visible) { 1160 if (visible) { 1161 this.$noResultsItem.show(); 1162 this.$selection.hide(); 1163 1164 if (this.$actionButtons) { 1165 this.$actionButtons.hide(); 1166 } 1167 } else { 1168 this.$noResultsItem.hide(); 1169 this.$selection.show(); 1170 1171 if (this.$actionButtons) { 1172 this.$actionButtons.show(); 1173 } 1174 } 1175 }, 1176 1177 _buildValMap: function () { 1178 if (this.$originalElement && this.$originalElement.prop('tagName').toLowerCase() === 'select') { 1179 var self = this; 1180 this.valMap = new Map(); 1181 this.$originalElement.find('option').each(function (index, item) { 1182 var $currentOriginalOption = $(item); 1183 self.valMap.set($currentOriginalOption.val(), $currentOriginalOption); 1184 }); 1185 } 1186 }, 1187 1188 isOpen: function () { 1189 return this.$container.hasClass('sol-active'); 1190 }, 1191 1192 isClosed: function () { 1193 return !this.isOpen(); 1194 }, 1195 1196 toggle: function () { 1197 if (this.isOpen()) { 1198 this.close(); 1199 } else { 1200 this.open(); 1201 } 1202 }, 1203 1204 open: function () { 1205 if (this.isClosed()) { 1206 this.$container.addClass('sol-active'); 1207 this.config.scrollTarget.bind('scroll', this.internalScrollWrapper).trigger('scroll'); 1208 $(window).on('resize', this.internalScrollWrapper); 1209 1210 if ($.isFunction(this.config.events.onOpen)) { 1211 this.config.events.onOpen.call(this, this); 1212 } 1213 } 1214 }, 1215 1216 close: function () { 1217 if (this.isOpen()) { 1218 this._setKeyBoardNavigationMode(false); 1219 1220 1221 this.$container.removeClass('sol-active'); 1222 this.config.scrollTarget.unbind('scroll', this.internalScrollWrapper); 1223 $(window).off('resize'); 1224 1225 // reset search on close 1226 this.$input.val(''); 1227 this._applySearchTermFilter(); 1228 1229 // clear to recalculate position again the next time sol is opened 1230 this.config.displayContainerAboveInput = undefined; 1231 1232 if ($.isFunction(this.config.events.onClose)) { 1233 this.config.events.onClose.call(this, this); 1234 } 1235 } 1236 }, 1237 /* 1238 * Modified for OpenGrok in 2016. 1239 */ 1240 selectAll: function (/* string or undefined */optgroup) { 1241 if (this.config.multiple) { 1242 this._buildValMap(); 1243 1244 var $changedInputs = !optgroup ? this.$selectionContainer 1245 : this.$selectionContainer 1246 .find(".sol-optiongroup-label") 1247 .filter(function () { 1248 return $(this).text() === optgroup; 1249 }).closest('.sol-optiongroup') 1250 1251 $changedInputs = $changedInputs.find('input[type="checkbox"]:not([disabled], :checked)') 1252 .prop('checked', true) 1253 .trigger('change', true); 1254 1255 this.config.closeOnClick && this.close(); 1256 1257 if ($.isFunction(this.config.events.onChange)) { 1258 this.config.events.onChange.call(this, this, $changedInputs); 1259 } 1260 1261 this.valMap = null; 1262 this._setXItemsSelected(); 1263 } 1264 }, 1265 /* 1266 * Modified for OpenGrok in 2016, 2019. 1267 */ 1268 invert: function () { 1269 if (this.config.multiple) { 1270 this._buildValMap(); 1271 1272 var $closedInputs = this.$selectionContainer 1273 .find('input[type="checkbox"][name=project]:not([disabled], :checked)') 1274 var $openedInputs = this.$selectionContainer 1275 .find('input[type="checkbox"][name=project]').filter('[disabled], :checked') 1276 1277 $openedInputs.prop('checked', false) 1278 .trigger('change', true); 1279 $closedInputs.prop('checked', true) 1280 .trigger('change', true) 1281 1282 this.config.closeOnClick && this.close(); 1283 1284 if ($.isFunction(this.config.events.onChange)) { 1285 this.config.events.onChange.call(this, this, $openedInputs.add($closedInputs)); 1286 } 1287 1288 this.valMap = null; 1289 this._setXItemsSelected(); 1290 } 1291 }, 1292 /* 1293 * Modified for OpenGrok in 2016. 1294 */ 1295 deselectAll: function ( /* string or undefined */ optgroup) { 1296 if (this.config.multiple) { 1297 this._buildValMap(); 1298 1299 var $changedInputs = !optgroup ? this.$selectionContainer 1300 : this.$selectionContainer 1301 .find(".sol-optiongroup-label") 1302 .filter(function () { 1303 return $(this).text() === optgroup; 1304 }).closest('.sol-optiongroup') 1305 1306 $changedInputs = $changedInputs.find('.sol-option input[type="checkbox"]:not([disabled]):checked') 1307 .prop('checked', false) 1308 .trigger('change', true); 1309 1310 this.config.closeOnClick && this.close(); 1311 1312 if ($.isFunction(this.config.events.onChange)) { 1313 this.config.events.onChange.call(this, this, $changedInputs); 1314 } 1315 1316 this.valMap = null; 1317 this._setXItemsSelected(); 1318 } 1319 }, 1320 1321 selectRadio: function(val) { 1322 this.$selectionContainer.find('input[type="radio"]') 1323 .each(function (index, item) { 1324 var $currentOptionItem = $(item); 1325 if ($currentOptionItem.val() === val) { 1326 if (!$currentOptionItem.is(':checked')) { 1327 $currentOptionItem.prop("checked", true).trigger('change', true); 1328 } 1329 return false; 1330 } 1331 }); 1332 }, 1333 1334 getSelection: function () { 1335 return this.$selection.find('input:checked'); 1336 } 1337 }; 1338 1339 // jquery plugin boiler plate code 1340 SearchableOptionList.defaults = SearchableOptionList.prototype.defaults; 1341 window.SearchableOptionList = SearchableOptionList; 1342 1343 $.fn.searchableOptionList = function (options) { 1344 var result = []; 1345 this.each(function () { 1346 var $this = $(this), 1347 $alreadyInitializedSol = $this.data(SearchableOptionList.prototype.DATA_KEY); 1348 1349 if ($alreadyInitializedSol) { 1350 result.push($alreadyInitializedSol); 1351 } else { 1352 var newSol = new SearchableOptionList($this, options); 1353 result.push(newSol); 1354 1355 setTimeout(function() { 1356 newSol.init(); 1357 }, 0); 1358 } 1359 }); 1360 1361 if (result.length === 1) { 1362 return result[0]; 1363 } 1364 1365 return result; 1366 }; 1367 1368}(jQuery, window, document)); 1369