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 + '">«</button>' 606 + '<button type="button" class="facet-page-prev" aria-label="' + labelPagePrev + '" title="' + labelPagePrev + '">‹</button>' 607 + indicatorHtml 608 + '<button type="button" class="facet-page-next" aria-label="' + labelPageNext + '" title="' + labelPageNext + '">
608›</button>' 609 + '<button type="button" class="facet-page-last" aria-label="' + labelLast + '" title="' + labelLast + '">»</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.