Skip to content

Commit 793b6e2

Browse files
authored
Telephone Number Input improvements (#152)
1 parent 6edd9de commit 793b6e2

6 files changed

Lines changed: 180 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- country labels in search country input for `TelephoneNumberInput` includes also national prefix.
13+
- Clicking on clear icon in search country input for `TelephoneNumberInput` will automatically open the select menu.
14+
15+
### Added
16+
17+
- `pinnedCountries` property in `TelephoneNumberInput` which allows to pin some countries in the top of the list.
18+
1019
## [3.12.0] - 2025-11-10
1120

1221
### Added

cypress/cypress/component/TelephoneNumberInput/TelephoneNumberInput.cy.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ it("correctly set telephone number", () => {
2222
cy.get("@onSubmitSpy").should("be.calledWith", { [name]: "+41123456789" });
2323
});
2424

25-
it("correctly change country telephone number", () => {
25+
it("correctly change country telephone number (searching for country or country prefix)", () => {
2626
const name = faker.random.alpha(10);
2727

2828
mount(
@@ -42,6 +42,16 @@ it("correctly change country telephone number", () => {
4242

4343
cy.get("input[type=submit]").click();
4444
cy.get("@onSubmitSpy").should("be.calledWith", { [name]: "+391234567890" });
45+
46+
cy.get(`.MuiInputAdornment-root`).click();
47+
cy.get(`.MuiInputBase-input:not(#${name})`).clear();
48+
cy.get(`.MuiInputBase-input:not(#${name})`).type("+234");
49+
cy.get(`.MuiAutocomplete-option`).click();
50+
cy.get(`#${name}`).clear();
51+
cy.get(`#${name}`).type("678901234");
52+
53+
cy.get("input[type=submit]").click();
54+
cy.get("@onSubmitSpy").should("be.calledWith", { [name]: "+234678901234" });
4555
});
4656

4757
it("recognize telephone number coming from default values (even if specified in different format)", () => {
@@ -59,3 +69,22 @@ it("recognize telephone number coming from default values (even if specified in
5969
cy.get(`.MuiInputAdornment-root`).contains("+44");
6070
cy.get(`#${name}`).should("have.value", "123456789");
6171
});
72+
73+
it("pinning contries works", () => {
74+
const name = faker.random.alpha(10);
75+
76+
mount(
77+
<div className="p-4">
78+
<Form defaultValues={{ [name]: "+4 4 123456789" }} onSubmit={cy.spy().as("onSubmitSpy")}>
79+
<TelephoneNumberInput name={name} useBootstrapStyle label="Phone Number" defaultCountry="CH" pinnedCountries={["GB", "IT"]} />
80+
<input type="submit" className="mt-4" />
81+
</Form>
82+
</div>,
83+
);
84+
85+
cy.get(`.MuiInputAdornment-root`).click();
86+
cy.get(`.MuiInputBase-input:not(#${name})`).clear();
87+
cy.get(`.MuiAutocomplete-listbox`).children().eq(0).should("contain", "+44");
88+
cy.get(`.MuiAutocomplete-listbox`).children().eq(1).should("contain", "+39");
89+
cy.get(`.MuiAutocomplete-listbox`).children().eq(2).should("contain", "──");
90+
});

src/lib/TelephoneNumberInput.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface TelephoneNumberInputProps<T extends FieldValues>
1313
onChange?: (telephoneNumber: string) => void;
1414
onBlur?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
1515
defaultCountry: RegionCode;
16+
pinnedCountries?: RegionCode[];
1617
placeholder?: string;
1718
renderAutocompleteField?: (children: ReactNode) => ReactNode;
1819
locale?: string;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Dispatch, SetStateAction, useMemo, useState } from "react";
2+
import { FieldValues, Path, PathValue } from "react-hook-form";
3+
import { Country, getCountriesOptions, getCountryFromCountryCode } from "../../helpers/telephoneNumber";
4+
import { TelephoneNumberInputProps } from "../../TelephoneNumberInput";
5+
import { LabelValueOption } from "../../types/LabelValueOption";
6+
import { RegionCode } from "google-libphonenumber";
7+
import { isNullOrWhitespace } from "@neolution-ch/javascript-utils";
8+
import Autocomplete from "@mui/material/Autocomplete";
9+
import { useFormContext } from "../../context/FormContext";
10+
import { PopupState } from "material-ui-popup-state/hooks";
11+
import { TextField } from "@mui/material";
12+
import { textFieldBootstrapStyle } from "src/lib/helpers/mui";
13+
14+
interface TelephoneNumberAutocompleteProps<T extends FieldValues>
15+
extends Pick<TelephoneNumberInputProps<T>, "pinnedCountries" | "locale" | "useBootstrapStyle" | "name" | "onChange"> {
16+
popupState: PopupState;
17+
nationalPhoneNumber: string | undefined;
18+
country: Country;
19+
setCountry: Dispatch<SetStateAction<Country>>;
20+
}
21+
22+
const TelephoneNumberAutocomplete = <T extends FieldValues>(props: TelephoneNumberAutocompleteProps<T>) => {
23+
const {
24+
pinnedCountries = [],
25+
locale,
26+
useBootstrapStyle,
27+
name,
28+
onChange: propsOnChange,
29+
popupState,
30+
nationalPhoneNumber,
31+
country,
32+
setCountry,
33+
} = props;
34+
35+
const [isOpen, setIsOpen] = useState(false);
36+
const countryOptions: LabelValueOption[] = useMemo(() => getCountriesOptions(pinnedCountries, locale), [locale, pinnedCountries]);
37+
const { setValue } = useFormContext<T>();
38+
39+
return (
40+
<Autocomplete
41+
options={countryOptions}
42+
value={countryOptions.find((x) => x.value === country.region) || null}
43+
disableClearable={false}
44+
open={isOpen}
45+
onOpen={() => setIsOpen(true)}
46+
onClose={() => setIsOpen(false)}
47+
getOptionDisabled={(option) => option.disabled ?? false}
48+
renderInput={(params) => <TextField {...params} />}
49+
sx={{ ...(useBootstrapStyle && textFieldBootstrapStyle), width: 200 }}
50+
onInputChange={(_, _value, reason) => {
51+
if (reason === "clear") {
52+
setIsOpen(true);
53+
return;
54+
}
55+
}}
56+
onChange={(_, value, reason) => {
57+
// cannot be cleared
58+
if (value === null) {
59+
return;
60+
}
61+
62+
if (reason === "clear") {
63+
setIsOpen(true);
64+
return;
65+
}
66+
67+
const country = getCountryFromCountryCode(value.value as RegionCode);
68+
setCountry(country);
69+
// the value in the form is probably undefined, therefore do not touch the form value
70+
if (isNullOrWhitespace(nationalPhoneNumber)) {
71+
// nothing to do, value in the form is not changing
72+
} else {
73+
const telephoneNumber = `+${country.code}${nationalPhoneNumber || ""}`;
74+
75+
if (propsOnChange) {
76+
propsOnChange(telephoneNumber);
77+
}
78+
79+
setValue(name, telephoneNumber as PathValue<T, Path<T>>);
80+
}
81+
popupState.close();
82+
}}
83+
/>
84+
);
85+
};
86+
87+
export { TelephoneNumberAutocomplete };

src/lib/components/TelephoneNumberInput/TelephoneNumberInputInternal.tsx

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
11
import TextField from "@mui/material/TextField";
22
import PopupState, { bindPopover } from "material-ui-popup-state";
3-
import { FieldError, FieldValues, get, Path, PathValue, useController } from "react-hook-form";
3+
import { FieldError, FieldValues, get, useController } from "react-hook-form";
44
import { useMarkOnFocusHandler } from "../../hooks/useMarkOnFocusHandler";
55
import { textFieldBootstrapStyle } from "../../helpers/mui";
66
import { useFormContext } from "../../context/FormContext";
77
import { useEffect, useMemo, useRef, useState } from "react";
88
import Popover from "@mui/material/Popover";
99
import { TelephoneNumberInputProps } from "../../TelephoneNumberInput";
10-
import { LabelValueOption } from "../../types/LabelValueOption";
11-
import { PhoneNumberUtil, RegionCode } from "google-libphonenumber";
12-
import { getName, langs } from "i18n-iso-countries";
13-
import {
14-
Country,
15-
extractCountryCodeFromTelephoneNumber,
16-
extractNationalNumberFromTelephoneNumber,
17-
getCountryFromCountryCode,
18-
} from "../../helpers/telephoneNumber";
19-
import { isNullOrWhitespace } from "@neolution-ch/javascript-utils";
20-
import Autocomplete from "@mui/material/Autocomplete";
10+
11+
import { Country, extractCountryCodeFromTelephoneNumber, extractNationalNumberFromTelephoneNumber } from "../../helpers/telephoneNumber";
2112
import { TelephoneNumberInputAdornment } from "./TelephoneNumberInputAdornment";
13+
import { isNullOrWhitespace } from "@neolution-ch/javascript-utils";
14+
import { TelephoneNumberAutocomplete } from "./TelephoneNumberAutocomplete";
2215

2316
const TelephoneNumberInputInternal = <T extends FieldValues>(props: TelephoneNumberInputProps<T>) => {
2417
const {
@@ -37,13 +30,11 @@ const TelephoneNumberInputInternal = <T extends FieldValues>(props: TelephoneNum
3730
useBootstrapStyle = false,
3831
hideValidationMessage,
3932
placeholder,
40-
locale,
4133
} = props;
4234
const {
4335
control,
4436
disabled: formDisabled,
4537
getFieldState,
46-
setValue,
4738
requiredFields,
4839
formState: { errors },
4940
hideValidationMessages,
@@ -70,22 +61,6 @@ const TelephoneNumberInputInternal = <T extends FieldValues>(props: TelephoneNum
7061
const fieldIsRequired = label && typeof label === "string" && requiredFields.includes(name);
7162
const finalLabel = useMemo(() => (fieldIsRequired ? `${String(label)} *` : label), [fieldIsRequired, label]);
7263

73-
const countryOptions: LabelValueOption[] = useMemo(() => {
74-
const phoneNumberUtil = new PhoneNumberUtil();
75-
const registeredLocales = langs();
76-
const internalLocale =
77-
registeredLocales.length === 1
78-
? registeredLocales[0]
79-
: !isNullOrWhitespace(locale) && registeredLocales.includes(locale as string)
80-
? locale
81-
: undefined;
82-
83-
return phoneNumberUtil.getSupportedRegions().map((region) => ({
84-
label: isNullOrWhitespace(internalLocale) ? region : getName(region, internalLocale as string) || region,
85-
value: region,
86-
}));
87-
}, [locale]);
88-
8964
// we need to control the country in the case the value inside the form is undefined
9065
const [country, setCountry] = useState<Country>(extractCountryCodeFromTelephoneNumber(field.value as string | undefined, defaultCountry));
9166
const resetCountry = useRef(true);
@@ -160,34 +135,12 @@ const TelephoneNumberInputInternal = <T extends FieldValues>(props: TelephoneNum
160135
}}
161136
>
162137
{renderAutocompleteField(
163-
<Autocomplete
164-
options={countryOptions}
165-
value={countryOptions.find((x) => x.value === country.region) || null}
166-
disableClearable={false}
167-
renderInput={(params) => <TextField {...params} />}
168-
sx={{ ...(useBootstrapStyle && textFieldBootstrapStyle), width: 200 }}
169-
onChange={(_, value, reason) => {
170-
// cannot be cleared
171-
if (value === null || reason === "clear") {
172-
return;
173-
}
174-
175-
const country = getCountryFromCountryCode(value.value as RegionCode);
176-
setCountry(country);
177-
// the value in the form is probably undefined, therefore do not touch the form value
178-
if (isNullOrWhitespace(nationalPhoneNumber)) {
179-
// nothing to do, value in the form is not changing
180-
} else {
181-
const telephoneNumber = `+${country.code}${nationalPhoneNumber || ""}`;
182-
183-
if (propsOnChange) {
184-
propsOnChange(telephoneNumber);
185-
}
186-
187-
setValue(name, telephoneNumber as PathValue<T, Path<T>>);
188-
}
189-
popupState.close();
190-
}}
138+
<TelephoneNumberAutocomplete<T>
139+
{...props}
140+
popupState={popupState}
141+
nationalPhoneNumber={nationalPhoneNumber}
142+
country={country}
143+
setCountry={setCountry}
191144
/>,
192145
)}
193146
</Popover>

src/lib/helpers/telephoneNumber.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { isNullOrWhitespace } from "@neolution-ch/javascript-utils";
22
import { PhoneNumberUtil, RegionCode, RegionCodeUnknown } from "google-libphonenumber";
3+
import { getName, langs } from "i18n-iso-countries";
4+
import { LabelValueOption } from "../types/LabelValueOption";
35

46
interface Country {
57
region: RegionCode;
@@ -80,4 +82,43 @@ const extractNationalNumberFromTelephoneNumber = (number: string | undefined, co
8082
}
8183
};
8284

83-
export { getCountryFromCountryCode, extractNationalNumberFromTelephoneNumber, extractCountryCodeFromTelephoneNumber, Country };
85+
const getCountriesOptions = (pinnedRegions: RegionCode[], locale?: string): LabelValueOption[] => {
86+
const phoneNumberUtil = new PhoneNumberUtil();
87+
88+
const getLabelValueOption = (region: RegionCode, effectiveLocale?: string): LabelValueOption => {
89+
return {
90+
label: `${isNullOrWhitespace(effectiveLocale) ? region : getName(region, effectiveLocale as string) || region} (+${phoneNumberUtil.getCountryCodeForRegion(region)})`,
91+
value: region,
92+
};
93+
};
94+
95+
const registeredLocales = langs();
96+
const internalLocale =
97+
registeredLocales.length === 1
98+
? registeredLocales[0]
99+
: !isNullOrWhitespace(locale) && registeredLocales.includes(locale as string)
100+
? locale
101+
: undefined;
102+
103+
const supportedRegions = phoneNumberUtil.getSupportedRegions().filter((x) => !pinnedRegions.includes(x));
104+
let labelValueOptions = [];
105+
106+
if (pinnedRegions.length > 0) {
107+
for (const region of pinnedRegions) {
108+
labelValueOptions.push(getLabelValueOption(region, internalLocale));
109+
}
110+
111+
labelValueOptions.push({ label: "────────────", value: "", disabled: true });
112+
}
113+
114+
labelValueOptions = [...labelValueOptions, ...supportedRegions.map((region) => getLabelValueOption(region, internalLocale))];
115+
return labelValueOptions;
116+
};
117+
118+
export {
119+
getCountryFromCountryCode,
120+
extractNationalNumberFromTelephoneNumber,
121+
extractCountryCodeFromTelephoneNumber,
122+
getCountriesOptions,
123+
Country,
124+
};

0 commit comments

Comments
 (0)