Skip to content

Commit

Permalink
feat(component): add "preferredCountries" feature
Browse files Browse the repository at this point in the history
A list of "preferredCountries" can be configured to display specific countries at the top of the
dropdown list.

fix #137
  • Loading branch information
Wei Wang committed Dec 18, 2023
1 parent 2bac4df commit ed8f6e5
Show file tree
Hide file tree
Showing 9 changed files with 93 additions and 11 deletions.
8 changes: 8 additions & 0 deletions packages/docs/docs/02-Usage/01-PhoneInput.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ description="An array of available countries to select (and guess)"
defaultValue="defaultCountries"
/>

### `preferredCountries`

<PropDescription
type="CountryIso2[]"
description="An array of countries to display at the top of the dropdown list"
defaultValue="[]"
/>

### `hideDropdown`

<PropDescription
Expand Down
3 changes: 3 additions & 0 deletions src/components/CountrySelector/CountrySelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface CountrySelectorProps extends CountrySelectorStyleProps {
disabled?: boolean;
hideDropdown?: boolean;
countries?: CountryData[];
preferredCountries?: CountryIso2[];
flags?: CountrySelectorDropdownProps['flags'];
renderButtonWrapper?: (props: {
children: React.ReactNode;
Expand All @@ -66,6 +67,7 @@ export const CountrySelector: React.FC<CountrySelectorProps> = ({
disabled,
hideDropdown,
countries = defaultCountries,
preferredCountries = [],
flags,
renderButtonWrapper,
...styleProps
Expand Down Expand Up @@ -186,6 +188,7 @@ export const CountrySelector: React.FC<CountrySelectorProps> = ({
<CountrySelectorDropdown
show={showDropdown}
countries={countries}
preferredCountries={preferredCountries}
flags={flags}
onSelect={(country) => {
setShowDropdown(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 31 additions & 10 deletions src/components/CountrySelector/CountrySelectorDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -38,6 +38,7 @@ export interface CountrySelectorDropdownProps
dialCodePrefix?: string;
selectedCountry: CountryIso2;
countries?: CountryData[];
preferredCountries?: CountryIso2[];
flags?: CustomFlagImage[];
onSelect?: (country: ParsedCountry) => void;
onClose?: () => void;
Expand All @@ -50,6 +51,7 @@ export const CountrySelectorDropdown: React.FC<
dialCodePrefix = '+',
selectedCountry,
countries = defaultCountries,
preferredCountries = [],
flags,
onSelect,
onClose,
Expand All @@ -58,6 +60,24 @@ export const CountrySelectorDropdown: React.FC<
const listRef = useRef<HTMLUListElement>(null);
const lastScrolledCountry = useRef<CountryIso2>();

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;
Expand All @@ -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),
);

Expand All @@ -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(
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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(
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand All @@ -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',
],
Expand Down
23 changes: 23 additions & 0 deletions src/components/PhoneInput/PhoneInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,29 @@ describe('PhoneInput', () => {
});
});

describe('preferred countries', () => {
test('should display preferred countries on top', () => {
render(
<PhoneInput
preferredCountries={['us', 'gb']}
/>,
);

expect(getCountrySelectorDropdown().childNodes[0]).toBe(getDropdownOption('us'));
expect(getCountrySelectorDropdown().childNodes[1]).toBe(getDropdownOption('gb'));
});

test('should ignore invalid preferred countries', () => {
render(
<PhoneInput
preferredCountries={['xxx', 'us']}
/>,
);

expect(getCountrySelectorDropdown().childNodes[0]).toBe(getDropdownOption('us'));
});
});

describe('cursor position', () => {
const user = userEvent.setup({ delay: null });

Expand Down
2 changes: 2 additions & 0 deletions src/components/PhoneInput/PhoneInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export const PhoneInput = forwardRef<PhoneInputRefType, PhoneInputProps>(
value,
onChange,
countries = defaultCountries,
preferredCountries = [],
hideDropdown,
showDisabledDialCodeAndPrefix,
flags,
Expand Down Expand Up @@ -181,6 +182,7 @@ export const PhoneInput = forwardRef<PhoneInputRefType, PhoneInputProps>(
flags={flags}
selectedCountry={country.iso2}
countries={countries}
preferredCountries={preferredCountries}
disabled={disabled}
hideDropdown={hideDropdown}
{...countrySelectorStyleProps}
Expand Down
9 changes: 8 additions & 1 deletion src/hooks/usePhoneInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "+"
Expand Down Expand Up @@ -128,6 +134,7 @@ export const defaultConfig: Required<
disableDialCodeAndPrefix: false,
disableFormatting: false,
countries: defaultCountries,
preferredCountries: [],
};

export const usePhoneInput = ({
Expand Down
2 changes: 2 additions & 0 deletions src/stories/PhoneInput/PhoneInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/stories/PhoneInput/stories/PreferredCountries.story.tsx
Original file line number Diff line number Diff line change
@@ -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) => <PhoneInput {...args} />,
args: {
preferredCountries: ['us', 'gb'],
},
};

0 comments on commit ed8f6e5

Please sign in to comment.