1/*! 2 * Postcode.eu international address autocompletion 3 * 4 * https://developer.postcode.eu/documentation/international/javascript 5 * 6 * Copyright Postcode.nl 7 * Released under the Apple MIT License (AML) 8 * https://www.tldrlegal.com/license/apple-mit-license-aml 9 * 10 * @author Postcode.nl 11 * @version 1.4.3 12 */ 13 14(function (root, factory) { 15 'use strict'; 16 17 // eslint-disable-next-line no-undef 18 if (typeof define === 'function' && define.amd) 19 { 20 // eslint-disable-next-line no-undef 21 define([], factory); 22 } 23 else 24 { 25 root.PostcodeNl = root.PostcodeNl || {}; 26 root.PostcodeNl.AutocompleteAddress = factory(); 27 } 28}(typeof self !== 'undefined' ? self : this, function () { 29 'use strict'; 30 31 const document = window.document, 32 $ = function (selector) { return document.querySelectorAll(selector); }, 33 elementData = new WeakMap(), 34 VERSION = '1.4.3', 35 EVENT_NAMESPACE = 'autocomplete-', 36 PRECISION_ADDRESS = 'Address', 37 KEY_ESC = 'Escape', 38 KEY_ESC_LEGACY = 'Esc', 39 KEY_ENTER = 'Enter', 40 KEY_UP = 'ArrowUp', 41 KEY_UP_LEGACY = 'Up', 42 KEY_DOWN = 'ArrowDown', 43 KEY_DOWN_LEGACY = 'Down', 44 BUILDING_LIST_MODES = { PAGED: 'paged', SHORT: 'short' }, 45 46 /** 47 * Default options. 48 * @type {Object} 49 */ 50 defaults = Object.create(null, { 51 /** 52 * Initial autocomplete context. E.g. a country code "nld", "bel" or "deu" to start searching in that country. 53 * @type {string} 54 */ 55 context: { 56 value: 'nld', 57 writable: true, 58 }, 59 60 /** 61 * URL that will return autocomplete JSON data. 62 * @type {string} 63 */ 64 autocompleteUrl: { 65 writable: true, 66 }, 67 68 /** 69 * URL that will return address details JSON data. 70 * @type {string} 71 */ 72 addressDetailsUrl: { 73 writable: true, 74 }, 75 76 /** 77 * Text to use with tags. 78 * @type {Object} 79 */ 80 tags: { 81 value: { 82 'unvalidated-housenumber': '(unknown house number)', 83 'unvalidated-housenumber-addition': '(unknown house number addition)', 84 }, 85 writable: true, 86 }, 87 88 /** 89 * CSS prefix 90 * @type {string} 91 */ 92 cssPrefix: { 93 value: 'postcodenl-autocomplete-', 94 writable: true, 95 }, 96 97 /** 98 * Minimum number of characters typed before a search is performed. 99 * @type {number} 100 */ 101 minLength: { 102 value: 1, 103 writable: true, 104 }, 105 106 /** 107 * Delay in milliseconds between when a keystroke occurs and when a search is performed. 108 * @type {number} 109 */ 110 delay: { 111 value: 300, 112 writable: true, 113 }, 114 115 /** 116 * Which element the menu should be appended to. 117 * @type {string|HTMLElement} 118 */ 119 appendTo: { 120 value: document.body, 121 writable: true, 122 }, 123 124 /** 125 * Focus the first item when the menu is shown. 126 * @type {boolean} 127 */ 128 autoFocus: { 129 value: true, 130 writable: true, 131 }, 132 133 /** 134 * Automatically select an address match if it's the only menu item. 135 * @type {boolean} 136 */ 137 autoSelectSingleAddress: { 138 value: true, 139 writable: true, 140 }, 141 142 /** 143 * Automatically calculate menu width. Disable to define width in CSS. 144 * @type {boolean} 145 */ 146 autoResize: { 147 value: true, 148 writable: true, 149 }, 150 151 /** 152 * Show the Postcode logo on the input 153 * @type {boolean} 154 */ 155 showLogo: { 156 value: true, 157 writable: true, 158 }, 159 160 /** 161 * Get screen reader text for a successful response with at least one match. 162 * Override this function to translate the message. 163 * @type {Function} 164 * 165 * @param {number} count - Number of matches. Will be at least one. 166 * @param {string} languageTag - Language tag, if specified via language option or setLanguage(). 167 * @return {string} Screen reader message based on the number of matches. 168 */ 169 getResponseMessage: { 170 value: function (count) 171 { 172 let message; 173 174 if (count > 1) 175 { 176 message = count + ' address suggestions available. '; 177 } 178 else 179 { 180 message = 'One address suggestion available. '; 181 } 182 183 message += 'Use up and down arrow keys to navigate.'; 184 185 return message; 186 }, 187 writable: true, 188 }, 189 190 /** 191 * The language used for API calls. 192 */ 193 language: { 194 writable: true, 195 }, 196 197 /** 198 * Set mode for listing buildings in `Street` context. Supported modes: 199 * 200 * - 'paged': get a larger list of building results (actual number determined by API). Includes link to view more buildings. 201 * - 'short': building results are limited to the first few items, similar to matches in other contexts. 202 */ 203 buildingListMode: { 204 set (mode) { 205 if (Object.values(BUILDING_LIST_MODES).includes(mode)) 206 { 207 this.mode = mode; 208 } 209 else 210 { 211 console.error('Invalid mode for option buildingListMode:', mode); 212 } 213 }, 214 get () {
215 return this.mode || BUILDING_LIST_MODES.SHORT; 216 }, 217 }, 218 }); 219 220 /** 221 * The autocomplete menu. 222 * 223 * @constructor 224 * @param {Object} options - Autocomplete options. 225 */ 226 const Menu = function (options) 227 { 228 const self = this, 229 ul = document.createElement('ul'), 230 wrapper = document.createElement('div'), 231 classNames = { 232 menuOpen: options.cssPrefix + 'menu-open', 233 itemFocus: options.cssPrefix + 'item-focus', 234 logo: options.cssPrefix + 'logo', 235 }, 236 237 /** 238 * Position the menu near an element. 239 * 240 * @param {HTMLElement} element 241 */ 242 positionTo = function (element) 243 { 244 const rect = element.getBoundingClientRect(); 245 wrapper.style.top = rect.bottom + (window.scrollY || window.pageYOffset) + 'px'; 246 wrapper.style.left = rect.left + (window.scrollX || window.pageXOffset) + 'px'; 247 248 if (options.autoResize) 249 { 250 wrapper.style.width = rect.width + 'px'; 251 } 252 }, 253 254 /** 255 * Move focus to the menu item specified by index and optionally set the input element value. 256 * 257 * @param {number} index - Move focus to the next item if true, to previous item otherwise. 258 * @param {boolean} [isRelative] - Treat index as relative to current item (optional). Default false. 259 * @param {boolean} [setValue] - Set the value of the associated input element (optional). Default false. 260 */ 261 moveItemFocus = function (index, isRelative, setValue) 262 { 263 const numberOfItems = ul.children.length; 264 if (numberOfItems === 0) 265 { 266 return; 267 } 268 269 setValue = setValue || false; 270 isRelative = isRelative || false; 271 272 let toIndex = index, 273 fromIndex; 274 if (isRelative) 275 { 276 if (item === null) 277 { 278 toIndex = index > 0 ? index - 1 : numberOfItems + index; 279 } 280 else 281 { 282 fromIndex = indexOf(item); 283 toIndex = fromIndex + index; 284 } 285 } 286 287 // Stay in range. 288 toIndex %= numberOfItems; 289 toIndex = toIndex >= 0 ? toIndex : numberOfItems + toIndex; 290 291 if ( 292 isRelative 293 && item !== null 294 && (index > 0 && toIndex < fromIndex || index < 0 && toIndex > fromIndex) // Wrapping 295 ) 296 { 297 setActiveItem(null); 298 inputElement.value = inputValue; 299 elementData.get(inputElement).context = inputContext; 300 return; 301 } 302 303 setActiveItem(ul.children[toIndex]); 304 305 const data = elementData.get(item); 306 307 // Update the input element value unless the focus event was cancelled. 308 if ( 309 setValue 310 && true === inputElement.dispatchEvent( 311 new CustomEvent(EVENT_NAMESPACE + 'focus', {detail: data, cancelable: true, bubbles: true}) 312 ) 313 ) 314 { 315 inputElement.value = data.value; 316 elementData.get(inputElement).context = data.context; 317 selectedIndex = toIndex; 318 319 // Scroll the menu item into view if needed. 320 if (ul.scrollTop > item.offsetTop) 321 { 322 ul.scrollTop = item.offsetTop; 323 } 324 else if (item.offsetTop >= (ul.clientHeight + ul.scrollTop)) 325 { 326 ul.scrollTop = (item.offsetHeight + item.offsetTop) - ul.clientHeight; 327 } 328 } 329 }, 330 331 /** 332 * Get the index of an element in the menu. 333 */ 334 indexOf = function (element) 335 { 336 return Array.prototype.indexOf.call(ul.children, element); 337 }, 338 339 /** 340 * Set and focus active item. Removes focus from previous item. 341 */ 342 setActiveItem = function (newItem) 343 { 344 removeItemFocus(); 345 346 item = newItem; 347 if (item) 348 { 349 item.classList.add(classNames.itemFocus); 350 } 351 }, 352 353 /** 354 * Remove the item focus CSS class from the active item, if any. 355 */ 356 removeItemFocus = function () 357 { 358 if (item !== null) 359 { 360 item.classList.remove(classNames.itemFocus); 361 } 362 }, 363 364 /** 365 * Event handler to close the menu on outside click. 366 */ 367 closeOnClickOutside = function (e) 368 { 369 if (isOpen && e.target !== inputElement && !wrapper.contains(e.target)) 370 { 371 self.close(); 372 } 373 }, 374 375 /** 376 * Event handler to close the menu on window resize. 377 */ 378 closeOnWindowResize = function () 379 { 380 if (isOpen) 381 { 382 self.close(); 383 } 384 }; 385 386 let isOpen = false, 387 isMousedown = false, 388 item = null, 389 selectedIndex = null, 390 inputElement, 391 inputValue, 392 inputContext; 393 394 Object.defineProperties(this, { 395 isOpen: { 396 get: function () { 397 return isOpen; 398 }, 399 }, 400 isMousedown: { 401 get: function () { 402 return isMousedown; 403 }, 404 }, 405 hasFocus: { 406 get: function () { 407 return item !== null; 408 }, 409 }, 410 wrapper: { 411 get: function () { 412 return wrapper; 413 }, 414 }, 415 ul: { 416 get: function () { 417 return ul; 418 }, 419 }, 420 suppress: { 421 value: false, 422 writable: true, 423 }, 424 }); 425 426 wrapper.classList.add(options.cssPrefix + 'menu'); 427 wrapper.classList.toggle(classNames.logo, options.showLogo); 428 429 ul.classList.add(options.cssPrefix + 'menu-items'); 430 wrapper.appendChild(ul); 431 432 ul.addEventListener('mousemove', function (e) { 433 if (e.target === item || e.target === ul) 434 { 435 return; 436 } 437 438 let target = e.target; 439 440 while (target.parentElement !== ul) 441 { 442 target = target.parentElement; 443 } 444 445 setActiveItem(target); 446 moveItemFocus(indexOf(target)); 447 }); 448 449 wrapper.addEventListener('mousedown', function () { 450 isMousedown = true; 451 }); 452 453 wrapper.addEventListener('click', function (e) { 454 e.preventDefault(); 455 456 if (item !== null) 457 { 458 self.select(); 459 } 460 461 // IEfix: Use setTimeout to assign after the event happens. 462 window.setTimeout(function () { isMousedown = false }); 463 }); 464 465 // Add the menu to the page. 466 if (Object.prototype.isPrototypeOf.call(HTMLElement.prototype, options.appendTo)) 467 { 468 options.appendTo.appendChild(wrapper); 469 } 470 else 471 { 472 (document.querySelector(options.appendTo) || document.body).appendChild(wrapper); 473 } 474 475 /** 476 * Render a list item for each match and map the match to the li element. 477 * 478 * @param {Object[]} matches - Matches from the autocomplete response. 479 * @param {Function} renderItem - Function to create a list item and add it to the menu element. 480 */ 481 this.setItems = function (matches, renderItem) 482 {
483 ul.innerHTML = ''; 484 485 for (let i = 0, li, match; (match = matches[i++]);) 486 { 487 li = renderItem(ul, match); 488 elementData.set(li, match); 489 } 490 491 setActiveItem(null); 492 selectedIndex = null; 493 ul.scrollTop = 0; 494 } 495 496 /** 497 * Open the menu (if it has items). 498 * 499 * @param {HTMLElement} element - Associated input element. 500 */ 501 this.open = function (element) 502 { 503 inputElement = element; 504 inputValue = inputElement.value; 505 inputContext = elementData.get(inputElement).context; 506 507 if (options.autoFocus && selectedIndex === null) 508 { 509 selectedIndex = 0; 510 } 511 512 if (selectedIndex !== null) 513 { 514 moveItemFocus(selectedIndex); 515 } 516 517 if (isOpen || this.suppress) 518 { 519 return; 520 } 521 522 positionTo(inputElement); 523 wrapper.classList.add(classNames.menuOpen); 524 ul.scrollTop = 0; 525 isOpen = true; 526 inputElement.dispatchEvent(new CustomEvent(EVENT_NAMESPACE + 'open', {bubbles: true})); 527 } 528 529 /** 530 * Close the menu. 531 * 532 * @param {boolean} [restoreValue] Restore input element value and context if true. 533 */ 534 this.close = function (restoreValue) 535 { 536 if (!isOpen) 537 { 538 return; 539 } 540 541 if (restoreValue) 542 { 543 inputElement.value = inputValue; 544 elementData.get(inputElement).context = inputContext; 545 selectedIndex = null; 546 } 547 548 self.blur(); 549 wrapper.classList.remove(classNames.menuOpen); 550 isOpen = false; 551 inputElement.dispatchEvent(new CustomEvent(EVENT_NAMESPACE + 'close', {bubbles: true})); 552 } 553 554 /** 555 * Focus the previous menu item. 556 */ 557 this.focusPrevious = moveItemFocus.bind(this, -1, true); 558 559 /** 560 * Focus the next menu item. 561 */ 562 this.focusNext = moveItemFocus.bind(this, 1, true); 563 564 /** 565 * Remove the item focus CSS class and clear the active item, if any. 566 */ 567 this.blur = function () 568 { 569 setActiveItem(null); 570 } 571 572 /** 573 * Select the active menu item, update and optionally focus the associated input element. 574 * 575 * @param {boolean} [focusInput] Focus the associated input element if true. Default true. 576 */ 577 this.select = function (focusInput) 578 { 579 const selectedMatch = elementData.get(item), 580 itemIndex = indexOf(item); 581 582 // Update the input element value unless the select event was cancelled. 583 if ( 584 true === inputElement.dispatchEvent( 585 new CustomEvent( 586 EVENT_NAMESPACE + 'select', 587 {detail: selectedMatch, cancelable: true, bubbles: true} 588 ) 589 ) 590 ) 591 { 592 inputElement.value = selectedMatch.value; 593 selectedIndex = itemIndex; 594 } 595 596 if (typeof focusInput === 'undefined' || focusInput) 597 { 598 inputElement.focus(); 599 } 600 } 601 602 /** 603 * Remove menu items. 604 */ 605 this.clear = function () 606 { 607 setActiveItem(null); 608 selectedIndex = null; 609 ul.innerHTML = ''; 610 } 611 612 /** 613 * Remove the menu from the DOM. 614 * 615 * @return {boolean} True if the menu is removed, false if already removed. 616 */ 617 this.destroy = function () 618 { 619 if (wrapper.parentNode === null) 620 { 621 return false; 622 } 623 624 wrapper.parentNode.removeChild(wrapper); 625 document.removeEventListener('click', closeOnClickOutside); 626 window.removeEventListener('resize', closeOnWindowResize); 627 return true; 628 } 629 630 document.addEventListener('click', closeOnClickOutside); 631 window.addEventListener('resize', closeOnWindowResize); 632 } 633 634 /** 635 * Get a random session identifier. 636 * 637 * @return {string} Session identifier. 638 */ 639 const getSessionId = function () 640 { 641 const length = 32, 642 randomIntegers = new Uint8Array(length), 643 randomCharacters = [], 644 characterSet = '0123456789abcdef'; 645 646 (window.crypto || window.msCrypto).getRandomValues(randomIntegers); 647 648 for (let i = 0, j = characterSet.length; i < length; i++) 649 { 650 randomCharacters.push(characterSet[randomIntegers[i] % j]); 651 } 652 653 return randomCharacters.join(''); 654 }; 655 656 /** 657 * Get a unique element identifier. 658 * 659 * @param {string} id - Element identifier. 660 * @return {string} Element identifier, with numeric suffix if the original identifier is already in use. 661 */ 662 const getUniqueId = function (id) 663 { 664 let i = 2, 665 result = id; 666 667 while (document.getElementById(result) !== null) 668 { 669 result = id + '-' + i; 670 i++; 671 } 672 673 return result; 674 } 675 676 /** 677 * Basic object extension method. 678 * 679 * @param {Object} target 680 * @param {Object} source 681 * @return {Object} target 682 */ 683 const extend = function (target, source) 684 { 685 for (let prop in source) 686 { 687 target[prop] = source[prop]; 688 } 689 690 return target; 691 } 692 693 /** 694 * Returns the name of the class of an object. 695 * 696 * To be used instead of typeof or instanceof, which are broken. 697 * @see {@link https://github.com/BonsaiDen/JavaScript-Garden/blob/master/doc/en/types/typeof.md} 698 * 699 * @param {*} value - Any value. 700 * @return {string}
700 Class name extracted from internal [[class]] property. 701 */ 702 const getClass = function (value) 703 { 704 return Object.prototype.toString.call(value).slice(8, -1); 705 } 706 707 if (typeof window.CustomEvent !== 'function') 708 { 709 /** 710 * Fix CustomEvent in IE11. 711 * 712 * @param {string} event - DOMString representing the name of the event. 713 * @param {Object} [params] - Optional dictionary that emulates CustomEventInit. 714 * @return {Event} Custom event. 715 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#Polyfill} 716 */ 717 const CustomEvent = function (event, params) 718 { 719 params = params || {bubbles: false, cancelable: false, detail: null}; 720 var evt = document.createEvent('CustomEvent'); 721 evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); 722 return evt; 723 } 724 725 window.CustomEvent = CustomEvent; 726 } 727 728 if (window.DOMTokenList.prototype.toggle.length === 0) 729 { 730 /** 731 * Fix DOMTokenList.toggle in IE11. 732 * 733 * @param {string} value - Class value to toggle. 734 * @param {boolean} [force] - Add class value if the argument evaluates to true, else remove it. 735 * @return {boolean} True if the class value is added, false if removed. 736 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/classList} 737 */ 738 window.DOMTokenList.prototype.toggle = function (value) 739 { 740 if (arguments.length > 1) 741 { 742 return (this[arguments[1] ? 'add' : 'remove'](value), !!arguments[1]); 743 } 744 745 return (this[this.contains(value) ? 'remove' : 'add'](value), this.contains(value)); 746 } 747 } 748 749 /** 750 * @constructor 751 * @param {HTMLElement|NodeList|string} elementsOrSelector - Element(s) or CSS selector string for element(s) to be used as autocomplete input. 752 * @param {Object} options - Options to override the defaults. @see PostcodeNl~defaults. 753 */ 754 const autocomplete = function (elementsOrSelector, options) 755 { 756 let inputElements; 757 758 if (getClass(elementsOrSelector) === 'String') 759 { 760 inputElements = $(elementsOrSelector); 761 } 762 else if (Object.prototype.isPrototypeOf.call(HTMLElement.prototype, elementsOrSelector)) 763 { 764 inputElements = [elementsOrSelector]; 765 } 766 else if (Object.prototype.isPrototypeOf.call(NodeList.prototype, elementsOrSelector)) 767 { 768 inputElements = elementsOrSelector; 769 } 770 else 771 { 772 throw new TypeError('Element(s) or selector has invalid type. Use HTMLElement, NodeList or valid CSS selector string.'); 773 } 774 775 if (inputElements.length === 0) 776 { 777 return; 778 } 779 780 // Create options object that inherits from defaults. 781 options = extend(Object.create(defaults), options); 782 783 // Make context lowercase as required by API. 784 options.context = options.context.toLowerCase(); 785 786 // Expose options. 787 Object.defineProperty(this, 'options', { 788 get: function () { 789 return options; 790 }, 791 }); 792 793 let searchTimeoutId = null, 794 previousValue = null, 795 previousContext = null, 796 xhr = null, 797 activeElement = null, 798 matches = []; 799 800 const self = this, 801 menu = new Menu(options), 802 inputBlankClassName = options.cssPrefix + 'address-input-blank', 803 instanceSessionId = getSessionId(); 804 805 // Create an ARIA live region for screen readers. 806 // See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions 807 const liveRegion = document.createElement('div'), 808 liveRegionId = 'aria-live-region'; 809 liveRegion.id = getUniqueId(liveRegionId); 810 liveRegion.setAttribute('role', 'status'); 811 liveRegion.setAttribute('aria-live', 'assertive'); 812 liveRegion.classList.add(options.cssPrefix + liveRegionId); 813 document.body.appendChild(liveRegion); 814 815 window.addEventListener('beforeunload', function () { 816 window.clearTimeout(searchTimeoutId); 817 }); 818 819 // Expose elements. 820 Object.defineProperty(this, 'elements', { 821 get: function () { 822 return { 823 menu: menu.wrapper, 824 menuItems: menu.ul, 825 liveRegion: liveRegion, 826 }; 827 }, 828 }); 829 830 /** 831 * Announce screen reader text via the live region. 832 * 833 * @param {string} str - Text to announce. 834 */ 835 this.announce = function (str) 836 { 837 liveRegion.innerHTML = '<div>' + str + '</div>'; 838 } 839 840 /** 841 * @callback successCallback 842 * @param {Object} Callback function to handle a succesful XMLHttpRequest response. 843 */ 844 845 /** 846 * Create an XMLHttpRequest GET request. 847 * 848 * @param {string} url - URL to which the request is sent. 849 * @param {successCallback} success - Function that is executed if the request succeeds.
850 * @return {XMLHttpRequest} Object representing the eventual completion/failure of the request, and its resulting value. 851 */ 852 this.xhrGet = function (url, success) 853 { 854 if (xhr !== null && xhr.readyState < 4) 855 { 856 xhr.abort(); 857 } 858 859 xhr = new XMLHttpRequest(); 860 861 xhr.addEventListener('load', function () { 862 if (this.status === 200) 863 { 864 success.call(this, JSON.parse(xhr.response)); 865 } 866 }); 867 868 let sessionId = instanceSessionId; 869 870 if (activeElement !== null) 871 { 872 const element = activeElement, 873 xhrErrorHandler = function (e) 874 { 875 if (this.status !== 200) 876 { 877 element.dispatchEvent(new CustomEvent(EVENT_NAMESPACE + 'error', {detail: {'event': e, request: this}, bubbles: true})); 878 } 879 }; 880 881 sessionId = elementData.get(element).sessionId; 882 883 // Trigger an error event for failed requests. 884 xhr.addEventListener('error', xhrErrorHandler); 885 xhr.addEventListener('load', xhrErrorHandler); 886 887 element.classList.add(options.cssPrefix + 'loading'); 888 889 xhr.addEventListener('loadend', function () { // All three load-ending conditions (abort, load, or error). 890 element.classList.remove(options.cssPrefix + 'loading'); 891 }); 892 } 893 894 xhr.open('GET', url); 895 xhr.setRequestHeader('X-Autocomplete-Session', sessionId); 896 activeElement?.dispatchEvent( 897 new CustomEvent(EVENT_NAMESPACE + 'xhr-send', {detail: xhr, bubbles: true}) 898 ); 899 xhr.send(); 900 901 return xhr; 902 } 903 904 /** 905 * Get autocomplete matches for the specified context and term. 906 * 907 * @see {@link https://developer.postcode.eu/documentation/international/v1/Autocomplete/autocomplete} 908 * @param {string} context - A place identifier denoting the context to search in. e.g. "nld". 909 * @param {string} term - The search query to process. e.g. "2012ES", "Haarlem", "Julian". 910 * @param {successCallback} response - Function that handles the response. 911 * @return {XMLHttpRequest} @see PostcodeNl.AutocompleteAddress.xhrGet. 912 */ 913 this.getSuggestions = function (context, term, response) 914 { 915 const url = [ 916 this.options.autocompleteUrl, 917 encodeURIComponent(context), 918 encodeURIComponent(term), 919 options.language || '', 920 ]; 921 922 if (options.buildingListMode !== BUILDING_LIST_MODES.SHORT) 923 { 924 // Only specify when not the API default, for better compatibility with client proxies 925 url.push(options.buildingListMode); 926 } 927 928 return this.xhrGet( 929 url.join('/').replace(/\/+$/, ''), 930 response 931 ); 932 } 933 934 /** 935 * Get address details for the specified address identifier. 936 * 937 * @see {@link https://developer.postcode.eu/documentation/international/v1/Autocomplete/getDetails} 938 * @param {string} addressId - Address identifier returned by a match of precision "Address". 939 * @param {string} [dispatchCountry] - Dispatching country ISO3 code, used to determine country address line presence and language. 940 * If not given, country is not added in mailLines. 941 * @param {successCallback} response - Function that handles the response. 942 * @return {XMLHttpRequest} @see PostcodeNl.AutocompleteAddress.xhrGet. 943 */ 944 this.getDetails = function (addressId) 945 { 946 let dispatchCountry = null, 947 response = arguments[1]; 948 949 const params = [addressId]; 950 951 if (arguments.length === 3) 952 { 953 dispatchCountry = arguments[1]; 954 response = arguments[2]; 955 params.push(dispatchCountry); 956 } 957 958 return this.xhrGet(this.options.addressDetailsUrl + '/' + params.join('/'), response); 959 } 960 961 /** 962 * Method that controls the creation of each menu item. 963 * 964 * @param {HTMLElement} ul - Element that the newly created list item must be appended to. 965 * @param {Object} item - Single autocomplete item. 966 * @return {HTMLElement} List item element containing an autocomplete match. 967 */ 968 this.renderItem = function (ul, item) 969 { 970 const li = document.createElement('li'), 971 label = document.createElement('span'); 972 973 li.classList.add(this.options.cssPrefix + 'item'); 974 label.classList.add(this.options.cssPrefix + 'item-label'); 975 label.innerHTML = this.highlight(item.label, item.highlights); 976 li.appendChild(label); 977 978 if (item.precision !== PRECISION_ADDRESS) 979 { 980 li.classList.add(this.options.cssPrefix + 'item-more'); 981 } 982 983 if (item.description) 984 { 985 const span = document.createElement('span'); 986 span.textContent = item.description; 987 span.classList.add(this.options.cssPrefix + 'item-description'); 988 li.appendChild(span); 989 } 990 991 if (item.tags) 992 { 993 for (let i = 0, tag; (tag = item.tags[i++]);) 994 { 995 const em = document.createElement('em'); 996 em.textContent = this.options.tags[tag]; 997 em.classList.add(this.options.cssPrefix + 'item-tag'); 998 li.appendChild(em); 999 } 1000 } 1001 1002 ul.appendChild(li); 1003 return li; 1004 } 1005 1006 /** 1007 * Highlight matched portions in the item label. 1008 * 1009 * @param {string} str - Item label to highlight. 1010 * @param {Array.Array.<number>} indices - Array of character offset pairs. 1011 * @return {string} Highlighted string (using "mark" elements). 1012 */ 1013 this.highlight = function (str, indices) 1014 { 1015 if (indices.length === 0) 1016 { 1017 return str; 1018 } 1019 1020 let i = 0, 1021 start = 0, 1022 end = 0, 1023 result = [], 1024 pair; 1025 1026 while ((pair = indices[i++])) 1027 { 1028 result.push(str.slice(end, pair[0])); 1029 start = pair[0]; 1030 end = pair[1]; 1031 result.push('<mark>' + str.slice(start, end) + '</mark>'); 1032 } 1033 1034 result.push(str.slice(end)); 1035 return result.join(''); 1036 } 1037 1038 /** 1039 * Set the country to start searching in. 1040 * 1041 * @param {string} iso3Code ISO 3166-1 alpha-3 country code. 1042 */ 1043 this.setCountry = function (iso3Code) 1044 { 1045 options.context = iso3Code.toLowerCase(); 1046 1047 for (let i = 0, element; (element = inputElements[i++]);) 1048 { 1049 elementData.get(element).context = options.context; 1050 } 1051 } 1052 1053 /** 1054 * Set the language used for API calls. 1055 * 1056 * @param {string} languageTag - Language tag, e.g. "nl", "nl_NL", "en-GB" or "de-DE". 1057 */ 1058 this.setLanguage = function (languageTag) 1059 { 1060 options.language = languageTag; 1061 } 1062 1063 /** 1064 * @typedef SearchOptions 1065 * @type {Object} 1066 * @property {string} term - Search query, optional. 1067 * @property {string} context - Autocomplete context, optional. 1068 * @property {boolean} showMenu - Wether to show the menu, optional. Default is true. 1069 */ 1070 1071 /** 1072 * Trigger a search on the specified input element. If invoked without a term, the current input's value is used. 1073 * 1074 * @param {HTMLElement} element - Input element associated with the autocomplete instance. 1075 * @param {(string|SearchOptions)} [term] - Search query, optional. Or SearchOptions object, optional. 1076 * @param {string} [context] - Autocomplete context, optional. Ignored if using SearchOptions. 1077 */ 1078 this.search = function (element) 1079 { 1080 let options; 1081 1082 if (getClass(arguments[1]) === 'Object') 1083 { 1084 options = arguments[1]; 1085 } 1086 else 1087 {
1088 options = { term: arguments[1], context: arguments[2] }; 1089 } 1090 1091 if (typeof options.term !== 'undefined') 1092 { 1093 element.value = options.term; 1094 } 1095 1096 if (typeof options.context !== 'undefined') 1097 { 1098 const data = elementData.get(element); 1099 data.context = options.context; 1100 elementData.set(element, data); 1101 } 1102 1103 const currentMenuSuppress = menu.suppress; 1104 menu.suppress = !(typeof options.showMenu === 'undefined' || options.showMenu); 1105 1106 activeElement = element; 1107 element.classList.toggle(inputBlankClassName, element.value === ''); 1108 search(element); 1109 1110 menu.suppress = currentMenuSuppress; 1111 } 1112 1113 /** 1114 * Reset to initial context, clear the menu, clear input values. 1115 */ 1116 this.reset = function () 1117 { 1118 previousValue = null; 1119 previousContext = null; 1120 matches = []; 1121 1122 menu.clear(); 1123 1124 for (let i = 0, element; (element = inputElements[i++]);) 1125 { 1126 const data = elementData.get(element); 1127 1128 data.context = options.context; 1129 data.match = {}; 1130 element.value = ''; 1131 element.classList.add(inputBlankClassName); 1132 } 1133 } 1134 1135 /** 1136 * Remove autocomplete functionality and return the input element to its pre-init state. 1137 */ 1138 this.destroy = function () 1139 { 1140 if (!menu.destroy()) 1141 { 1142 return; // Menu is not part of the DOM, assume already destroyed. 1143 } 1144 1145 // Prevent potential race condition with pending timeout scheduled by searchDebounced(). 1146 window.clearTimeout(searchTimeoutId); 1147 1148 document.body.removeChild(liveRegion); 1149 1150 for (let i = 0, element; (element = inputElements[i++]);) 1151 { 1152 const data = elementData.get(element); 1153 1154 for (var eventType in data.eventHandlers) 1155 { 1156 element.removeEventListener(eventType, data.eventHandlers[eventType]); 1157 } 1158 1159 for (var attr in data.initialAttributeValues) 1160 { 1161 const value = data.initialAttributeValues[attr]; 1162 1163 if (value === null) 1164 { 1165 element.removeAttribute(attr); 1166 } 1167 else 1168 { 1169 element.setAttribute(attr, value); 1170 } 1171 } 1172 1173 elementData.delete(element); 1174 element.classList.remove( 1175 options.cssPrefix + 'address-input', 1176 options.cssPrefix + 'loading', 1177 inputBlankClassName, 1178 options.cssPrefix + 'logo' 1179 ); 1180 } 1181 } 1182 1183 Array.prototype.forEach.call(inputElements, function (element) { 1184 const eventHandlers = Object.create(null); 1185 let isKeyEvent = false; 1186 1187 // Map match and context to this element. 1188 elementData.set(element, { 1189 match: {}, 1190 context: options.context, 1191 eventHandlers: eventHandlers, 1192 initialAttributeValues: { 1193 spellcheck: element.hasAttribute('spellcheck') ? element.getAttribute('spellcheck') : null, 1194 autocomplete: element.hasAttribute('autocomplete') ? element.getAttribute('autocomplete') : null, 1195 'aria-controls': element.hasAttribute('aria-controls') ? element.getAttribute('aria-controls') : null, 1196 }, 1197 sessionId: getSessionId(), 1198 }); 1199 1200 if (false === element.hasAttribute('autocomplete')) 1201 { 1202 element.autocomplete = 'off'; 1203 } 1204 1205 element.spellcheck = false; 1206 element.setAttribute('aria-controls', liveRegion.id); 1207 element.classList.add(options.cssPrefix + 'address-input'); 1208 element.classList.toggle(inputBlankClassName, element.value === ''); 1209 element.classList.toggle(options.cssPrefix + 'logo', options.showLogo); 1210 1211 element.addEventListener('keydown', eventHandlers.keydown = function (e) { 1212 isKeyEvent = true; 1213 1214 switch (e.key) 1215 { 1216 case KEY_UP: 1217 case KEY_UP_LEGACY: 1218 if (menu.isOpen) 1219 { 1220 menu.focusPrevious(true); 1221 } 1222 else 1223 { 1224 search(element); 1225 } 1226 1227 e.preventDefault(); 1228 break; 1229 1230 case KEY_DOWN: 1231 case KEY_DOWN_LEGACY: 1232 if (menu.isOpen) 1233 { 1234 menu.focusNext(true); 1235 } 1236 else 1237 { 1238 search(element); 1239 } 1240 1241 e.preventDefault(); 1242 break; 1243 1244 case KEY_ESC: 1245 case KEY_ESC_LEGACY: 1246 menu.close(true); 1247 break; 1248 1249 case KEY_ENTER: 1250 if (menu.hasFocus) 1251 { 1252 menu.select(); 1253 e.preventDefault(); 1254 } 1255 else 1256 { 1257 menu.close(); 1258 } 1259 break; 1260 1261 default: 1262 searchDebounced(element); 1263 } 1264 }); 1265 1266 element.addEventListener('input', eventHandlers.input = function () { 1267 element.classList.remove(inputBlankClassName); 1268 menu.blur(); // Prevent focus on old menu item when value has changed. 1269 matches = []; // Prevent auto-selecting old menu item on blur. 1270 1271 // Skip key event to prevent searching twice. 1272 if (isKeyEvent) 1273 { 1274 isKeyEvent = false;
1275 return; 1276 } 1277 1278 searchDebounced(element); 1279 }); 1280 1281 element.addEventListener('focus', eventHandlers.focus = function () { 1282 if (menu.isMousedown) 1283 { 1284 return; 1285 } 1286 1287 menu.open(element); 1288 1289 const data = elementData.get(element); 1290 1291 // Trigger search if the address is incomplete. 1292 if (typeof data.match.precision === 'undefined' || data.match.precision !== PRECISION_ADDRESS) 1293 { 1294 search(element); 1295 } 1296 }); 1297 1298 element.addEventListener('click', eventHandlers.click = menu.open.bind(menu, element)); 1299 1300 element.addEventListener(EVENT_NAMESPACE + 'select', eventHandlers[EVENT_NAMESPACE + 'select'] = function (e) { 1301 const data = elementData.get(element); 1302 1303 data.match = e.detail; 1304 1305 if (e.detail.precision === PRECISION_ADDRESS) 1306 { 1307 menu.close(); 1308 } 1309 else 1310 { 1311 menu.open(element); 1312 data.context = e.detail.context; 1313 window.setTimeout(() => { 1314 if (!e.defaultPrevented) 1315 { 1316 // Make sure value isn't changed (e.g. via React render). 1317 element.value = data.match.value; 1318 } 1319 1320 search(element); 1321 }); 1322 } 1323 }); 1324 1325 element.addEventListener('blur', eventHandlers.blur = function () { 1326 if (menu.isMousedown) 1327 { 1328 return; 1329 } 1330 1331 window.clearTimeout(searchTimeoutId); 1332 1333 autoSelectSingleAddress(); 1334 menu.close(); 1335 element.classList.toggle(inputBlankClassName, element.value === ''); 1336 }); 1337 1338 element.dispatchEvent(new CustomEvent(EVENT_NAMESPACE + 'create', {detail: self, bubbles: true})); 1339 }); 1340 1341 const autoSelectSingleAddress = function () 1342 { 1343 if ( 1344 options.autoFocus 1345 && options.autoSelectSingleAddress 1346 && menu.hasFocus 1347 && matches.length === 1 1348 && matches[0].precision === PRECISION_ADDRESS 1349 ) 1350 { 1351 menu.select(false); 1352 } 1353 } 1354 1355 /** 1356 * Search after input has stopped arriving for the amount of milliseconds specified by options.delay. 1357 * Values of up to three characters have practically no delay because most of these queries will be served from cache. 1358 * 1359 * @param {HTMLElement} element - Associated input element. 1360 */ 1361 const searchDebounced = function (element) 1362 { 1363 window.clearTimeout(searchTimeoutId); 1364 1365 let delay = 10; 1366 1367 if (element.value.length > 3) 1368 { 1369 delay = options.delay; 1370 } 1371 1372 searchTimeoutId = window.setTimeout(search, delay, element); 1373 } 1374 1375 /** 1376 * Search for address matches and toggle the menu based on the result. 1377 * 1378 * @param {HTMLElement} element - Associated input element. 1379 */ 1380 const search = function (element) 1381 { 1382 menu.open(element); 1383 1384 const data = elementData.get(element); 1385 activeElement = element; 1386 1387 if (element.value === previousValue && data.context === previousContext) 1388 { 1389 return; 1390 } 1391 1392 const hasSubstring = ( 1393 data.context === previousContext 1394 && previousValue !== null 1395 && (element.value.indexOf(previousValue) === 0 || previousValue.indexOf(element.value) === 0) 1396 ); 1397 previousValue = element.value; 1398 previousContext = data.context; 1399 data.match = {}; 1400 1401 // Reset context if we are below minimum length and clear menu instead of searching. 1402 if (element.value.length < options.minLength) 1403 { 1404 data.context = options.context; 1405 menu.clear(); 1406 return; 1407 } 1408 1409 // Trigger the search event. Cancel this event to prevent the request for address suggestions. 1410 if (false === element.dispatchEvent(new CustomEvent(EVENT_NAMESPACE + 'search', {cancelable: true, bubbles: true}))) 1411 { 1412 return; 1413 } 1414 1415 const menuSuppress = menu.suppress; 1416 self.getSuggestions.call(self, data.context, element.value, function (result) { 1417 if (getClass(result.newContext) === 'String') 1418 { 1419 // Forced switch away from current context - current context no longer valid given user input. 1420 data.context = result.newContext; 1421 } 1422 1423 // Trigger the response event. Cancel this event to prevent rendering address suggestions. 1424 if (true === element.dispatchEvent(new CustomEvent(EVENT_NAMESPACE + 'response', {detail: result, cancelable: true, bubbles: true}))) 1425 { 1426 matches = result.matches || []; 1427 1428 if (hasSubstring && matches.length === 0) 1429 { 1430 return; 1431 } 1432 1433 menu.setItems(matches, self.renderItem.bind(self)); 1434 self.announce(options.getResponseMessage(matches.length, options.language)); 1435 1436 if (menuSuppress === false) 1437 { 1438 menu.open(element); 1439 } 1440 } 1441 }); 1442 } 1443 } 1444 1445 // Expose plugin defaults. 1446 Object.defineProperty(autocomplete, 'defaults', { 1447 get: function () { 1448 return defaults; 1449 }, 1450 }); 1451 1452 // Expose readonly version number. 1453 Object.defineProperty(autocomplete, 'version', { 1454 value: VERSION, 1455 }); 1456 1457 return autocomplete; 1458}));
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.