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). + +[![npm](https://img.shields.io/npm/v/antd-phone-input)](https://www.npmjs.com/package/antd-phone-input) +[![types](https://img.shields.io/npm/types/antd-phone-input)](https://www.npmjs.com/package/antd-phone-input) +[![License](https://img.shields.io/npm/l/antd-phone-input)](https://github.com/ArtyomVancyan/antd-phone-input/blob/master/LICENSE) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://makeapullrequest.com) +[![Tests](https://github.com/ArtyomVancyan/antd-phone-input/actions/workflows/tests.yml/badge.svg)](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(
{ + assert(phone.countryCode === 1); + assert(phone.areaCode === 702); + assert(phone.phoneNumber === "1234567"); + assert(phone.isoCode === "us"); + }}> + + + + +
); + const input = screen.getByDisplayValue("+1"); + await userEvent.type(input, "702123456789"); + assert(input.getAttribute("value") === "+1 (702) 123 4567"); + screen.getByTestId("button").click(); }) })