0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-14 23:11:31 -05:00

refactor(ui): optimize the countryCode search box (#3448)

This commit is contained in:
simeng-li 2023-03-17 16:33:38 +08:00 committed by GitHub
parent 62843e20c8
commit 209c232140
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 207 additions and 12 deletions

View file

@ -102,6 +102,7 @@ const translation = {
'Für zusätzliche Sicherheit, vervollständige bitte deine Informationen.',
create_your_account: 'Erstelle dein Konto',
welcome_to_sign_in: 'Willkommen zur Anmeldung',
no_region_code_found: 'Kein Regionencode gefunden',
},
error: {
general_required: `{{types, list(type: disjunction;)}} ist erforderlich`,

View file

@ -96,6 +96,7 @@ const translation = {
continue_with_more_information: 'For added security, please complete below account details.',
create_your_account: 'Create your account',
welcome_to_sign_in: 'Welcome to sign in',
no_region_code_found: 'No region code found',
},
error: {
general_required: `{{types, list(type: disjunction;)}} is required`,

View file

@ -102,6 +102,7 @@ const translation = {
'Pour une sécurité accrue, veuillez compléter les détails du compte ci-dessous.',
create_your_account: 'Créer votre compte',
welcome_to_sign_in: 'Bienvenue pour vous connecter',
no_region_code_found: 'Aucun code de région trouvé',
},
error: {
general_required: `Le {{types, list(type: disjunction;)}} est requis`,

View file

@ -95,6 +95,7 @@ const translation = {
continue_with_more_information: '더 나은 보안을 위해 아래 자세한 내용을 따라 주세요.',
create_your_account: '계정 생성하기',
welcome_to_sign_in: '로그인을 환영합니다',
no_region_code_found: '지역 코드를 찾을 수 없습니다.',
},
error: {
general_required: `{{types, list(type: disjunction;)}} 필수예요.`,

View file

@ -97,6 +97,7 @@ const translation = {
continue_with_more_information: 'Para maior segurança, preencha os detalhes da conta abaixo.',
create_your_account: 'Crie sua conta',
welcome_to_sign_in: 'Bem-vindo(a) para entrar',
no_region_code_found: 'Não foi possível encontrar o código de região do seu telefone.',
},
error: {
general_required: `{{types, list(type: disjunction;)}} é obrigatório`,

View file

@ -97,6 +97,7 @@ const translation = {
'Para maior segurança, por favor complete os detalhes da conta abaixo.',
create_your_account: 'Criar conta',
welcome_to_sign_in: 'Bem-vindo(a) para iniciar sessão',
no_region_code_found: 'Não foi possível encontrar o código de região do seu telefone.',
},
error: {
general_required: `{{types, list(type: disjunction;)}} is necessário`,

View file

@ -100,6 +100,7 @@ const translation = {
'Для дополнительной безопасности, пожалуйста, заполните приведенные ниже данные учетной записи.',
create_your_account: 'Создайте свой аккаунт',
welcome_to_sign_in: 'Добро пожаловать для входа в систему',
no_region_code_found: 'Не удалось определить код региона',
},
error: {
general_required: `Введите {{types, list(type: disjunction;)}}`,

View file

@ -97,6 +97,7 @@ const translation = {
'Daha fazla güvenlik için lütfen aşağıdaki hesap ayrıntılarını tamamlayın.',
create_your_account: 'hesabını oluştur',
welcome_to_sign_in: 'oturum açmaya hoş geldiniz',
no_region_code_found: 'Bölge kodu bulunamadı',
},
error: {
general_required: `{{types, list(type: disjunction;)}} gerekli`,

View file

@ -90,6 +90,7 @@ const translation = {
continue_with_more_information: '为保障你的账号安全,需要你补充以下信息。',
create_your_account: '注册你的账号',
welcome_to_sign_in: '欢迎登录',
no_region_code_found: '没有找到区域码',
},
error: {
general_required: `{{types, list(type: disjunction;)}}必填`,

View file

@ -1,3 +1,3 @@
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5837 6.5C15.2503 6.16667 14.7503 6.16667 14.417 6.5L8.16699 12.75L5.58366 10.1667C5.25033 9.83333 4.75033 9.83333 4.41699 10.1667C4.08366 10.5 4.08366 11 4.41699 11.3333L7.58366 14.5C7.75033 14.6667 7.91699 14.75 8.16699 14.75C8.41699 14.75 8.58366 14.6667 8.75033 14.5L15.5837 7.66667C15.917 7.33333 15.917 6.83333 15.5837 6.5Z" fill="#5D34F2"/>
<path d="M15.5837 6.5C15.2503 6.16667 14.7503 6.16667 14.417 6.5L8.16699 12.75L5.58366 10.1667C5.25033 9.83333 4.75033 9.83333 4.41699 10.1667C4.08366 10.5 4.08366 11 4.41699 11.3333L7.58366 14.5C7.75033 14.6667 7.91699 14.75 8.16699 14.75C8.41699 14.75 8.58366 14.6667 8.75033 14.5L15.5837 7.66667C15.917 7.33333 15.917 6.83333 15.5837 6.5Z" fill="currentcolor"/>
</svg>

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 468 B

View file

@ -56,7 +56,7 @@
padding-left: calc(_.unit(7) - _.unit(6));
}
&:hover {
&.selected {
border-radius: _.unit(2);
background: var(--color-overlay-neutral-hover);
color: var(--color-type-link);
@ -64,6 +64,12 @@
}
}
.notFound {
color: var(--color-type-secondary);
padding: _.unit(1) _.unit(2);
text-align: center;
}
:global(body.desktop) {
.dropdownContent {
@ -98,6 +104,7 @@
}
.countryList {
font: var(--font-body-1);
overflow: auto;
flex: 1;
}

View file

@ -10,6 +10,10 @@ jest.mock('i18next', () => ({
t: (key: string) => key,
}));
// Need to mock the scrollIntoView method because jsdom doesn't support it
// eslint-disable-next-line @silverhand/fp/no-mutation
Element.prototype.scrollIntoView = jest.fn();
describe('CountryCodeDropdown', () => {
const onChange = jest.fn();
const onClose = jest.fn();
@ -47,33 +51,98 @@ describe('CountryCodeDropdown', () => {
expect(onClose).toBeCalled();
});
it('should render properly with search', async () => {
const countryCode = '1';
const alterCountryCode = '86';
it('should render search results properly and auto focus ', async () => {
const initialCountryCode = '1';
const search = '8';
const firstSearchResult = countryList
.map(({ countryCallingCode }) => countryCallingCode)
.find((countryCode) => countryCode.startsWith(search));
const { queryByText, container } = render(
<CountryCodeDropdown
isOpen
countryCode={countryCode}
countryCode={initialCountryCode}
countryList={countryList}
onClose={onClose}
onChange={onChange}
/>
);
expect(queryByText(`+${countryCode}`)).not.toBeNull();
expect(queryByText(`+${alterCountryCode}`)).not.toBeNull();
assert(firstSearchResult, new Error('First search result not found'));
expect(queryByText(`+${initialCountryCode}`)).not.toBeNull();
expect(queryByText(`+${firstSearchResult}`)).not.toBeNull();
const searchInput = container.parentElement?.querySelector('input[name="country-code-search"]');
assert(searchInput, new Error('Search input not found'));
act(() => {
fireEvent.change(searchInput, { target: { value: alterCountryCode } });
fireEvent.change(searchInput, { target: { value: search } });
});
await waitFor(() => {
expect(queryByText(`+${countryCode}`)).toBeNull();
expect(queryByText(`+${alterCountryCode}`)).not.toBeNull();
expect(queryByText(`+${initialCountryCode}`)).toBeNull();
expect(queryByText(`+${firstSearchResult}`)).not.toBeNull();
});
act(() => {
fireEvent.keyDown(searchInput, { key: 'Enter' });
});
expect(onChange).toBeCalledWith(firstSearchResult);
});
it('should navigate through search results properly on KeyUp and KeyDown press', async () => {
const initialCountryCode = '1';
const search = '8';
const searchResults = countryList
.map(({ countryCallingCode }) => countryCallingCode)
.filter((countryCode) => countryCode.startsWith(search));
const { queryByText, container } = render(
<CountryCodeDropdown
isOpen
countryCode={initialCountryCode}
countryList={countryList}
onClose={onClose}
onChange={onChange}
/>
);
expect(queryByText(`+${initialCountryCode}`)).not.toBeNull();
for (const element of searchResults) {
expect(queryByText(`+${element}`)).not.toBeNull();
}
const searchInput = container.parentElement?.querySelector('input[name="country-code-search"]');
assert(searchInput, new Error('Search input not found'));
act(() => {
fireEvent.change(searchInput, { target: { value: search } });
});
await waitFor(() => {
expect(queryByText(`+${initialCountryCode}`)).toBeNull();
for (const element of searchResults) {
expect(queryByText(`+${element}`)).not.toBeNull();
}
});
if (searchResults.length <= 1) {
return;
}
act(() => {
fireEvent.keyDown(searchInput, { key: 'ArrowDown' });
});
act(() => {
fireEvent.keyDown(searchInput, { key: 'Enter' });
});
expect(onChange).toBeCalledWith(searchResults[1]);
});
});

View file

@ -1,5 +1,7 @@
import type { Nullable } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import type { KeyboardEventHandler } from 'react';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
@ -36,6 +38,7 @@ const CountryCodeDropdown = ({
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState('');
const [position, setPosition] = useState({});
const [selectedCountryCode, setSelectedCountryCode] = useState('');
const debouncedSearchValue = useDebounce(searchValue, 100);
const onSearchChange = useCallback(({ target }: React.ChangeEvent<HTMLInputElement>) => {
@ -93,6 +96,97 @@ const CountryCodeDropdown = ({
[countryList, debouncedSearchValue]
);
useLayoutEffect(() => {
if (!debouncedSearchValue) {
setSelectedCountryCode('');
return;
}
// Auto Focus on the first available element
const firstCountryCode = filteredCountryList[0]?.countryCallingCode;
setSelectedCountryCode(firstCountryCode ?? '');
}, [filteredCountryList, debouncedSearchValue]);
const onInputKeyDown = useCallback<KeyboardEventHandler<HTMLInputElement>>(
(event) => {
const { key } = event;
switch (key) {
case 'Enter':
case ' ': {
event.preventDefault();
event.stopPropagation();
if (selectedCountryCode) {
onCodeChange(selectedCountryCode);
}
break;
}
case 'Escape': {
event.preventDefault();
event.stopPropagation();
onDestroy();
break;
}
case 'ArrowUp':
case 'ArrowLeft': {
event.preventDefault();
event.stopPropagation();
const currentSelectedIndex = filteredCountryList.findIndex(
({ countryCallingCode }) => countryCallingCode === selectedCountryCode
);
if (currentSelectedIndex <= 0) {
return;
}
const nextSelectedCountryCode = filteredCountryList[currentSelectedIndex - 1];
setSelectedCountryCode(nextSelectedCountryCode?.countryCallingCode ?? '');
break;
}
case '-':
case 'e':
case '.': {
event.preventDefault();
event.stopPropagation();
break;
}
case 'ArrowRight':
case 'ArrowDown': {
event.preventDefault();
event.stopPropagation();
const currentSelectedIndex = filteredCountryList.findIndex(
({ countryCallingCode }) => countryCallingCode === selectedCountryCode
);
if (currentSelectedIndex >= filteredCountryList.length - 1) {
return;
}
const nextSelectedCountryCode = filteredCountryList[currentSelectedIndex + 1];
setSelectedCountryCode(nextSelectedCountryCode?.countryCallingCode ?? '');
break;
}
default: {
break;
}
}
},
[filteredCountryList, onCodeChange, onDestroy, selectedCountryCode]
);
useLayoutEffect(() => {
const selectedItemDom = document.querySelector(`li[data-id="${selectedCountryCode}"]`);
if (selectedItemDom) {
selectedItemDom.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [selectedCountryCode]);
return (
<ReactModal
id="country-code-dropdown"
@ -130,21 +224,28 @@ const CountryCodeDropdown = ({
autoFocus
name="country-code-search"
type="number"
min={0}
prefix={<SearchIcon />}
value={searchValue}
className={styles.searchInputField}
placeholder={t('input.search_region_code')}
onChange={onSearchChange}
onKeyDown={onInputKeyDown}
/>
<ul className={styles.countryList}>
{filteredCountryList.map(({ countryCallingCode, countryCode: countryKeyCode }) => {
const isActive = countryCallingCode === countryCode;
const isSelected = countryCallingCode === selectedCountryCode;
return (
<li
key={countryKeyCode}
tabIndex={0}
className={conditional(isActive && styles.active)}
data-id={countryCallingCode}
className={classNames(
conditional(isActive && styles.active),
conditional(isSelected && styles.selected)
)}
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role="button"
onKeyDown={onKeyDownHandler({
@ -155,6 +256,12 @@ const CountryCodeDropdown = ({
onClick={() => {
onCodeChange(countryCallingCode);
}}
onMouseEnter={() => {
setSelectedCountryCode(countryCallingCode);
}}
onMouseLeave={() => {
setSelectedCountryCode('');
}}
>
{isActive && <CheckMark />}
{`+${countryCallingCode}`}
@ -162,6 +269,9 @@ const CountryCodeDropdown = ({
);
})}
</ul>
{filteredCountryList.length === 0 && (
<div className={styles.notFound}>{t('description.no_region_code_found')}</div>
)}
</div>
</ReactModal>
);