intl-tel-input 18.4.0 → 18.5.1

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
@@ -133,6 +135,11 @@ class Iti {
133
135
  this.options.fixDropdownWidth = false;
134
136
  }
135
137
 
138
+ // when search enabled, we must fix the width else it would change with different results
139
+ if (this.options.countrySearch && !this.options.useFullscreenPopup) {
140
+ this.options.fixDropdownWidth = true;
141
+ }
142
+
136
143
  // if in nationalMode, do not insert dial codes
137
144
  if (this.options.nationalMode) {
138
145
  this.options.autoInsertDialCode = false;
@@ -357,6 +364,8 @@ class Iti {
357
364
 
358
365
  // generate all of the markup for the plugin: the selected flag overlay, and the dropdown
359
366
  _generateMarkup() {
367
+ this.telInput.classList.add("iti__tel-input");
368
+
360
369
  // if autocomplete does not exist on the element and its form, then
361
370
  // prevent autocomplete as there's no safe, cross-browser event we can react to, so it can
362
371
  // easily put the plugin in an inconsistent state e.g. the wrong flag selected for the
@@ -376,7 +385,8 @@ class Iti {
376
385
  hiddenInput,
377
386
  dropdownContainer,
378
387
  fixDropdownWidth,
379
- useFullscreenPopup
388
+ useFullscreenPopup,
389
+ countrySearch
380
390
  } = this.options;
381
391
 
382
392
  // containers (mostly for positioning)
@@ -461,14 +471,34 @@ class Iti {
461
471
  this.selectedFlag
462
472
  );
463
473
 
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"
474
+ this.dropdownContent = this._createEl("div", {
475
+ class: "iti__dropdown-content iti__hide"
470
476
  });
471
- if (this.preferredCountries.length) {
477
+
478
+ if (countrySearch) {
479
+ this.searchInput = this._createEl(
480
+ "input",
481
+ {
482
+ type: "text",
483
+ class: "iti__search-input",
484
+ placeholder: "Search"
485
+ },
486
+ this.dropdownContent
487
+ );
488
+ }
489
+
490
+ // country list: preferred countries, then divider, then all countries
491
+ this.countryList = this._createEl(
492
+ "ul",
493
+ {
494
+ class: "iti__country-list",
495
+ id: `iti-${this.id}__country-listbox`,
496
+ role: "listbox",
497
+ "aria-label": "List of countries"
498
+ },
499
+ this.dropdownContent
500
+ );
501
+ if (this.preferredCountries.length && !countrySearch) {
472
502
  this._appendListItems(this.preferredCountries, "iti__preferred", true);
473
503
  this._createEl(
474
504
  "li",
@@ -483,11 +513,17 @@ class Iti {
483
513
 
484
514
  // create dropdownContainer markup
485
515
  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);
516
+ let dropdownClasses = "iti iti--container";
517
+ if (useFullscreenPopup) {
518
+ dropdownClasses += " iti--fullscreen-popup";
519
+ }
520
+ if (countrySearch) {
521
+ dropdownClasses += " iti--country-search";
522
+ }
523
+ this.dropdown = this._createEl("div", { class: dropdownClasses });
524
+ this.dropdown.appendChild(this.dropdownContent);
489
525
  } else {
490
- this.flagsContainer.appendChild(this.countryList);
526
+ this.flagsContainer.appendChild(this.dropdownContent);
491
527
  }
492
528
  }
493
529
 
@@ -510,28 +546,39 @@ class Iti {
510
546
  }
511
547
  }
512
548
 
513
- // add a country <li> to the countryList <ul> container
549
+ // for each of the passed countries: add a country <li> to the countryList <ul> container
514
550
  _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
551
  for (let i = 0; i < countries.length; i++) {
520
552
  const c = countries[i];
521
553
  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'>`;
554
+
555
+ const listItem = this._createEl(
556
+ "li",
557
+ {
558
+ id: `iti-${this.id}__item-${c.iso2}${idSuffix}`,
559
+ class: `iti__country ${className}`,
560
+ tabindex: "-1",
561
+ role: "option",
562
+ "data-dial-code": c.dialCode,
563
+ "data-country-code": c.iso2,
564
+ "aria-selected": "false"
565
+ },
566
+ this.countryList
567
+ );
568
+ // store this for later use e.g. country search filtering
569
+ c.node = listItem;
570
+
571
+ let content = "";
524
572
  // add the flag
525
573
  if (this.options.showFlags) {
526
- tmp += `<div class='iti__flag-box'><div class='iti__flag iti__${c.iso2}'></div></div>`;
574
+ content += `<div class='iti__flag-box'><div class='iti__flag iti__${c.iso2}'></div></div>`;
527
575
  }
528
576
  // 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>";
577
+ content += `<span class='iti__country-name'>${c.name}</span>`;
578
+ content += `<span class='iti__dial-code'>+${c.dialCode}</span>`;
579
+
580
+ listItem.insertAdjacentHTML("beforeend", content);
533
581
  }
534
- this.countryList.insertAdjacentHTML("beforeend", tmp);
535
582
  }
536
583
 
537
584
  // set the initial state of the input value and the selected flag by:
@@ -633,7 +680,7 @@ class Iti {
633
680
  // close it again
634
681
  this._handleLabelClick = (e) => {
635
682
  // if the dropdown is closed, then focus the input, else ignore the click
636
- if (this.countryList.classList.contains("iti__hide")) {
683
+ if (this.dropdownContent.classList.contains("iti__hide")) {
637
684
  this.telInput.focus();
638
685
  } else {
639
686
  e.preventDefault();
@@ -650,7 +697,7 @@ class Iti {
650
697
  // else let it bubble up to the top ("click-off-to-close" listener)
651
698
  // we cannot just stopPropagation as it may be needed to close another instance
652
699
  if (
653
- this.countryList.classList.contains("iti__hide") &&
700
+ this.dropdownContent.classList.contains("iti__hide") &&
654
701
  !this.telInput.disabled &&
655
702
  !this.telInput.readOnly
656
703
  ) {
@@ -659,14 +706,14 @@ class Iti {
659
706
  };
660
707
  this.selectedFlag.addEventListener("click", this._handleClickSelectedFlag);
661
708
 
662
- // open dropdown list if currently focused
709
+ // open dropdown if selected flag is focused and they press up/down/space/enter
663
710
  this._handleFlagsContainerKeydown = (e) => {
664
- const isDropdownHidden = this.countryList.classList.contains("iti__hide");
711
+ const isDropdownHidden =
712
+ this.dropdownContent.classList.contains("iti__hide");
665
713
 
666
714
  if (
667
715
  isDropdownHidden &&
668
- ["ArrowUp", "Up", "ArrowDown", "Down", " ", "Enter"].indexOf(e.key) !==
669
- -1
716
+ ["ArrowUp", "ArrowDown", " ", "Enter"].includes(e.key)
670
717
  ) {
671
718
  // prevent form from being submitted if "ENTER" was pressed
672
719
  e.preventDefault();
@@ -811,15 +858,19 @@ class Iti {
811
858
  // show the dropdown
812
859
  _showDropdown() {
813
860
  if (this.options.fixDropdownWidth) {
814
- this.countryList.style.width = `${this.telInput.offsetWidth}px`;
861
+ this.dropdownContent.style.width = `${this.telInput.offsetWidth}px`;
815
862
  }
816
- this.countryList.classList.remove("iti__hide");
863
+ this.dropdownContent.classList.remove("iti__hide");
817
864
  this.selectedFlag.setAttribute("aria-expanded", "true");
818
865
 
819
866
  this._setDropdownPosition();
820
867
 
821
- // update highlighting and scroll to active list item
822
- if (this.activeItem) {
868
+ if (this.options.countrySearch) {
869
+ // start by highlighting the first item in the list
870
+ this._highlightListItem(this.countryList.firstElementChild, false);
871
+ this.searchInput.focus();
872
+ } else if (this.activeItem) {
873
+ // update highlighting and scroll to active list item
823
874
  this._highlightListItem(this.activeItem, false);
824
875
  this._scrollTo(this.activeItem, true);
825
876
  }
@@ -854,19 +905,21 @@ class Iti {
854
905
  const windowTop =
855
906
  window.pageYOffset || document.documentElement.scrollTop;
856
907
  const inputTop = pos.top + windowTop;
857
- const dropdownHeight = this.countryList.offsetHeight;
908
+ const dropdownHeight = this.dropdownContent.offsetHeight;
858
909
  // dropdownFitsBelow = (dropdownBottom < windowBottom)
859
910
  const dropdownFitsBelow =
860
911
  inputTop + this.telInput.offsetHeight + dropdownHeight <
861
912
  windowTop + window.innerHeight;
862
913
  const dropdownFitsAbove = inputTop - dropdownHeight > windowTop;
914
+ // dont allow positioning above when country search enabled as the search box jumps around as you filter countries
915
+ const positionDropdownAboveInput = !this.options.countrySearch && !dropdownFitsBelow && dropdownFitsAbove;
863
916
 
864
917
  // by default, the dropdown will be below the input. If we want to position it above the
865
918
  // input, we add the dropup class.
866
919
  this._toggleClass(
867
- this.countryList,
868
- "iti__country-list--dropup",
869
- !dropdownFitsBelow && dropdownFitsAbove
920
+ this.dropdownContent,
921
+ "iti__dropdown-content--dropup",
922
+ positionDropdownAboveInput
870
923
  );
871
924
 
872
925
  // if dropdownContainer is enabled, calculate postion
@@ -874,7 +927,7 @@ class Iti {
874
927
  // by default the dropdown will be directly over the input because it's not in the flow.
875
928
  // If we want to position it below, we need to add some extra top value.
876
929
  const extraTop =
877
- !dropdownFitsBelow && dropdownFitsAbove
930
+ positionDropdownAboveInput
878
931
  ? 0
879
932
  : this.telInput.offsetHeight;
880
933
 
@@ -943,7 +996,7 @@ class Iti {
943
996
  this._handleClickOffToClose
944
997
  );
945
998
 
946
- // listen for up/down scrolling, enter to select, or letters to jump to country name.
999
+ // listen for up/down scrolling, enter to select, or escape to close
947
1000
  // use keydown as keypress doesn't fire for non-char keys and we want to catch if they
948
1001
  // just hit down and hold it to scroll down (no keyup event).
949
1002
  // listen on the document because that's where key events are triggered if no input has focus
@@ -952,28 +1005,28 @@ class Iti {
952
1005
  this._handleKeydownOnDropdown = (e) => {
953
1006
  // prevent down key from scrolling the whole page,
954
1007
  // and enter key from submitting a form etc
955
- e.preventDefault();
1008
+ if (["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(e.key)) {
1009
+ e.preventDefault();
1010
+ e.stopPropagation();
956
1011
 
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();
1012
+ // up and down to navigate
1013
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
1014
+ this._handleUpDownKey(e.key);
1015
+ }
1016
+ // enter to select
1017
+ else if (e.key === "Enter") {
1018
+ this._handleEnterKey();
1019
+ }
1020
+ // esc to close
1021
+ else if (e.key === "Escape") {
1022
+ this._closeDropdown();
1023
+ }
973
1024
  }
1025
+
974
1026
  // alpha chars to perform search
975
1027
  // 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)) {
1028
+ if (!this.options.countrySearch && /^[a-zA-ZÀ-ÿа-яА-Я ]$/.test(e.key)) {
1029
+ e.stopPropagation();
977
1030
  // jump to countries that start with the query string
978
1031
  if (queryTimer) {
979
1032
  clearTimeout(queryTimer);
@@ -987,23 +1040,85 @@ class Iti {
987
1040
  }
988
1041
  };
989
1042
  document.addEventListener("keydown", this._handleKeydownOnDropdown);
1043
+
1044
+ if (this.options.countrySearch) {
1045
+ const doFilter = () => {
1046
+ const inputQuery = this.searchInput.value.trim();
1047
+ if (inputQuery) {
1048
+ this._filterCountries(inputQuery.toLowerCase());
1049
+ } else {
1050
+ this._filterCountries(null, true);
1051
+ }
1052
+ };
1053
+
1054
+ let keyupTimer = null;
1055
+ this._handleSearchChange = () => {
1056
+ // filtering country nodes is expensive (lots of DOM manipulation), so rate limit it
1057
+ if (keyupTimer) {
1058
+ clearTimeout(keyupTimer);
1059
+ }
1060
+ keyupTimer = setTimeout(() => {
1061
+ doFilter();
1062
+ keyupTimer = null;
1063
+ }, 100);
1064
+ };
1065
+ this.searchInput.addEventListener("input", this._handleSearchChange);
1066
+
1067
+ // stop propagation on search input click, so doesn't trigger click-off-to-close listener
1068
+ this.searchInput.addEventListener("click", (e) => e.stopPropagation());
1069
+ }
1070
+ }
1071
+
1072
+ _filterCountries(query, isReset = false) {
1073
+ let isFirst = true;
1074
+ this.countryList.innerHTML = "";
1075
+ for (let i = 0; i < this.countries.length; i++) {
1076
+ const c = this.countries[i];
1077
+ const nameLower = c.name.toLowerCase();
1078
+ const fullDialCode = `+${c.dialCode}`;
1079
+ if (
1080
+ isReset ||
1081
+ nameLower.includes(query) ||
1082
+ fullDialCode.includes(query)
1083
+ ) {
1084
+ this.countryList.appendChild(c.node);
1085
+ // highlight the first item
1086
+ if (isFirst) {
1087
+ this._highlightListItem(c.node, false);
1088
+ isFirst = false;
1089
+ }
1090
+ }
1091
+ }
990
1092
  }
991
1093
 
992
1094
  // highlight the next/prev item in the list (and ensure it is visible)
993
1095
  _handleUpDownKey(key) {
994
1096
  let next =
995
- key === "ArrowUp" || key === "Up"
1097
+ key === "ArrowUp"
996
1098
  ? this.highlightedItem.previousElementSibling
997
1099
  : this.highlightedItem.nextElementSibling;
998
1100
  if (next) {
999
1101
  // skip the divider
1000
1102
  if (next.classList.contains("iti__divider")) {
1001
1103
  next =
1002
- key === "ArrowUp" || key === "Up"
1104
+ key === "ArrowUp"
1003
1105
  ? next.previousElementSibling
1004
1106
  : next.nextElementSibling;
1005
1107
  }
1006
- this._highlightListItem(next, true);
1108
+ } else if (this.countryList.childElementCount > 1) {
1109
+ // otherwise, we must be at the end, so loop round again
1110
+ next =
1111
+ key === "ArrowUp"
1112
+ ? this.countryList.lastElementChild
1113
+ : this.countryList.firstElementChild;
1114
+ }
1115
+ if (next) {
1116
+ // if country search enabled, dont lose focus from the search input on up/down
1117
+ const doFocus = !this.options.countrySearch;
1118
+ this._highlightListItem(next, doFocus);
1119
+ if (this.options.countrySearch) {
1120
+ this._scrollTo(next, false);
1121
+ }
1007
1122
  }
1008
1123
  }
1009
1124
 
@@ -1018,9 +1133,7 @@ class Iti {
1018
1133
  _searchForCountry(query) {
1019
1134
  for (let i = 0; i < this.countries.length; i++) {
1020
1135
  if (this._startsWith(this.countries[i].name, query)) {
1021
- const listItem = this.countryList.querySelector(
1022
- `#iti-${this.id}__item-${this.countries[i].iso2}`
1023
- );
1136
+ const listItem = this.countries[i].node;
1024
1137
  // update highlighting and scroll
1025
1138
  this._highlightListItem(listItem, false);
1026
1139
  this._scrollTo(listItem, true);
@@ -1344,7 +1457,7 @@ class Iti {
1344
1457
 
1345
1458
  // close the dropdown and unbind any listeners
1346
1459
  _closeDropdown() {
1347
- this.countryList.classList.add("iti__hide");
1460
+ this.dropdownContent.classList.add("iti__hide");
1348
1461
  this.selectedFlag.setAttribute("aria-expanded", "false");
1349
1462
  this.selectedFlag.removeAttribute("aria-activedescendant");
1350
1463
 
@@ -1353,6 +1466,9 @@ class Iti {
1353
1466
 
1354
1467
  // unbind key events
1355
1468
  document.removeEventListener("keydown", this._handleKeydownOnDropdown);
1469
+ if (this.options.countrySearch) {
1470
+ this.searchInput.removeEventListener("input", this._handleSearchChange);
1471
+ }
1356
1472
  document.documentElement.removeEventListener(
1357
1473
  "click",
1358
1474
  this._handleClickOffToClose
@@ -1378,7 +1494,7 @@ class Iti {
1378
1494
 
1379
1495
  // check if an element is visible within it's container, else scroll until it is
1380
1496
  _scrollTo(element, middle) {
1381
- const container = this.countryList;
1497
+ const container = this.dropdownContent;
1382
1498
  // windowTop from https://stackoverflow.com/a/14384091/217866
1383
1499
  const windowTop = window.pageYOffset || document.documentElement.scrollTop;
1384
1500
  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