diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..1e8d35a
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,18 @@
+name: Tests
+
+on: push
+
+jobs:
+ tests:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Install dependencies
+ run: yarn && yarn install
+
+ - name: Run tests
+ run: yarn test
diff --git a/README.md b/README.md
index d8b905f..92e03de 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,102 @@
# antd-phone-input
-Advanced Phone Number Input for Ant Design
+
+Advanced Phone Number Input for [Ant Design](https://github.com/ant-design/ant-design).
+
+[](https://www.npmjs.com/package/antd-phone-input)
+[](https://www.npmjs.com/package/antd-phone-input)
+[](https://github.com/ArtyomVancyan/antd-phone-input/blob/master/LICENSE)
+[](https://makeapullrequest.com)
+[](https://github.com/ArtyomVancyan/antd-phone-input/actions/workflows/tests.yml)
+
+## Install
+
+```shell
+npm i antd-phone-input
+```
+
+```shell
+yarn add antd-phone-input
+```
+
+## Usage
+
+### Antd 4.x
+
+```javascript
+import React from "react";
+import PhoneInput from "antd-phone-input";
+import FormItem from "antd/es/form/FormItem";
+
+const Demo = () => {
+ return (
+
+
+
+ )
+}
+```
+
+### Antd 5.x
+
+```ascii
+v5.x does not have support yet
+this issue is covered in GH-20
+```
+
+## Value
+
+The value of the component is an object containing the parts of a phone number. This format of value gives a wide range
+of opportunities for handling the data in your custom way. For example, you can easily merge the parts of the phone
+number into a single string.
+
+```json
+{
+ "countryCode": 1,
+ "areaCode": 702,
+ "phoneNumber": "1234567",
+ "isoCode": "us"
+}
+```
+
+## Props
+
+| Property | Description | Type |
+|--------------------|---------------------------------------------------------------------------------------------------------------------------------|---------------------|
+| size | Either `large`, `middle` or `small`. Default value is `middle`. See at ant [docs][antInputProps] for more. | string |
+| value | An object containing the parts of phone number. E.g. `value={{countryCode: 1, areaCode: 702, phoneNumber: "1234567"}}`. | object |
+| style | Applies CSS styles to the container element. | CSSProperties |
+| className | The value will be assigned to the container element. | string |
+| disabled | Disables the whole input component. | boolean |
+| enableSearch | Enables search in the country selection dropdown menu. Default value is `false`. | boolean |
+| disableDropdown | Disables the manual country selection through the dropdown menu. | boolean |
+| inputProps | [HTML properties of input][htmlInputProps] to pass into the input. E.g. `inputProps={{autoFocus: true}}`. | InputHTMLAttributes |
+| searchPlaceholder | The value is shown if `enableSearch` is `true`. Default value is `search`. | string |
+| searchNotFound | The value is shown if `enableSearch` is `true` and the query does not match any country. Default value is `No entries to show`. | string |
+| placeholder | Custom placeholder. Default placeholder is `1 (702) 123-4567`. | string |
+| country | Country code to be selected by default. By default, it will show the flag of the user's country. | string |
+| regions | Show only the countries of the specified regions. See the list of [available regions][reactPhoneRegions]. | string[] |
+| onlyCountries | Country codes to be included in the list. E.g. `onlyCountries={['us', 'ca', 'uk']}`. | string[] |
+| excludeCountries | Country codes to be excluded from the list of countries. E.g. `excludeCountries={['us', 'ca', 'uk']}`. | string[] |
+| preferredCountries | Country codes to be at the top of the list. E.g. `preferredCountries={['us', 'ca', 'uk']}`. | string[] |
+| onChange | Callback when the user is inputting. See at ant [docs][antInputProps] for more. | function(value, e) |
+| onPressEnter | The callback function that is triggered when Enter key is pressed. | function(e) |
+| onFocus | The callback is triggered when the input element is focused. | function(e, value) |
+| onClick | The callback is triggered when the user clicks on the input element. | function(e, value) |
+| onBlur | The callback is triggered when the input element gets blurred or unfocused. | function(e, value) |
+| onKeyDown | The callback is triggered when any key is pressed down. | function(e) |
+| onMount | The callback is triggered once the component gets mounted. | function(e) |
+
+## Contribute
+
+Any contribution is welcome. If you have any ideas or suggestions, feel free to open an issue or a pull request. And
+don't forget to add tests for your changes.
+
+## License
+
+Copyright (C) 2023 Artyom Vancyan. [MIT](LICENSE)
+
+[antInputProps]:https://ant.design/components/input#input
+
+[reactPhoneRegions]:https://github.com/bl00mber/react-phone-input-2#regions
+
+[htmlInputProps]:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attributes
diff --git a/package.json b/package.json
index b473660..3a10d78 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,26 @@
{
+ "version": "0.1.0",
"name": "antd-phone-input",
- "version": "0.0.2",
+ "description": "Advanced Phone Number Input for Ant Design",
+ "keywords": [
+ "ant",
+ "antd",
+ "react",
+ "design",
+ "frontend",
+ "component",
+ "components",
+ "phone-input",
+ "phone-number"
+ ],
+ "homepage": "https://github.com/ArtyomVancyan/antd-phone-input",
+ "bugs": {
+ "url": "https://github.com/ArtyomVancyan/antd-phone-input/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ArtyomVancyan/antd-phone-input"
+ },
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
@@ -21,11 +41,12 @@
"@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-typescript": "^11.0.0",
"@testing-library/react": "^14.0.0",
+ "@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.4.0",
"@types/node": "^18.14.1",
"@types/react": "^18.0.28",
"@types/rollup-plugin-less": "^1.1.2",
- "antd": "^5.2.2",
+ "antd": "^4.24.8",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.4.3",
"jest-environment-jsdom": "^29.4.3",
@@ -35,7 +56,6 @@
"rollup": "3.17.2",
"rollup-plugin-dts": "^5.2.0",
"rollup-plugin-less": "^1.1.3",
- "rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-postcss": "^4.0.2",
"ts-jest": "^29.0.5",
"tslib": "^2.5.0",
diff --git a/rollup.config.ts b/rollup.config.ts
index 2ba1855..2171a22 100644
--- a/rollup.config.ts
+++ b/rollup.config.ts
@@ -2,7 +2,6 @@ import dts from "rollup-plugin-dts";
import less from "rollup-plugin-less";
import json from "@rollup/plugin-json";
import postcss from "rollup-plugin-postcss";
-import resolve from "rollup-plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import {readFileSync} from "fs";
@@ -14,19 +13,19 @@ const esmOutput = {file: pkg.module, format: "es"};
const dtsOutput = {file: pkg.types, format: "es"};
const jsonPlugin = json();
-const cssPlugin = postcss();
const tsPlugin = typescript();
-const resolvePlugin = resolve();
-const lessPlugin = less({insert: true, output: false});
+const cssPlugin = postcss({exclude: /\.less$/, include: /\.css$/});
+const lessPlugin = less({insert: true, output: false, option: {javascriptEnabled: true}});
const external = [
...Object.keys({...pkg.dependencies, ...pkg.peerDependencies}),
/^react($|\/)/,
/^antd($|\/)/,
+ /\.css$/,
];
export default [
- {input, output: cjsOutput, plugins: [tsPlugin, jsonPlugin, resolvePlugin, cssPlugin], external},
- {input, output: esmOutput, plugins: [tsPlugin, jsonPlugin, resolvePlugin, cssPlugin], external},
- {input, output: dtsOutput, plugins: [dts(), lessPlugin], external: [/\.(?:le|c)ss$/]},
+ {input, output: cjsOutput, plugins: [tsPlugin, jsonPlugin, cssPlugin, lessPlugin], external},
+ {input, output: esmOutput, plugins: [tsPlugin, jsonPlugin, cssPlugin, lessPlugin], external},
+ {input, output: dtsOutput, plugins: [dts()], external: [/\.(le|c)ss$/]},
];
diff --git a/src/index.less b/src/index.less
index 0558d84..5ad10d2 100644
--- a/src/index.less
+++ b/src/index.less
@@ -1,79 +1,87 @@
-@import "~react-phone-input-2/lib/style.css";
+@import "../node_modules/antd/lib/style/themes/variable";
+@import "../node_modules/antd/lib/input/style/index";
-//.phone-number-form-item {
-//
-// &.ant-form-item-has-error {
-// .react-tel-input {
-// .form-control {
-// border-color: @error-color;
-//
-// &:hover {
-// border-color: @error-color;
-// }
-//
-// &:focus {
-// border-color: @error-color;
-// }
-// }
-// }
-// }
-//
-// .react-tel-input {
-//
-// .form-control {
-// z-index: 3;
-// width: 100%;
-// border-radius: @border-radius-base;
-// line-height: @line-height-base;
-// height: inherit;
-// background: transparent;
-// border-color: @border-color-base;
-//
-// &:hover {
-// border-color: @input-hover-border-color;
-// }
-//
-// &:focus {
-// border-color: @primary-color;
-// }
-// }
-//
-// .country-list {
-// margin: 3px;
-// border-radius: 2px;
-// width: calc(100% - 6px);
-// box-shadow: 0 3px 6px -4px rgba(0, 0, 0, .12),
-// 0 6px 16px 0 rgba(0, 0, 0, .08),
-// 0 9px 28px 8px rgba(0, 0, 0, .05);
-//
-// .search {
-// padding: @spacing-2 @spacing-2 @spacing-1 @spacing-2;
-//
-// .search-box {
-// .ant-input();
-// }
-// }
-// }
-//
-// .flag-dropdown,
-// .flag-dropdown.open {
-// border: 0;
-// width: 100%;
-// border-radius: 0;
-// background-color: transparent;
-//
-// .arrow {
-// display: none;
-// }
-//
-// .selected-flag {
-// z-index: 4;
-// padding: 0;
-// display: flex;
-// justify-content: center;
-// border-right: @border-base;
-// background-color: transparent;
-// }
-// }
-// }
-//}
+.ant-form-item {
+ &.ant-form-item-has-error {
+ .react-tel-input {
+ .form-control {
+ border-color: @error-color;
+
+ &:hover {
+ border-color: @error-color;
+ }
+
+ &:focus {
+ border-color: @error-color;
+ }
+ }
+ }
+ }
+}
+
+.react-tel-input {
+ .form-control {
+ z-index: 3;
+ width: 100%;
+ border-radius: @border-radius-base;
+ line-height: @line-height-base;
+ height: inherit;
+ background: transparent;
+ border-color: @border-color-base;
+
+ &:hover {
+ border-color: @input-hover-border-color;
+ }
+
+ &:focus {
+ border-color: @primary-color;
+ }
+
+ &.open {
+ z-index: 6 !important;
+ transition: 0s !important;
+ }
+ }
+
+ .country-list {
+ margin: 3px;
+ border-radius: 2px;
+ width: calc(100% - 6px);
+ box-shadow: 0 3px 6px -4px rgba(0, 0, 0, .12),
+ 0 6px 16px 0 rgba(0, 0, 0, .08),
+ 0 9px 28px 8px rgba(0, 0, 0, .05);
+
+ .search {
+ padding: 10px 10px 5px 10px;
+
+ .search-box {
+ .ant-input();
+ }
+ }
+ }
+
+ .flag-dropdown,
+ .flag-dropdown.open {
+ border: 0;
+ width: 100%;
+ border-radius: 0;
+ background-color: @component-background;
+
+ .arrow {
+ display: none;
+ }
+
+ .selected-flag {
+ z-index: 4;
+ padding: 0;
+ display: flex;
+ justify-content: center;
+ background-color: transparent;
+ border-right: @border-width-base @border-style-base @border-color-base;
+ }
+ }
+
+ .flag-dropdown.open {
+ z-index: 5 !important;
+ }
+}
diff --git a/src/index.tsx b/src/index.tsx
index 14f04e7..62dd9fc 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,84 +1,107 @@
-import {ChangeEvent, useMemo, useState} from "react";
+import {useMemo, useState} from "react";
import ReactPhoneInput from "react-phone-input-2";
+import {ParsePhoneNumber, PhoneInputProps, ReactPhoneOnChange, ReactPhoneOnMount} from "./types";
+
import masks from "./phoneMasks.json";
import timezones from "./timezones.json";
-import validations from "./validations.json";
import "react-phone-input-2/lib/style.css";
import "./index.less";
-type CountryData = {
- countryCode: ISO2Code,
-}
+type ISO2Code = keyof typeof masks;
+type Timezone = keyof typeof timezones;
-type PhoneNumber = {
- countryCode?: number | null,
- areaCode?: number | null,
- phoneNumber?: string,
+const getDefaultISO2Code = () => {
+ /** Returns the default ISO2 code based on the user's timezone */
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone as Timezone;
+ return (timezones[timezone] || "").toLowerCase() || "us";
}
-type PhoneNumberInputProps = {
- value?: PhoneNumber | object,
- onChange?: (value: PhoneNumber) => void,
-}
+const parsePhoneNumber: ParsePhoneNumber = (value, data, formattedNumber) => {
+ const isoCode = data?.countryCode;
+ const countryCodePattern = /\+\d+/;
+ const areaCodePattern = /\((\d+)\)/;
-type OnChangeFunction = {
- (number: string, data: CountryData, event: Event, formattedNumber: string): void,
-}
+ /** Parse the matching partials of the phone number by predefined regex patterns */
+ const countryCodeMatch = formattedNumber ? (formattedNumber.match(countryCodePattern) || []) : [];
+ const areaCodeMatch = formattedNumber ? (formattedNumber.match(areaCodePattern) || []) : [];
-type Timezone = keyof typeof timezones;
-type ISO2Code = keyof typeof validations;
-type Event = ChangeEvent;
+ /** Convert the parsed values of the country and area codes to integers if values present */
+ const countryCode = countryCodeMatch.length > 0 ? parseInt(countryCodeMatch[0]) : null;
+ const areaCode = areaCodeMatch.length > 1 ? parseInt(areaCodeMatch[1]) : null;
-const getDefaultISO2Code = () => {
- /** Returns the default ISO2 code based on the user's timezone */
- const timezone: Timezone = Intl.DateTimeFormat().resolvedOptions().timeZone as Timezone;
- return timezones[timezone].toLowerCase() || "us";
+ /** Parse the phone number by removing the country and area codes from the formatted value */
+ const phoneNumberPattern = new RegExp(`^${countryCode}${(areaCode || "")}(\\d+)`);
+ const phoneNumberMatch = value ? (value.match(phoneNumberPattern) || []) : [];
+ const phoneNumber = phoneNumberMatch.length > 1 ? phoneNumberMatch[1] : null;
+
+ return {countryCode, areaCode, phoneNumber, isoCode};
}
-const PhoneInput = ({value = {}, onChange: handleChange}: PhoneNumberInputProps) => {
+const PhoneInput = ({
+ value,
+ style,
+ country,
+ className,
+ size = "middle",
+ onPressEnter = () => null,
+ onMount: handleMount = () => null,
+ onChange: handleChange = () => null,
+ ...reactPhoneInputProps
+ }: PhoneInputProps) => {
const [currentCode, setCurrentCode] = useState("");
- const rawPhone = useMemo(() => Object.values(value).map(v => v || "").join(""), [value]);
- const onChange: OnChangeFunction = (value, data, _, formattedValue) => {
- const code: ISO2Code = data?.countryCode;
- const countryCodePattern = /\+\d+/;
- const areaCodePattern = /\((\d+)\)/;
+ const countryCode = useMemo(() => country || getDefaultISO2Code(), [country]);
- /** Parse the matching partials of the phone number by predefined regex patterns */
- const countryCodeMatch = formattedValue ? (formattedValue.match(countryCodePattern) || []) : [];
- const areaCodeMatch = formattedValue ? (formattedValue.match(areaCodePattern) || []) : [];
+ const rawPhone = useMemo(() => {
+ const {countryCode, areaCode, phoneNumber} = {...value};
+ return [countryCode, areaCode, phoneNumber].map(v => v || "").join("");
+ }, [value]);
- /** Convert the parsed values of the country and area codes to integers if values present */
- const countryCode = countryCodeMatch.length > 0 ? parseInt(countryCodeMatch[0]) : null;
- const areaCode = areaCodeMatch.length > 1 ? parseInt(areaCodeMatch[1]) : null;
+ const inputClass = useMemo(() => {
+ const suffix = {small: "sm", middle: "", large: "lg"}[size];
+ return "ant-input" + (suffix ? " ant-input-" + suffix : "");
+ }, [size]);
- /** Parse the phone number by removing the country and area codes from the formatted value */
- const phoneNumberPattern = new RegExp(`^${countryCode}${(areaCode || "")}(\\d+)`);
- const phoneNumberMatch = value ? (value.match(phoneNumberPattern) || []) : [];
- const phoneNumber = phoneNumberMatch.length > 1 ? phoneNumberMatch[1] : "";
+ const onChange: ReactPhoneOnChange = (value, data, event, formattedNumber) => {
+ const metadata = parsePhoneNumber(value, data, formattedNumber);
+ const code = metadata.isoCode as ISO2Code;
- /** Clear phone number when the country is selected manually */
- if (currentCode !== undefined && code !== currentCode) {
- if (handleChange) handleChange({countryCode, areaCode: null, phoneNumber: ""});
+ if (code !== currentCode) {
+ /** Clear phone number when the country is selected manually */
+ handleChange({...metadata, areaCode: null, phoneNumber: null}, event);
setCurrentCode(code);
return;
}
- if (handleChange) handleChange({countryCode, areaCode, phoneNumber});
- };
+ handleChange(metadata, event);
+ }
+
+ const onMount: ReactPhoneOnMount = (rawValue, {countryCode, ...event}, formattedNumber) => {
+ const metadata = parsePhoneNumber(rawValue, {countryCode}, formattedNumber);
+ /** Initiates the existing value when Antd FormItem is used */
+ if (value === undefined) handleChange(metadata, event);
+ handleMount(metadata);
+ }
return (
)
}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..26d5ab1
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,69 @@
+import {ChangeEvent, CSSProperties, FocusEvent, InputHTMLAttributes, KeyboardEvent, MouseEvent} from "react";
+
+export interface CountryData {
+ countryCode: string,
+}
+
+export interface PhoneNumber {
+ countryCode?: number | null,
+ areaCode?: number | null,
+ phoneNumber?: string | null,
+ isoCode?: string,
+}
+
+export interface AntInputProps {
+ size?: "small" | "middle" | "large",
+ value?: PhoneNumber,
+ style?: CSSProperties,
+ className?: string,
+ disabled?: boolean,
+}
+
+export interface AntInputEventsProps {
+ onChange?(value: PhoneNumber, event: ChangeEvent): void;
+
+ onPressEnter?(event: KeyboardEvent): void;
+}
+
+export interface ReactPhoneInputProps {
+ inputProps?: InputHTMLAttributes,
+ searchPlaceholder?: string,
+ searchNotFound?: string,
+ placeholder?: string,
+ enableSearch?: boolean,
+ disableDropdown?: boolean,
+ country?: string,
+ regions?: string[],
+ onlyCountries?: string[],
+ excludeCountries?: string[],
+ preferredCountries?: string[],
+}
+
+export interface ReactPhoneEventsProps {
+ onFocus?(event: FocusEvent, value: PhoneNumber): void;
+
+ onClick?(event: MouseEvent, value: PhoneNumber): void;
+
+ onBlur?(event: FocusEvent, value: PhoneNumber): void;
+
+ onKeyDown?(event: KeyboardEvent): void;
+
+ onMount?(value: PhoneNumber): void;
+}
+
+export interface ReactPhoneOnChange {
+ (value: string, data: CountryData, event: ChangeEvent, formattedNumber: string): void;
+}
+
+export interface ReactPhoneOnMount {
+ (value: string, event: ChangeEvent & CountryData, formattedNumber: string): void;
+}
+
+export interface ParsePhoneNumber {
+ (value: string, data: CountryData, formattedNumber: string): PhoneNumber;
+}
+
+export interface PhoneInputProps extends AntInputProps, AntInputEventsProps, ReactPhoneInputProps, ReactPhoneEventsProps {
+ // TODO add onValidate: https://github.com/ArtyomVancyan/antd-phone-input/issues/19
+ // onValidate?: (value: PhoneNumber) => boolean;
+}
diff --git a/tests/common.test.tsx b/tests/common.test.tsx
index 04124f8..f4cdd08 100644
--- a/tests/common.test.tsx
+++ b/tests/common.test.tsx
@@ -1,6 +1,11 @@
-import {render} from "@testing-library/react";
+import assert from "assert";
+import Form from "antd/lib/form";
+import Button from "antd/lib/button";
+import FormItem from "antd/lib/form/FormItem";
+import userEvent from "@testing-library/user-event";
+import {render, screen} from "@testing-library/react";
-import PhoneNumberInput from "../src";
+import PhoneInput from "../src";
Object.defineProperty(window, "matchMedia", {
writable: true,
@@ -14,10 +19,52 @@ Object.defineProperty(window, "matchMedia", {
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
-});
+})
+
+describe("Checks the basic rendering and functionality", () => {
+ it("Renders without crashing", () => {
+ render();
+ })
+
+ it("Renders with an initial value", () => {
+ render( {
+ assert(value.countryCode === 1);
+ assert(value.areaCode === 702);
+ assert(value.phoneNumber === "1234567");
+ assert(value.isoCode === "us");
+ }}
+ value={{countryCode: 1, areaCode: 702, phoneNumber: "1234567"}}
+ />);
+ })
+
+ it("Checks the component on user input", async () => {
+ render( {
+ assert(value.isoCode === "us");
+ }}
+ country="us"
+ />);
+ const input = screen.getByDisplayValue("+1");
+ await userEvent.type(input, "702123456789");
+ assert(input.getAttribute("value") === "+1 (702) 123 4567");
+ })
-describe("PhoneNumberInput render", () => {
- it("renders without crashing", () => {
- render()
+ it("Uses the input with FormItem", async () => {
+ render();
+ const input = screen.getByDisplayValue("+1");
+ await userEvent.type(input, "702123456789");
+ assert(input.getAttribute("value") === "+1 (702) 123 4567");
+ screen.getByTestId("button").click();
})
})