0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(ui): implement a fullscreen iframe modal container (#3347)

This commit is contained in:
simeng-li 2023-03-15 14:53:50 +08:00 committed by GitHub
parent e29b2241d1
commit 55cd785aaa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 775 additions and 37 deletions

View file

@ -0,0 +1,7 @@
---
"@logto/ui": minor
---
## Add iframe modal for mobile platform
Implement a full screen iframe modal on the mobile platform. As for most of the webview containers, opening a new tab is not allowed. So we need to implement a full screen iframe modal to show the external link page on the mobile platform.

View file

@ -0,0 +1,6 @@
---
"@logto/phrases-ui": minor
"@logto/ui": minor
---
Implement a country code selector dropdown component with search box. Users may able to quick search for a country code by typing in the search box.

View file

@ -7,6 +7,7 @@ const translation = {
email: 'Email',
phone_number: 'Telefonnummer',
confirm_password: 'Passwort bestätigen',
search_region_code: 'Suche region code',
},
secondary: {
social_bind_with:

View file

@ -5,6 +5,7 @@ const translation = {
email: 'Email',
phone_number: 'Phone number',
confirm_password: 'Confirm password',
search_region_code: 'Search region code',
},
secondary: {
social_bind_with:

View file

@ -7,6 +7,7 @@ const translation = {
email: 'Email',
phone_number: 'Numéro de téléphone',
confirm_password: 'Confirmer le mot de passe',
search_region_code: 'Rechercher le code de région',
},
secondary: {
social_bind_with:

View file

@ -7,6 +7,7 @@ const translation = {
email: '이메일',
phone_number: '휴대전화번호',
confirm_password: '비밀번호 확인',
search_region_code: '지역 코드 검색',
},
secondary: {
social_bind_with:

View file

@ -7,6 +7,7 @@ const translation = {
email: 'E-mail',
phone_number: 'Número de telefone',
confirm_password: 'Confirme a senha',
search_region_code: 'Pesquisar código de região',
},
secondary: {
social_bind_with:

View file

@ -7,6 +7,7 @@ const translation = {
email: 'Email',
phone_number: 'Telefone',
confirm_password: 'Confirmar palavra-passe',
search_region_code: 'Procurar código de região',
},
secondary: {
social_bind_with:

View file

@ -7,6 +7,7 @@ const translation = {
email: 'Электронная почта',
phone_number: 'Номер телефона',
confirm_password: 'Подтверждение пароля',
search_region_code: 'Поиск кода региона',
},
secondary: {
social_bind_with:

View file

@ -7,6 +7,7 @@ const translation = {
email: 'E-posta Adresi',
phone_number: 'Telefon Numarası',
confirm_password: 'Şifreyi Doğrula',
search_region_code: 'Bölge kodunu ara',
},
secondary: {
social_bind_with:

View file

@ -7,6 +7,7 @@ const translation = {
email: '邮箱',
phone_number: '手机号',
confirm_password: '确认密码',
search_region_code: '搜索区域码',
},
secondary: {
social_bind_with:

View file

@ -74,6 +74,7 @@
"react-router-dom": "^6.2.2",
"react-string-replace": "^1.0.0",
"react-timer-hook": "^3.0.5",
"react-top-loading-bar": "^2.3.1",
"stylelint": "^15.0.0",
"superstruct": "^0.16.0",
"ts-jest": "^29.0.5",

View file

@ -7,6 +7,7 @@ import { PageContext } from '@/hooks/use-page-context';
import useTheme from '@/hooks/use-theme';
import ConfirmModalProvider from '../ConfirmModalProvider';
import IframeModalProvider from '../IframeModalProvider';
import ToastProvider from '../ToastProvider';
import * as styles from './index.module.scss';
@ -18,6 +19,7 @@ const AppBoundary = ({ children }: Props) => {
// Set Primary Color
useColorTheme();
const theme = useTheme();
const { platform } = useContext(PageContext);
// Set Theme Mode
@ -33,9 +35,11 @@ const AppBoundary = ({ children }: Props) => {
}, [platform]);
return (
<ConfirmModalProvider>
<ToastProvider>{children}</ToastProvider>
</ConfirmModalProvider>
<IframeModalProvider>
<ConfirmModalProvider>
<ToastProvider>{children}</ToastProvider>
</ConfirmModalProvider>
</IframeModalProvider>
);
};

View file

@ -0,0 +1,71 @@
@use '@/scss/underscore' as _;
.overlay {
z-index: 101;
}
.modal {
z-index: 101;
position: absolute;
inset: 0;
overflow: auto;
}
.container {
background: var(--color-bg-body);
height: 100%;
@include _.flex-column;
align-items: stretch;
overflow: hidden;
}
.modal,
.container {
&:focus-visible {
outline: none;
}
}
.header {
padding: _.unit(2) _.unit(5);
}
.content {
flex: 1;
width: 100%;
}
iframe {
width: 100%;
height: 100%;
border: none;
background: var(--color-bg-body);
opacity: 0%;
transition: opacity 0.2s ease-in-out;
&.loaded {
opacity: 100%;
}
}
.loader {
background: var(--color-brand-default);
}
/* stylelint-disable selector-class-pattern */
:global {
.ReactModal__Content[id='iframe-modal'] {
transform: translateY(100%);
transition: transform 0.3s ease-in-out;
}
.ReactModal__Content--after-open[id='iframe-modal'] {
transform: translateY(0);
}
.ReactModal__Content--before-close[id='iframe-modal'] {
transform: translateY(100%);
}
}
/* stylelint-enable selector-class-pattern */

View file

@ -0,0 +1,72 @@
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useRef, useState } from 'react';
import ReactModal from 'react-modal';
import type { LoadingBarRef } from 'react-top-loading-bar';
import LoadingBar from 'react-top-loading-bar';
import NavBar from '@/components/NavBar';
import * as styles from './index.module.scss';
type ModalProps = {
className?: string;
title?: string;
href?: string;
onClose: () => void;
};
const IframeModal = ({ className, title = '', href = '', onClose }: ModalProps) => {
const [isLoaded, setIsLoaded] = useState(false);
const loadingBarRef = useRef<LoadingBarRef>(null);
const brandingColor = document.body.style.getPropertyValue('--color-brand-default') || '#5d34f2';
return (
<ReactModal
shouldCloseOnEsc
id="iframe-modal"
role="dialog"
isOpen={Boolean(href)}
className={classNames(styles.modal, className)}
overlayClassName={styles.overlay}
closeTimeoutMS={300}
onAfterOpen={() => {
loadingBarRef.current?.continuousStart();
}}
onRequestClose={onClose}
>
<div className={styles.container}>
<div className={styles.header}>
<NavBar type="close" title={title} onClose={onClose} />
</div>
<LoadingBar
ref={loadingBarRef}
containerStyle={{ position: 'relative' }}
shadow={false}
color={brandingColor}
waitingTime={300}
className={styles.loader}
/>
<div className={styles.content}>
<iframe
title={title}
src={href}
sandbox="allow-scripts"
className={conditional(isLoaded && styles.loaded)}
onLoad={() => {
setIsLoaded(true);
loadingBarRef.current?.complete();
}}
onError={() => {
setIsLoaded(true);
loadingBarRef.current?.complete();
}}
/>
</div>
</div>
</ReactModal>
);
};
export default IframeModal;

View file

@ -0,0 +1,55 @@
import { noop } from '@silverhand/essentials';
import { useState, useMemo, createContext, useContext } from 'react';
import usePlatform from '@/hooks/use-platform';
import IframeModal from './IframeModal';
type ModalState = {
href?: string;
title?: string;
};
export const IframeModalContext = createContext<
ModalState & { setModalState: (props: ModalState) => void }
>({
href: undefined,
title: undefined,
setModalState: noop,
});
export const useIframeModal = () => useContext(IframeModalContext);
type Props = {
children: React.ReactNode;
};
const IframeModalProvider = ({ children }: Props) => {
const [modalState, setModalState] = useState<ModalState>();
const { isMobile } = usePlatform();
const context = useMemo(
() => ({
...modalState,
setModalState,
}),
[modalState]
);
return (
<IframeModalContext.Provider value={context}>
{children}
{isMobile && (
<IframeModal
href={modalState?.href}
title={modalState?.title}
onClose={() => {
setModalState(undefined);
}}
/>
)}
</IframeModalContext.Provider>
);
};
export default IframeModalProvider;

View file

@ -0,0 +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"/>
</svg>

After

Width:  |  Height:  |  Size: 463 B

View file

@ -0,0 +1,3 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 10.5C3.791 10.5 2 8.709 2 6.5C2 4.291 3.791 2.5 6 2.5C8.209 2.5 10 4.291 10 6.5C10 8.709 8.209 10.5 6 10.5ZM15.707 14.793L10.887 9.973C11.585 8.992 12 7.796 12 6.5C12 3.187 9.313 0.5 6 0.5C2.687 0.5 0 3.187 0 6.5C0 9.813 2.687 12.5 6 12.5C7.296 12.5 8.492 12.085 9.473 11.387L14.293 16.207C14.488 16.402 14.744 16.5 15 16.5C15.256 16.5 15.512 16.402 15.707 16.207C16.098 15.816 16.098 15.184 15.707 14.793Z" fill="currentcolor"/>
</svg>

After

Width:  |  Height:  |  Size: 587 B

View file

@ -29,8 +29,6 @@ const AcModal = ({
return (
<ReactModal
shouldCloseOnEsc
role="dialog"
isOpen={isOpen}
className={classNames(styles.modal, className)}
overlayClassName={classNames(modalStyles.overlay, styles.overlay)}

View file

@ -0,0 +1,132 @@
@use '@/scss/underscore' as _;
.dropdownOverlay {
background: transparent;
position: fixed;
inset: 0;
z-index: 40;
}
.dropdownModal {
position: absolute;
z-index: 50;
&:focus-visible {
outline: none;
}
}
.dropdownContent {
background: var(--color-bg-float);
padding: _.unit(2) _.unit(3);
}
.searchInputField {
margin-bottom: _.unit(2);
input {
padding-left: _.unit(2);
}
svg {
color: var(--color-type-secondary);
align-self: center;
}
}
.countryList {
margin: 0;
padding: 0;
list-style: none;
overflow: auto;
li {
padding: _.unit(1) _.unit(2) _.unit(1) _.unit(7);
@include _.flex-row;
cursor: pointer;
> svg {
margin-right: _.unit(1);
}
&.active {
color: var(--color-type-link);
padding-left: calc(_.unit(7) - _.unit(6));
}
&:hover {
background: var(--color-overlay-neutral-hover);
color: var(--color-type-link);
}
}
}
:global(body.desktop) {
.dropdownContent {
border: _.border(var(--color-line-divider));
box-shadow: var(--color-shadow-2);
border-radius: _.unit(2);
}
.searchInputField > div {
padding: _.unit(1.5) _.unit(3);
height: auto;
}
.countryList {
max-height: 400px;
}
}
:global(body.mobile) {
.dropdownOverlay {
z-index: 200;
}
.dropdownModal {
inset: 0;
}
.dropdownContent {
@include _.flex-column;
align-items: stretch;
height: 100%;
}
.countryList {
overflow: auto;
flex: 1;
}
.searchInputField {
&:not(:first-child) {
margin-top: _.unit(2);
}
> div {
padding-left: _.unit(4);
}
}
}
:global {
body.mobile {
/* stylelint-disable selector-class-pattern */
.ReactModal__Content[id='country-code-dropdown'] {
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
}
.ReactModal__Content--after-open[id='country-code-dropdown'] {
transform: translateX(0);
}
.ReactModal__Content--before-close[id='country-code-dropdown'] {
transform: translateX(100%);
}
/* stylelint-enable selector-class-pattern */
}
}

View file

@ -0,0 +1,79 @@
import { assert } from '@silverhand/essentials';
import { render, fireEvent, waitFor, act } from '@testing-library/react';
import { getCountryList } from '@/utils/country-code';
import CountryCodeDropdown from '.';
jest.mock('i18next', () => ({
language: 'en',
t: (key: string) => key,
}));
describe('CountryCodeDropdown', () => {
const onChange = jest.fn();
const onClose = jest.fn();
const countryList = getCountryList();
afterEach(() => {
jest.clearAllMocks();
});
it('should render properly', async () => {
const countryCode = '1';
const alterCountryCode = '86';
const { queryByText, container, getByText } = render(
<CountryCodeDropdown
isOpen
countryCode={countryCode}
countryList={countryList}
onClose={onClose}
onChange={onChange}
/>
);
await waitFor(() => {
expect(
container.parentElement?.querySelector('input[name="country-code-search"]')
).not.toBeNull();
});
expect(queryByText(`+${countryCode}`)).not.toBeNull();
expect(queryByText(`+${alterCountryCode}`)).not.toBeNull();
fireEvent.click(getByText(`+${alterCountryCode}`));
expect(onChange).toBeCalledWith(alterCountryCode);
expect(onClose).toBeCalled();
});
it('should render properly with search', async () => {
const countryCode = '1';
const alterCountryCode = '86';
const { queryByText, container } = render(
<CountryCodeDropdown
isOpen
countryCode={countryCode}
countryList={countryList}
onClose={onClose}
onChange={onChange}
/>
);
expect(queryByText(`+${countryCode}`)).not.toBeNull();
expect(queryByText(`+${alterCountryCode}`)).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 } });
});
await waitFor(() => {
expect(queryByText(`+${countryCode}`)).toBeNull();
expect(queryByText(`+${alterCountryCode}`)).not.toBeNull();
});
});
});

View file

@ -0,0 +1,170 @@
import type { Nullable } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import CheckMark from '@/assets/icons/check-mark.svg';
import SearchIcon from '@/assets/icons/search-icon.svg';
import InputField from '@/components/InputFields/InputField';
import NavBar from '@/components/NavBar';
import useDebounce from '@/hooks/use-debounce';
import usePlatform from '@/hooks/use-platform';
import { onKeyDownHandler } from '@/utils/a11y';
import type { CountryMetaData } from '@/utils/country-code';
import * as styles from './index.module.scss';
type Props = {
isOpen: boolean;
countryCode: string;
countryList: CountryMetaData[];
inputRef?: Nullable<HTMLInputElement>;
onClose: () => void;
onChange?: (value: string) => void;
};
const CountryCodeDropdown = ({
isOpen,
countryCode,
countryList,
inputRef,
onClose,
onChange,
}: Props) => {
const { isMobile } = usePlatform();
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState('');
const [position, setPosition] = useState({});
const debouncedSearchValue = useDebounce(searchValue, 100);
const onSearchChange = useCallback(({ target }: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(target.value);
}, []);
const onDestroy = useCallback(() => {
setSearchValue('');
onClose();
}, [onClose]);
const onCodeChange = useCallback(
(value: string) => {
onChange?.(value);
onDestroy();
},
[onChange, onDestroy]
);
const updatePosition = useCallback(() => {
const parent = inputRef?.parentElement;
const offset = 8;
if (!isMobile && parent) {
const { top, left, height, width } = parent.getBoundingClientRect();
setPosition({ top: top + height + offset, left, width });
return;
}
setPosition({});
}, [inputRef?.parentElement, isMobile]);
useLayoutEffect(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition);
};
}, [updatePosition]);
const filteredCountryList = useMemo(
() =>
countryList.filter(({ countryCallingCode }) => {
const searchValue = debouncedSearchValue.startsWith('+')
? debouncedSearchValue.slice(1)
: debouncedSearchValue;
return countryCallingCode.startsWith(searchValue);
}),
[countryList, debouncedSearchValue]
);
return (
<ReactModal
id="country-code-dropdown"
isOpen={isOpen}
overlayClassName={styles.dropdownOverlay}
className={styles.dropdownModal}
style={{
content: {
...position,
},
}}
closeTimeoutMS={200}
onRequestClose={(event) => {
event.stopPropagation();
onDestroy();
}}
>
<div
className={styles.dropdownContent}
role="button"
tabIndex={0}
onClick={(event) => {
// Prevent parent node trigger onClick show modal event
event.stopPropagation();
}}
onKeyDown={(event) => {
// Prevent parent node trigger onClick show modal event
event.stopPropagation();
}}
>
{isMobile && (
<NavBar type="back" title={t('input.search_region_code')} onClose={onDestroy} />
)}
<InputField
autoFocus
name="country-code-search"
type="number"
prefix={<SearchIcon />}
value={searchValue}
className={styles.searchInputField}
placeholder={t('input.search_region_code')}
onChange={onSearchChange}
/>
<ul className={styles.countryList}>
{filteredCountryList.map(({ countryCallingCode, countryCode: countryKeyCode }) => {
const isActive = countryCallingCode === countryCode;
return (
<li
key={countryKeyCode}
tabIndex={0}
className={conditional(isActive && styles.active)}
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role="button"
onKeyDown={onKeyDownHandler({
Enter: () => {
onCodeChange(countryCallingCode);
},
})}
onClick={() => {
onCodeChange(countryCallingCode);
}}
>
{isActive && <CheckMark />}
{`+${countryCallingCode}`}
</li>
);
})}
</ul>
</div>
</ReactModal>
);
};
export default CountryCodeDropdown;

View file

@ -1,40 +1,62 @@
import type { Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import type { ChangeEventHandler, ForwardedRef } from 'react';
import { useMemo, forwardRef } from 'react';
import type { ForwardedRef } from 'react';
import { useState, useMemo, forwardRef } from 'react';
import DownArrowIcon from '@/assets/icons/arrow-down.svg';
import { onKeyDownHandler } from '@/utils/a11y';
import { getCountryList, getDefaultCountryCallingCode } from '@/utils/country-code';
import CountryCodeDropdown from './CountryCodeDropdown';
import * as styles from './index.module.scss';
type Props = {
className?: string;
value?: string;
onChange?: ChangeEventHandler<HTMLSelectElement>;
inputRef?: Nullable<HTMLInputElement>;
onChange?: (value: string) => void;
};
const CountryCodeSelector = (
{ className, value, onChange }: Props,
{ className, value, inputRef, onChange }: Props,
ref: ForwardedRef<HTMLDivElement>
) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const countryList = useMemo(getCountryList, []);
const defaultCountCode = useMemo(getDefaultCountryCallingCode, []);
const showDropDown = () => {
setIsDropdownOpen(true);
};
const hideDropDown = () => {
setIsDropdownOpen(false);
};
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const countryCode = value || defaultCountCode;
return (
<div ref={ref} className={classNames(styles.countryCodeSelector, className)}>
<div
ref={ref}
className={classNames(styles.countryCodeSelector, className)}
role="button"
tabIndex={0}
onClick={showDropDown}
onKeyDown={onKeyDownHandler({
Enter: showDropDown,
})}
>
<span>{`+${countryCode}`}</span>
<DownArrowIcon />
<select name="countryCode" autoComplete="country-code" onChange={onChange}>
{countryList.map(({ countryCallingCode, countryCode }) => (
<option key={countryCode} value={countryCallingCode}>
{`+${countryCallingCode}`}
</option>
))}
</select>
<CountryCodeDropdown
inputRef={inputRef}
isOpen={isDropdownOpen}
countryCode={countryCode}
countryList={countryList}
onClose={hideDropDown}
onChange={onChange}
/>
</div>
);
};

View file

@ -1,7 +1,7 @@
import { SignInIdentifier } from '@logto/schemas';
import { Globals } from '@react-spring/web';
import { assert } from '@silverhand/essentials';
import { fireEvent, render } from '@testing-library/react';
import { act, fireEvent, render } from '@testing-library/react';
import { getBoundingClientRectMock } from '@/__mocks__/logto';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
@ -65,21 +65,25 @@ describe('SmartInputField Component', () => {
);
test('phone', async () => {
const { container, queryAllByText, queryByTestId } = renderInputField({
const { container, getByText, queryByTestId } = renderInputField({
enabledTypes: [SignInIdentifier.Phone],
});
const countryCode = queryAllByText(`+${defaultCountryCallingCode}`);
expect(countryCode).toHaveLength(2);
const countryCode = getByText(`+${defaultCountryCallingCode}`);
expect(countryCode).not.toBeNull();
expect(queryByTestId('prefix')?.style.width).toBe('100px');
const selector = container.querySelector('select');
assert(selector, new Error('selector should not be null'));
act(() => {
fireEvent.click(countryCode);
});
const newCountryCode = '86';
fireEvent.change(selector, { target: { value: newCountryCode } });
// Expect country code modal shown
const newCodeButton = getByText(`+${newCountryCode}`);
fireEvent.click(newCodeButton);
expect(onChange).toBeCalledWith({
type: SignInIdentifier.Phone,
value: '',

View file

@ -75,8 +75,9 @@ const SmartInputField = (
<AnimatedPrefix isVisible={isPrefixVisible}>
<CountryCodeSelector
value={countryCode}
onChange={(event) => {
onCountryCodeChange(event);
inputRef={innerRef.current}
onChange={(value) => {
onCountryCodeChange(value);
innerRef.current?.focus();
}}
/>

View file

@ -96,8 +96,8 @@ const useSmartInputField = ({ _defaultType, defaultValue, enabledTypes }: Props)
[defaultType, currentType, enabledTypeSet]
);
const onCountryCodeChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
({ target: { value } }) => {
const onCountryCodeChange = useCallback(
(value: string) => {
if (currentType === SignInIdentifier.Phone) {
const code = value.replace(/\D/g, '');
setCountryCode(code);

View file

@ -1,3 +1,4 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@ -10,20 +11,30 @@ import * as styles from './index.module.scss';
type Props = {
title?: string;
type?: 'back' | 'close';
onClose?: () => void;
};
const NavBar = ({ title, type = 'back' }: Props) => {
const NavBar = ({ title, type = 'back', onClose }: Props) => {
const navigate = useNavigate();
const { t } = useTranslation();
const isClosable = type === 'close';
const clickHandler = () => {
const clickHandler = useCallback(() => {
if (onClose) {
onClose();
return;
}
if (isClosable) {
window.close();
return;
}
navigate(-1);
};
}, [isClosable, navigate, onClose]);
return (
<div className={styles.navBar}>

View file

@ -5,14 +5,13 @@ import TextLink from '@/components/TextLink';
import * as styles from './index.module.scss';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
inline?: boolean;
termsOfUseUrl?: string;
privacyPolicyUrl?: string;
};
const TermsLinks = ({ className, inline, termsOfUseUrl, privacyPolicyUrl }: Props) => {
const TermsLinks = ({ inline, termsOfUseUrl, privacyPolicyUrl }: Props) => {
const { t } = useTranslation();
return (

View file

@ -1,10 +1,14 @@
import classNames from 'classnames';
import { useMemo } from 'react';
import type { ReactNode, AnchorHTMLAttributes } from 'react';
import type { TFuncKey } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import type { LinkProps } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { useIframeModal } from '@/Providers/IframeModalProvider';
import usePlatform from '@/hooks/use-platform';
import * as styles from './index.module.scss';
export type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
@ -17,6 +21,34 @@ export type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
const TextLink = ({ className, children, text, icon, type = 'primary', to, ...rest }: Props) => {
const { t } = useTranslation();
const { isMobile } = usePlatform();
const { setModalState } = useIframeModal();
// By default the behavior of opening a new window is not supported in WkWebView, or in android webview.
// Hijack the hyperlink props and open the link in an iframe modal instead.
const hyperLinkProps = useMemo(() => {
const { href, target, onClick, ...others } = rest;
// Keep the original behavior if the link is not external.
if (!href || target !== '_blank') {
return rest;
}
return {
href,
target,
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => {
if (isMobile) {
const title = text && t(text);
event.preventDefault();
setModalState({ href, title: typeof title === 'string' ? title : undefined });
}
onClick?.(event);
},
...others,
};
}, [isMobile, rest, setModalState, t, text]);
if (to) {
return (
@ -28,7 +60,11 @@ const TextLink = ({ className, children, text, icon, type = 'primary', to, ...re
}
return (
<a className={classNames(styles.link, styles[type], className)} {...rest} rel="noreferrer">
<a
className={classNames(styles.link, styles[type], className)}
{...hyperLinkProps}
rel="noopener"
>
{icon}
{children ?? (text ? t(text) : '')}
</a>

View file

@ -0,0 +1,44 @@
/**
* Original Reference: https://github.com/juliencrn/usehooks-ts/blob/master/src/useDebounce/useDebounce.ts
*
* The MIT License (MIT)
*
* Copyright (c) 2020 Julien CARON
*Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { useEffect, useState } from 'react';
function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay ?? 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;

View file

@ -81,7 +81,7 @@ describe('ForgotPassword', () => {
test.each(stateCases)('render the forgot password page with state %o', async (state) => {
mockUseLocation.mockImplementation(() => ({ state }));
const { queryByText, queryAllByText, container, queryByTestId } = renderPage(settings);
const { queryByText, container, queryByTestId } = renderPage(settings);
const inputField = container.querySelector('input[name="identifier"]');
const countryCodeSelectorPrefix = queryByTestId('prefix');
@ -95,7 +95,7 @@ describe('ForgotPassword', () => {
if (state.identifier === SignInIdentifier.Phone && settings.phone) {
expect(inputField.getAttribute('value')).toBe(phone);
expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
expect(queryAllByText(`+${countryCode}`)).toHaveLength(2);
expect(queryByText(`+${countryCode}`)).not.toBeNull();
} else if (state.identifier === SignInIdentifier.Phone) {
// Phone Number not enabled
expect(inputField.getAttribute('value')).toBe('');

11
pnpm-lock.yaml generated
View file

@ -925,6 +925,7 @@ importers:
react-router-dom: ^6.2.2
react-string-replace: ^1.0.0
react-timer-hook: ^3.0.5
react-top-loading-bar: ^2.3.1
stylelint: ^15.0.0
superstruct: ^0.16.0
ts-jest: ^29.0.5
@ -989,6 +990,7 @@ importers:
react-router-dom: 6.2.2_biqbaboplfbrettd7655fr4n2y
react-string-replace: 1.0.0
react-timer-hook: 3.0.5_biqbaboplfbrettd7655fr4n2y
react-top-loading-bar: 2.3.1_react@18.2.0
stylelint: 15.0.0
superstruct: 0.16.0
ts-jest: 29.0.5_cdjgginuefokmzmklysahvrmme
@ -12389,6 +12391,15 @@ packages:
react-dom: 18.2.0_react@18.2.0
dev: true
/react-top-loading-bar/2.3.1_react@18.2.0:
resolution: {integrity: sha512-rQk2Nm+TOBrM1C4E3e6KwT65iXyRSgBHjCkr2FNja1S51WaPulRA5nKj/xazuQ3x89wDDdGsrqkqy0RBIfd0xg==}
engines: {node: '>=10'}
peerDependencies:
react: ^16 || ^17 || ^18 || ^18.0.0
dependencies:
react: 18.2.0
dev: true
/react-transition-group/2.9.0_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==}
peerDependencies: