diff --git a/.changeset/hot-dots-poke.md b/.changeset/hot-dots-poke.md new file mode 100644 index 0000000000..2c05f901d7 --- /dev/null +++ b/.changeset/hot-dots-poke.md @@ -0,0 +1,9 @@ +--- +"@alfalab/core-components-intl-phone-input": major +--- + +- Добавлено состояние невыбранной страны — `canBeEmptyCountry` +- Добавлена возможность отключить селект выбора стран — `hideCountrySelect` +- Колбэк `onCountryChange` теперь может принимать undefined в случаях, когда установлен пропс `canBeEmptyCountry: true` +- Добавлен режим приоритета ввода российского номера (при дефолтно выбранном российском флаге ввод числа добавит +7) — `ruNumberPriority` +- Добавлен пропс `clear` для сброса страны при очистке поля diff --git a/packages/intl-phone-input/src/component.test.tsx b/packages/intl-phone-input/src/component.test.tsx index 7ef5df9091..e17c0dfca1 100644 --- a/packages/intl-phone-input/src/component.test.tsx +++ b/packages/intl-phone-input/src/component.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { IntlPhoneInput } from './index'; @@ -146,7 +147,7 @@ describe('IntlPhoneInput', () => { ); const input = await screen.getByDisplayValue(''); - fireEvent.change(input, { target: { value: '+998 12 345 67 89' } }); + fireEvent.change(input, { target: { value: '+998 12 345 67 89', selectionStart: 0 } }); expect(onCountryChange).toBeCalledWith('UZ'); expect(onCountryChange).toHaveBeenCalledTimes(1); @@ -196,4 +197,122 @@ describe('IntlPhoneInput', () => { expect(countrySelect).toBeNull(); }); + + it('should show empty country', async () => { + const onCountryChange = jest.fn(); + render( + null} + dataTestId={testId} + onCountryChange={onCountryChange} + canBeEmptyCountry={true} + defaultCountryIso2='ru' + />, + ); + + const input = screen.getByDisplayValue(''); + + fireEvent.change(input, { target: { value: '+', selectionStart: 0 } }); + + const icons = screen.getAllByRole('img'); + + expect(icons[0]).toHaveClass('emptyCountryIcon'); + expect(onCountryChange).toBeCalledWith(undefined); + }); + + it('should call `onChange` with value "+7" when type "8" in empty field with `ruNumberPriority`', async () => { + const onChange = jest.fn(); + render( + , + ); + + const input = screen.getByDisplayValue(''); + + userEvent.type(input, '8'); + + await waitFor(() => { + expect(onChange).toBeCalledWith('+7'); + }); + }); + + it('should call `onChange` with value "+7 9" when type "9" in empty field with `ruNumberPriority`', async () => { + const onChange = jest.fn(); + render( + , + ); + + const input = screen.getByDisplayValue(''); + + userEvent.type(input, '9'); + + await waitFor(() => { + expect(onChange).toBeCalledWith('+7 9'); + }); + }); + + it('should call `onChange` with value "+7" when type "7" in empty field with `ruNumberPriority`', async () => { + const onChange = jest.fn(); + render( + , + ); + + const input = screen.getByDisplayValue(''); + + userEvent.type(input, '7'); + + await waitFor(() => { + expect(onChange).toBeCalledWith('+7'); + }); + }); + + it('should not render country select when use `hideCountrySelect`', () => { + const onChange = jest.fn(); + render( + , + ); + + const countrySelect = screen.queryByTestId('countries-select'); + + expect(countrySelect).toBeNull(); + }); + + it('should show "+" when clear value', async () => { + const onChange = jest.fn(); + render(); + + const clearButton = await screen.findByLabelText('Очистить'); + + expect(clearButton).toBeInTheDocument(); + + fireEvent.click(clearButton); + + await waitFor(() => { + expect(onChange).toBeCalledWith('+'); + }); + }); }); diff --git a/packages/intl-phone-input/src/component.tsx b/packages/intl-phone-input/src/component.tsx index 7e3112a964..213b7f6569 100644 --- a/packages/intl-phone-input/src/component.tsx +++ b/packages/intl-phone-input/src/component.tsx @@ -9,10 +9,12 @@ import { InputAutocomplete, InputAutocompleteProps, } from '@alfalab/core-components-input-autocomplete'; -import { CountriesSelect } from './components'; +import WorldMagnifierMIcon from '@alfalab/icons-glyph/WorldMagnifierMIcon'; +import { CountriesSelect, FlagIcon } from './components'; import { formatPhoneWithUnclearableCountryCode } from './utils/format-phone-with-unclearable-country-code'; import { calculateCaretPos } from './utils/calculateCaretPos'; import { useCaretAvoidCountryCode } from './useCaretAvoidCountryCode'; +import { preparePasteData } from './utils/preparePasteData'; import styles from './index.module.css'; @@ -53,7 +55,7 @@ export type IntlPhoneInputProps = Partial void; + onCountryChange?: (countryCode?: CountryCode) => void; /** * Список стран @@ -69,6 +71,27 @@ export type IntlPhoneInputProps = Partial( @@ -76,6 +99,10 @@ export const IntlPhoneInput = forwardRef( { disabled = false, readOnly = false, + hideCountrySelect = false, + canBeEmptyCountry = false, + ruNumberPriority = false, + clear = false, size = 'm', colors = 'default', options = [], @@ -93,8 +120,9 @@ export const IntlPhoneInput = forwardRef( }, ref, ) => { - const [countryIso2, setCountryIso2] = useState(defaultCountryIso2.toLowerCase()); - const [fieldWidth, setFieldWidth] = useState(0); + const [countryIso2, setCountryIso2] = useState( + defaultCountryIso2.toLowerCase(), + ); const inputRef = useRef(null); const [inputWrapperRef, setInputWrapperRef] = useState(null); @@ -108,11 +136,26 @@ export const IntlPhoneInput = forwardRef( if (phoneLibUtils.current) { const Utils = phoneLibUtils.current; - const utils = new Utils(countryIso2.toUpperCase() as CountryCode); + const utils = new Utils( + countryIso2 ? (countryIso2.toUpperCase() as CountryCode) : undefined, + ); newValue = utils.input(inputValue); } + if (countryIso2 === 'ru') { + const parts = newValue.split(' '); + newValue = parts.reduce((acc, part, index) => { + if (index === 0) { + return part; + } + if (index > 2) { + return `${acc}-${part}`; + } + return `${acc} ${part}`; + }, ''); + } + return newValue; }; @@ -127,59 +170,76 @@ export const IntlPhoneInput = forwardRef( return country; }; - const handleCountryChange = (countryCode: string) => { + const handleCountryChange = (countryCode?: string) => { if (onCountryChange) { - onCountryChange(countryCode.toUpperCase() as CountryCode); + onCountryChange( + countryCode ? (countryCode.toUpperCase() as CountryCode) : undefined, + ); } }; - const setCountryByDialCode = (inputValue: string) => { - for (let i = 0; i < countries.length; i++) { - const country = countries[i]; + const getCountryByNumber = (inputValue: string) => { + // dialcode казахстанских номеров совпадает с российскими, поэтому проверяем отдельно + if (new RegExp('^\\+7(\\s)?7').test(inputValue)) { + const kzCoutry = countries.find(item => item.iso2 === 'kz'); + if (kzCoutry) { + return kzCoutry; + } + } + const targetCountry = countries.find(country => { if (new RegExp(`^\\+${country.dialCode}`).test(inputValue)) { // Сначала проверяем, если приоритет не указан if (country.priority === undefined) { - onChange(formatPhone(inputValue)); - setCountryIso2(country.iso2); - handleCountryChange(country.iso2); - - break; + return true; } // Если страна уже была выставлена через селект, и коды совпадают - if (countryIso2 === country.iso2) { - onChange(formatPhone(inputValue)); - setCountryIso2(country.iso2); - handleCountryChange(country.iso2); - - break; - // Если не совпадают - выбираем по приоритету - } else if (country.priority === 0) { - onChange(formatPhone(inputValue)); - setCountryIso2(country.iso2); - handleCountryChange(country.iso2); - - break; + if (countryIso2 === country.iso2 && countryIso2 !== 'kz') { + return true; + } + + // Если не совпадают - выбираем по приоритету + if (country.priority === 0) { + return true; } + return false; } + return false; + }); + + return targetCountry; + }; + + const setCountryByDialCode = (inputValue: string) => { + const country = getCountryByNumber(inputValue); + onChange(formatPhone(inputValue)); + if (country) { + setCountryIso2(country.iso2); + handleCountryChange(country.iso2); + } else if (canBeEmptyCountry) { + setCountryIso2(undefined); + handleCountryChange(undefined); } }; const setCountryByDialCodeWithLengthCheck = (inputValue: string) => { - if (value.length < MAX_DIAL_CODE_LENGTH) { - setCountryByDialCode(inputValue); + if (inputRef.current) { + const { selectionStart } = inputRef.current; + if ((selectionStart || 0) <= MAX_DIAL_CODE_LENGTH) { + setCountryByDialCode(inputValue); + } } }; const addCountryCode = (inputValue: string) => { - const country = countriesHash[countryIso2]; - - if (clearableCountryCode) { + if (clearableCountryCode || !countryIso2) { return inputValue.length === 1 && inputValue !== '+' ? `+${inputValue}` : inputValue; } + const country = countriesHash[countryIso2]; + return formatPhoneWithUnclearableCountryCode(inputValue, country); }; @@ -213,8 +273,11 @@ export const IntlPhoneInput = forwardRef( onChange(formatPhone(selected.key)); }; - const country = countriesHash[countryIso2]; - const countryCodeLength = `+${country.dialCode}`.length; + const country = countryIso2 && countriesHash[countryIso2]; + const countryCodeLength = country ? `+${country.dialCode}`.length : 0; + const isEmptyValue = clearableCountryCode + ? value === '' || value === '+' + : value.length <= countryCodeLength; const handleInputNewChar = ( event: React.KeyboardEvent, @@ -222,7 +285,8 @@ export const IntlPhoneInput = forwardRef( ) => { const input = event.target as HTMLInputElement; const currentValue = input.value; - const maxPhoneLength = maxPhoneLen?.[countryIso2.toUpperCase()] || MAX_PHONE_LEN; + const maxPhoneLength = + (countryIso2 && maxPhoneLen?.[countryIso2.toUpperCase()]) || MAX_PHONE_LEN; // Если номер полностью заполнен, то перезатираем цифры, если каретка не в самом конце. const shouldReplace = maxPhoneLength === currentValue.replace(/\D/g, '').length; @@ -240,11 +304,20 @@ export const IntlPhoneInput = forwardRef( let newValue = currentValue.slice(0, caretPosition) + event.key + endPhonePart; + const newValueDecimal = newValue.replace(/\D/g, ''); // Запрещаем ввод, если номер уже заполнен. - if (newValue.replace(/\D/g, '').length > maxPhoneLength) { + if (newValueDecimal.length > maxPhoneLength) { newValue = newValue.slice(0, -1); } + if (ruNumberPriority && !value && countryIso2 === 'ru') { + if (newValue === '7' || newValue === '8') { + newValue = '+7'; + } else if (newValueDecimal.length === 1) { + newValue = `+7${newValueDecimal}`; + } + } + newValue = formatPhone(addCountryCode(newValue)); let phonePartWithoutMask = @@ -254,6 +327,10 @@ export const IntlPhoneInput = forwardRef( phonePartWithoutMask = phonePartWithoutMask.slice(0, -1); } + if (newValue && newValue[0] !== '+') { + newValue = `+${newValue}`; + } + setCaretPos(calculateCaretPos(phonePartWithoutMask, newValue)); setCountryByDialCodeWithLengthCheck(newValue); onChange(newValue); @@ -317,6 +394,43 @@ export const IntlPhoneInput = forwardRef( } }; + const handleClear = () => { + if (clearableCountryCode) { + onChange('+'); + if (canBeEmptyCountry) { + setCountryIso2(undefined); + handleCountryChange(undefined); + } + } else { + onChange(value.substring(0, countryCodeLength)); + } + }; + + const handlePaste: React.ClipboardEventHandler = event => { + event.preventDefault(); + const text = event.clipboardData?.getData('Text'); + if (!text || !inputRef.current) { + return; + } + + const { selectionStart, selectionEnd } = inputRef.current; + const preparedNumber = preparePasteData( + value, + text, + selectionStart || 0, + selectionEnd || 0, + ); + const targetCountry = getCountryByNumber(preparedNumber); + const maxPhoneLength = + (targetCountry && maxPhoneLen?.[targetCountry.iso2.toUpperCase()]) || MAX_PHONE_LEN; + const resultNumber = preparedNumber.substring(0, maxPhoneLength + 1); + + if (resultNumber) { + setCountryIso2(targetCountry ? targetCountry.iso2 : undefined); + onChange(formatPhone(addCountryCode(resultNumber))); + } + }; + useEffect(() => { if (inputRef.current && caretPos !== undefined) { inputRef.current.setSelectionRange(caretPos, caretPos); @@ -333,7 +447,11 @@ export const IntlPhoneInput = forwardRef( .then(utils => { phoneLibUtils.current = utils.AsYouType; - setCountryByDialCode(value); + if (canBeEmptyCountry) { + onChange(formatPhone(value)); + } else { + setCountryByDialCode(value); + } }) .catch(error => `An error occurred while loading libphonenumber-js:\n${error}`); @@ -342,17 +460,13 @@ export const IntlPhoneInput = forwardRef( useCaretAvoidCountryCode({ inputRef, countryCodeLength, clearableCountryCode }); - useEffect(() => { - if (inputWrapperRef) { - setFieldWidth(inputWrapperRef.getBoundingClientRect().width); - } - }, [inputWrapperRef]); - return ( ( className: cn(className, styles[size]), addonsClassName: styles.addons, onKeyDown: handleKeyDown, - leftAddons: countries.length > 1 && ( - + onPaste: handlePaste, + leftAddons: hideCountrySelect ? ( + + {countryIso2 ? ( + + ) : ( + + )} + + ) : ( + countries.length > 1 && ( + + ) ), }} optionsListWidth='field' diff --git a/packages/intl-phone-input/src/components/select-field/component.tsx b/packages/intl-phone-input/src/components/select-field/component.tsx index 7b3720fca8..f32101a871 100644 --- a/packages/intl-phone-input/src/components/select-field/component.tsx +++ b/packages/intl-phone-input/src/components/select-field/component.tsx @@ -3,11 +3,17 @@ import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; import { FieldProps } from '@alfalab/core-components-select'; import { useFocus } from '@alfalab/hooks'; +import WorldMagnifierMIcon from '@alfalab/icons-glyph/WorldMagnifierMIcon'; import { FlagIcon } from '../flag-icon'; import styles from './index.module.css'; +export const EMPTY_COUNTRY_SELECT_FIELD = { + value: 'EMPTY_COUNTRY_SELECT_VALUE', + key: 'EMPTY_COUNTRY_SELECT_KEY', +}; + export const SelectField: FC = ({ selected, Arrow, @@ -30,11 +36,13 @@ export const SelectField: FC = ({ })} >
- {selected && ( - + + {!selected || selected === EMPTY_COUNTRY_SELECT_FIELD ? ( + + ) : ( - - )} + )} + {Arrow}
diff --git a/packages/intl-phone-input/src/components/select-field/index.module.css b/packages/intl-phone-input/src/components/select-field/index.module.css index 490adfa404..c06f4b30ba 100644 --- a/packages/intl-phone-input/src/components/select-field/index.module.css +++ b/packages/intl-phone-input/src/components/select-field/index.module.css @@ -16,6 +16,10 @@ margin-right: var(--gap-2xs); } +.emptyCountryIcon { + color: var(--color-light-graphic-secondary); +} + .disabled { cursor: var(--disabled-cursor); } diff --git a/packages/intl-phone-input/src/components/select/component.tsx b/packages/intl-phone-input/src/components/select/component.tsx index 8df8a799dd..7532fcf5aa 100644 --- a/packages/intl-phone-input/src/components/select/component.tsx +++ b/packages/intl-phone-input/src/components/select/component.tsx @@ -10,7 +10,7 @@ import { } from '@alfalab/core-components-select'; import { Country } from '@alfalab/utils'; -import { SelectField } from '../select-field'; +import { EMPTY_COUNTRY_SELECT_FIELD, SelectField } from '../select-field'; import { FlagIcon } from '../flag-icon'; import styles from './index.module.css'; @@ -19,7 +19,7 @@ type CountriesSelectProps = Pick< SelectProps, 'size' | 'dataTestId' | 'disabled' | 'onChange' | 'preventFlip' > & { - selected: string; + selected?: string; countries: Country[]; fieldWidth: number | null; }; @@ -69,7 +69,7 @@ export const CountriesSelect: FC = ({ disabled={disabled} size={size} options={options} - selected={selected} + selected={selected || EMPTY_COUNTRY_SELECT_FIELD} onChange={onChange} Field={SelectField} OptionsList={renderOptionsList} diff --git a/packages/intl-phone-input/src/docs/Component.stories.mdx b/packages/intl-phone-input/src/docs/Component.stories.mdx index 4209c36744..e4b1e8a2a0 100644 --- a/packages/intl-phone-input/src/docs/Component.stories.mdx +++ b/packages/intl-phone-input/src/docs/Component.stories.mdx @@ -29,6 +29,10 @@ import Changelog from '../../CHANGELOG.md'; const size = select('size', ['s', 'm', 'l', 'xl'], 'm'); const block = boolean('block', false); const disabled = boolean('disabled', false); + const hideCountrySelect = boolean('hideCountrySelect', false); + const canBeEmptyCountry = boolean('canBeEmptyCountry', false); + const ruNumberPriority = boolean('ruNumberPriority', false); + const clear = boolean('clear', false); const label = text('label', 'Номер телефона'); const clearableCountryCode = boolean('clearableCountryCode', true); const handleCountryChange = React.useCallback(countryCode => { @@ -43,6 +47,10 @@ import Changelog from '../../CHANGELOG.md'; block={block} label={label} disabled={disabled} + canBeEmptyCountry={canBeEmptyCountry} + hideCountrySelect={hideCountrySelect} + clear={clear} + ruNumberPriority={ruNumberPriority} defaultCountryIso2='RU' readOnly={boolean('readOnly', false)} onCountryChange={handleCountryChange} diff --git a/packages/intl-phone-input/src/docs/description.mdx b/packages/intl-phone-input/src/docs/description.mdx index db8f8e4ecd..9a20dfe58d 100644 --- a/packages/intl-phone-input/src/docs/description.mdx +++ b/packages/intl-phone-input/src/docs/description.mdx @@ -6,40 +6,6 @@ **Если вы используете `arui-scripts` для сборки, версия `arui-scripts` должна быть не ниже 9.7.0.** -### Кейс с очисткой поля - -```jsx live -render(() => { - const [value, setValue] = React.useState('+7'); - const [selectedCountry, setSelectedCountry] = React.useState('RU'); - - const handleChange = newValue => setValue(newValue); - - const handleCountryChange = countryCode => setSelectedCountry(countryCode); - - const handleClear = () => setValue(''); - - return ( -
- -
- Код выбранной страны: {selectedCountry} -
- ); -}); -``` - ### Кейс с автокомплитом ```jsx live diff --git a/packages/intl-phone-input/src/index.module.css b/packages/intl-phone-input/src/index.module.css index fbba207ac8..7bc781cb51 100644 --- a/packages/intl-phone-input/src/index.module.css +++ b/packages/intl-phone-input/src/index.module.css @@ -1,3 +1,5 @@ +@import '../../themes/src/default.css'; + .addons { padding-left: 0; } @@ -8,3 +10,16 @@ padding-left: 0; } } + +.flagIconWrapper { + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + margin-left: var(--gap-s); +} + +.emptyCountryIcon { + color: var(--color-light-graphic-secondary); +} diff --git a/packages/intl-phone-input/src/utils/preparePasteData.test.ts b/packages/intl-phone-input/src/utils/preparePasteData.test.ts new file mode 100644 index 0000000000..7c7a326149 --- /dev/null +++ b/packages/intl-phone-input/src/utils/preparePasteData.test.ts @@ -0,0 +1,37 @@ +import { preparePasteData } from './preparePasteData'; + +describe('preparePasteData', () => { + it('should return number with +7 when paste number start with "7" or "8" in empty field', () => { + expect(preparePasteData('', '79491234567', 0, 0)).toEqual('+79491234567'); + expect(preparePasteData('', '89491234567', 0, 0)).toEqual('+79491234567'); + }); + + it('should return number with +7 when paste number doesn\'t start with "7" or "8" in empty field', () => { + expect(preparePasteData('', '6491234567', 0, 0)).toEqual('+76491234567'); + expect(preparePasteData('', '9491234567', 0, 0)).toEqual('+79491234567'); + }); + + it('should return number when paste number start with "+" in empty field', () => { + expect(preparePasteData('', '+79491234567', 0, 0)).toEqual('+79491234567'); + }); + + it('should return number when paste number with letters in empty field', () => { + expect(preparePasteData('', '123aaa456', 0, 0)).toEqual('+7123456'); + }); + + it('should return number when paste number in field with "+"', () => { + expect(preparePasteData('+', '79491234567', 0, 0)).toEqual('+79491234567'); + }); + + it('should return number when paste number in field with partitial number', () => { + expect(preparePasteData('+7949', '1234567', 5, 5)).toEqual('+79491234567'); + }); + + it('should return old number when paste only letters in field with partitial number', () => { + expect(preparePasteData('+7949', 'aaaaaaa', 5, 5)).toEqual('+7949'); + }); + + it('should return number when paste number in the middle of field', () => { + expect(preparePasteData('+7949', '1234567', 2, 2)).toEqual('+71234567949'); + }); +}); diff --git a/packages/intl-phone-input/src/utils/preparePasteData.ts b/packages/intl-phone-input/src/utils/preparePasteData.ts new file mode 100644 index 0000000000..4aa75e7d05 --- /dev/null +++ b/packages/intl-phone-input/src/utils/preparePasteData.ts @@ -0,0 +1,53 @@ +/** + * Подготовка данных для вставки из буфера обмена. + * @param phoneValue Телефон уже введённый в поле ввода. + * @param phoneFromBuffer Текст номера телефона из буфера обмена. + * @param input Input в который осуществляется вставка. + */ +export function preparePasteData( + phoneValue: string, + phoneFromBuffer: string, + selectionStart?: number, + selectionEnd?: number, +) { + const trimNuber = phoneFromBuffer.trim(); + const cutNumberWithPlus = trimNuber.replace(/[^+\d]/g, ''); + const isTextHavePlus = cutNumberWithPlus[0] === '+'; + const cutNumber = trimNuber.replace(/[^\d]/g, ''); + const isRuNumberInBuffer = cutNumber[0] === '7' || cutNumber[0] === '8'; + let resultNumber = ''; + + // вставка в поле c "+" + if (phoneValue === '+') { + resultNumber = `+${cutNumber}`; + // вставка в поле, в которое введена часть номера + } else if (phoneValue) { + const startText = phoneValue.substring(0, selectionStart || 0); + const endText = phoneValue.substring(selectionEnd || 0); + const isSelectPlus = selectionStart === 0 && selectionEnd !== 0; + + if (selectionStart === 0 && selectionEnd === 0 && !isTextHavePlus) { + resultNumber = `+${cutNumber}${phoneValue.substring(1)}`.replace(/[^+\d]/g, ''); + } else if (!isTextHavePlus && !isSelectPlus) { + resultNumber = `${startText}${cutNumber}${endText}`.replace(/[^+\d]/g, ''); + } else if (isTextHavePlus && isSelectPlus) { + resultNumber = `${cutNumberWithPlus}${endText}`.replace(/[^+\d]/g, ''); + } else if (!isTextHavePlus && isSelectPlus) { + resultNumber = `+${cutNumber}${endText}`.replace(/[^+\d]/g, ''); + } + // вставка в пустое поле + } else if (!phoneValue) { + // вставка номера начинающегося с "+" в пустое поле + if (isTextHavePlus) { + resultNumber = cutNumberWithPlus; + // вставка номера начинающегося с "7" или "8" в пустое поле + } else if (isRuNumberInBuffer) { + resultNumber = `+7${cutNumber.substring(1)}`; + // вставка номера начинающегося НЕ с "7", "8", "+" в пустое поле + } else { + resultNumber = `+7${cutNumber}`; + } + } + + return resultNumber; +}