From ed8f6e5a3f3fcb396c1a2379e5e259e148862824 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Fri, 15 Dec 2023 15:18:10 -0500 Subject: [PATCH 1/4] feat(component): add "preferredCountries" feature A list of "preferredCountries" can be configured to display specific countries at the top of the dropdown list. fix #137 --- packages/docs/docs/02-Usage/01-PhoneInput.md | 8 ++++ .../CountrySelector/CountrySelector.tsx | 3 ++ .../CountrySelectorDropdown.style.scss | 4 ++ .../CountrySelectorDropdown.tsx | 41 ++++++++++++++----- src/components/PhoneInput/PhoneInput.test.tsx | 23 +++++++++++ src/components/PhoneInput/PhoneInput.tsx | 2 + src/hooks/usePhoneInput.ts | 9 +++- src/stories/PhoneInput/PhoneInput.stories.tsx | 2 + .../stories/PreferredCountries.story.tsx | 12 ++++++ 9 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 src/stories/PhoneInput/stories/PreferredCountries.story.tsx 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'], + }, +}; From c35a266234fd6888d39b0cacba1ca39a62ea9594 Mon Sep 17 00:00:00 2001 From: Yurii Brusentsov Date: Mon, 15 Jan 2024 21:03:01 +0200 Subject: [PATCH 2/4] chore: fix format errors --- .../CountrySelectorDropdown.tsx | 19 +++++++++++---- src/components/PhoneInput/PhoneInput.test.tsx | 24 +++++++++---------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/components/CountrySelector/CountrySelectorDropdown.tsx b/src/components/CountrySelector/CountrySelectorDropdown.tsx index 6312a9f..f76f4b5 100644 --- a/src/components/CountrySelector/CountrySelectorDropdown.tsx +++ b/src/components/CountrySelector/CountrySelectorDropdown.tsx @@ -1,6 +1,12 @@ import './CountrySelectorDropdown.style.scss'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { defaultCountries } from '../../data/countryData'; import { buildClassNames } from '../../style/buildClassNames'; @@ -108,7 +114,9 @@ export const CountrySelectorDropdown: React.FC< const getCountryIndex = useCallback( (country: CountryIso2) => { - return orderedCountries.findIndex((c) => parseCountry(c).iso2 === country); + return orderedCountries.findIndex( + (c) => parseCountry(c).iso2 === country, + ); }, [orderedCountries], ); @@ -200,7 +208,9 @@ export const CountrySelectorDropdown: React.FC< const scrollToFocusedCountry = useCallback(() => { if (!listRef.current || focusedItemIndex === undefined) return; - const focusedCountry = parseCountry(orderedCountries[focusedItemIndex]).iso2; + const focusedCountry = parseCountry( + orderedCountries[focusedItemIndex], + ).iso2; if (focusedCountry === lastScrolledCountry.current) return; const element = listRef.current.querySelector( @@ -268,7 +278,8 @@ export const CountrySelectorDropdown: React.FC< className={buildClassNames({ addPrefix: [ 'country-selector-dropdown__list-item', - preferredCountrySet.has(country.iso2) && 'country-selector-dropdown__list-item--preferred', + 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 a6598f7..f90813b 100644 --- a/src/components/PhoneInput/PhoneInput.test.tsx +++ b/src/components/PhoneInput/PhoneInput.test.tsx @@ -900,24 +900,22 @@ describe('PhoneInput', () => { describe('preferred countries', () => { test('should display preferred countries on top', () => { - render( - , - ); + render(); - expect(getCountrySelectorDropdown().childNodes[0]).toBe(getDropdownOption('us')); - expect(getCountrySelectorDropdown().childNodes[1]).toBe(getDropdownOption('gb')); + expect(getCountrySelectorDropdown().childNodes[0]).toBe( + getDropdownOption('us'), + ); + expect(getCountrySelectorDropdown().childNodes[1]).toBe( + getDropdownOption('gb'), + ); }); test('should ignore invalid preferred countries', () => { - render( - , - ); + render(); - expect(getCountrySelectorDropdown().childNodes[0]).toBe(getDropdownOption('us')); + expect(getCountrySelectorDropdown().childNodes[0]).toBe( + getDropdownOption('us'), + ); }); }); From bcb8b8945f53a78b131f729e312f4ac3886ede31 Mon Sep 17 00:00:00 2001 From: Yurii Brusentsov Date: Mon, 15 Jan 2024 23:01:42 +0200 Subject: [PATCH 3/4] fix(CountrySelector): update countries ordering logic --- .../CountrySelectorDropdown.tsx | 28 ++++++++----------- src/components/PhoneInput/PhoneInput.test.tsx | 3 ++ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/components/CountrySelector/CountrySelectorDropdown.tsx b/src/components/CountrySelector/CountrySelectorDropdown.tsx index f76f4b5..44621f5 100644 --- a/src/components/CountrySelector/CountrySelectorDropdown.tsx +++ b/src/components/CountrySelector/CountrySelectorDropdown.tsx @@ -66,22 +66,17 @@ 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]); - } - }); + const orderedCountries = useMemo(() => { + if (!preferredCountries || !preferredCountries.length) { + return countries; + } - return preferred.concat(others); + return [...countries].sort((c) => { + const country = parseCountry(c); + const isPreferredCountry = preferredCountries.includes(country.iso2); + + return isPreferredCountry ? -1 : 0; + }); }, [countries, preferredCountries]); const searchRef = useRef<{ @@ -265,6 +260,7 @@ export const CountrySelectorDropdown: React.FC< const country = parseCountry(c); const isSelected = country.iso2 === selectedCountry; const isFocused = index === focusedItemIndex; + const isPreferred = preferredCountries.includes(country.iso2); const flag = flags?.find((f) => f.iso2 === country.iso2); return ( @@ -278,7 +274,7 @@ export const CountrySelectorDropdown: React.FC< className={buildClassNames({ addPrefix: [ 'country-selector-dropdown__list-item', - preferredCountrySet.has(country.iso2) && + isPreferred && '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 f90813b..ddb7475 100644 --- a/src/components/PhoneInput/PhoneInput.test.tsx +++ b/src/components/PhoneInput/PhoneInput.test.tsx @@ -908,6 +908,9 @@ describe('PhoneInput', () => { expect(getCountrySelectorDropdown().childNodes[1]).toBe( getDropdownOption('gb'), ); + expect(getCountrySelectorDropdown().childNodes.length).toBe( + defaultCountries.length, + ); }); test('should ignore invalid preferred countries', () => { From d1df65175a7b7d1f1c99717e4b5f5fa51d302ada Mon Sep 17 00:00:00 2001 From: Yurii Brusentsov Date: Mon, 15 Jan 2024 23:32:24 +0200 Subject: [PATCH 4/4] fix(CountrySelector): render preferred country list divider as hr element --- .../02-CountrySelectorDropdown.md | 4 +- .../CountrySelectorDropdown.style.scss | 21 +++- .../CountrySelectorDropdown.tsx | 113 ++++++++++-------- src/components/PhoneInput/PhoneInput.test.tsx | 2 +- 4 files changed, 87 insertions(+), 53 deletions(-) diff --git a/packages/docs/docs/03-Subcomponents API/02-CountrySelectorDropdown.md b/packages/docs/docs/03-Subcomponents API/02-CountrySelectorDropdown.md index 85bff0c..062479a 100644 --- a/packages/docs/docs/03-Subcomponents API/02-CountrySelectorDropdown.md +++ b/packages/docs/docs/03-Subcomponents API/02-CountrySelectorDropdown.md @@ -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` | diff --git a/src/components/CountrySelector/CountrySelectorDropdown.style.scss b/src/components/CountrySelector/CountrySelectorDropdown.style.scss index fcfd5af..db02af4 100644 --- a/src/components/CountrySelector/CountrySelectorDropdown.style.scss +++ b/src/components/CountrySelector/CountrySelectorDropdown.style.scss @@ -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; @@ -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 @@ -93,10 +110,6 @@ $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 44621f5..e711ce4 100644 --- a/src/components/CountrySelector/CountrySelectorDropdown.tsx +++ b/src/components/CountrySelector/CountrySelectorDropdown.tsx @@ -36,6 +36,9 @@ export interface CountrySelectorDropdownStyleProps { listItemDialCodeStyle?: React.CSSProperties; listItemDialCodeClassName?: string; + + preferredListDividerStyle?: React.CSSProperties; + preferredListDividerClassName?: string; } export interface CountrySelectorDropdownProps @@ -261,61 +264,77 @@ export const CountrySelectorDropdown: React.FC< 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 ( -
  • handleCountrySelect(country)} - style={styleProps.listItemStyle} - title={country.name} - > - - +
  • - {country.name} - - handleCountrySelect(country)} + style={styleProps.listItemStyle} + title={country.name} > - {dialCodePrefix} - {country.dialCode} - -
  • + + + {country.name} + + + {dialCodePrefix} + {country.dialCode} + + + {isLastPreferred ? ( +
    + ) : null} + ); })} diff --git a/src/components/PhoneInput/PhoneInput.test.tsx b/src/components/PhoneInput/PhoneInput.test.tsx index ddb7475..664c3e0 100644 --- a/src/components/PhoneInput/PhoneInput.test.tsx +++ b/src/components/PhoneInput/PhoneInput.test.tsx @@ -909,7 +909,7 @@ describe('PhoneInput', () => { getDropdownOption('gb'), ); expect(getCountrySelectorDropdown().childNodes.length).toBe( - defaultCountries.length, + defaultCountries.length + 1, // sections divider included ); });