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:
parent
62843e20c8
commit
209c232140
13 changed files with 207 additions and 12 deletions
|
@ -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`,
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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;)}} 필수예요.`,
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -100,6 +100,7 @@ const translation = {
|
|||
'Для дополнительной безопасности, пожалуйста, заполните приведенные ниже данные учетной записи.',
|
||||
create_your_account: 'Создайте свой аккаунт',
|
||||
welcome_to_sign_in: 'Добро пожаловать для входа в систему',
|
||||
no_region_code_found: 'Не удалось определить код региона',
|
||||
},
|
||||
error: {
|
||||
general_required: `Введите {{types, list(type: disjunction;)}}`,
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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;)}}必填`,
|
||||
|
|
|
@ -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 |
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
Loading…
Add table
Reference in a new issue