0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(ui): optimize smart input field (#3046)

This commit is contained in:
simeng-li 2023-02-10 10:25:34 +08:00 committed by GitHub
parent 401465e01d
commit fa57680b55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 121 additions and 46 deletions

View file

@ -6,7 +6,6 @@ import PasswordHideIcon from '@/assets/icons/password-hide-icon.svg';
import PasswordShowIcon from '@/assets/icons/password-show-icon.svg';
import IconButton from '@/components/Button/IconButton';
import useToggle from '@/hooks/use-toggle';
import useUpdateEffect from '@/hooks/use-update-effect';
import InputField from '../InputField';
import type { Props as InputFieldProps } from '../InputField';
@ -19,21 +18,17 @@ const PasswordInputField = (props: Props, forwardRef: Ref<Nullable<HTMLInputElem
useImperativeHandle(forwardRef, () => innerRef.current);
// Refocus and move cursor to the end of the input field after password visibility is toggled
useUpdateEffect(() => {
if (innerRef.current) {
const { length } = innerRef.current.value;
innerRef.current.focus();
innerRef.current.setSelectionRange(length, length);
}
}, [showPassword]);
return (
<InputField
isSuffixFocusVisible
type={showPassword ? 'text' : 'password'}
suffix={
<IconButton onClick={toggleShowPassword}>
<IconButton
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={toggleShowPassword}
>
{showPassword ? <PasswordShowIcon /> : <PasswordHideIcon />}
</IconButton>
}

View file

@ -8,6 +8,7 @@ import SmartInputField from '.';
jest.mock('i18next', () => ({
language: 'en',
t: (key: string) => key,
}));
describe('SmartInputField Component', () => {

View file

@ -1,7 +1,8 @@
import { SignInIdentifier } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import type { ForwardedRef, HTMLProps } from 'react';
import { forwardRef } from 'react';
import type { HTMLProps, Ref } from 'react';
import { useImperativeHandle, useRef, forwardRef } from 'react';
import ClearIcon from '@/assets/icons/clear-icon.svg';
import IconButton from '@/components/Button/IconButton';
@ -12,6 +13,7 @@ import AnimatedPrefix from './AnimatedPrefix';
import CountryCodeSelector from './CountryCodeSelector';
import type { EnabledIdentifierTypes, IdentifierInputType } from './use-smart-input-field';
import useSmartInputField from './use-smart-input-field';
import { getInputHtmlProps } from './utils';
export type { IdentifierInputType, EnabledIdentifierTypes } from './use-smart-input-field';
@ -30,14 +32,16 @@ const SmartInputField = (
{
value,
onChange,
type = 'text',
currentType = SignInIdentifier.Username,
enabledTypes = [currentType],
onTypeChange,
...rest
}: Props,
ref: ForwardedRef<HTMLInputElement>
ref: Ref<Nullable<HTMLInputElement>>
) => {
const innerRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => innerRef.current);
const { countryCode, onCountryCodeChange, inputValue, onInputValueChange, onInputValueClear } =
useSmartInputField({
onChange,
@ -51,20 +55,31 @@ const SmartInputField = (
return (
<InputField
{...rest}
ref={ref}
isSuffixFocusVisible
ref={innerRef}
isSuffixFocusVisible={Boolean(inputValue)}
isPrefixVisible={isPhoneEnabled && currentType === SignInIdentifier.Phone}
type={type}
{...getInputHtmlProps(currentType, enabledTypes)}
value={inputValue}
prefix={conditional(
isPhoneEnabled && (
<AnimatedPrefix isVisible={currentType === SignInIdentifier.Phone}>
<CountryCodeSelector value={countryCode} onChange={onCountryCodeChange} />
<CountryCodeSelector
value={countryCode}
onChange={(event) => {
onCountryCodeChange(event);
innerRef.current?.focus();
}}
/>
</AnimatedPrefix>
)
)}
suffix={
<IconButton onClick={onInputValueClear}>
<IconButton
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={onInputValueClear}
>
<ClearIcon />
</IconButton>
}

View file

@ -0,0 +1,49 @@
import { SignInIdentifier } from '@logto/schemas';
import { getInputHtmlProps } from './utils';
jest.mock('i18next', () => ({
t: (key: string) => key,
}));
describe('Smart Input Field Util Methods', () => {
const enabledTypes = [SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone];
describe('getInputHtmlProps', () => {
it('Should return correct html props for phone', () => {
const props = getInputHtmlProps(SignInIdentifier.Phone, [SignInIdentifier.Phone]);
expect(props.type).toBe('tel');
expect(props.pattern).toBe('[0-9]*');
expect(props.inputMode).toBe('numeric');
expect(props.placeholder).toBe('input.phone_number');
});
it('Should return correct html props for email', () => {
const props = getInputHtmlProps(SignInIdentifier.Email, [SignInIdentifier.Email]);
expect(props.type).toBe('email');
expect(props.inputMode).toBe('email');
expect(props.placeholder).toBe('input.email');
});
it('Should return correct html props for username', () => {
const props = getInputHtmlProps(SignInIdentifier.Username, [SignInIdentifier.Username]);
expect(props.type).toBe('text');
expect(props.placeholder).toBe('input.username');
});
it('Should return correct html props for username email or phone', () => {
const props = getInputHtmlProps(SignInIdentifier.Username, enabledTypes);
expect(props.type).toBe('text');
expect(props.placeholder).toBe('input.username / input.email / input.phone_number');
});
it('Should return correct html props for email or phone', () => {
const props = getInputHtmlProps(SignInIdentifier.Email, [
SignInIdentifier.Email,
SignInIdentifier.Phone,
]);
expect(props.type).toBe('text');
expect(props.placeholder).toBe('input.email / input.phone_number');
});
});
});

View file

@ -0,0 +1,41 @@
import { SignInIdentifier } from '@logto/schemas';
import i18next from 'i18next';
import type { HTMLProps } from 'react';
import type { TFuncKey } from 'react-i18next';
import type { IdentifierInputType, EnabledIdentifierTypes } from './use-smart-input-field';
const identifierInputPlaceholderMap: { [K in IdentifierInputType]: TFuncKey } = {
[SignInIdentifier.Phone]: 'input.phone_number',
[SignInIdentifier.Email]: 'input.email',
[SignInIdentifier.Username]: 'input.username',
};
export const getInputHtmlProps = (
currentType: IdentifierInputType,
enabledTypes: EnabledIdentifierTypes
): Pick<HTMLProps<HTMLInputElement>, 'type' | 'pattern' | 'inputMode' | 'placeholder'> => {
if (currentType === SignInIdentifier.Phone && enabledTypes.length === 1) {
return {
type: 'tel',
pattern: '[0-9]*',
inputMode: 'numeric',
placeholder: i18next.t<'translation', TFuncKey>('input.phone_number'),
};
}
if (currentType === SignInIdentifier.Email && enabledTypes.length === 1) {
return {
type: 'email',
inputMode: 'email',
placeholder: i18next.t<'translation', TFuncKey>('input.email'),
};
}
return {
type: 'text',
placeholder: enabledTypes
.map((type) => i18next.t<'translation', TFuncKey>(identifierInputPlaceholderMap[type]))
.join(' / '),
};
};

View file

@ -1,26 +0,0 @@
import type { DependencyList, EffectCallback } from 'react';
import { useEffect, useRef } from 'react';
const useUpdateEffect = (effect: EffectCallback, dependencies: DependencyList | undefined = []) => {
const isMounted = useRef(false);
useEffect(() => {
return () => {
// eslint-disable-next-line @silverhand/fp/no-mutation
isMounted.current = false;
};
}, []);
useEffect(() => {
if (!isMounted.current) {
// eslint-disable-next-line @silverhand/fp/no-mutation
isMounted.current = true;
return;
}
return effect();
}, [effect, ...dependencies]);
};
export default useUpdateEffect;