@input-kit/phone 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -1,11 +1,19 @@
1
1
  # @input-kit/phone
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/%40input-kit%2Fphone)](https://www.npmjs.com/package/@input-kit/phone)
4
+ [![npm downloads](https://img.shields.io/npm/dm/%40input-kit%2Fphone)](https://www.npmjs.com/package/@input-kit/phone)
5
+ [![CI](https://github.com/harshit-d3v/input-kit-phone/actions/workflows/ci.yml/badge.svg)](https://github.com/harshit-d3v/input-kit-phone/actions/workflows/ci.yml)
6
+ [![license](https://img.shields.io/npm/l/%40input-kit%2Fphone)](https://github.com/harshit-d3v/input-kit-phone/blob/main/LICENSE)
7
+ [![GitHub stars](https://img.shields.io/github/stars/harshit-d3v/input-kit-phone?style=social)](https://github.com/harshit-d3v/input-kit-phone)
8
+
3
9
  Headless React phone input with a complete world country-code dataset, searchable country selection, and `libphonenumber-js` powered formatting and validation.
4
10
 
5
- Source, issues, and contributions: **[github.com/harshit-d3v/input-kit-phone](https://github.com/harshit-d3v/input-kit-phone)**
11
+ Source, issues, and contributions: **[github.com/harshit-d3v/input-kit-phone](https://github.com/harshit-d3v/input-kit-phone)** — if this package saves you time, a ⭐ there helps others find it.
6
12
 
7
13
  ## Latest update
8
14
 
15
+ **0.4.0** — **correctness fixes, please upgrade.** E.164 was built by concatenating the dial code onto whatever was typed, so the national trunk prefix people actually type survived into the international form: UK `07400123456` became `+4407400123456`, and India, Germany, France and Australia were wrong the same way. Around 106 countries were affected, validation still reported these as valid, and North American numbers never were — which is why it went unnoticed. Now resolved through libphonenumber metadata, so countries that genuinely keep a leading zero (Italy) still do. Also fixes: an inline `onValidationChange` causing "Maximum update depth exceeded"; input past the country maximum being discarded instead of truncated, which silently blanked an empty field on paste; a country selection that reverted on international numbers; the country button not announcing the selected country to screen readers; focus dropping to the top of the page when the dropdown closed; and types failing to resolve for CommonJS TypeScript consumers under `node16`/`nodenext`.
16
+
9
17
  **0.3.0** — auto-detect respects manual country selection, metadata-based length validation (no more false `too_long` in variable-length countries), input capped at the country's maximum length, `isPhoneTooLong()`, SSR-safe caret handling, dropdown `Home`/`End` + search→list keyboard navigation, unminified published output.
10
18
 
11
19
  **0.2.2** — npm README cleanup: release notes stay inline; removed pointers to repo-only markdown files.
@@ -33,7 +41,7 @@ npm install @input-kit/phone
33
41
 
34
42
  ```tsx
35
43
  import { PhoneInput } from '@input-kit/phone';
36
- import '@your-app/phone-input.css'; // style .phone-input-* classes
44
+ import './phone-input.css'; // your own stylesheet — the package ships no CSS
37
45
 
38
46
  function Example() {
39
47
  return (
package/dist/index.cjs CHANGED
@@ -395,9 +395,11 @@ function addDialCode(phone, country) {
395
395
  if (!normalized) return "";
396
396
  if (normalized === "+") return "";
397
397
  if (normalized.startsWith("+")) return normalized;
398
- const digits = cleanPhone(normalized);
399
- const matchingDialCode = country.dialCodes.find((dialCode) => digits.startsWith(dialCode.replace("+", "")));
400
- return matchingDialCode ? `+${digits}` : `${country.dialCode}${digits}`;
398
+ const parsed = libphonenumberJs.parsePhoneNumberFromString(normalized, getCountryCode(country));
399
+ if (parsed) {
400
+ return parsed.number;
401
+ }
402
+ return `${country.dialCode}${cleanPhone(normalized)}`;
401
403
  }
402
404
  function removeDialCode(phone, country) {
403
405
  if (!country) return phone;
@@ -690,13 +692,21 @@ function usePhoneInput(options = {}) {
690
692
  () => validatePhoneNumber(phone, country, required, validator),
691
693
  [phone, country, required, validator]
692
694
  );
695
+ const onValidationChangeRef = react.useRef(onValidationChange);
696
+ useIsomorphicLayoutEffect(() => {
697
+ onValidationChangeRef.current = onValidationChange;
698
+ });
693
699
  react.useEffect(() => {
694
- onValidationChange?.(validation);
695
- }, [validation, onValidationChange]);
700
+ onValidationChangeRef.current?.(validation);
701
+ }, [validation]);
696
702
  const localizedCountry = react.useMemo(
697
703
  () => localizeCountry(country, locale),
698
704
  [country, locale]
699
705
  );
706
+ const countryButtonAriaLabel = react.useMemo(
707
+ () => localizedCountry ? `${resolvedLabels.countryButtonAriaLabel}, ${localizedCountry.name} ${localizedCountry.dialCode}` : resolvedLabels.countryButtonAriaLabel,
708
+ [localizedCountry, resolvedLabels.countryButtonAriaLabel]
709
+ );
700
710
  const filteredCountries = react.useMemo(
701
711
  () => filterCountries(localizedCountries, searchQuery, preferredCountries),
702
712
  [localizedCountries, searchQuery, preferredCountries]
@@ -746,16 +756,20 @@ function usePhoneInput(options = {}) {
746
756
  (selectedCountry) => {
747
757
  const rawCountry = getCountryByCode(selectedCountry.code) ?? selectedCountry;
748
758
  manualCountrySelectionRef.current = true;
759
+ const rewritten = phone.trim().startsWith("+") ? addDialCode(removeDialCode(phone, country), rawCountry) : phone;
760
+ if (rewritten !== phone && !isControlled) {
761
+ setInternalPhone(rewritten);
762
+ }
749
763
  setCountry(rawCountry);
750
764
  setIsOpen(false);
751
765
  setSearchQuery("");
752
766
  onCountryChange?.(getOutputCountry(rawCountry));
753
767
  onChange?.(
754
- includeDialCode ? addDialCode(phone, rawCountry) : phone,
768
+ includeDialCode ? addDialCode(rewritten, rawCountry) : rewritten,
755
769
  getOutputCountry(rawCountry)
756
770
  );
757
771
  },
758
- [phone, onChange, onCountryChange, includeDialCode, getOutputCountry]
772
+ [phone, country, isControlled, onChange, onCountryChange, includeDialCode, getOutputCountry]
759
773
  );
760
774
  const toggleDropdown = react.useCallback(() => {
761
775
  setIsOpen((prev) => !prev);
@@ -781,18 +795,23 @@ function usePhoneInput(options = {}) {
781
795
  const rawValue = event.target.value;
782
796
  const caretPosition = event.target.selectionStart ?? rawValue.length;
783
797
  const unformatted = rawValue.trim().startsWith("+") ? `+${rawValue.replace(/\D/g, "")}` : unformatPhone(rawValue);
784
- if (cleanPhone(unformatted).length > cleanPhone(phone).length && isPhoneTooLong(unformatted, country)) {
785
- event.target.value = formattedPhone;
786
- const restoredCaret = Math.min(Math.max(caretPosition - 1, 0), formattedPhone.length);
798
+ const isInternationalEntry = unformatted.startsWith("+") || unformatted.startsWith("00");
799
+ if (!isInternationalEntry && cleanPhone(unformatted).length > cleanPhone(phone).length && isPhoneTooLong(unformatted, country)) {
800
+ const truncated = limitInputLength(unformatted, country);
801
+ const truncatedDisplay = formatPhone(truncated, country);
802
+ event.target.value = truncatedDisplay;
803
+ const restoredCaret = Math.min(Math.max(caretPosition - 1, 0), truncatedDisplay.length);
787
804
  if (typeof event.target.setSelectionRange === "function") {
788
805
  event.target.setSelectionRange(restoredCaret, restoredCaret);
789
806
  }
807
+ pendingCaretDigitIndexRef.current = countDigitsBeforePosition(truncatedDisplay, restoredCaret);
808
+ setPhone(truncated);
790
809
  return;
791
810
  }
792
811
  pendingCaretDigitIndexRef.current = countDigitsBeforePosition(rawValue, caretPosition);
793
812
  setPhone(unformatted);
794
813
  },
795
- [setPhone, phone, country, formattedPhone]
814
+ [setPhone, phone, country]
796
815
  );
797
816
  const handleFocus = react.useCallback(() => {
798
817
  onFocus?.();
@@ -866,13 +885,13 @@ function usePhoneInput(options = {}) {
866
885
  onClick: toggleDropdown,
867
886
  "aria-expanded": isOpen,
868
887
  "aria-haspopup": "listbox",
869
- "aria-label": resolvedLabels.countryButtonAriaLabel
888
+ "aria-label": countryButtonAriaLabel
870
889
  },
871
890
  countrySelectorProps: {
872
891
  onClick: toggleDropdown,
873
892
  "aria-expanded": isOpen,
874
893
  "aria-haspopup": "listbox",
875
- "aria-label": resolvedLabels.countryButtonAriaLabel
894
+ "aria-label": countryButtonAriaLabel
876
895
  },
877
896
  dropdownProps: {
878
897
  role: "listbox",
@@ -911,6 +930,7 @@ var PhoneInput = react.forwardRef(
911
930
  const containerRef = react.useRef(null);
912
931
  const inputRef = react.useRef(null);
913
932
  const optionRefs = react.useRef([]);
933
+ const countryButtonRef = react.useRef(null);
914
934
  const [highlightedIndex, setHighlightedIndex] = react.useState(0);
915
935
  const listboxId = react.useId();
916
936
  const {
@@ -953,10 +973,14 @@ var PhoneInput = react.forwardRef(
953
973
  const blur = react.useCallback(() => {
954
974
  inputRef.current?.blur();
955
975
  }, []);
976
+ const returnFocusToButton = react.useCallback(() => {
977
+ countryButtonRef.current?.focus();
978
+ }, []);
956
979
  const handleCountryClick = react.useCallback((c) => {
957
980
  selectCountry(c);
958
981
  setHighlightedIndex(0);
959
- }, [selectCountry]);
982
+ returnFocusToButton();
983
+ }, [selectCountry, returnFocusToButton]);
960
984
  const handleCountryKeyDown = react.useCallback((e, c, index) => {
961
985
  switch (e.key) {
962
986
  case "Enter":
@@ -964,6 +988,7 @@ var PhoneInput = react.forwardRef(
964
988
  e.preventDefault();
965
989
  selectCountry(c);
966
990
  setHighlightedIndex(0);
991
+ returnFocusToButton();
967
992
  break;
968
993
  case "ArrowDown":
969
994
  e.preventDefault();
@@ -996,9 +1021,10 @@ var PhoneInput = react.forwardRef(
996
1021
  case "Escape":
997
1022
  e.preventDefault();
998
1023
  closeDropdown();
1024
+ returnFocusToButton();
999
1025
  break;
1000
1026
  }
1001
- }, [selectCountry, filteredCountries.length, closeDropdown]);
1027
+ }, [selectCountry, filteredCountries.length, closeDropdown, returnFocusToButton]);
1002
1028
  react.useEffect(() => {
1003
1029
  if (!isOpen) {
1004
1030
  return;
@@ -1050,13 +1076,15 @@ var PhoneInput = react.forwardRef(
1050
1076
  } else if (e.key === "Escape") {
1051
1077
  e.preventDefault();
1052
1078
  closeDropdown();
1079
+ returnFocusToButton();
1053
1080
  }
1054
- }, [closeDropdown]);
1081
+ }, [closeDropdown, returnFocusToButton]);
1055
1082
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, className: containerClassName, "data-valid": isValid, children: [
1056
1083
  /* @__PURE__ */ jsxRuntime.jsxs(
1057
1084
  "button",
1058
1085
  {
1059
1086
  ...countryButtonProps,
1087
+ ref: countryButtonRef,
1060
1088
  type: "button",
1061
1089
  className: selectorClassName,
1062
1090
  disabled,