PageSourceSearch

https://lmbtqitortenelem.hu/modules/AdvancedSearch/asset/js/search.js?v=3.4.64

js lmbtqitortenelem.hu collected 2026-10-08 20:08:15 UTC 76,185 bytes, 1,828 lines download raw bytes

1'use strict';
2
3/**
4 * Copyright BibLibre, 2016
5 * Copyright Daniel Berthereau, 2017-2026
6 *
7 * This software is governed by the CeCILL license under French law and abiding
8 * by the rules of distribution of free software.  You can use, modify and/ or
9 * redistribute the software under the terms of the CeCILL license as circulated
10 * by CEA, CNRS and INRIA at the following URL "http://www.cecill.info".
11 *
12 * As a counterpart to the access to the source code and rights to copy, modify
13 * and redistribute granted by the license, users are provided only with a
14 * limited warranty and the software's author, the holder of the economic
15 * rights, and the successive licensors have only limited liability.
16 *
17 * In this respect, the user's attention is drawn to the risks associated with
18 * loading, using, modifying and/or developing or reproducing the software by
19 * the user in light of its specific status of free software, that may mean that
20 * it is complicated to manipulate, and that also therefore means that it is
21 * reserved for developers and experienced professionals having in-depth
22 * computer knowledge. Users are therefore encouraged to load and test the
23 * software's suitability as regards their requirements in conditions enabling
24 * the security of their systems and/or data to be ensured and, more generally,
25 * to use and operate it in the same conditions as regards security.
26 *
27 * The fact that you are presently reading this means that you have had
28 * knowledge of the CeCILL license and that you accept its terms.
29 */
30
31if (typeof hasChosenSelect === 'undefined') {
32    var hasChosenSelect = typeof $.fn.chosen === 'function';
33}
34if (typeof hasOmekaTranslate === 'undefined') {
35    var hasOmekaTranslate = typeof Omeka !== 'undefined' && typeof Omeka.jsTranslate === 'function';
36}
37
38const $searchFacets = $('#search-facets');
39
40/**
41 * Manage search form, search filters, search results, search facets.
42 */
43
44var Search = (function() {
45
46    var self = {};
47
48    /**
49     * Data about search filter types.
50     *
51     * @see \AdvancedSearch\Stdlib\SearchResources
52     */
53    self.filterTypes = {
54        withValue: [
55            'eq',
56            'neq',
57            'in',
58            'nin',
59            'ma',
60            'nma',
61            'res',
62            'nres',
63            'resq',
64            'nresq',
65            'list',
66            'nlist',
67            'sw',
68            'nsw',
69            'ew',
70            'new',
71            'near',
72            'nnear',
73            'lres',
74            'nlres',
75            'lkq',
76            'nlkq',
77            'dt',
78            'ndt',
79            'dtp',
80            'ndtp',
81            'tp',
82            'ntp',
83            'lt',
84            'lte',
85            'gte',
86            'gt',
87            '<',
88            '≤',
89            '≥',
90            '>',
91            'yreq',
92            'nyreq',
93            'yrlt',
94            'yrlte',
95            'yrgte',
96            'yrgt',
97        ],
98        withoutValue: [
99            'ex',
100            'nex',
101            'exs',
102            'nexs',
103            'exm',
104            'nexm',
105            'resq',
106            'nresq',
107            'lex',
108            'nlex',
109            'lkq',
110            'nlkq',
111            'dtp',
112            'ndtp',
113            'tp',
114            'ntp',
115            'tpl',
116            'ntpl',
117            'tpr',
118            'ntpr',
119            'tpu',
120            'ntpu',
121            'dup',
122            'ndup',
123            'dupl',
124            'ndupl',
125            'dupt',
126            'ndupt',
127            'duptl',
128            'nduptl',
129            'dupv',
130            'ndupv',
131            'dupvl',
132            'ndupvl',
133            'dupvt',
134            'ndupvt',
135            'dupvtl',
136            'ndupvtl',
137            'dupr',
138            'ndupr',
139            'duprl',
140            'nduprl',
141            'duprt',
142            'nduprt',
143            'duprtl',
144            'nduprtl',
145            'dupu',
146            'ndupu',
147            'dupul',
148            'ndupul',
149            'duput',
150            'nduput',
151            'duputl',
152            'nduputl',
153        ],
154    };
155
156    /**
157     * Chosen default options.
158     * @see https://harvesthq.github.io/chosen/
159     */
160    self.chosenOptions = {
161        allow_single_deselect: true,
162        disable_search_threshold: 10,
163        width: '100%',
164        include_group_label_in_selected: true,
165    };
166
167    /**
168     * Check if the current query is an advanced search one.
169     */
170    self.isAdvancedSearchQuery = function() {
171        let searchParams = new URLSearchParams(document.location.search);
172        let  params = Array.from(searchParams.entries());
173        for (let i in params) {
174            let k = params[i][0];
175            let v = params[i][1];
176            if (v !== ''
177                && !['q', 'search', 'fulltext_search', 'sort', 'sort_by', 'sort_order', 'page', 'per_page', 'limit', 'offset', 'csrf'].includes(k)
178                && !k.startsWith('facet[')
179                && !k.startsWith('facet%5B')
180            ) {
181                return true;
182            }
183        }
184        return false;
185    }
186
187    /**
188     * Autocomplete or autosuggest for any input element.
189     *
190     * @see https://github.com/devbridge/jQuery-Autocomplete
191     */
192    self.autosuggestOptions = function(element) {
193        let serviceUrl = element.data('autosuggest-url');
194        if (!serviceUrl || !serviceUrl.length) {
195            return null;
196        }
197
198        let transformResult = function(response) {
199            // Managed by Solr endpoint.
200            // @see https://solr.apache.org/guide/suggester.html#example-usages
201            if (response.suggest) {
202                const answer = response.suggest[Object.keys(response.suggest)[0]];
203                const searchString = answer[Object.keys(answer)[0]] instanceof String
204                    ? answer[Object.keys(answer)[0]]
205                    : Object.keys(answer)[0];
206                if (!Object.keys(answer[searchString]).find((key) => 'suggestions' === key)) {
207                    return {};
208                }
209                return {
210                    query: searchString,
211                    suggestions: $.map(answer[searchString].suggestions, function(dataItem) {
212                        return {
213                            value: dataItem.term,
214                            data: dataItem.weight,
215                        };
216                    }),
217                };
218            }
219            // Managed by module, that uses the format of jQuery-Autocomplete
220            // in data, or jQuery-Autocomplete directly.
221            return response.data ? response.data : response;
222        };
223
224        // Get the param name (always "q" for the internal suggester).
225        let paramName = element.data('autosuggest-param-name');
226        paramName = paramName && paramName.length ? paramName : 'q';
227        return {
228            serviceUrl: serviceUrl,
229            dataType: 'json',
230            paramName: paramName,
231            triggerSelectOnValidInput: false,
232            transformResult: transformResult,
233            onSearchError: function(query, jqXHR, textStatus, errorThrown) {
234                if (jqXHR.responseJSON && jqXHR.responseJSON.message) {
235                    console.error(jqXHR.responseJSON.message);
236                } else if (errorThrown.length) {
237                    console.error(errorThrown);
238                }
239            },
240            onSelect: function (suggestion) {
241                if (!$(this).data('autosuggest-fill-input')) {
242                    $(this).closest('form').submit();
243                }
244            },
245        };
246    };
247
248    /**
249     * Advanced filters.
250     */
251
252    // Factory: one controller per advanced filters fieldset, so the same form
253    // can be duplicated on a page (e.g. a sticky header search) without relying
254    // on a unique id. Each instance is scoped to its own
255    // ".search-filters-advanced" fieldset, passed as $searchFiltersAdvanced.
256    self.createFiltersAdvanced = function($searchFiltersAdvanced) {
257        var self = {};
258
259        // At least one default.
260        self.countDefault = $searchFiltersAdvanced.data('count-default') ? $searchFiltersAdvanced.data('count-default') : 0;
261
262        self.countMax = $searchFiltersAdvanced.data('count-max') ? $searchFiltersAdvanced.data('count-max') : 0;
263
264        self.countAll = function() {
265            return $searchFiltersAdvanced.find('>
265 fieldset.filter').length;
266        };
267
268        self.countFilled = function() {
269            var countFilled = 0;
270            $searchFiltersAdvanced.find('> fieldset.filter').each(function(index, filterFieldset) {
271                // The fields may be select or radio or checkboxes.
272                const field = $(filterFieldset).find('[name^="filter["][name$="][field]"]');
273                const type = $(filterFieldset).find('[name^="filter["][name$="][type]"]');
274                const val = $(filterFieldset).find('[name^="filter["][name$="][val]"]');
275                // A filter is filled when there is a field and either:
276                // - a type that requires no value (like "is empty")
277                // - or a value in the val field
278                const hasField = field.length && field.val();
279                const typeRequiresNoValue = type.length && Search.filterTypes.withoutValue.includes(type.val());
280                const hasValue = val.length && val.val();
281                if (hasField && (typeRequiresNoValue || hasValue)) {
282                    ++countFilled;
283                }
284            });
285            return countFilled;
286        };
287
288        self.init = function() {
289            // Warning: nth-child() counts all childs, even not fieldsets, so use nth-of-type().
290            // Remove unmanaged fieldset.
291            if (self.countMax) {
292                $searchFiltersAdvanced.find('> fieldset.filter:nth-of-type(n+' + (self.countMax + 1) + ')').remove();
293            }
294            // Display or remove fieldsets.
295            const countFilled = self.countFilled();
296            const countNeeded = Math.max(self.countDefault, countFilled);
297            $searchFiltersAdvanced.find('> fieldset.filter:nth-of-type(-n+' + (countNeeded + 1) + ')').removeAttr('hidden');
298            $searchFiltersAdvanced.find('> fieldset.filter:nth-of-type(n+' + (countNeeded + 1) + ')').remove();
299            // Create missing fieldsets.
300            self.appendFilter(self.countDefault - self.countAll());
301            self.updatePlus();
302            return self;
303        };
304
305        self.appendFilter = function(countAppend = 1) {
306            countAppend = self.countMax ? Math.min(countAppend, self.countMax - self.countAll()) : countAppend;
307            if (countAppend <= 0) {
308                return self;
309            }
310            const fieldsetTemplate = $searchFiltersAdvanced.find('span[data-template]').attr('data-template');
311            let maxIndex = 0;
312            $searchFiltersAdvanced.find('> fieldset.filter').each(function(no, item) {
313                const fieldsetName = $(item).attr('name');
314                const fieldsetIndex = fieldsetName.replace(/\D+/g, '');
315                maxIndex = Math.max(maxIndex, fieldsetIndex);
316            });
317            for (var i = 0; i < countAppend; i++) {
318                $searchFiltersAdvanced.append(fieldsetTemplate.split('__index__').join(++maxIndex));
319            }
320            $searchFiltersAdvanced.trigger('o:advanced-search.filter.append');
321            return self;
322        };
323
324        self.removeFilter = function(filter) {
325            $(filter).remove();
326            $searchFiltersAdvanced.trigger('o:advanced-search.filter.remove');
327            return self;
328        };
329
330        self.updatePlus = function() {
331            const buttonPlus = $searchFiltersAdvanced.find('.search-filter-plus');
332            if (self.countMax && self.countAll() >= self.countMax) {
333                buttonPlus.hide();
334            } else {
335                buttonPlus.show();
336            }
337            return self;
338        };
339
340        return self;
341    };
342
343    /* Results */
344
345    self.setViewType = function(viewType) {
346        // In some themes, the mode for resource list is set with a different class.
347        // Or different search engines are used, some with grid, some with list.
348        var resourceLists = document.querySelectorAll('.search-results .resource-list, .search-results .resources-list-content');
349        var searchResultsList = document.querySelector('.search-results-list');
350        var searchResultsMap = document.querySelector('.search-results-map');
351
352        var hasOnlyMode = Array.prototype.some.call(resourceLists, function(el) {
353            return el.classList.contains('only-mode');
354        });
355        if (hasOnlyMode) {
356            return;
357        }
358
359        // Handle map view type.
360        if (viewType === 'map') {
361            // Hide results list, show map.
362            if (searchResultsList) {
363                searchResultsList.style.display = 'none';
364            }
365            if (searchResultsMap) {
366                searchResultsMap.style.display = 'block';
367                // Initialize map if not already done.
368                self.initSearchMap();
369            }
370        } else {
371            // Show results list, hide map.
372            if (searchResultsList) {
373                searchResultsList.style.display = '';
374            }
375            if (searchResultsMap) {
376                searchResultsMap.style.display = 'none';
377            }
378            // Apply grid/list class to resource lists.
379            for (var i = 0; i < resourceLists.length; i++) {
380                var resourceItem = resourceLists[i];
381                resourceItem.className = resourceItem.className.replace(' grid', '').replace(' list', '')
382                    + ' ' + viewType;
383            }
384        }
385        localStorage.setItem('search_view_type', viewType);
386    };
387
388    /**
389     * Map state tracking.
390     */
391    self.mapInitialized = false;
392    self.map = null;
393    self.features = null;
394    self.featuresPoint = null;
395    self.featuresPoly = null;
396
397    /**
398     * Initialize the search results map.
399     * Uses the Mapping module's API to load features.
400     */
401    self.initSearchMap = function() {
402        if (self.mapInitialized) {
403            // Map already initialized, just invalidate size in case container was hidden.
404            if (self.map) {
405                self.map.invalidateSize();
406            }
407            return;
408        }
409
410        var searchMapDiv = document.getElementById('search-map');
411        if (!searchMapDiv) {
412            return;
413        }
414
415        // Check if MappingModule is available.
416        if (typeof MappingModule === 'undefined') {
417            console.warn('MappingModule not loaded. Map view requires the Mapping module.');
418            return;
419        }
420
421        var featuresUrl = searchMapDiv.dataset.featuresUrl;
422        var popupUrl = searchMapDiv.dataset.featurePopupContentUrl;
423        var basemapProvider = searchMapDiv.dataset.basemapProvider || 'OpenStreetMap.Mapnik';
424        var disableClustering = searchMapDiv.dataset.disableClustering === '1';
425
426        // Get item ids from the search results (passed from PHP as comma-separated string).
427        // Empty string means: show all features (browse mode or too many results).
428        var itemIdsString = searchMapDiv.dataset.itemIds || '';
429        var itemsQuery = {};
430
431        if (itemIdsString) {
432            // Filtered search with reasonable number of results: pass ids.
433            // Omeka API supports "id=x,y,z" format.
434            itemsQuery.id = itemIdsString;
435        }
436        // If empty, itemsQuery stays empty and Mapping will show all features.
437
438        // Set map height.
439        searchMapDiv.style.height = '600px';
440
441        // Initialize map using MappingModule.
442        var mapResult = MappingModule.initializeMap(searchMapDiv, {}, {
443            disableClustering: disableClustering,
444            basemapProvider: basemapProvider
445        });
446
447        self.map = mapResult[0];
448        self.features = mapResult[1];
449        self.featuresPoint = mapResult[2];
450        self.featuresPoly = mapResult[3];
451
452        // Load features.
453        var onFeaturesLoad = function() {
454            if (!self.map.mapping_map_interaction) {
455                var bounds = self.features.getBounds();
456                if (bounds.isValid()) {
457                    self.map.fitBounds(bounds);
458                }
459            }
460        };
461
462        MappingModule.loadFeaturesAsync(
463            self.map,
464            self.featuresPoint,
465            self.featuresPoly,
466            featuresUrl,
467            popupUrl,
468            JSON.stringify(itemsQuery),
469            JSON.stringify({}),
470            onFeaturesLoad
471        );
472
473        self.mapInitialized = true;
474    };
475
476    /* Facets. */
477
478    self.facets = (function() {
479        var self = {};
480
481        /**
482         * Modes may be "click a button to apply facets" or "reload the page directly".
483         * .apply-facets is kept for compatibility with old themes.
484         */
485        self.useApplyFacets = $searchFacets.find('.facets-apply, .apply-facets').length > 0;
486
487        self.expandOrCollapse = function(button) {
488            button = $(button);
489            if (button.hasClass('expand')) {
490                button.attr('aria-expanded', 'false');
491                button.closest('.facet').find('.facet-elements').attr('hidden', 'hidden');
492            } else {
493                button.attr('aria-expanded', 'true');
494                button.closest('.facet').find('.facet-elements').removeAttr('hidden');
495            }
496            $searchFacets.trigger('o:advanced-search.facet.expand-or-collapse');
497            return self;
498        };
499
500        /**
501         * Expand or collapse all facets at once.
502         *
503         * @param {boolean} expand True to expand all, false to collapse all.
504         */
505        self.expandOrCollapseAll = function(expand) {
506            $searchFacets.find('.facet > .facet-button').each(function() {
507                const button = $(this);
508                const isExpanded = button.hasClass('collapse');
509                if (expand && !isExpanded) {
510                    button.removeClass('expand').addClass('collapse');
511                    self.expandOrCollapse(button);
512                } else if (!expand && isExpanded) {
513                    button.removeClass('collapse').addClass('expand');
514                    self.expandOrCollapse(button);
515                }
516            });
517            self.updateExpandAllButton();
518            return self;
519        };
520
521        /**
522         * Update the global expand/collapse toggle state from the current
523         * state of facet buttons.
524         */
525        self.updateExpandAllButton = function() {
526            const toggle = $searchFacets.find('.facets-expand-all');
527            if (!toggle.length) {
528                return self;
529            }
530            const buttons = $searchFacets.find('.facet > .facet-button');
531            if (!buttons.length) {
532                return self;
533            }
534            const allExpanded = buttons.filter('.collapse').length === buttons.length;
535            if (allExpanded) {
536                toggle.removeClass('expand').addClass('collapse');
537                toggle.attr('aria-expanded', 'true');
538                toggle.text(toggle.attr('data-label-collapse-all') || (hasOmekaTranslate ? Omeka.jsTranslate('Collapse all') : 'Collapse all'));
539            } else {
540                toggle.removeClass('collapse').addClass('expand');
541                toggle.attr('aria-expanded', 'false');
542                toggle.text(toggle.attr('data-label-expand-all') || (hasOmekaTranslate ? Omeka.jsTranslate('Expand all') : 'Expand all'));
543            }
544            return self;
545        };
546
547
548        self.seeMoreOrLess = function(button) {
549            button = $(button);
550            if (button.hasClass('expand')) {
551                // Collapsing: remove pagination and show only default items.
552                self.removePagination(button);
553                button.text(button.attr('data-label-see-more') ? button.attr('data-label-see-more') : (hasOmekaTranslate ? Omeka.jsTranslate('See more') : 'See more'));
554                button.attr('aria-expanded', 'false');
555                const defaultCount = Number(button.attr('data-default-count')) + 1;
556                button.closest('.facet').find('.facet-items .facet-item:nth-child(n+' + defaultCount + ')').css('display', '').attr('hidden', 'hidden');
557            } else {
558                // Expanding: show all items. Pagination is a separate mode,
559                // handled by ".facet-paginate-onload", not by "see more".
560                button.text(button.attr('data-label-see-less') ? button.attr('data-label-see-less') : (hasOmekaTranslate ? Omeka.jsTranslate('See less') : 'See less'));
561                button.closest('.facet').find('.facet-items .facet-item').removeAttr('hidden');
562                button.attr('aria-expanded', 'true');
563            }
564            $searchFacets.trigger('o:advanced-search.facet.see-more-or-less');
565            return self;
566        };
567
568        /**
569         * Initialize pagination for a facet.
570         *
571         * @param {jQuery} button The "see more/less" button.
572         * @param {number} perPage Number of items per page.
573         */
574        self.initPagination = function(button, perPage) {
575            button = $(button);
576            var facet = button.closest('.facet');
577            var items = facet.find('.facet-items .facet-item');
578
579            // Count all non-active (unchecked) items for pagination.
580            var paginableItems = items.filter(function() {
581                var input = $(this).find('input[type=checkbox]');
582                return !input.length || !input.prop('checked');
583            });
584            var totalPages = Math.ceil(paginableItems.length / perPage);
585            if (totalPages <= 0) totalPages = 1;
586
587            // Remove any existing pagination.
588            facet.find('.facet-pagination').remove();
589
590            // Don't show pagination when there is only one page.
591            if (totalPages <= 1) {
592                facet.find('.facet-items li').css('display', '').removeAttr('hidden');
593                return self;
594            }
595
596            var labelPage = button.attr('data-label-page') || 'Page';
597            var labelPagePrev = button.attr('data-label-page-prev') || 'Previous page';
598            var labelPageNext = button.attr('data-label-page-next') || 'Next page';
599            var labelFirst = labelPage + ' 1';
600            var labelLast = labelPage + ' ' + totalPages;
601            var indicatorHtml = totalPages > 4
602                ? '<span class="facet-page-indicator"><input type="number" class="facet-page-input page-input-top" value="1" min="1" max="' + totalPages + '" aria-label="' + labelPage + '" title="' + labelPage + '"><span class="page-count"> / ' + totalPages + '</span></span>'
603                : '<span class="facet-page-indicator"><span class="facet-page-current">1</span><span class="page-count"> / ' + totalPages + '</span></span>';
604            var paginationHtml = '<nav class="facet-pagination" aria-label="' + labelPage + '">'
605                + '<button type="button" class="facet-page-first" aria-label="' + labelFirst + '" title="' + labelFirst + '">&laquo;</button>'
606                + '<button type="button" class="facet-page-prev" aria-label="' + labelPagePrev + '" title="' + labelPagePrev + '">&lsaquo;</button>'
607                + indicatorHtml
608                + '<button type="button" class="facet-page-next" aria-label="' + labelPageNext + '" title="' + labelPageNext + '">
608&rsaquo;</button>'
609                + '<button type="button" class="facet-page-last" aria-label="' + labelLast + '" title="' + labelLast + '">&raquo;</button>'
610                + '</nav>';
611            button.closest('.facet-see-more').before(paginationHtml);
612
613            // Store pagination state on the facet element.
614            facet.data('facet-page', 1);
615            facet.data('facet-total-pages', totalPages);
616            facet.data('facet-per-page', perPage);
617
618            self.showPage(button, 1);
619            return self;
620        };
621
622        /**
623         * Show a specific page of facet items.
624         *
625         * Active/checked items always remain visible.
626         *
627         * @param {jQuery} button The "see more/less" button.
628         * @param {number} page The page number (1-based).
629         */
630        self.showPage = function(button, page) {
631            button = $(button);
632            var facet = button.closest('.facet');
633            var items = facet.find('.facet-items .facet-item');
634            var perPage = facet.data('facet-per-page') || Number(button.attr('data-per-page')) || 10;
635            var totalPages = facet.data('facet-total-pages') || 1;
636
637            if (page < 1) page = 1;
638            if (page > totalPages) page = totalPages;
639
640            facet.data('facet-page', page);
641
642            var paginableIndex = 0;
643            var startIndex = (page - 1) * perPage;
644            var endIndex = page * perPage;
645
646            items.each(function() {
647                var item = $(this);
648                var input = item.find('input[type=checkbox]');
649                var isActive = input.length && input.prop('checked');
650
651                if (isActive) {
652                    // Active/checked items: visible on first page only.
653                    if (page === 1) {
654                        item.removeAttr('hidden');
655                    } else {
656                        item.attr('hidden', 'hidden');
657                    }
658                } else {
659                    // All non-active items are paginated.
660                    if (paginableIndex >= startIndex && paginableIndex < endIndex) {
661                        item.removeAttr('hidden');
662                    } else {
663                        item.attr('hidden', 'hidden');
664                    }
665                    paginableIndex++;
666                }
667            });
668
669            // Update indicator.
670            var pageInput = facet.find('.facet-page-input');
671            if (pageInput.length) {
672                pageInput.val(page);
673            } else {
674                facet.find('.facet-page-indicator .facet-page-current').text(page);
675            }
676
677            // Update button states.
678            facet.find('.facet-page-first, .facet-page-prev').prop('disabled', page <= 1);
679            facet.find('.facet-page-next, .facet-page-last').prop('disabled', page >= totalPages);
680
681            return self;
682        };
683
684        /**
685         * Remove pagination controls and reset state.
686         *
687         * @param {jQuery} button The "see more/less" button.
688         */
689        self.removePagination = function(button) {
690            button = $(button);
691            var facet = button.closest('.facet');
692            facet.find('.facet-pagination').remove();
693            facet.find('.facet-items').css({
694                'height': '',
695                'overflow-y': '',
696            });
697            facet.removeData('facet-page');
698            facet.removeData('facet-total-pages');
699            facet.removeData('facet-per-page');
700            facet.removeData('facet-fixed-height');
701            return self;
702        };
703
704        self.directLinkCheckbox = function (facetItem) {
705            if (!self.useApplyFacets) {
706                facetItem = facetItem ? $(facetItem) : $(this);
707                if (facetItem.data('url')) {
708                    window.location = facetItem.data('url');
709                }
710            }
711            return self;
712        };
713
714        self.directLinkSelect = function (facet) {
715            if (self.useApplyFacets) {
716                return self;
717            }
718            // Replace the current select args by new ones.
719            // Names in facets may have no index in array ("[]") when it is a
720            // multiple one. But the select may be a single select too, in which
721            // case the url is already in data.
722            facet = facet ? $(facet) : $(this);
723            let url;
724            let selectValues = facet.val();
725            if (typeof selectValues !== 'object') {
726                let option =  facet.find('option:selected');
727                if (option.length && option[0].value !== '') {
728                    url = option.data('url');
729                    if (url && url.length) {
730                        window.location = url;
731                    }
732                } else {
733                    url = new URL(window.location.href);
734                    url.searchParams.delete(facet.prop('name'));
735                    window.location = url.toString();
736                }
737            } else {
738                // Prepare the url with the selected values.
739                url = new URL(window.location.href);
740                let selectName = facet.prop('name');
741                url.searchParams.delete(selectName);
742                selectValues.forEach((element, index) => {
743                    url.searchParams.set(selectName.substring(0, selectName.length - 2) + '[' + index + ']', element);
744                });
745                window.location = url.toString();
746            }
747            return self;
748        };
749
750        /**
751         * Active facets are always a link, but the link may be skipped when
752         * the mode is to use the apply button.
753         */
754        self.removeActiveFacet = function (activeFacet) {
755            // Reload with the link when there is no button to apply facets.
756            if (!Search.facets.useApplyFacets) {
757                return true;
758            }
759            e.preventDefault();
760            activeFacet = activeFacet ? $(activeFacet) : $(this);
761            activeFacet.closest('li').hide();
762            var facetName = activeFacet.data('facetName');
763            var facetValue = activeFacet.data('facetValue');
764            $searchFacets.find('.facet-item input:checked').each(function() {
765                if ($(this).prop('name') === facetName
766                    && $(this).prop('value') === String(facetValue)
767                ) {
768                    $(this).prop('checked', false);
769                }
770            });
771            $searchFacets.find('select.facet-items option:selected').each(function() {
772                if ($(this).closest('select').prop('name') === facetName
773                    && $(this).prop('value') === String(facetValue)
774                ) {
775                    $(this).prop('selected', false);
776                    if (hasChosenSelect) {
777                        $(this).closest('select').trigger('chosen:updated');
778                    }
779                }
780            });
781            // Manage the special case where the active facet is a range.
782            var facetRange = $searchFacets.find(`input[type=range][name="${facetName}"]`);
783            if (facetRange.length) {
784                if (facetName.includes('[to]')) {
785                    facetRange.val(facetRange.attr('max'));
786                    Search.rangeSliderDouble.controlSliderTo(facetRange[0]);
787                } else {
788                    facetRange.val(facetRange.attr('min'));
789                    Search.rangeSliderDouble.controlSliderFrom(facetRange[0]);
790                }
791            }
792            return self;
793        };
794
795        return self;
796    })();
797
798    /* Forms tools. */
799
800    /**
801     * Clear the form with hidden and min/max management.
802     *
803     * The html button "reset" resets to default values, not empty values.
804     * Furthermore, the min/max of ranges and numbers should be managed.
805     * For select and radio, set the default or the first value.
806     * Keep hidden values.
807     */
808    self.smartClearForm = function(form) {
809        if (!form) {
810            return;
811        }
812        // TODO Function reset() does not work on search form, only on facets form.
813        typeof form.reset === 'function' ? form.reset() : $(form).trigger('reset');
814        const elements = form.elements;
815        var element, type;
816        for (var i = 0; i < elements.length; i++) {
817            element = elements[i];
818            type = element.type.toLowerCase();
819            switch(type) {
820                case 'hidden':
821                case 'button':
822                case 'reset':
823                case 'submit':
824                    // Keep hidden fields.
825                    break;
826                case 'checkbox':
827                case 'radio':
828                    element.checked = false;
829                    // Fix reset issue with some config.
830                    $(element).prop('checked', false);
831                    $(element).removeAttr('checked');
832                    break;
833                case 'select-one':
834                case 'select-multiple':
835                    $(element).find('option').removeAttr('selected').end().trigger('chosen:updated');
836                    break;
837                case 'number':
838                    if ((element.name.endsWith('[to]')) || element.className.endsWith('-to')) {
839                        element.defaultValue = element.value = element.max;
840                        Search.rangeSliderDouble.controlNumericTo(element);
841                    } else {
842                        element.defaultValue = element.value = element.min;
843                        if (element.name.endsWith('[from]') || element.className.endsWith('-from')) {
844                            Search.rangeSliderDouble.controlNumericFrom(element);
845                        }
846                    }
847                    break;
848                case 'range':
849                    if (element.name.endsWith('[to]') || element.className.endsWith('-to')) {
850                        element.defaultValue = element.value = element.max;
851                        Search.rangeSliderDouble.controlSliderTo(element);
852                    } else {
853                        element.defaultValue = element.value = element.min;
854                        if (element.name.endsWith('[from]') || element.className.endsWith('-from')) {
855                            Search.rangeSliderDouble.controlSliderFrom(element);
856                        }
857                    }
858                    break;
859                default:
860                    // Text area, text, password, etc.
861                    element.defaultValue = element.value = '';
862                    break;
863            }
864        }
865        return self;
866    }
867
868    /**
869     * Clean search query by removing empty inputs before form submission.
870     *
871     * This produces cleaner URLs like ?q=pont instead of
872     * ?q=pont&submit=&filter[0][join]=and&filter[0][field]=...&filter[0][val]=
873     *
874     * Generic solution for any form structure:
875     * - Groups with empty value field (val, text) AND type requiring value => remove group
876     * - Types without value (ex, nex, etc.) are kept even if val is empty
877     * - Empty simple inputs are removed directly
878     * - "0" is kept as a valid value
879     */
880    self.cleanSearchQuery = function(form) {
881        if (!form) return self;
882        const $form = $(form);
883
884        // Trim text inputs (q, refine, filter values…).
885        $form.find('input[type=search], input[type=text]').each(function() {
886            const $input = $(this);
887            const v = $input.val();
888            if (v && v !== v.trim()) {
889                $input.val(v.trim());
890            }
891        });
892
893        const isEmpty = (v) => v === '' || v === null || (Array.isArray(v) && !v.length);
894        const typesWithValue = self.filterTypes.withValue;
895
896        // Groups to remove (identified by their prefix).
897        const groupsToRemove = new Set();
898
899        // First pass: find groups with empty value fields where type requires value.
900        $form.find(':input[name]').each(function() {
901            const $input = $(this);
902            const name = $input.attr('name');
903            const value = $input.val();
904
905            // Keep "0" as valid value.
906            if (value === '0' || value === 0) return;
907
908            // Match grouped inputs: prefix[val] or prefix[text].
909            const match = name.match(/^(.+)\[(val|text)\]$/);
910            if (match && isEmpty(value)) {
911                const prefix = match[1]; // e.g., "filter[0]" or "property[1]"
912                // Check if the type requires a value.
913                const typeInput = $form.find(`[name="${prefix}[type]"]`);
914                const typeVal = typeInput.length ? typeInput.val() : null;
915                // Remove group if no type field, or type requires value.
916                if (!typeVal || typesWithValue.includes(typeVal)) {
917                    groupsToRemove.add(prefix);
918                }
919            }
920        });
921
922        // Second pass: remove groups and empty simple inputs.
923        $form.find(':input[name]').each(function() {
924            const $input = $(this);
925            const name = $input.attr('name');
926            const value = $input.val();
927
928            // Keep "0" as valid value.
929            if (value === '0' || value === 0) return;
930
931            // Check if this input belongs to a group to remove.
932            for (const prefix of groupsToRemove) {
933                if (name.startsWith(prefix + '[')) {
934                    $input.prop('name', '');
935                    return;
936                }
937            }
938
939            // Simple input: remove if empty.
940            if (isEmpty(value)) {
941                $input.prop('name', '');
942            }
943        });
944
945        return self;
946    }
947
948    /**
949    * Search range double / sliders.
950    *
951    * "min" and "max" values are required to compute color.
952    *
953    * @todo Get slider colors from the css.
954    * @see https://medium.com/@predragdavidovic10/native-dual-range-slider-html-css-javascript-91e778134816
955    */
956    self.rangeSliderDouble = (function() {
957        var self = {};
958
959        self.minDefault = 0;
960        self.maxDefault = 100;
961        self.colorRangeDefault = '#a7a7a7';
962        self.colorSliderDefault = '#e9e9ed';
963
964        self.getRangeDoubleElements = function(element) {
965            const rangeDouble = element.closest('.range-double');
966            return rangeDouble
967                ? [
968                    rangeDouble.querySelector('.range-numeric-from'),
969                    rangeDouble.querySelector('.range-numeric-to'),
970                    rangeDouble.querySelector('.range-slider-from'),
971                    rangeDouble.querySelector('.range-slider-to'),
972                ]
973                : [null, null, null, null];
974        };
975
976        /**
977         * Read scale config from data attributes on .range-double. Returns {
978         * mode: 'linear'|'piecewise', breakpoints: [[v,p],...] }.
979         */
980        self.getScale = function(element) {
981            const rangeDouble = element ? element.closest('.range-double') : null;
982            if (!rangeDouble) {
983                return { mode: 'linear', breakpoints: [] };
984            }
985            const mode = rangeDouble.getAttribute('data-scale-mode') || 'linear';
986            if (mode === 'log') {
987                const lmin = parseFloat(rangeDouble.getAttribute('data-scale-min'));
988                const lmax = parseFloat(rangeDouble.getAttribute('data-scale-max'));
989                if (isNaN(lmin) || isNaN(lmax) || lmax <= lmin) {
990                    return { mode: 'linear', breakpoints: [] };
991                }
992                return { mode: 'log', min: lmin, max: lmax, breakpoints: [[lmin, 0], [lmax, 100]] };
993            }
994            if (mode !== 'piecewise') {
995                return { mode: 'linear', breakpoints: [] };
996            }
997            const raw = rangeDouble.getAttribute('data-scale-breakpoints') || '[]';
998            let bp;
999            try {
1000                bp = JSON.parse(raw);
1001            } catch (e) {
1002                return { mode: 'linear', breakpoints: [] };
1003            }
1004            if (!Array.isArray(bp) || bp.length < 2) {
1005                return { mode: 'linear', breakpoints: [] };
1006            }
1007            // Sort by value to be safe.
1008            bp = bp.map(p => [parseFloat(p[0]), parseFloat(p[1])]).sort((a, b) => a[0] - b[0]);
1009            return { mode: 'piecewise', breakpoints: bp };
1010        };
1011
1012        self.logValueToPos = function(val, scale) {
1013            if (val <= scale.min) return 0;
1014            if (val >= scale.max) return 100;
1015            const denom = Math.log10(scale.max - scale.min + 1);
1016            return denom > 0 ? 100 * Math.log10(val - scale.min + 1) / denom : 0;
1017        };
1018
1019        self.logPosToValue = function(pos, scale) {
1020            if (pos <= 0) return scale.min;
1021            if (pos >= 100) return scale.max;
1022            const denom = Math.log10(scale.max - scale.min + 1);
1023            return Math.round(scale.min + Math.pow(10, pos / 100 * denom) - 1);
1024        };
1025
1026        self.domainToPos = function(val, scale) {
1027            if (scale.mode === 'piecewise') return self.valueToPos(val, scale.breakpoints);
1028            if (scale.mode === 'log') return self.logValueToPos(val, scale);
1029            return val;
1030        };
1031
1032        self.posToDomain = function(pos, scale) {
1033            if (scale.mode === 'piecewise') return self.posToValue(pos, scale.breakpoints);
1034            if (scale.mode === 'log') return self.logPosToValue(pos, scale);
1035            return pos;
1036        };
1037
1038        /**
1039         * Convert a domain value to a slider position (0..100).
1040         */
1041        self.valueToPos = function(val, bp) {
1042            if (val <= bp[0][0]) return bp[0][1];
1043            const last = bp.length - 1;
1044            if (val >= bp[last][0]) return bp[last][1];
1045            for (let i = 0; i < last; i++) {
1046                const [v0, p0] = bp[i];
1047                const [v1, p1] = bp[i + 1];
1048                if (val >= v0 && val <= v1) {
1049                    return p0 + (p1 - p0) * (val - v0) / (v1 - v0);
1050                }
1051            }
1052            return bp[last][1];
1053        };
1054
1055        /**
1056         * Convert a slider position (0..100) to a domain value.
1057         */
1058        self.posToValue = function(pos, bp) {
1059            if (pos <= bp[0][1]) return bp[0][0];
1060            const last = bp.length - 1;
1061            if (pos >= bp[last][1]) return bp[last][0];
1062            for (let i = 0; i < last; i++) {
1063                const [v0, p0] = bp[i];
1064                const [v1, p1] = bp[i + 1];
1065                if (pos >= p0 && pos <= p1) {
1066                    return Math.round(v0 + (v1 - v0) * (pos - p0) / (p1 - p0));
1067                }
1068            }
1069            return bp[last][0];
1070        };
1071
1072        self.controlNumericFrom = function(element) {
1073            const [inputFrom, inputTo, sliderFrom, sliderTo] = self.getRangeDoubleElements(element);
1074            // Let the user clear/edit the input freely;
1074 do not sync while the
1075            // input is empty or not a valid number.
1076            if (inputFrom.value === '' || isNaN(parseFloat(inputFrom.value))) {
1077                return self;
1078            }
1079            const scale = self.getScale(element);
1080            const [fromVal, toVal] = self.parseTwoElementsToNumber(inputFrom, inputTo);
1081            const fromClamped = (!isNaN(toVal) && fromVal > toVal) ? toVal : fromVal;
1082            sliderFrom.value = scale.mode === 'linear'
1083                ? String(fromClamped)
1084                : String(self.domainToPos(fromClamped, scale));
1085            self.fillSlider(inputFrom, inputTo, sliderTo);
1086            return self;
1087        };
1088
1089        self.controlNumericTo = function(element) {
1090            const [inputFrom, inputTo, sliderFrom, sliderTo] = self.getRangeDoubleElements(element);
1091            if (inputTo.value === '' || isNaN(parseFloat(inputTo.value))) {
1092                return self;
1093            }
1094            const scale = self.getScale(element);
1095            const [fromVal, toVal] = self.parseTwoElementsToNumber(inputFrom, inputTo);
1096            const toClamped = (!isNaN(fromVal) && fromVal > toVal) ? fromVal : toVal;
1097            sliderTo.value = scale.mode === 'linear'
1098                ? String(toClamped)
1099                : String(self.domainToPos(toClamped, scale));
1100            self.fillSlider(inputFrom, inputTo, sliderTo);
1101            self.toggleRangeSliderAccessible(sliderTo);
1102            return self;
1103        }
1104
1105        self.controlSliderFrom = function(element) {
1106            const [inputFrom, inputTo, sliderFrom, sliderTo] = self.getRangeDoubleElements(element);
1107            const scale = self.getScale(element);
1108            const [fromPos, toPos] = self.parseTwoElementsToNumber(sliderFrom, sliderTo);
1109            const clampedPos = fromPos > toPos ? toPos : fromPos;
1110            sliderFrom.value = String(clampedPos);
1111            inputFrom.value = scale.mode === 'linear'
1112                ? String(clampedPos)
1113                : String(self.posToDomain(clampedPos, scale));
1114            self.fillSlider(sliderFrom, sliderTo, sliderTo);
1115            return self;
1116        }
1117
1118        self.controlSliderTo = function(element) {
1119            const [inputFrom, inputTo, sliderFrom, sliderTo] = self.getRangeDoubleElements(element);
1120            const scale = self.getScale(element);
1121            const [fromPos, toPos] = self.parseTwoElementsToNumber(sliderFrom, sliderTo);
1122            const clampedPos = fromPos > toPos ? fromPos : toPos;
1123            sliderTo.value = String(clampedPos);
1124            inputTo.value = scale.mode === 'linear'
1125                ? String(clampedPos)
1126                : String(self.posToDomain(clampedPos, scale));
1127            self.fillSlider(sliderFrom, sliderTo, sliderTo);
1128            self.toggleRangeSliderAccessible(sliderTo);
1129            return self;
1130        }
1131
1132        self.fillSlider = function(fromEl, toEl, controlSlider, colorSlider, colorRange) {
1133            // Here, from and to may be the input or the slider. This is the
1134            // main point to manage the double slider simply.
1135
1136            const rangeDouble = controlSlider.closest('.range-double');
1137            if (!rangeDouble) {
1138                return self;
1139            }
1140            const prog = rangeDouble.querySelector('.range-progress');
1141            if (!prog) {
1142                return self;
1143            }
1144
1145            const scale = self.getScale(controlSlider);
1146            let fromPct;
1147            let toPct;
1148            if (scale.mode === 'piecewise' || scale.mode === 'log') {
1149                // Slider value is the position (0..100); read directly from
1150                // sliders to drive the progress bar regardless of which element
1151                // pair (slider or numeric) was passed in.
1152                const sliderFrom = rangeDouble.querySelector('.range-slider-from');
1153                const sliderTo = rangeDouble.querySelector('.range-slider-to');
1154                fromPct = sliderFrom && sliderFrom.value !== '' ? parseFloat(sliderFrom.value) : 0;
1155                toPct = sliderTo && sliderTo.value !== '' ? parseFloat(sliderTo.value) : 100;
1156            } else {
1157                // Validate bounds (linear: slider min/max are domain extremes).
1158                const min = toEl.min !== '' && !isNaN(parseFloat(toEl.min)) ? parseFloat(toEl.min) : self.minDefault;
1159                const max = toEl.max !== '' && !isNaN(parseFloat(toEl.max)) ? parseFloat(toEl.max) : self.maxDefault;
1160
1161                // Parse current values.
1162                const fromVal = fromEl.value !== '' && !isNaN(parseFloat(fromEl.value)) ? parseFloat(fromEl.value) : min;
1163                const toVal = toEl.value !== '' && !isNaN(parseFloat(toEl.value)) ? parseFloat(toEl.value) : max;
1164
1165                fromPct = ((Math.min(Math.max(fromVal, min), max) - min) / (max - min)) * 100;
1166                toPct = ((Math.min(Math.max(toVal, min), max) - min) / (max - min)) * 100;
1167            }
1168
1169            const fromProgress = Math.min(fromPct, toPct);
1170            const toProgress = Math.max(fromPct, toPct);
1171
1172            // Drive CSS variables on the visible progress bar. Previous version
1173            // used a linear-gradient on main range.
1174            prog.style.setProperty('--from', fromProgress + '%');
1175            prog.style.setProperty('--to', toProgress + '%');
1176
1177            return self;
1178        }
1179
1180        self.toggleRangeSliderAccessible = function(sliderCurrent) {
1181            const [inputFrom, inputTo, sliderFrom, sliderTo] = self.getRangeDoubleElements(sliderCurrent);
1182            if (!sliderFrom || !sliderTo) return self;
1183            const min = sliderFrom.min !== '' && !isNaN(parseFloat(sliderFrom.min)) ? parseFloat(sliderFrom.min) : self.minDefault;
1184            const max = sliderTo.max !== '' && !isNaN(parseFloat(sliderTo.max)
1184) ? parseFloat(sliderTo.max) : self.maxDefault;
1185            const fromValue = sliderFrom.value !== '' && !isNaN(parseFloat(sliderFrom.value)) ? parseFloat(sliderFrom.value) : min;
1186            const toValue = sliderTo.value !== '' && !isNaN(parseFloat(sliderTo.value)) ? parseFloat(sliderTo.value) : max;
1187
1188            // Allow right slider to capture events when overlap.
1189            sliderTo.style.zIndex = (fromValue === toValue) ? '2' : '0';
1190            return self;
1191        }
1192
1193        self.parseTwoElementsToNumber = function (currentFrom, currentTo) {
1194            const from = parseFloat(currentFrom.value, 10);
1195            const to = parseFloat(currentTo.value, 10);
1196            return [from, to];
1197        }
1198
1199        self.ensureValidBounds = function(el) {
1200            if (!el) {
1201                return self;
1202            }
1203
1204            const minDefault = self.minDefault;
1205            const maxDefault = self.maxDefault;
1206
1207            // Validate min and max. Swap if needed.
1208            let min = el.min !== '' ? parseFloat(el.min) : NaN;
1209            let max = el.max !== '' ? parseFloat(el.max) : NaN;
1210            if (isNaN(min)) {
1211                min = minDefault;
1212            }
1213            if (isNaN(max)) {
1214                max = maxDefault;
1215            }
1216            if (min > max) {
1217                const tmp = min;
1218                min = max;
1219                max = tmp;
1220            }
1221            el.min = String(min);
1222            el.max = String(max);
1223
1224            // Clamp current value into bounds when present.
1225            if (el.value !== '') {
1226                const val = parseFloat(el.value);
1227                el.value = isNaN(val) ? String(min) : String(Math.min(Math.max(val, min), max));
1228            } else {
1229                // Default to lower bound for "from", upper for "to" if class hints exist.
1230                if (el.classList.contains('range-slider-to') || el.classList.contains('range-numeric-to')) {
1231                    el.value = String(max);
1232                } else {
1233                    el.value = String(min);
1234                }
1235            }
1236
1237            return self;
1238        }
1239
1240        self.normalizeRangeDouble = function(rangeDouble) {
1241            if (!rangeDouble) {
1242                return self;
1243            }
1244
1245            const inputFrom = rangeDouble.querySelector('.range-numeric-from');
1246            const inputTo = rangeDouble.querySelector('.range-numeric-to');
1247            const sliderFrom = rangeDouble.querySelector('.range-slider-from');
1248            const sliderTo = rangeDouble.querySelector('.range-slider-to');
1249
1250            // Ensure valid bounds on all parts.
1251            [inputFrom, inputTo, sliderFrom, sliderTo].forEach(self.ensureValidBounds);
1252
1253            const scale = self.getScale(rangeDouble);
1254            if (scale.mode === 'piecewise' || scale.mode === 'log') {
1255                // Numeric inputs hold domain values; sliders hold positions
1256                // 0..100. Clamp domain values to scale extremes, then derive
1257                // slider positions from domain values.
1258                const minVal = scale.mode === 'log' ? scale.min : scale.breakpoints[0][0];
1259                const maxVal = scale.mode === 'log' ? scale.max : scale.breakpoints[scale.breakpoints.length - 1][0];
1260                const fromDomain = inputFrom && inputFrom.value !== '' && !isNaN(parseFloat(inputFrom.value))
1261                    ? parseFloat(inputFrom.value) : minVal;
1262                const toDomain = inputTo && inputTo.value !== '' && !isNaN(parseFloat(inputTo.value))
1263                    ? parseFloat(inputTo.value) : maxVal;
1264                const fromClampedVal = Math.min(Math.max(Math.min(fromDomain, toDomain), minVal), maxVal);
1265                const toClampedVal = Math.min(Math.max(Math.max(fromDomain, toDomain), minVal), maxVal);
1266                if (inputFrom) inputFrom.value = String(fromClampedVal);
1267                if (inputTo) inputTo.value = String(toClampedVal);
1268                if (sliderFrom) sliderFrom.value = String(self.domainToPos(fromClampedVal, scale));
1269                if (sliderTo) sliderTo.value = String(self.domainToPos(toClampedVal, scale));
1270            } else {
1271                // Linear: slider value = domain value.
1272                const fromVal = parseFloat(sliderFrom && sliderFrom.value || inputFrom && inputFrom.value || self.minDefault);
1273                const toVal = parseFloat(sliderTo && sliderTo.value || inputTo && inputTo.value || self.maxDefault);
1274                const min = parseFloat(sliderTo ? sliderTo.min : self.minDefault);
1275                const max = parseFloat(sliderTo ? sliderTo.max : self.maxDefault);
1276
1277                const from = isNaN(fromVal) ? min : Math.min(Math.max(fromVal, min), max);
1278                const to = isNaN(toVal) ? max : Math.min(Math.max(toVal, min), max);
1279                const fromClamped = Math.min(from, to);
1280                const toClamped = Math.max(from, to);
1281
1282                if (inputFrom) inputFrom.value = String(fromClamped);
1283                if (sliderFrom) sliderFrom.value = String(fromClamped);
1284                if (inputTo) inputTo.value = String(toClamped);
1285                if (sliderTo) sliderTo.value = String(toClamped);
1286            }
1287
1288            // Render and adjust accessibility.
1289            if (sliderFrom && sliderTo) {
1290                self.fillSlider(sliderFrom, sliderTo, sliderTo);
1291                self.toggleRangeSliderAccessible(sliderTo);
1292            } else if (inputFrom && inputTo && sliderTo) {
1293                self.fillSlider(inputFrom, inputTo, sliderTo);
1294                self.toggleRangeSliderAccessible(sliderTo);
1295            }
1296
1297            return self;
1298        };
1299
1300        /**
1301         * Clear range values at extremes before form submission.
1302         *
1303         * When the slider is at the minimum, don't filter by "from" (any start).
1304         * When the slider is at the maximum, don't filter by "to" (any end).
1305         * This provides better UX: user hasn't moved the slider = no filter.
1306         *
1307         * Note: For <input type="range">, setting value='' resets to min,
1308         * so we remove the name attribute to exclude from form submission.
1309         *
1310         * @todo Backend: sort items without date values last when no filter is active.
1311         * @todo Backend: exclude items without date when filter is active, include when not.
1312         * @todo Add admin option to enable/disable this behavior per field.
1313         */
1314        self.clearExtremesBeforeSubmit = function(rangeDouble) {
1315            if (!rangeDouble) {
1316                return self;
1317            }
1318
1319            const inputFrom = rangeDouble.querySelector('.range-numeric-from');
1320            const inputTo = rangeDouble.querySelector('.range-numeric-to');
1321            const sliderFrom = rangeDouble.querySelector('.range-slider-from');
1322            const sliderTo = rangeDouble.querySelector('.range-slider-to');
1323
1324            // Get min/max from slider or input attributes.
1325            const refElement = sliderFrom || inputFrom;
1326            if (!refElement) {
1327                return self;
1328            }
1329
1330            const min = parseFloat(refElement.min);
1331            const max = parseFloat(refElement.max);
1332
1333            // Remove "from" from submission if it equals min.
1334            // For range inputs, we remove the name attribute (value='' doesn't work).
1335            const fromValue = parseFloat(sliderFrom ? sliderFrom.value : (inputFrom ? inputFrom.value : NaN));
1336            if (!isNaN(fromValue) && !isNaN(min) && fromValue === min) {
1337                if (sliderFrom) sliderFrom.removeAttribute('name');
1338                if (inputFrom && inputFrom.name) inputFrom.removeAttribute('name');
1339            }
1340
1341            // Remove "to" from submission if it equals max.
1342            const toValue = parseFloat(sliderTo ? sliderTo.value : (inputTo ? inputTo.value : NaN));
1343            if (!isNaN(toValue) && !isNaN(max) && toValue === max) {
1344                if (sliderTo) sliderTo.removeAttribute('name');
1345                if (inputTo && inputTo.name) inputTo.removeAttribute('name');
1346            }
1347
1348            return self;
1349        };
1350
1351        return self;
1352    })();
1353
1354    return self;
1355})();
1356
1357$(document).ready(function() {
1358
1359    const hasAutocomplete = typeof $.fn.autocomplete === 'function';
1360
1361    /**
1362     * Fix when the simple and the advanced form are the same form.
1363     */
1364    $('.advanced-search-form-toggle a').on('click', function(e) {
1365        e.preventDefault();
1366        $('.advanced-search-form, .advanced-search-form-toggle').toggleClass('open');
1367        const isOpen = $('.advanced-search-form').hasClass('open');
1368        const toggleLink = $('.advanced-search-form-toggle a');
1369        if (isOpen) {
1370            toggleLink.text($('.advanced-search-form-toggle').data('msgOpen'));
1371        } else {
1372            toggleLink.text($('.advanced-search-form-toggle').data('msgClosed'));
1373        }
1374        toggleLink.attr('aria-expanded', isOpen ? 'true' : 'false');
1375        // TODO Don't open autosuggestion when toggle.
1376        // $('#search-form [name=q]').focus();
1377    });
1378
1379    /**
1380     * Events.
1381     */
1382
1383    $('#search-reset, #facets-reset').on('click', function (ev) {
1384        ev.preventDefault();
1385        // The button may be outside the form.
1386        const form = $(this)[0].form;
1387        Search.smartClearForm(form);
1388        // Reload page only for facets form, when facets apply immediately.
1389        if (this.id === 'facets-reset' && !Search.facets.useApplyFacets) {
1390            const url = new URL(window.location.href);
1391            const params = new URLSearchParams(url.search);
1392            for (const key of params.keys()) {
1393                if (key.startsWith('facet[') || key.startsWith('facet%5B')) {
1394                    params.delete(key);
1395                }
1396            }
1397            url.search = params.toString();
1398            window.location.href = url.toString();
1399        }
1400    });
1401
1402    /**
1403     * Init advanced search filters.
1404     */
1405
1406    // Init one controller per advanced filters fieldset (there may be more than
1407    // one on a page, e.g. a duplicated sticky header search). Each controller
1408    // is stored on its fieldset so delegated handlers can reach the right
1409    // instance.
1410    Search.initFiltersAdvanced = function(context) {
1411        var $context = context ? $(context) : $(document);
1412        $context.find('.search-filters-advanced')
1413            .addBack('.search-filters-advanced')
1414            .each(function() {
1415                var $fieldset = $(this);
1416                // The fieldset may have been initialized in a previous call.
1417                if ($fieldset.data('filtersAdvanced')) {
1418                    return;
1419                }
1420                var controller = Search.createFiltersAdvanced($fieldset);
1421                $fieldset.data('filtersAdvanced', controller);
1422                controller.init();
1423            });
1424        return Search;
1425    };
1426
1427    Search.initFiltersAdvanced(document);
1428
1429    var getFiltersAdvanced = function(ev) {
1430        return $(ev.target).closest('.search-filters-advanced').data('filtersAdvanced');
1431    };
1432
1433    $(document).on('click', '.search-filters-advanced .search-filter-minus', function (ev) {
1434        var controller = getFiltersAdvanced(ev);
1435        if (!controller) return;
1436        const filter = $(ev.target).closest('fieldset.filter');
1437        controller
1438            .removeFilter(filter)
1439            .updatePlus();
1440    });
1441
1442    $(document).on('click', '.search-filters-advanced .search-filter-plus', function (ev) {
1443        var controller = getFiltersAdvanced(ev);
1444        if (!controller) return;
1445        controller
1446            .appendFilter()
1447            .updatePlus();
1448    });
1449
1450    /**
1451     * Autosuggest on advanced filter values.
1452     *
1453     * Use search engine values endpoint to get field values with prefix filtering.
1454     */
1455    if (typeof $.fn.autocomplete === 'function') {
1456        var initAdvFilterAutosuggest = function(filterFieldset) {
1457            var advFilterAutosuggestUrl = filterFieldset
1458                .closest('.search-filters-advanced').data('autosuggest-url');
1459            if (!advFilterAutosuggestUrl) return;
1460            var input = filterFieldset.find('input[name$="[val]"]');
1461            if (!input.length) return;
1462            if (input.data('autocomplete')) {
1463                input.autocomplete('dispose');
1464            }
1465            var fieldSelect = filterFieldset.find('select[name$="[field]"]');
1466            var field = fieldSelect.val();
1467            if (!field) return;
1468
1469            input.autocomplete({
1470                serviceUrl: advFilterAutosuggestUrl
1471                    + '&field=' + encodeURIComponent(field),
1472                dataType: 'json',
1473                paramName: 'q',
1474                minChars: 2,
1475                transformResult: function(response) {
1476                    return response.data ? response.data : response;
1477                },
1478            });
1479            input.attr('autocomplete', 'off');
1480        };
1481
1482        $(document).on('change', '.search-filters-advanced select[name$="[field]"]', function() {
1483            initAdvFilterAutosuggest($(this).closest('fieldset.filter'));
1484        });
1485
1486        $(document).on('o:advanced-search.filter.append', '.search-filters-advanced', function() {
1487            $(this).find('> fieldset.filter').each(function() {
1488                initAdvFilterAutosuggest($(this));
1489            });
1490        });
1491
1492        $('.search-filters-advanced > fieldset.filter').each(function() {
1493            initAdvFilterAutosuggest($(this));
1494        });
1495
1496        Search.initFilterAutosuggest = initAdvFilterAutosuggest;
1497    }
1498
1499    /**
1500     * Results tools (sort, pagination, per-page).
1501     */
1502
1503    var setActiveViewType = function(activeClass) {
1504        $('.search-view-type').removeClass('active').attr('aria-pressed', 'false');
1505        $('.' + activeClass).addClass('active').attr('aria-pressed', 'true');
1506    };
1507
1508    $('.search-view-type-list').on('click', function(e) {
1509        e.preventDefault();
1510        Search.setViewType('list');
1511        setActiveViewType('search-view-type-list');
1512    });
1513
1514    $('.search-view-type-grid').on('click', function(e) {
1515        e.preventDefault();
1516        Search.setViewType('grid');
1517        setActiveViewType('search-view-type-grid');
1518    });
1519
1520    /**
1521     * Map view handler. Requires the Mapping module to be installed.
1522     */
1523    $('.search-view-type-map').on('click', function(e) {
1524        e.preventDefault();
1525        Search.setViewType('map');
1526        setActiveViewType('search-view-type-map');
1527    });
1528
1529    $('.as-url select, select.as-url').on('change', function(e) {
1530        const url = $(this).find('option:selected').data('url');
1531        if (url && url.length && window.location !== url) {
1532            window.location = url;
1533        };
1534    });
1535
1536    /* Facets. */
1537
1538    if ($searchFacets.length) {
1539        if (Search.facets.useApplyFacets) {
1540            $searchFacets.on('click', '.facets-active a', (event) => Search.facets.removeActiveFacet(event.target));
1541        } else {
1542            $searchFacets.on('change', 'input[type=checkbox][data-url]', (event) => Search.facets.directLinkCheckbox(event.target));
1543            $searchFacets.on('change', 'select', (event) => Search.facets.directLinkSelect(event.target));
1544        }
1545
1546        $searchFacets.on('click', '.facet-button, .facet-see-more-or-less', function() {
1547            const button = $(this);
1548            if (button.hasClass('expand')) {
1549                $(this).removeClass('expand').addClass('collapse');
1550            } else {
1551                $(this).removeClass('collapse').addClass('expand');
1552            }
1553            if (button.hasClass('facet-see-more-or-less')) {
1554                Search.facets.seeMoreOrLess(button);
1555            } else {
1556                Search.facets.expandOrCollapse(button);
1557                Search.facets.updateExpandAllButton();
1558            }
1559        });
1560
1561        $searchFacets.on('click', '.facets-expand-all', function() {
1562            const button = $(this);
1563            Search.facets.expandOrCollapseAll(button.hasClass('expand'));
1564        });
1565
1566        $searchFacets.find('.facet-see-more-or-less:not(.facet-paginate-onload)').each((index, button) => Search.facets.seeMoreOrLess(button));
1567
1568        // Facets configured to paginate from load (more empty + per_page > 0):
1569        // show the pagination immediately, without any "see more" button.
1570        $searchFacets.find('.facet-paginate-onload').each(function() {
1571            const button = $(this);
1572            Search.facets.initPagination(button, Number(button.attr('data-per-page')) || 0);
1573        });
1574
1575        // Pagination navigation buttons.
1576        $searchFacets.on('click', '.facet-page-first', function() {
1577            var facet = $(this).closest('.facet');
1578            var button = facet.find('.facet-see-more-or-less');
1579            Search.facets.showPage(button, 1);
1580        });
1581        $searchFacets.on('click', '.facet-page-prev', function() {
1582            var facet = $(this).closest('.facet');
1583            var button = facet.find('.facet-see-more-or-less');
1584            var page = facet.data('facet-page') || 1;
1585            Search.facets.showPage(button, page - 1);
1586        });
1587        $searchFacets.on('click', '.facet-page-next', function() {
1588            var facet = $(this).closest('.facet');
1589            var button = facet.find('.facet-see-more-or-less');
1590            var page = facet.data('facet-page') || 1;
1591            Search.facets.showPage(button, page + 1);
1592        });
1593        $searchFacets.on('click', '.facet-page-last', function() {
1594            var facet = $(this).closest('.facet');
1595            var button = facet.find('.facet-see-more-or-less');
1596            var totalPages = facet.data('facet-total-pages') || 1;
1597            Search.facets.showPage(button, totalPages);
1598        });
1599        $searchFacets.on('change', '.facet-page-input', function() {
1600            var input = $(this);
1601            var facet = input.closest('.facet');
1602            var button = facet.find('.facet-see-more-or-less');
1603            var page = parseInt(input.val(), 10) || 1;
1604            Search.facets.showPage(button, page);
1605        });
1606        $searchFacets.on('keydown', '.facet-page-input', function(e) {
1607            if (e.key === 'Enter') {
1608                e.preventDefault();
1609                $(this).trigger('change');
1610            }
1611        });
1612
1613        // Filter facet values via the search input (CheckboxSearch type).
1614        $searchFacets.on('input', '.facet-search-input', function() {
1615            const input = $(this);
1616            const filter = input.val().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
1617            const facet = input.closest('.facet');
1618            const seeMore = facet.find('.facet-see-more-or-less');
1619            const pagination = facet.find('.facet-pagination');
1620            if (filter.length) {
1621                // Hide pagination while filtering.
1622                if (pagination.length) {
1623                    pagination.hide();
1624                }
1625                // Show all items (remove hidden from pagination) so filter works on all.
1626                facet.find('.facet-items .facet-item').removeAttr('hidden');
1627                // Expand if collapsed.
1628                if (seeMore.length && seeMore.hasClass('expand')) {
1629                    seeMore.removeClass('expand').addClass('collapse');
1630                    // Don't call seeMoreOrLess here to avoid re-pagination,
1631                    // just update the label.
1632                    seeMore.text(seeMore.attr('data-label-see-less') || 'See less');
1633                }
1634            } else {
1635                // Restore pagination when filter is cleared.
1636                if (pagination.length) {
1637                    pagination.show();
1638                    var page = facet.data('facet-page') || 1;
1639                    Search.facets.showPage(seeMore, page);
1640                } else if (seeMore.length && seeMore.hasClass('collapse')) {
1641                    // No pagination: re-apply see-more state.
1642                    var perPage = Number(seeMore.attr('data-per-page')) || 0;
1643                    if (perPage > 0) {
1644                        // Re-init pagination.
1645                        Search.facets.initPagination(seeMore, perPage);
1646                    }
1647                }
1648            }
1649            facet.find('.facet-items .facet-item').each(function() {
1650                const item = $(this);
1651                const text = item.text().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
1652                if (!filter.length || text.indexOf(filter) !== -1) {
1653                    item.removeClass('facet-search-hidden');
1654                } else {
1655                    item.addClass('facet-search-hidden');
1656                }
1657            });
1658        });
1659    }
1660
1661    const rangeDoubles = document.querySelectorAll('.range-double');
1662
1663    // Clean empty query parameters for cleaner URLs.
1664    // Note: form id can be "form-search", "search-form", or custom.
1665    $('#search-form, #form-search, #search-headroom-form, #search-filters-form, #facets-form, .search-facets form, #advanced-search-form form').on('submit', function() {
1666        $(this).find('.range-double').each(function() {
1667            Search.rangeSliderDouble.clearExtremesBeforeSubmit(this);
1668        });
1669        Search.cleanSearchQuery(this);
1670    });
1671
1672    // Init ranges only when present.
1673    if (rangeDoubles.length) {
1674        rangeDoubles.forEach((rangeDouble) => {
1675            Search.rangeSliderDouble.normalizeRangeDouble(rangeDouble);
1676        });
1677        $('.range-numeric-from').on('input', (event) => Search.rangeSliderDouble.controlNumericFrom(event.target));
1678        $('.range-numeric-to').on('input', (event) => Search.rangeSliderDouble.controlNumericTo(event.target));
1679        $('.range-slider-from').on('input', (event) => Search.rangeSliderDouble.controlSliderFrom(event.target));
1680        $('.range-slider-to').on('input', (event) => Search.rangeSliderDouble.controlSliderTo(event.target));
1681
1682        // Handle range-double submit button click (for link/js mode with Ok button).
1683        $('.range-double-submit').on('click', function(e) {
1684            const rangeDouble = $(this).closest('.range-double');
1685            if (!rangeDouble.length) return;
1686
1687            // Check if using direct link mode (data-url on inputs).
1688            const inputFrom = rangeDouble.find('.range-numeric-from, .range-slider-from').filter('[data-url]').first();
1689            const inputTo = rangeDouble.find('.range-numeric-to, .range-slider-to').filter('[data-url]').first();
1690
1691            if (!inputFrom.length && !inputTo.length) {
1692                // Not direct link mode, let form submission handle it.
1693                return;
1694            }
1695
1696            e.preventDefault();
1697
1698            // Get domain min/max (scale extremes in piecewise mode, else slider
1699            // attribute values).
1700            const refEl = rangeDouble.find('.range-slider-from, .range-numeric-from')[0];
1701            const scale = Search.rangeSliderDouble.getScale(refEl);
1702            const isMapped = scale.mode === 'piecewise' || scale.mode === 'log';
1703            const numericFrom = rangeDouble.find('.range-numeric-from')[0];
1704            const numericTo = rangeDouble.find('.range-numeric-to')[0];
1705            const min = scale.mode === 'piecewise'
1706                ? scale.breakpoints[0][0]
1707                : (scale.mode === 'log' ? scale.min : parseFloat(numericFrom?.min || refEl?.min));
1708            const max = scale.mode === 'piecewise'
1709                ? scale.breakpoints[scale.breakpoints.length - 1][0]
1710                : (scale.mode === 'log' ? scale.max : parseFloat(numericTo?.max || refEl?.max));
1711            const fromVal = parseFloat(rangeDouble.find('.range-numeric-from').val() || rangeDouble.find('.range-slider-from').val());
1712            const toVal = parseFloat(rangeDouble.find('.range-numeric-to').val() || rangeDouble.find('.range-slider-to').val());
1713
1714            // Build URL: use "to" input URL as base, modify from/to params.
1715            let url = inputTo.data('url') || inputFrom.data('url');
1716            if (!url) return;
1717
1718            // Parse URL to modify query parameters.
1719            const urlObj = new URL(url, window.location.origin);
1720            const facetName = inputFrom.attr('name')?.match(/facet\[([^\]]+)\]/)?.[1]
1721                || inputTo.attr('name')?.match(/facet\[([^\]]+)\]/)?.[1];
1722
1723            if (facetName) {
1724                // Only add from/to if not at extremes.
1725                if (!isNaN(fromVal) && !isNaN(min) && fromVal !== min) {
1726                    urlObj.searchParams.set(`facet[${facetName}][from]`, fromVal);
1727                } else {
1728                    urlObj.searchParams.delete(`facet[${facetName}][from]`);
1729                }
1730                if (!isNaN(toVal) && !isNaN(max) && toVal !== max) {
1731                    urlObj.searchParams.set(`facet[${facetName}][to]`, toVal);
1732                } else {
1733                    urlObj.searchParams.delete(`facet[${facetName}][to]`);
1734                }
1735            }
1736
1737            window.location.href = urlObj.toString();
1738        });
1739    }
1740
1741    /**********
1742     * Initialisation.
1743     */
1744
1745    /**
1746     * Open advanced search when it is used according to the query.
1747     *
1748     * @todo Check if we are on the advanced search page first.
1749     * @todo Use focus on load, but don't open autosuggestion on focus.
1750     */
1751    if (Search.isAdvancedSearchQuery()) {
1752        $('.advanced-search-form-toggle a').click();
1753    }
1754
1755    /**
1756     * Auto-scroll to results when enabled.
1757     */
1758    var searchResults = document.getElementById('search-results');
1759    if (searchResults && searchResults.dataset.autoscroll) {
1760        searchResults.scrollIntoView({behavior: 'smooth', block: 'start'});
1761    }
1762
1763    /**
1764     * Init display of results list or grid.
1765     */
1766    var view_type = localStorage.getItem('search_view_type');
1767    if (!view_type) {
1768        // No stored preference: honour the server default view, i.e. the
1769        // view-type button rendered active from the "grid_list_mode" setting,
1770        // and only fall back to list when none is marked active.
1771        var $activeViewType = $('.search-view-type.active').first();
1772        view_type = $activeViewType.hasClass('search-view-type-grid') ? 'grid'
1773            : ($activeViewType.hasClass('search-view-type-map') ? 'map' : 'list');
1774    }
1775    $('.search-view-type-' + view_type).click();
1776
1777    /**
1778     * Init chosen select.
1779     */
1780    Search.initChosen = function(context) {
1781        if (!$.fn.chosen) {
1782            return Search;
1783        }
1784        var $context = context ? $(context) : $(document);
1785        $context.find('select.chosen-select')
1786            .addBack('select.chosen-select')
1787            .chosen(Search.chosenOptions);
1788        return Search;
1789    };
1790
1791    if (hasChosenSelect) {
1792        Search.initChosen(document);
1793    }
1794
1795    /**
1796     * Init autocompletion/autosuggestion of all specified input fields.
1797     */
1798    Search.initAutosuggest = function(context) {
1799        if (typeof $.fn.autocomplete !== 'function') {
1800            return Search;
1801        }
1802        var $context = context ? $(context) : $(document);
1803        var selector = 'input[type=search].autosuggest, input[type=text].autosuggest';
1804        $context.find(selector).addBack(selector).each(function(index, element) {
1805            element = $(element);
1806            // The input may have been initialized in a previous call.
1807            if (element.data('autocomplete')) {
1808                return;
1809            }
1810            let autosuggestOptions = Search.autosuggestOptions(element);
1811            if (autosuggestOptions) {
1812                element.autocomplete(autosuggestOptions);
1813            }
1814        });
1815        // The values of the advanced filters have their own endpoint.
1816        if (Search.initFilterAutosuggest) {
1817            $context.find('.search-filters-advanced >
1817 fieldset.filter').each(function() {
1818                Search.initFilterAutosuggest($(this));
1819            });
1820        }
1821        return Search;
1822    };
1823
1824    if (hasAutocomplete) {
1825        Search.initAutosuggest(document);
1826    }
1827
1828});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.