xref: /OpenGrok/opengrok-web/src/main/webapp/js/searchable-option-list-2.0.15.js (revision 87e827912b828682c1fa2526fb09b09f073c339b)
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: '&times;',
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 ? '&nbsp;' : 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