Skip to content

Commit

Permalink
Merge pull request #145 from goveo/development
Browse files Browse the repository at this point in the history
Add `preferredCountries` prop
  • Loading branch information
ybrusentsov authored Jan 15, 2024
2 parents d0a1eeb + d1df651 commit 312d766
Show file tree
Hide file tree
Showing 10 changed files with 181 additions and 57 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
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@ defaultValue="undefined"
| --react-international-phone-dropdown-item-dial-code-color | `gray` |
| --react-international-phone-selected-dropdown-item-text-color | --react-international-phone-text-color |
| --react-international-phone-selected-dropdown-item-background-color | `whitesmoke` |
| --react-international-phone-selected-dropdown-item-dial-code-color | -react-international-phone-dropdown-item-dial-code-color |
| --react-international-phone-selected-dropdown-item-dial-code-color | --react-international-phone-dropdown-item-dial-code-color |
| --react-international-phone-focused-dropdown-item-background-color | --react-international-phone-selected-dropdown-item-background-color |
| --react-international-phone-dropdown-shadow | `2px 2px 16px rgb(0 0 0 / 25%)` |
| --react-international-phone-dropdown-left | `0` |
| --react-international-phone-dropdown-top | `44px` |
| --react-international-phone-dropdown-preferred-list-divider-color | --react-international-phone-border-color |
| --react-international-phone-dropdown-preferred-list-divider-margin | `0` |
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
17 changes: 17 additions & 0 deletions src/components/CountrySelector/CountrySelectorDropdown.style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ $dropdown-shadow: var(
$dropdown-left: var(--react-international-phone-dropdown-left, 0);
$dropdown-top: var(--react-international-phone-dropdown-left, 44px);

$dropdown-preferred-list-divider-color: var(
--react-international-phone-dropdown-preferred-list-divider-color,
base.$border-color
);

$dropdown-preferred-list-divider-margin: var(
--react-international-phone-dropdown-preferred-list-divider-margin,
0
);

.react-international-phone-country-selector-dropdown {
position: absolute;
z-index: 1;
Expand All @@ -64,6 +74,13 @@ $dropdown-top: var(--react-international-phone-dropdown-left, 44px);
list-style: none;
overflow-y: scroll;

&__preferred-list-divider {
height: 1px;
border: none;
margin: $dropdown-preferred-list-divider-margin;
background: $dropdown-preferred-list-divider-color;
}

&__list-item {
display: flex;
min-height: $dropdown-item-height; // min-height (instead of just height) for safari compatibility
Expand Down
157 changes: 102 additions & 55 deletions src/components/CountrySelector/CountrySelectorDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
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 @@ -30,6 +36,9 @@ export interface CountrySelectorDropdownStyleProps {

listItemDialCodeStyle?: React.CSSProperties;
listItemDialCodeClassName?: string;

preferredListDividerStyle?: React.CSSProperties;
preferredListDividerClassName?: string;
}

export interface CountrySelectorDropdownProps
Expand All @@ -38,6 +47,7 @@ export interface CountrySelectorDropdownProps
dialCodePrefix?: string;
selectedCountry: CountryIso2;
countries?: CountryData[];
preferredCountries?: CountryIso2[];
flags?: CustomFlagImage[];
onSelect?: (country: ParsedCountry) => void;
onClose?: () => void;
Expand All @@ -50,6 +60,7 @@ export const CountrySelectorDropdown: React.FC<
dialCodePrefix = '+',
selectedCountry,
countries = defaultCountries,
preferredCountries = [],
flags,
onSelect,
onClose,
Expand All @@ -58,6 +69,19 @@ export const CountrySelectorDropdown: React.FC<
const listRef = useRef<HTMLUListElement>(null);
const lastScrolledCountry = useRef<CountryIso2>();

const orderedCountries = useMemo<CountryData[]>(() => {
if (!preferredCountries || !preferredCountries.length) {
return countries;
}

return [...countries].sort((c) => {
const country = parseCountry(c);
const isPreferredCountry = preferredCountries.includes(country.iso2);

return isPreferredCountry ? -1 : 0;
});
}, [countries, preferredCountries]);

const searchRef = useRef<{
updatedAt: Date | undefined;
value: string;
Expand All @@ -76,7 +100,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 +112,11 @@ 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 +137,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 +159,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 +206,9 @@ 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 +218,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,66 +256,85 @@ 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;
const isPreferred = preferredCountries.includes(country.iso2);
const isLastPreferred = index === preferredCountries.length - 1;
const flag = flags?.find((f) => f.iso2 === country.iso2);

return (
<li
key={country.iso2}
data-country={country.iso2}
role="option"
aria-selected={isSelected}
aria-label={`${country.name} ${dialCodePrefix}${country.dialCode}`}
id={`react-international-phone__${country.iso2}-option`}
className={buildClassNames({
addPrefix: [
'country-selector-dropdown__list-item',
isSelected && 'country-selector-dropdown__list-item--selected',
isFocused && 'country-selector-dropdown__list-item--focused',
],
rawClassNames: [styleProps.listItemClassName],
})}
onClick={() => handleCountrySelect(country)}
style={styleProps.listItemStyle}
title={country.name}
>
<FlagImage
iso2={country.iso2}
src={flag?.src}
className={buildClassNames({
addPrefix: ['country-selector-dropdown__list-item-flag-emoji'],
rawClassNames: [styleProps.listItemFlagClassName],
})}
style={styleProps.listItemFlagStyle}
/>
<span
<React.Fragment key={country.iso2}>
<li
data-country={country.iso2}
role="option"
aria-selected={isSelected}
aria-label={`${country.name} ${dialCodePrefix}${country.dialCode}`}
id={`react-international-phone__${country.iso2}-option`}
className={buildClassNames({
addPrefix: [
'country-selector-dropdown__list-item-country-name',
'country-selector-dropdown__list-item',
isPreferred &&
'country-selector-dropdown__list-item--preferred',
isSelected &&
'country-selector-dropdown__list-item--selected',
isFocused && 'country-selector-dropdown__list-item--focused',
],
rawClassNames: [styleProps.listItemCountryNameClassName],
})}
style={styleProps.listItemCountryNameStyle}
>
{country.name}
</span>
<span
className={buildClassNames({
addPrefix: ['country-selector-dropdown__list-item-dial-code'],
rawClassNames: [styleProps.listItemDialCodeClassName],
rawClassNames: [styleProps.listItemClassName],
})}
style={styleProps.listItemDialCodeStyle}
onClick={() => handleCountrySelect(country)}
style={styleProps.listItemStyle}
title={country.name}
>
{dialCodePrefix}
{country.dialCode}
</span>
</li>
<FlagImage
iso2={country.iso2}
src={flag?.src}
className={buildClassNames({
addPrefix: [
'country-selector-dropdown__list-item-flag-emoji',
],
rawClassNames: [styleProps.listItemFlagClassName],
})}
style={styleProps.listItemFlagStyle}
/>
<span
className={buildClassNames({
addPrefix: [
'country-selector-dropdown__list-item-country-name',
],
rawClassNames: [styleProps.listItemCountryNameClassName],
})}
style={styleProps.listItemCountryNameStyle}
>
{country.name}
</span>
<span
className={buildClassNames({
addPrefix: ['country-selector-dropdown__list-item-dial-code'],
rawClassNames: [styleProps.listItemDialCodeClassName],
})}
style={styleProps.listItemDialCodeStyle}
>
{dialCodePrefix}
{country.dialCode}
</span>
</li>
{isLastPreferred ? (
<hr
className={buildClassNames({
addPrefix: [
'country-selector-dropdown__preferred-list-divider',
],
rawClassNames: [styleProps.preferredListDividerClassName],
})}
style={styleProps.preferredListDividerStyle}
/>
) : null}
</React.Fragment>
);
})}
</ul>
Expand Down
24 changes: 24 additions & 0 deletions src/components/PhoneInput/PhoneInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,30 @@ 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'),
);
expect(getCountrySelectorDropdown().childNodes.length).toBe(
defaultCountries.length + 1, // sections divider included
);
});

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 @@ -107,6 +107,7 @@ export const PhoneInput = forwardRef<PhoneInputRefType, PhoneInputProps>(
value,
onChange,
countries = defaultCountries,
preferredCountries = [],
hideDropdown,
showDisabledDialCodeAndPrefix,
disableFocusAfterCountrySelect,
Expand Down Expand Up @@ -192,6 +193,7 @@ export const PhoneInput = forwardRef<PhoneInputRefType, PhoneInputProps>(
flags={flags}
selectedCountry={country.iso2}
countries={countries}
preferredCountries={preferredCountries}
disabled={disabled}
hideDropdown={hideDropdown}
{...countrySelectorStyleProps}
Expand Down
Loading

2 comments on commit 312d766

@vercel
Copy link

@vercel vercel bot commented on 312d766 Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 312d766 Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.