diff --git a/packages/docs/docs/02-Usage/01-PhoneInput.md b/packages/docs/docs/02-Usage/01-PhoneInput.md index 1c14a37..a2baaed 100644 --- a/packages/docs/docs/02-Usage/01-PhoneInput.md +++ b/packages/docs/docs/02-Usage/01-PhoneInput.md @@ -62,6 +62,14 @@ description="An array of available countries to select (and guess)" defaultValue="defaultCountries" /> +### `preferredCountries` + + + ### `hideDropdown` = ({ disabled, hideDropdown, countries = defaultCountries, + preferredCountries = [], flags, renderButtonWrapper, ...styleProps @@ -186,6 +188,7 @@ export const CountrySelector: React.FC = ({ { setShowDropdown(false); diff --git a/src/components/CountrySelector/CountrySelectorDropdown.style.scss b/src/components/CountrySelector/CountrySelectorDropdown.style.scss index a6f1c5b..fcfd5af 100644 --- a/src/components/CountrySelector/CountrySelectorDropdown.style.scss +++ b/src/components/CountrySelector/CountrySelectorDropdown.style.scss @@ -93,6 +93,10 @@ $dropdown-top: var(--react-international-phone-dropdown-left, 44px); cursor: pointer; } + &--preferred + &:not(&--preferred) { + border-top: 1px solid base.$border-color; + } + &--selected, &--focused { background-color: $selected-dropdown-item-background-color; diff --git a/src/components/CountrySelector/CountrySelectorDropdown.tsx b/src/components/CountrySelector/CountrySelectorDropdown.tsx index 24f73a4..6312a9f 100644 --- a/src/components/CountrySelector/CountrySelectorDropdown.tsx +++ b/src/components/CountrySelector/CountrySelectorDropdown.tsx @@ -1,6 +1,6 @@ import './CountrySelectorDropdown.style.scss'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { defaultCountries } from '../../data/countryData'; import { buildClassNames } from '../../style/buildClassNames'; @@ -38,6 +38,7 @@ export interface CountrySelectorDropdownProps dialCodePrefix?: string; selectedCountry: CountryIso2; countries?: CountryData[]; + preferredCountries?: CountryIso2[]; flags?: CustomFlagImage[]; onSelect?: (country: ParsedCountry) => void; onClose?: () => void; @@ -50,6 +51,7 @@ export const CountrySelectorDropdown: React.FC< dialCodePrefix = '+', selectedCountry, countries = defaultCountries, + preferredCountries = [], flags, onSelect, onClose, @@ -58,6 +60,24 @@ export const CountrySelectorDropdown: React.FC< const listRef = useRef(null); const lastScrolledCountry = useRef(); + const preferredCountrySet = useMemo(() => { + return new Set(preferredCountries); + }, [preferredCountries]); + + const orderedCountries = useMemo(() => { + const preferred: CountryData[] = []; + const others = [...countries]; + + preferredCountries.forEach((iso2) => { + const idx = others.findIndex((c) => parseCountry(c).iso2 === iso2); + if (idx !== -1) { + preferred.push(others.splice(idx, 1)[0]); + } + }); + + return preferred.concat(others); + }, [countries, preferredCountries]); + const searchRef = useRef<{ updatedAt: Date | undefined; value: string; @@ -76,7 +96,7 @@ export const CountrySelectorDropdown: React.FC< updatedAt: new Date(), }; - const searchedCountryIndex = countries.findIndex((c) => + const searchedCountryIndex = orderedCountries.findIndex((c) => parseCountry(c).name.toLowerCase().startsWith(searchRef.current.value), ); @@ -88,9 +108,9 @@ export const CountrySelectorDropdown: React.FC< const getCountryIndex = useCallback( (country: CountryIso2) => { - return countries.findIndex((c) => parseCountry(c).iso2 === country); + return orderedCountries.findIndex((c) => parseCountry(c).iso2 === country); }, - [countries], + [orderedCountries], ); const [focusedItemIndex, setFocusedItemIndex] = useState( @@ -111,7 +131,7 @@ export const CountrySelectorDropdown: React.FC< ); const moveFocusedItem = (to: 'prev' | 'next' | 'first' | 'last') => { - const lastPossibleIndex = countries.length - 1; + const lastPossibleIndex = orderedCountries.length - 1; const getNewIndex = (currentIndex: number) => { if (to === 'prev') return currentIndex - 1; @@ -133,7 +153,7 @@ export const CountrySelectorDropdown: React.FC< if (e.key === 'Enter') { e.preventDefault(); - const focusedCountry = parseCountry(countries[focusedItemIndex]); + const focusedCountry = parseCountry(orderedCountries[focusedItemIndex]); handleCountrySelect(focusedCountry); return; } @@ -180,7 +200,7 @@ export const CountrySelectorDropdown: React.FC< const scrollToFocusedCountry = useCallback(() => { if (!listRef.current || focusedItemIndex === undefined) return; - const focusedCountry = parseCountry(countries[focusedItemIndex]).iso2; + const focusedCountry = parseCountry(orderedCountries[focusedItemIndex]).iso2; if (focusedCountry === lastScrolledCountry.current) return; const element = listRef.current.querySelector( @@ -190,7 +210,7 @@ export const CountrySelectorDropdown: React.FC< scrollToChild(listRef.current, element as HTMLElement); lastScrolledCountry.current = focusedCountry; - }, [focusedItemIndex, countries]); + }, [focusedItemIndex, orderedCountries]); // Scroll to focused item on change useEffect(() => { @@ -228,10 +248,10 @@ export const CountrySelectorDropdown: React.FC< onBlur={onClose} tabIndex={-1} aria-activedescendant={`react-international-phone__${ - parseCountry(countries[focusedItemIndex]).iso2 + parseCountry(orderedCountries[focusedItemIndex]).iso2 }-option`} > - {countries.map((c, index) => { + {orderedCountries.map((c, index) => { const country = parseCountry(c); const isSelected = country.iso2 === selectedCountry; const isFocused = index === focusedItemIndex; @@ -248,6 +268,7 @@ export const CountrySelectorDropdown: React.FC< className={buildClassNames({ addPrefix: [ 'country-selector-dropdown__list-item', + preferredCountrySet.has(country.iso2) && 'country-selector-dropdown__list-item--preferred', isSelected && 'country-selector-dropdown__list-item--selected', isFocused && 'country-selector-dropdown__list-item--focused', ], diff --git a/src/components/PhoneInput/PhoneInput.test.tsx b/src/components/PhoneInput/PhoneInput.test.tsx index f4bf16f..e4b7529 100644 --- a/src/components/PhoneInput/PhoneInput.test.tsx +++ b/src/components/PhoneInput/PhoneInput.test.tsx @@ -898,6 +898,29 @@ describe('PhoneInput', () => { }); }); + describe('preferred countries', () => { + test('should display preferred countries on top', () => { + render( + , + ); + + expect(getCountrySelectorDropdown().childNodes[0]).toBe(getDropdownOption('us')); + expect(getCountrySelectorDropdown().childNodes[1]).toBe(getDropdownOption('gb')); + }); + + test('should ignore invalid preferred countries', () => { + render( + , + ); + + expect(getCountrySelectorDropdown().childNodes[0]).toBe(getDropdownOption('us')); + }); + }); + describe('cursor position', () => { const user = userEvent.setup({ delay: null }); diff --git a/src/components/PhoneInput/PhoneInput.tsx b/src/components/PhoneInput/PhoneInput.tsx index ffb7545..f5adadc 100644 --- a/src/components/PhoneInput/PhoneInput.tsx +++ b/src/components/PhoneInput/PhoneInput.tsx @@ -101,6 +101,7 @@ export const PhoneInput = forwardRef( value, onChange, countries = defaultCountries, + preferredCountries = [], hideDropdown, showDisabledDialCodeAndPrefix, flags, @@ -181,6 +182,7 @@ export const PhoneInput = forwardRef( flags={flags} selectedCountry={country.iso2} countries={countries} + preferredCountries={preferredCountries} disabled={disabled} hideDropdown={hideDropdown} {...countrySelectorStyleProps} diff --git a/src/hooks/usePhoneInput.ts b/src/hooks/usePhoneInput.ts index d823603..d510a8f 100644 --- a/src/hooks/usePhoneInput.ts +++ b/src/hooks/usePhoneInput.ts @@ -26,11 +26,17 @@ export interface UsePhoneInputConfig { value?: string; /** - * @description Array of available countries for guessing + * @description Array of available countries for guessing. * @default defaultCountries // full country list */ countries?: CountryData[]; + /** + * @description Countries to display at the top of the list of dropdown options. + * @default [] + */ + preferredCountries?: CountryIso2[]; + /** * @description Prefix for phone value. * @default "+" @@ -128,6 +134,7 @@ export const defaultConfig: Required< disableDialCodeAndPrefix: false, disableFormatting: false, countries: defaultCountries, + preferredCountries: [], }; export const usePhoneInput = ({ diff --git a/src/stories/PhoneInput/PhoneInput.stories.tsx b/src/stories/PhoneInput/PhoneInput.stories.tsx index dca92d6..f31c1d3 100644 --- a/src/stories/PhoneInput/PhoneInput.stories.tsx +++ b/src/stories/PhoneInput/PhoneInput.stories.tsx @@ -21,6 +21,7 @@ import { HiddenDialCode } from './stories/HiddenDialCode.story'; import { WithCodePreview } from './stories/WithCodePreview.story'; import { CustomStyles } from './stories/CustomStyles.story'; import { OnlyBalticCountries } from './stories/OnlyBalticCountries.story'; +import { PreferredCountries } from './stories/PreferredCountries.story'; import { WithAutofocus } from './stories/WithAutofocus.story'; import { DisableFormatting } from './stories/DisableFormatting.story'; import { ControlledMode } from './stories/ControlledMode.story'; @@ -36,6 +37,7 @@ export const _HiddenDialCode = HiddenDialCode; export const _WithCodePreview = WithCodePreview; export const _CustomStyles = CustomStyles; export const _OnlyBalticCountries = OnlyBalticCountries; +export const _PreferredCountries = PreferredCountries; export const _WithAutofocus = WithAutofocus; export const _DisableFormatting = DisableFormatting; export const _ControlledMode = ControlledMode; diff --git a/src/stories/PhoneInput/stories/PreferredCountries.story.tsx b/src/stories/PhoneInput/stories/PreferredCountries.story.tsx new file mode 100644 index 0000000..d8401ed --- /dev/null +++ b/src/stories/PhoneInput/stories/PreferredCountries.story.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { PhoneInput } from '../../../index'; +import { PhoneInputStory } from '../PhoneInput.stories'; + +export const PreferredCountries: PhoneInputStory = { + name: 'With Preferred Countries', + render: (args) => , + args: { + preferredCountries: ['us', 'gb'], + }, +};