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:
parent
401465e01d
commit
fa57680b55
6 changed files with 121 additions and 46 deletions
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import SmartInputField from '.';
|
|||
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
describe('SmartInputField Component', () => {
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(' / '),
|
||||
};
|
||||
};
|
|
@ -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;
|
Loading…
Reference in a new issue