mirror of
https://github.com/logto-io/logto.git
synced 2025-03-17 22:31:28 -05:00
feat(ui): add smart input field (#3038)
This commit is contained in:
parent
f804e06252
commit
8bf28df796
15 changed files with 776 additions and 28 deletions
|
@ -27,6 +27,7 @@
|
|||
"@parcel/transformer-sass": "2.8.3",
|
||||
"@parcel/transformer-svg-react": "2.8.3",
|
||||
"@peculiar/webcrypto": "^1.3.3",
|
||||
"@react-spring/web": "^9.6.1",
|
||||
"@silverhand/eslint-config": "1.3.0",
|
||||
"@silverhand/eslint-config-react": "1.3.0",
|
||||
"@silverhand/essentials": "2.1.0",
|
||||
|
|
|
@ -9,26 +9,23 @@
|
|||
transition-property: outline, border;
|
||||
transition-timing-function: ease-in-out;
|
||||
transition-duration: 0.2s;
|
||||
align-items: stretch;
|
||||
|
||||
// fix in safari input field line-height issue
|
||||
height: 44px;
|
||||
|
||||
input {
|
||||
transition: width 0.3s ease-in;
|
||||
padding: 0 _.unit(4);
|
||||
flex: 1;
|
||||
background: none;
|
||||
caret-color: var(--color-brand-default);
|
||||
font: var(--font-body-1);
|
||||
color: var(--color-type-primary);
|
||||
align-self: stretch;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-type-secondary);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-right: _.unit(10);
|
||||
}
|
||||
}
|
||||
|
||||
.suffix {
|
||||
|
@ -39,22 +36,15 @@
|
|||
width: _.unit(8);
|
||||
height: _.unit(8);
|
||||
display: none;
|
||||
|
||||
&.visible {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&:focus-within {
|
||||
border: _.border(var(--color-brand-default));
|
||||
|
||||
input {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.focusVisible {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
|
@ -64,6 +54,23 @@
|
|||
caret-color: var(--color-danger-default);
|
||||
}
|
||||
}
|
||||
|
||||
&.isPrefixVisible {
|
||||
input {
|
||||
padding-left: _.unit(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.isSuffixVisible,
|
||||
&.isSuffixFocusVisible:focus-within {
|
||||
input {
|
||||
padding-right: _.unit(10);
|
||||
}
|
||||
|
||||
.suffix {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
|
@ -75,6 +82,7 @@
|
|||
.inputField {
|
||||
outline: 3px solid transparent;
|
||||
|
||||
/* stylelint-disable-next-line no-descending-specificity */
|
||||
input {
|
||||
font: var(--font-body-2);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import InputField from './InputField';
|
||||
import InputField from '.';
|
||||
|
||||
describe('InputField Component', () => {
|
||||
const text = 'foo';
|
|
@ -5,32 +5,48 @@ import { forwardRef, cloneElement } from 'react';
|
|||
import type { ErrorType } from '@/components/ErrorMessage';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
|
||||
import * as styles from './InputField.module.scss';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = HTMLProps<HTMLInputElement> & {
|
||||
export type Props = Omit<HTMLProps<HTMLInputElement>, 'prefix'> & {
|
||||
className?: string;
|
||||
error?: ErrorType;
|
||||
isDanger?: boolean;
|
||||
prefix?: ReactElement;
|
||||
isPrefixVisible?: boolean;
|
||||
suffix?: ReactElement;
|
||||
isSuffixFocusVisible?: boolean;
|
||||
isSuffixVisible?: boolean;
|
||||
isSuffixFocusVisible?: boolean;
|
||||
};
|
||||
|
||||
const InputField = (
|
||||
{ className, error, isDanger, suffix, isSuffixFocusVisible, isSuffixVisible, ...props }: Props,
|
||||
{
|
||||
className,
|
||||
error,
|
||||
isDanger,
|
||||
prefix,
|
||||
suffix,
|
||||
isPrefixVisible,
|
||||
isSuffixFocusVisible,
|
||||
isSuffixVisible,
|
||||
...props
|
||||
}: Props,
|
||||
reference: ForwardedRef<HTMLInputElement>
|
||||
) => (
|
||||
<div className={className}>
|
||||
<div className={classNames(styles.inputField, isDanger && styles.danger)}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.inputField,
|
||||
isDanger && styles.danger,
|
||||
isPrefixVisible && styles.isPrefixVisible,
|
||||
isSuffixFocusVisible && styles.isSuffixFocusVisible,
|
||||
isSuffixVisible && styles.isSuffixVisible
|
||||
)}
|
||||
>
|
||||
{prefix}
|
||||
<input {...props} ref={reference} />
|
||||
{suffix &&
|
||||
cloneElement(suffix, {
|
||||
className: classNames([
|
||||
suffix.props.className,
|
||||
styles.suffix,
|
||||
isSuffixFocusVisible && styles.focusVisible,
|
||||
isSuffixVisible && styles.visible,
|
||||
]),
|
||||
className: classNames([suffix.props.className, styles.suffix]),
|
||||
})}
|
||||
</div>
|
||||
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
|
|
@ -1,6 +1,6 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import PasswordInputField from './PasswordInputField';
|
||||
import PasswordInputField from '.';
|
||||
|
||||
describe('Input Field UI Component', () => {
|
||||
const text = 'foo';
|
|
@ -8,8 +8,8 @@ import IconButton from '@/components/Button/IconButton';
|
|||
import useToggle from '@/hooks/use-toggle';
|
||||
import useUpdateEffect from '@/hooks/use-update-effect';
|
||||
|
||||
import type { Props as InputFieldProps } from './InputField';
|
||||
import InputField from './InputField';
|
||||
import InputField from '../InputField';
|
||||
import type { Props as InputFieldProps } from '../InputField';
|
||||
|
||||
type Props = Omit<InputFieldProps, 'type' | 'suffix' | 'isSuffixFocusVisible'>;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.prefix {
|
||||
overflow: hidden;
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { useSpring, animated } from '@react-spring/web';
|
||||
import { useState } from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
const AnimatedPrefix = ({ children, isVisible }: Props) => {
|
||||
const [show, setShow] = useState(isVisible);
|
||||
|
||||
const [animation] = useSpring(
|
||||
() => ({
|
||||
from: { maxWidth: isVisible ? 0 : 100 },
|
||||
to: { maxWidth: isVisible ? 100 : 0 },
|
||||
config: { duration: 200 },
|
||||
onStart: () => {
|
||||
if (isVisible) {
|
||||
setShow(true);
|
||||
}
|
||||
},
|
||||
onResolve: () => {
|
||||
if (!isVisible) {
|
||||
setShow(false);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[isVisible]
|
||||
);
|
||||
|
||||
return (
|
||||
<animated.div className={styles.prefix} style={animation}>
|
||||
{show && children}
|
||||
</animated.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedPrefix;
|
|
@ -0,0 +1,53 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.countryCodeSelector {
|
||||
font: var(--font-label-1);
|
||||
color: var(--color-type-primary);
|
||||
border: none;
|
||||
background: none;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding-right: _.unit(1);
|
||||
padding-left: _.unit(4);
|
||||
@include _.flex-row;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
> select {
|
||||
appearance: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 0;
|
||||
|
||||
option {
|
||||
font: var(--font-label-1);
|
||||
}
|
||||
}
|
||||
|
||||
> svg {
|
||||
color: var(--color-type-secondary);
|
||||
margin-left: _.unit(1);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.countryCodeSelector {
|
||||
font: var(--font-body-2);
|
||||
|
||||
> select option {
|
||||
font: var(--font-body-2);
|
||||
}
|
||||
|
||||
> svg {
|
||||
margin-left: _.unit(2);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import classNames from 'classnames';
|
||||
import type { ChangeEventHandler, ForwardedRef } from 'react';
|
||||
import { useMemo, forwardRef } from 'react';
|
||||
|
||||
import DownArrowIcon from '@/assets/icons/arrow-down.svg';
|
||||
import { getCountryList, getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
value?: string;
|
||||
onChange?: ChangeEventHandler<HTMLSelectElement>;
|
||||
};
|
||||
|
||||
const CountryCodeSelector = (
|
||||
{ className, value, onChange }: Props,
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const countryList = useMemo(getCountryList, []);
|
||||
const defaultCountCode = useMemo(getDefaultCountryCallingCode, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const countryCode = value || defaultCountCode;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classNames(styles.countryCodeSelector, className)}>
|
||||
<span>{`+${countryCode}`}</span>
|
||||
<DownArrowIcon />
|
||||
|
||||
<select autoComplete="region" onChange={onChange}>
|
||||
{countryList.map(({ countryCallingCode, countryCode }) => (
|
||||
<option key={countryCode} value={countryCallingCode}>
|
||||
{`+${countryCallingCode}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(CountryCodeSelector);
|
|
@ -0,0 +1,333 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import type { EnabledIdentifierTypes, IdentifierInputType } from '.';
|
||||
import SmartInputField from '.';
|
||||
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
}));
|
||||
|
||||
describe('SmartInputField Component', () => {
|
||||
const onChange = jest.fn();
|
||||
const onTypeChange = jest.fn();
|
||||
const defaultCountryCallingCode = getDefaultCountryCallingCode();
|
||||
|
||||
const renderInputField = (props: {
|
||||
currentType: IdentifierInputType;
|
||||
enabledTypes?: EnabledIdentifierTypes;
|
||||
}) => render(<SmartInputField {...props} onTypeChange={onTypeChange} onChange={onChange} />);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('standard input field', () => {
|
||||
it.each([SignInIdentifier.Username, SignInIdentifier.Email])(
|
||||
`should render %s input field`,
|
||||
(currentType) => {
|
||||
const { container } = renderInputField({ currentType });
|
||||
|
||||
// Country code should not be rendered
|
||||
expect(container.querySelector('select')).toBeNull();
|
||||
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: 'foo' } });
|
||||
expect(onChange).toBeCalledWith('foo');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
|
||||
fireEvent.change(input, { target: { value: 'foo@' } });
|
||||
expect(onChange).toBeCalledWith('foo@');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
|
||||
fireEvent.change(input, { target: { value: '12315' } });
|
||||
expect(onChange).toBeCalledWith('12315');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('phone', async () => {
|
||||
const { container, queryAllByText } = renderInputField({
|
||||
currentType: SignInIdentifier.Phone,
|
||||
});
|
||||
|
||||
const countryCode = queryAllByText(`+${defaultCountryCallingCode}`);
|
||||
expect(countryCode).toHaveLength(2);
|
||||
|
||||
const selector = container.querySelector('select');
|
||||
expect(selector).not.toBeNull();
|
||||
|
||||
const newCountryCode = '86';
|
||||
|
||||
if (selector) {
|
||||
fireEvent.change(selector, { target: { value: newCountryCode } });
|
||||
expect(onChange).toBeCalledWith(newCountryCode);
|
||||
}
|
||||
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12315' } });
|
||||
expect(onChange).toBeCalledWith(`${newCountryCode}12315`);
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('username with email', () => {
|
||||
const config = {
|
||||
currentType: SignInIdentifier.Username,
|
||||
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Username],
|
||||
};
|
||||
|
||||
it('should not update inputType if no @ char present', () => {
|
||||
const { container } = renderInputField(config);
|
||||
|
||||
// Country code should not be rendered
|
||||
expect(container.querySelector('select')).toBeNull();
|
||||
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: 'foo' } });
|
||||
expect(onChange).toBeCalledWith('foo');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not update inputType to phone as phone is not enabled', () => {
|
||||
const { container } = renderInputField(config);
|
||||
|
||||
// Country code should not be rendered
|
||||
expect(container.querySelector('select')).toBeNull();
|
||||
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12315' } });
|
||||
expect(onChange).toBeCalledWith('12315');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should update inputType to email with @ char', () => {
|
||||
const { container } = renderInputField(config);
|
||||
|
||||
// Country code should not be rendered
|
||||
expect(container.querySelector('select')).toBeNull();
|
||||
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: 'foo@' } });
|
||||
expect(onChange).toBeCalledWith('foo@');
|
||||
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Email);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('username with phone', () => {
|
||||
const config = {
|
||||
currentType: SignInIdentifier.Username,
|
||||
enabledTypes: [SignInIdentifier.Username, SignInIdentifier.Phone],
|
||||
};
|
||||
|
||||
it('should not update inputType if non digit chars present', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12345@' } });
|
||||
expect(onChange).toBeCalledWith('12345@');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not update inputType if less than 3 digits', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12' } });
|
||||
expect(onChange).toBeCalledWith('12');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should update inputType to phone with more than 3 digits', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12315' } });
|
||||
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}12315`);
|
||||
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Phone);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('email with phone', () => {
|
||||
const config = {
|
||||
currentType: SignInIdentifier.Email,
|
||||
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Phone],
|
||||
};
|
||||
|
||||
it('should not update inputType non digit char present', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12315@' } });
|
||||
expect(onChange).toBeCalledWith('12315@');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not update inputType if less than 3 digits', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12' } });
|
||||
expect(onChange).toBeCalledWith('12');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should update inputType to phone with more than 3 digits', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12315' } });
|
||||
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}12315`);
|
||||
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Phone);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not update inputType if @ present', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12315@' } });
|
||||
expect(onChange).toBeCalledWith('12315@');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('email with username', () => {
|
||||
const config = {
|
||||
currentType: SignInIdentifier.Email,
|
||||
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Username],
|
||||
};
|
||||
|
||||
it('should not update inputType if @ present', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: 'foo@' } });
|
||||
expect(onChange).toBeCalledWith('foo@');
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should update inputType to username with pure digits as phone is not enabled', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12315' } });
|
||||
expect(onChange).toBeCalledWith('12315');
|
||||
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Username);
|
||||
}
|
||||
});
|
||||
|
||||
it('should update inputType to username with no @ present', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: 'foo' } });
|
||||
expect(onChange).toBeCalledWith('foo');
|
||||
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Username);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone with username', () => {
|
||||
const config = {
|
||||
currentType: SignInIdentifier.Phone,
|
||||
enabledTypes: [SignInIdentifier.Phone, SignInIdentifier.Username],
|
||||
};
|
||||
|
||||
it('should not update inputType if all chars are digits', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '123' } });
|
||||
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}123`);
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should update inputType if non digit char found', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '123@' } });
|
||||
expect(onChange).toBeCalledWith('123@');
|
||||
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Username);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone with email', () => {
|
||||
const config = {
|
||||
currentType: SignInIdentifier.Phone,
|
||||
enabledTypes: [SignInIdentifier.Phone, SignInIdentifier.Email],
|
||||
};
|
||||
|
||||
it('should not update inputType if all chars are digits', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '123' } });
|
||||
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}123`);
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not update inputType if no all chars are digits and no @', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '123a' } });
|
||||
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}123a`);
|
||||
expect(onTypeChange).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should update inputType if @ char found', () => {
|
||||
const { container } = renderInputField(config);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '12315@' } });
|
||||
expect(onChange).toBeCalledWith('12315@');
|
||||
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Email);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,76 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import type { ForwardedRef, HTMLProps } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import ClearIcon from '@/assets/icons/clear-icon.svg';
|
||||
import IconButton from '@/components/Button/IconButton';
|
||||
import type { ErrorType } from '@/components/ErrorMessage';
|
||||
|
||||
import InputField from '../InputField';
|
||||
import AnimatedPrefix from './AnimatedPrefix';
|
||||
import CountryCodeSelector from './CountryCodeSelector';
|
||||
import type { EnabledIdentifierTypes, IdentifierInputType } from './use-smart-input-field';
|
||||
import useSmartInputField from './use-smart-input-field';
|
||||
|
||||
export type { IdentifierInputType, EnabledIdentifierTypes } from './use-smart-input-field';
|
||||
|
||||
type Props = Omit<HTMLProps<HTMLInputElement>, 'onChange' | 'prefix'> & {
|
||||
className?: string;
|
||||
error?: ErrorType;
|
||||
isDanger?: boolean;
|
||||
|
||||
enabledTypes?: EnabledIdentifierTypes;
|
||||
currentType?: IdentifierInputType;
|
||||
onTypeChange?: (type: IdentifierInputType) => void;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
const SmartInputField = (
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
currentType = SignInIdentifier.Username,
|
||||
enabledTypes = [currentType],
|
||||
onTypeChange,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) => {
|
||||
const { countryCode, onCountryCodeChange, inputValue, onInputValueChange, onInputValueClear } =
|
||||
useSmartInputField({
|
||||
onChange,
|
||||
enabledTypes,
|
||||
currentType,
|
||||
onTypeChange,
|
||||
});
|
||||
|
||||
const isPhoneEnabled = enabledTypes.includes(SignInIdentifier.Phone);
|
||||
|
||||
return (
|
||||
<InputField
|
||||
{...rest}
|
||||
ref={ref}
|
||||
isSuffixFocusVisible
|
||||
isPrefixVisible={isPhoneEnabled && currentType === SignInIdentifier.Phone}
|
||||
type={type}
|
||||
value={inputValue}
|
||||
prefix={conditional(
|
||||
isPhoneEnabled && (
|
||||
<AnimatedPrefix isVisible={currentType === SignInIdentifier.Phone}>
|
||||
<CountryCodeSelector value={countryCode} onChange={onCountryCodeChange} />
|
||||
</AnimatedPrefix>
|
||||
)
|
||||
)}
|
||||
suffix={
|
||||
<IconButton onClick={onInputValueClear}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
}
|
||||
onChange={onInputValueChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(SmartInputField);
|
|
@ -0,0 +1,115 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
export type IdentifierInputType =
|
||||
| SignInIdentifier.Email
|
||||
| SignInIdentifier.Phone
|
||||
| SignInIdentifier.Username;
|
||||
|
||||
export type EnabledIdentifierTypes = IdentifierInputType[];
|
||||
|
||||
const digitsRegex = /^\d*$/;
|
||||
|
||||
type Props = {
|
||||
onChange?: (value: string) => void;
|
||||
enabledTypes: EnabledIdentifierTypes;
|
||||
currentType: IdentifierInputType;
|
||||
onTypeChange?: (type: IdentifierInputType) => void;
|
||||
};
|
||||
|
||||
const useSmartInputField = ({ onChange, currentType, enabledTypes, onTypeChange }: Props) => {
|
||||
const [countryCode, setCountryCode] = useState<string>(getDefaultCountryCallingCode());
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const enabledTypeSet = useMemo(() => new Set(enabledTypes), [enabledTypes]);
|
||||
|
||||
assert(enabledTypeSet.has(currentType), new Error('Invalid input type'));
|
||||
|
||||
const detectInputType = useCallback(
|
||||
(value: string) => {
|
||||
if (!value || enabledTypeSet.size === 1) {
|
||||
return currentType;
|
||||
}
|
||||
|
||||
const hasAtSymbol = value.includes('@');
|
||||
const isAllDigits = digitsRegex.test(value);
|
||||
|
||||
const isEmailDetected = enabledTypeSet.has(SignInIdentifier.Email) && hasAtSymbol;
|
||||
|
||||
const isPhoneDetected =
|
||||
enabledTypeSet.has(SignInIdentifier.Phone) && value.length > 3 && isAllDigits;
|
||||
|
||||
if (isPhoneDetected) {
|
||||
return SignInIdentifier.Phone;
|
||||
}
|
||||
|
||||
if (isEmailDetected) {
|
||||
return SignInIdentifier.Email;
|
||||
}
|
||||
|
||||
if (
|
||||
currentType === SignInIdentifier.Email &&
|
||||
enabledTypeSet.has(SignInIdentifier.Username) &&
|
||||
!hasAtSymbol
|
||||
) {
|
||||
return SignInIdentifier.Username;
|
||||
}
|
||||
|
||||
if (
|
||||
currentType === SignInIdentifier.Phone &&
|
||||
enabledTypeSet.has(SignInIdentifier.Username) &&
|
||||
!isAllDigits
|
||||
) {
|
||||
return SignInIdentifier.Username;
|
||||
}
|
||||
|
||||
return currentType;
|
||||
},
|
||||
[currentType, enabledTypeSet]
|
||||
);
|
||||
|
||||
const onCountryCodeChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
|
||||
({ target: { value } }) => {
|
||||
if (currentType === SignInIdentifier.Phone) {
|
||||
const code = value.replace(/\D/g, '');
|
||||
setCountryCode(code);
|
||||
onChange?.(`${code}${inputValue}`);
|
||||
}
|
||||
},
|
||||
[currentType, inputValue, onChange]
|
||||
);
|
||||
|
||||
const onInputValueChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||
({ target: { value } }) => {
|
||||
const trimValue = value.trim();
|
||||
setInputValue(trimValue);
|
||||
|
||||
const type = detectInputType(trimValue);
|
||||
|
||||
if (type !== currentType) {
|
||||
onTypeChange?.(type);
|
||||
}
|
||||
|
||||
onChange?.(type === SignInIdentifier.Phone ? `${countryCode}${trimValue}` : trimValue);
|
||||
},
|
||||
[countryCode, currentType, detectInputType, onChange, onTypeChange]
|
||||
);
|
||||
|
||||
const onInputValueClear = useCallback(() => {
|
||||
setInputValue('');
|
||||
onChange?.('');
|
||||
}, [onChange]);
|
||||
|
||||
return {
|
||||
countryCode,
|
||||
onCountryCodeChange,
|
||||
inputValue,
|
||||
onInputValueChange,
|
||||
onInputValueClear,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSmartInputField;
|
|
@ -1,2 +1,7 @@
|
|||
export { default as InputField } from './InputField';
|
||||
export { default as PasswordInputField } from './PasswordInputField';
|
||||
export {
|
||||
default as SmartInputField,
|
||||
type IdentifierInputType,
|
||||
type EnabledIdentifierTypes,
|
||||
} from './SmartInputField';
|
||||
|
|
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
|
@ -779,6 +779,7 @@ importers:
|
|||
'@parcel/transformer-sass': 2.8.3
|
||||
'@parcel/transformer-svg-react': 2.8.3
|
||||
'@peculiar/webcrypto': ^1.3.3
|
||||
'@react-spring/web': ^9.6.1
|
||||
'@silverhand/eslint-config': 1.3.0
|
||||
'@silverhand/eslint-config-react': 1.3.0
|
||||
'@silverhand/essentials': 2.1.0
|
||||
|
@ -835,6 +836,7 @@ importers:
|
|||
'@parcel/transformer-sass': 2.8.3_@parcel+core@2.8.3
|
||||
'@parcel/transformer-svg-react': 2.8.3_@parcel+core@2.8.3
|
||||
'@peculiar/webcrypto': 1.3.3
|
||||
'@react-spring/web': 9.6.1_biqbaboplfbrettd7655fr4n2y
|
||||
'@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4
|
||||
'@silverhand/eslint-config-react': 1.3.0_kvwdjz5hpaxioiksuaratzdsqy
|
||||
'@silverhand/essentials': 2.1.0
|
||||
|
@ -3340,6 +3342,60 @@ packages:
|
|||
resolution: {integrity: sha512-Yykovind6xzqAqd0t5umrdAGPlGLTE80cy80UkEnbt8Zv5zEYTFzJSNPQ81TY8BSpRreubu1oE54iHBv2UVnTQ==}
|
||||
dev: true
|
||||
|
||||
/@react-spring/animated/9.6.1_react@18.2.0:
|
||||
resolution: {integrity: sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
'@react-spring/shared': 9.6.1_react@18.2.0
|
||||
'@react-spring/types': 9.6.1
|
||||
react: 18.2.0
|
||||
dev: true
|
||||
|
||||
/@react-spring/core/9.6.1_react@18.2.0:
|
||||
resolution: {integrity: sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
'@react-spring/animated': 9.6.1_react@18.2.0
|
||||
'@react-spring/rafz': 9.6.1
|
||||
'@react-spring/shared': 9.6.1_react@18.2.0
|
||||
'@react-spring/types': 9.6.1
|
||||
react: 18.2.0
|
||||
dev: true
|
||||
|
||||
/@react-spring/rafz/9.6.1:
|
||||
resolution: {integrity: sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==}
|
||||
dev: true
|
||||
|
||||
/@react-spring/shared/9.6.1_react@18.2.0:
|
||||
resolution: {integrity: sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
'@react-spring/rafz': 9.6.1
|
||||
'@react-spring/types': 9.6.1
|
||||
react: 18.2.0
|
||||
dev: true
|
||||
|
||||
/@react-spring/types/9.6.1:
|
||||
resolution: {integrity: sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==}
|
||||
dev: true
|
||||
|
||||
/@react-spring/web/9.6.1_biqbaboplfbrettd7655fr4n2y:
|
||||
resolution: {integrity: sha512-X2zR6q2Z+FjsWfGAmAXlQaoUHbPmfuCaXpuM6TcwXPpLE1ZD4A1eys/wpXboFQmDkjnrlTmKvpVna1MjWpZ5Hw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
'@react-spring/animated': 9.6.1_react@18.2.0
|
||||
'@react-spring/core': 9.6.1_react@18.2.0
|
||||
'@react-spring/shared': 9.6.1_react@18.2.0
|
||||
'@react-spring/types': 9.6.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
dev: true
|
||||
|
||||
/@sideway/address/4.1.4:
|
||||
resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==}
|
||||
dependencies:
|
||||
|
|
Loading…
Add table
Reference in a new issue