intl-tel-input 18.4.0 → 18.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,8 @@ const defaults = {
22
22
  autoInsertDialCode: false,
23
23
  // add a placeholder in the input with an example number for the selected country
24
24
  autoPlaceholder: "polite",
25
+ // add a country search input at the top of the dropdown
26
+ countrySearch: false,
25
27
  // modify the parentClass
26
28
  customContainer: "",
27
29
  // modify the auto placeholder
@@ -131,6 +133,12 @@ class Iti {
131
133
  // if showing fullscreen popup, do not fix the width
132
134
  if (this.options.useFullscreenPopup) {
133
135
  this.options.fixDropdownWidth = false;
136
+ this.options.countrySearch = false;
137
+ }
138
+
139
+ // when search enabled, we must fix the width else it would change with different results
140
+ if (this.options.countrySearch) {
141
+ this.options.fixDropdownWidth = true;
134
142
  }
135
143
 
136
144
  // if in nationalMode, do not insert dial codes
@@ -357,6 +365,8 @@ class Iti {
357
365
 
358
366
  // generate all of the markup for the plugin: the selected flag overlay, and the dropdown
359
367
  _generateMarkup() {
368
+ this.telInput.classList.add("iti__tel-input");
369
+
360
370
  // if autocomplete does not exist on the element and its form, then
361
371
  // prevent autocomplete as there's no safe, cross-browser event we can react to, so it can
362
372
  // easily put the plugin in an inconsistent state e.g. the wrong flag selected for the
@@ -376,7 +386,8 @@ class Iti {
376
386
  hiddenInput,
377
387
  dropdownContainer,
378
388
  fixDropdownWidth,
379
- useFullscreenPopup
389
+ useFullscreenPopup,
390
+ countrySearch
380
391
  } = this.options;
381
392
 
382
393
  // containers (mostly for positioning)
@@ -461,14 +472,34 @@ class Iti {
461
472
  this.selectedFlag
462
473
  );
463
474
 
464
- // country dropdown: preferred countries, then divider, then all countries
465
- this.countryList = this._createEl("ul", {
466
- class: "iti__country-list iti__hide",
467
- id: `iti-${this.id}__country-listbox`,
468
- role: "listbox",
469
- "aria-label": "List of countries"
475
+ this.dropdownContent = this._createEl("div", {
476
+ class: "iti__dropdown-content iti__hide"
470
477
  });
471
- if (this.preferredCountries.length) {
478
+
479
+ if (countrySearch) {
480
+ this.searchInput = this._createEl(
481
+ "input",
482
+ {
483
+ type: "text",
484
+ class: "iti__search-input",
485
+ placeholder: "Search"
486
+ },
487
+ this.dropdownContent
488
+ );
489
+ }
490
+
491
+ // country list: preferred countries, then divider, then all countries
492
+ this.countryList = this._createEl(
493
+ "ul",
494
+ {
495
+ class: "iti__country-list",
496
+ id: `iti-${this.id}__country-listbox`,
497
+ role: "listbox",
498
+ "aria-label": "List of countries"
499
+ },
500
+ this.dropdownContent
501
+ );
502
+ if (this.preferredCountries.length && !countrySearch) {
472
503
  this._appendListItems(this.preferredCountries, "iti__preferred", true);
473
504
  this._createEl(
474
505
  "li",
@@ -483,11 +514,15 @@ class Iti {
483
514
 
484
515
  // create dropdownContainer markup
485
516
  if (dropdownContainer) {
486
- const fullscreenClass = useFullscreenPopup ? "iti--fullscreen-popup" : "";
487
- this.dropdown = this._createEl("div", { class: `iti iti--container ${fullscreenClass}` });
488
- this.dropdown.appendChild(this.countryList);
517
+ const fullscreenClass = useFullscreenPopup
518
+ ? "iti--fullscreen-popup"
519
+ : "";
520
+ this.dropdown = this._createEl("div", {
521
+ class: `iti iti--container ${fullscreenClass}`
522
+ });
523
+ this.dropdown.appendChild(this.dropdownContent);
489
524
  } else {
490
- this.flagsContainer.appendChild(this.countryList);
525
+ this.flagsContainer.appendChild(this.dropdownContent);
491
526
  }
492
527
  }
493
528
 
@@ -510,28 +545,39 @@ class Iti {
510
545
  }
511
546
  }
512
547
 
513
- // add a country <li> to the countryList <ul> container
548
+ // for each of the passed countries: add a country <li> to the countryList <ul> container
514
549
  _appendListItems(countries, className, preferred) {
515
- // we create so many DOM elements, it is faster to build a temp string
516
- // and then add everything to the DOM in one go at the end
517
- let tmp = "";
518
- // for each country
519
550
  for (let i = 0; i < countries.length; i++) {
520
551
  const c = countries[i];
521
552
  const idSuffix = preferred ? "-preferred" : "";
522
- // open the list item
523
- tmp += `<li class='iti__country ${className}' tabIndex='-1' id='iti-${this.id}__item-${c.iso2}${idSuffix}' role='option' data-dial-code='${c.dialCode}' data-country-code='${c.iso2}' aria-selected='false'>`;
553
+
554
+ const listItem = this._createEl(
555
+ "li",
556
+ {
557
+ id: `iti-${this.id}__item-${c.iso2}${idSuffix}`,
558
+ class: `iti__country ${className}`,
559
+ tabindex: "-1",
560
+ role: "option",
561
+ "data-dial-code": c.dialCode,
562
+ "data-country-code": c.iso2,
563
+ "aria-selected": "false"
564
+ },
565
+ this.countryList
566
+ );
567
+ // store this for later use e.g. country search filtering
568
+ c.node = listItem;
569
+
570
+ let content = "";
524
571
  // add the flag
525
572
  if (this.options.showFlags) {
526
- tmp += `<div class='iti__flag-box'><div class='iti__flag iti__${c.iso2}'></div></div>`;
573
+ content += `<div class='iti__flag-box'><div class='iti__flag iti__${c.iso2}'></div></div>`;
527
574
  }
528
575
  // and the country name and dial code
529
- tmp += `<span class='iti__country-name'>${c.name}</span>`;
530
- tmp += `<span class='iti__dial-code'>+${c.dialCode}</span>`;
531
- // close the list item
532
- tmp += "</li>";
576
+ content += `<span class='iti__country-name'>${c.name}</span>`;
577
+ content += `<span class='iti__dial-code'>+${c.dialCode}</span>`;
578
+
579
+ listItem.insertAdjacentHTML("beforeend", content);
533
580
  }
534
- this.countryList.insertAdjacentHTML("beforeend", tmp);
535
581
  }
536
582
 
537
583
  // set the initial state of the input value and the selected flag by:
@@ -633,7 +679,7 @@ class Iti {
633
679
  // close it again
634
680
  this._handleLabelClick = (e) => {
635
681
  // if the dropdown is closed, then focus the input, else ignore the click
636
- if (this.countryList.classList.contains("iti__hide")) {
682
+ if (this.dropdownContent.classList.contains("iti__hide")) {
637
683
  this.telInput.focus();
638
684
  } else {
639
685
  e.preventDefault();
@@ -650,7 +696,7 @@ class Iti {
650
696
  // else let it bubble up to the top ("click-off-to-close" listener)
651
697
  // we cannot just stopPropagation as it may be needed to close another instance
652
698
  if (
653
- this.countryList.classList.contains("iti__hide") &&
699
+ this.dropdownContent.classList.contains("iti__hide") &&
654
700
  !this.telInput.disabled &&
655
701
  !this.telInput.readOnly
656
702
  ) {
@@ -659,14 +705,14 @@ class Iti {
659
705
  };
660
706
  this.selectedFlag.addEventListener("click", this._handleClickSelectedFlag);
661
707
 
662
- // open dropdown list if currently focused
708
+ // open dropdown if selected flag is focused and they press up/down/space/enter
663
709
  this._handleFlagsContainerKeydown = (e) => {
664
- const isDropdownHidden = this.countryList.classList.contains("iti__hide");
710
+ const isDropdownHidden =
711
+ this.dropdownContent.classList.contains("iti__hide");
665
712
 
666
713
  if (
667
714
  isDropdownHidden &&
668
- ["ArrowUp", "Up", "ArrowDown", "Down", " ", "Enter"].indexOf(e.key) !==
669
- -1
715
+ ["ArrowUp", "ArrowDown", " ", "Enter"].includes(e.key)
670
716
  ) {
671
717
  // prevent form from being submitted if "ENTER" was pressed
672
718
  e.preventDefault();
@@ -811,15 +857,19 @@ class Iti {
811
857
  // show the dropdown
812
858
  _showDropdown() {
813
859
  if (this.options.fixDropdownWidth) {
814
- this.countryList.style.width = `${this.telInput.offsetWidth}px`;
860
+ this.dropdownContent.style.width = `${this.telInput.offsetWidth}px`;
815
861
  }
816
- this.countryList.classList.remove("iti__hide");
862
+ this.dropdownContent.classList.remove("iti__hide");
817
863
  this.selectedFlag.setAttribute("aria-expanded", "true");
818
864
 
819
865
  this._setDropdownPosition();
820
866
 
821
- // update highlighting and scroll to active list item
822
- if (this.activeItem) {
867
+ if (this.options.countrySearch) {
868
+ // start by highlighting the first item in the list
869
+ this._highlightListItem(this.countryList.firstElementChild, false);
870
+ this.searchInput.focus();
871
+ } else if (this.activeItem) {
872
+ // update highlighting and scroll to active list item
823
873
  this._highlightListItem(this.activeItem, false);
824
874
  this._scrollTo(this.activeItem, true);
825
875
  }
@@ -854,7 +904,7 @@ class Iti {
854
904
  const windowTop =
855
905
  window.pageYOffset || document.documentElement.scrollTop;
856
906
  const inputTop = pos.top + windowTop;
857
- const dropdownHeight = this.countryList.offsetHeight;
907
+ const dropdownHeight = this.dropdownContent.offsetHeight;
858
908
  // dropdownFitsBelow = (dropdownBottom < windowBottom)
859
909
  const dropdownFitsBelow =
860
910
  inputTop + this.telInput.offsetHeight + dropdownHeight <
@@ -864,7 +914,7 @@ class Iti {
864
914
  // by default, the dropdown will be below the input. If we want to position it above the
865
915
  // input, we add the dropup class.
866
916
  this._toggleClass(
867
- this.countryList,
917
+ this.dropdownContent,
868
918
  "iti__country-list--dropup",
869
919
  !dropdownFitsBelow && dropdownFitsAbove
870
920
  );
@@ -943,7 +993,7 @@ class Iti {
943
993
  this._handleClickOffToClose
944
994
  );
945
995
 
946
- // listen for up/down scrolling, enter to select, or letters to jump to country name.
996
+ // listen for up/down scrolling, enter to select, or escape to close
947
997
  // use keydown as keypress doesn't fire for non-char keys and we want to catch if they
948
998
  // just hit down and hold it to scroll down (no keyup event).
949
999
  // listen on the document because that's where key events are triggered if no input has focus
@@ -952,28 +1002,28 @@ class Iti {
952
1002
  this._handleKeydownOnDropdown = (e) => {
953
1003
  // prevent down key from scrolling the whole page,
954
1004
  // and enter key from submitting a form etc
955
- e.preventDefault();
1005
+ if (["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(e.key)) {
1006
+ e.preventDefault();
1007
+ e.stopPropagation();
956
1008
 
957
- // up and down to navigate
958
- if (
959
- e.key === "ArrowUp" ||
960
- e.key === "Up" ||
961
- e.key === "ArrowDown" ||
962
- e.key === "Down"
963
- ) {
964
- this._handleUpDownKey(e.key);
965
- }
966
- // enter to select
967
- else if (e.key === "Enter") {
968
- this._handleEnterKey();
969
- }
970
- // esc to close
971
- else if (e.key === "Escape") {
972
- this._closeDropdown();
1009
+ // up and down to navigate
1010
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
1011
+ this._handleUpDownKey(e.key);
1012
+ }
1013
+ // enter to select
1014
+ else if (e.key === "Enter") {
1015
+ this._handleEnterKey();
1016
+ }
1017
+ // esc to close
1018
+ else if (e.key === "Escape") {
1019
+ this._closeDropdown();
1020
+ }
973
1021
  }
1022
+
974
1023
  // alpha chars to perform search
975
1024
  // regex allows one latin alpha char or space, based on https://stackoverflow.com/a/26900132/217866)
976
- else if (/^[a-zA-ZÀ-ÿа-яА-Я ]$/.test(e.key)) {
1025
+ if (!this.options.countrySearch && /^[a-zA-ZÀ-ÿа-яА-Я ]$/.test(e.key)) {
1026
+ e.stopPropagation();
977
1027
  // jump to countries that start with the query string
978
1028
  if (queryTimer) {
979
1029
  clearTimeout(queryTimer);
@@ -987,23 +1037,85 @@ class Iti {
987
1037
  }
988
1038
  };
989
1039
  document.addEventListener("keydown", this._handleKeydownOnDropdown);
1040
+
1041
+ if (this.options.countrySearch) {
1042
+ const doFilter = () => {
1043
+ const inputQuery = this.searchInput.value.trim();
1044
+ if (inputQuery) {
1045
+ this._filterCountries(inputQuery.toLowerCase());
1046
+ } else {
1047
+ this._filterCountries(null, true);
1048
+ }
1049
+ };
1050
+
1051
+ let keyupTimer = null;
1052
+ this._handleSearchChange = () => {
1053
+ // filtering country nodes is expensive (lots of DOM manipulation), so rate limit it
1054
+ if (keyupTimer) {
1055
+ clearTimeout(keyupTimer);
1056
+ }
1057
+ keyupTimer = setTimeout(() => {
1058
+ doFilter();
1059
+ keyupTimer = null;
1060
+ }, 100);
1061
+ };
1062
+ this.searchInput.addEventListener("input", this._handleSearchChange);
1063
+
1064
+ // stop propagation on search input click, so doesn't trigger click-off-to-close listener
1065
+ this.searchInput.addEventListener("click", (e) => e.stopPropagation());
1066
+ }
1067
+ }
1068
+
1069
+ _filterCountries(query, isReset = false) {
1070
+ let isFirst = true;
1071
+ this.countryList.innerHTML = "";
1072
+ for (let i = 0; i < this.countries.length; i++) {
1073
+ const c = this.countries[i];
1074
+ const nameLower = c.name.toLowerCase();
1075
+ const fullDialCode = `+${c.dialCode}`;
1076
+ if (
1077
+ isReset ||
1078
+ nameLower.includes(query) ||
1079
+ fullDialCode.includes(query)
1080
+ ) {
1081
+ this.countryList.appendChild(c.node);
1082
+ // highlight the first item
1083
+ if (isFirst) {
1084
+ this._highlightListItem(c.node, false);
1085
+ isFirst = false;
1086
+ }
1087
+ }
1088
+ }
990
1089
  }
991
1090
 
992
1091
  // highlight the next/prev item in the list (and ensure it is visible)
993
1092
  _handleUpDownKey(key) {
994
1093
  let next =
995
- key === "ArrowUp" || key === "Up"
1094
+ key === "ArrowUp"
996
1095
  ? this.highlightedItem.previousElementSibling
997
1096
  : this.highlightedItem.nextElementSibling;
998
1097
  if (next) {
999
1098
  // skip the divider
1000
1099
  if (next.classList.contains("iti__divider")) {
1001
1100
  next =
1002
- key === "ArrowUp" || key === "Up"
1101
+ key === "ArrowUp"
1003
1102
  ? next.previousElementSibling
1004
1103
  : next.nextElementSibling;
1005
1104
  }
1006
- this._highlightListItem(next, true);
1105
+ } else if (this.countryList.childElementCount > 1) {
1106
+ // otherwise, we must be at the end, so loop round again
1107
+ next =
1108
+ key === "ArrowUp"
1109
+ ? this.countryList.lastElementChild
1110
+ : this.countryList.firstElementChild;
1111
+ }
1112
+ if (next) {
1113
+ // if country search enabled, dont lose focus from the search input on up/down
1114
+ const doFocus = !this.options.countrySearch;
1115
+ this._highlightListItem(next, doFocus);
1116
+ if (this.options.countrySearch) {
1117
+ this._scrollTo(next, false);
1118
+ }
1007
1119
  }
1008
1120
  }
1009
1121
 
@@ -1018,9 +1130,7 @@ class Iti {
1018
1130
  _searchForCountry(query) {
1019
1131
  for (let i = 0; i < this.countries.length; i++) {
1020
1132
  if (this._startsWith(this.countries[i].name, query)) {
1021
- const listItem = this.countryList.querySelector(
1022
- `#iti-${this.id}__item-${this.countries[i].iso2}`
1023
- );
1133
+ const listItem = this.countries[i].node;
1024
1134
  // update highlighting and scroll
1025
1135
  this._highlightListItem(listItem, false);
1026
1136
  this._scrollTo(listItem, true);
@@ -1344,7 +1454,7 @@ class Iti {
1344
1454
 
1345
1455
  // close the dropdown and unbind any listeners
1346
1456
  _closeDropdown() {
1347
- this.countryList.classList.add("iti__hide");
1457
+ this.dropdownContent.classList.add("iti__hide");
1348
1458
  this.selectedFlag.setAttribute("aria-expanded", "false");
1349
1459
  this.selectedFlag.removeAttribute("aria-activedescendant");
1350
1460
 
@@ -1353,6 +1463,9 @@ class Iti {
1353
1463
 
1354
1464
  // unbind key events
1355
1465
  document.removeEventListener("keydown", this._handleKeydownOnDropdown);
1466
+ if (this.options.countrySearch) {
1467
+ this.searchInput.removeEventListener("input", this._handleSearchChange);
1468
+ }
1356
1469
  document.documentElement.removeEventListener(
1357
1470
  "click",
1358
1471
  this._handleClickOffToClose
@@ -1378,7 +1491,7 @@ class Iti {
1378
1491
 
1379
1492
  // check if an element is visible within it's container, else scroll until it is
1380
1493
  _scrollTo(element, middle) {
1381
- const container = this.countryList;
1494
+ const container = this.dropdownContent;
1382
1495
  // windowTop from https://stackoverflow.com/a/14384091/217866
1383
1496
  const windowTop = window.pageYOffset || document.documentElement.scrollTop;
1384
1497
  const containerHeight = container.offsetHeight;
@@ -58,10 +58,10 @@ describe("dropdown shortcuts: init plugin (with nationalMode=false, autoInsertDi
58
58
  expect(getListElement()).not.toBeVisible();
59
59
  });
60
60
 
61
- it("pressing up while on the top item does not change the highlighted item", function() {
61
+ it("pressing up while on the top item highlights the bottom item", function() {
62
62
  triggerKeyOnBody("ArrowUp");
63
- var topItem = getListElement().find("li.iti__country:eq(0)");
64
- expect(topItem).toHaveClass("iti__highlight");
63
+ var lastItem = getListElement().find("li.iti__country:last");
64
+ expect(lastItem).toHaveClass("iti__highlight");
65
65
  });
66
66
 
67
67
  it("pressing z highlights Zambia", function() {
@@ -82,23 +82,22 @@ describe("dropdown shortcuts: init plugin (with nationalMode=false, autoInsertDi
82
82
 
83
83
  describe("typing z then i then DOWN", function() {
84
84
 
85
- var lastItem;
86
-
87
85
  beforeEach(function() {
88
- lastItem = getListElement().find("li.iti__country:last");
89
86
  triggerKeyOnBody("z");
90
87
  triggerKeyOnBody("i");
91
88
  triggerKeyOnBody("ArrowDown");
92
89
  });
93
90
 
94
91
  it("highlights the last item, which is Åland Islands", function() {
92
+ var lastItem = getListElement().find("li.iti__country:last");
95
93
  expect(lastItem).toHaveClass("iti__highlight");
96
94
  expect(lastItem.attr("data-country-code")).toEqual("ax");
97
95
  });
98
96
 
99
- it("pressing down while on the last item does not change the highlighted item", function() {
97
+ it("pressing down while on the last item highlights the first item", function() {
100
98
  triggerKeyOnBody("ArrowDown");
101
- expect(lastItem).toHaveClass("iti__highlight");
99
+ var topItem = getListElement().find("li.iti__country:eq(0)");
100
+ expect(topItem).toHaveClass("iti__highlight");
102
101
  });
103
102
  });
104
103