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

refactor(ui): optimize smart input animation (#3176)

Co-authored-by: simeng-li <simeng@silverhand.io>
This commit is contained in:
Gao Sun 2023-02-23 17:48:09 +08:00 committed by GitHub
parent e8e623a2bf
commit 2ad285541f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 135 additions and 95 deletions

View file

@ -27,6 +27,7 @@
"@parcel/transformer-sass": "2.8.3", "@parcel/transformer-sass": "2.8.3",
"@parcel/transformer-svg-react": "2.8.3", "@parcel/transformer-svg-react": "2.8.3",
"@peculiar/webcrypto": "^1.3.3", "@peculiar/webcrypto": "^1.3.3",
"@react-spring/shared": "^9.6.1",
"@react-spring/web": "^9.6.1", "@react-spring/web": "^9.6.1",
"@silverhand/eslint-config": "2.0.1", "@silverhand/eslint-config": "2.0.1",
"@silverhand/eslint-config-react": "2.0.1", "@silverhand/eslint-config-react": "2.0.1",

View file

@ -18,7 +18,7 @@
transition: width 0.3s ease-in; transition: width 0.3s ease-in;
padding: 0 _.unit(4); padding: 0 _.unit(4);
flex: 1; flex: 1;
background: none; background: var(--color-bg-body);
caret-color: var(--color-brand-default); caret-color: var(--color-brand-default);
font: var(--font-body-1); font: var(--font-body-1);
color: var(--color-type-primary); color: var(--color-type-primary);
@ -55,12 +55,6 @@
} }
} }
&.isPrefixVisible {
input {
padding-left: _.unit(1);
}
}
&.isSuffixVisible, &.isSuffixVisible,
&.isSuffixFocusVisible:focus-within { &.isSuffixFocusVisible:focus-within {
input { input {
@ -85,6 +79,7 @@
/* stylelint-disable-next-line no-descending-specificity */ /* stylelint-disable-next-line no-descending-specificity */
input { input {
font: var(--font-body-2); font: var(--font-body-2);
background: var(--color-bg-float);
} }
&:focus-within { &:focus-within {

View file

@ -11,7 +11,6 @@ export type Props = Omit<HTMLProps<HTMLInputElement>, 'prefix'> & {
errorMessage?: string; errorMessage?: string;
isDanger?: boolean; isDanger?: boolean;
prefix?: ReactElement; prefix?: ReactElement;
isPrefixVisible?: boolean;
suffix?: ReactElement; suffix?: ReactElement;
isSuffixVisible?: boolean; isSuffixVisible?: boolean;
isSuffixFocusVisible?: boolean; isSuffixFocusVisible?: boolean;
@ -24,7 +23,6 @@ const InputField = (
isDanger, isDanger,
prefix, prefix,
suffix, suffix,
isPrefixVisible,
isSuffixFocusVisible, isSuffixFocusVisible,
isSuffixVisible, isSuffixVisible,
...props ...props
@ -36,7 +34,6 @@ const InputField = (
className={classNames( className={classNames(
styles.inputField, styles.inputField,
isDanger && styles.danger, isDanger && styles.danger,
isPrefixVisible && styles.isPrefixVisible,
isSuffixFocusVisible && styles.isSuffixFocusVisible, isSuffixFocusVisible && styles.isSuffixFocusVisible,
isSuffixVisible && styles.isSuffixVisible isSuffixVisible && styles.isSuffixVisible
)} )}

View file

@ -1,3 +1,7 @@
.prefix { .prefix {
overflow: hidden; overflow: hidden;
> div {
position: absolute;
}
} }

View file

@ -1,38 +1,50 @@
import { useSpring, animated } from '@react-spring/web'; import { onResize, useIsomorphicLayoutEffect } from '@react-spring/shared';
import { useState } from 'react'; import { useSpring, animated, config } from '@react-spring/web';
import type { Nullable } from '@silverhand/essentials';
import { cloneElement, useCallback, useRef } from 'react';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
type Props = { type Props = {
children: React.ReactNode; children: JSX.Element; // Limit to one element
isVisible: boolean; isVisible: boolean;
}; };
const AnimatedPrefix = ({ children, isVisible }: Props) => { const AnimatedPrefix = ({ children, isVisible }: Props) => {
const [show, setShow] = useState(isVisible); const elementRef = useRef<Nullable<HTMLElement>>(null);
const [animation] = useSpring( // Get target width for the children
() => ({ const getTargetWidth = useCallback(
from: { maxWidth: isVisible ? 0 : 100 }, () => (isVisible ? elementRef.current?.getBoundingClientRect().width : 0),
to: { maxWidth: isVisible ? 100 : 0 },
config: { duration: 200 },
onStart: () => {
if (isVisible) {
setShow(true);
}
},
onResolve: () => {
if (!isVisible) {
setShow(false);
}
},
}),
[isVisible] [isVisible]
); );
const [animation, api] = useSpring(
() => ({ width: getTargetWidth(), config: { ...config.default, clamp: true } }),
[getTargetWidth]
);
useIsomorphicLayoutEffect(() => {
const { current } = elementRef;
const cleanup =
current &&
onResize(
() => {
api.start({ width: getTargetWidth() });
},
{ container: current }
);
return () => {
// Stop animation before cleanup
api.stop();
cleanup?.();
};
}, [api, getTargetWidth]);
return ( return (
<animated.div className={styles.prefix} style={animation}> <animated.div className={styles.prefix} style={animation} data-testid="prefix">
{show && children} {cloneElement(children, { ref: elementRef })}
</animated.div> </animated.div>
); );
}; };

View file

@ -14,11 +14,14 @@
white-space: nowrap; white-space: nowrap;
> select { > select {
flex-shrink: 0;
appearance: none; appearance: none;
border: none; border: none;
outline: none; outline: none;
background: none; background: none;
position: absolute; position: absolute;
left: 0;
top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
font-size: 0; font-size: 0;
@ -29,6 +32,7 @@
} }
> svg { > svg {
flex-shrink: 0;
color: var(--color-type-secondary); color: var(--color-type-secondary);
margin-left: _.unit(1); margin-left: _.unit(1);
width: 16px; width: 16px;

View file

@ -1,4 +1,5 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { Globals } from '@react-spring/web';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import { fireEvent, render } from '@testing-library/react'; import { fireEvent, render } from '@testing-library/react';
@ -22,6 +23,12 @@ describe('SmartInputField Component', () => {
enabledTypes?: IdentifierInputType[]; enabledTypes?: IdentifierInputType[];
}) => render(<SmartInputField {...props} onChange={onChange} />); }) => render(<SmartInputField {...props} onChange={onChange} />);
beforeAll(() => {
Globals.assign({
skipAnimation: true,
});
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -29,11 +36,11 @@ describe('SmartInputField Component', () => {
describe('standard input field', () => { describe('standard input field', () => {
test.each([SignInIdentifier.Username, SignInIdentifier.Email])( test.each([SignInIdentifier.Username, SignInIdentifier.Email])(
`should render %s input field`, `should render %s input field`,
(currentType) => { async (currentType) => {
const { container } = renderInputField({ enabledTypes: [currentType] }); const { container, queryByTestId } = renderInputField({ enabledTypes: [currentType] });
// Country code should not be rendered // Country code select should have a 0 width
expect(container.querySelector('select')).toBeNull(); expect(queryByTestId('prefix')?.style.width).toBe('0px');
const input = container.querySelector('input'); const input = container.querySelector('input');
@ -51,35 +58,37 @@ describe('SmartInputField Component', () => {
); );
test('phone', async () => { test('phone', async () => {
const { container, queryAllByText } = renderInputField({ const { container, queryAllByText, queryByTestId } = renderInputField({
enabledTypes: [SignInIdentifier.Phone], enabledTypes: [SignInIdentifier.Phone],
}); });
const countryCode = queryAllByText(`+${defaultCountryCallingCode}`); const countryCode = queryAllByText(`+${defaultCountryCallingCode}`);
expect(countryCode).toHaveLength(2); expect(countryCode).toHaveLength(2);
// Country code select should have a >0 width.
// The React Spring acquires the child element's width ahead of elementRef is properly set.
// So the value returns null. Assert style is null to represent the width is >0.
expect(queryByTestId('prefix')?.getAttribute('style')).toBe(null);
const selector = container.querySelector('select'); const selector = container.querySelector('select');
expect(selector).not.toBeNull(); assert(selector, new Error('selector should not be null'));
const newCountryCode = '86'; const newCountryCode = '86';
if (selector) {
fireEvent.change(selector, { target: { value: newCountryCode } }); fireEvent.change(selector, { target: { value: newCountryCode } });
expect(onChange).toBeCalledWith({ expect(onChange).toBeCalledWith({
type: SignInIdentifier.Phone, type: SignInIdentifier.Phone,
value: '', value: '',
}); });
}
const input = container.querySelector('input'); const input = container.querySelector('input');
assert(input, new Error('input should not be null'));
if (input) {
fireEvent.change(input, { target: { value: '12315' } }); fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith({ expect(onChange).toBeCalledWith({
type: SignInIdentifier.Phone, type: SignInIdentifier.Phone,
value: `${newCountryCode}12315`, value: `${newCountryCode}12315`,
}); });
}
}); });
}); });
@ -88,11 +97,11 @@ describe('SmartInputField Component', () => {
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Username], enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Username],
}; };
test('should return username type if no @ char present', () => { test('should return username type if no @ char present', async () => {
const { container } = renderInputField(config); const { container, queryByTestId } = renderInputField(config);
// Country code should not be rendered // Country code select should have a 0 width
expect(container.querySelector('select')).toBeNull(); expect(queryByTestId('prefix')?.style.width).toBe('0px');
const input = container.querySelector('input'); const input = container.querySelector('input');
@ -102,11 +111,11 @@ describe('SmartInputField Component', () => {
} }
}); });
test('should return username type with all digits input', () => { test('should return username type with all digits input', async () => {
const { container } = renderInputField(config); const { container, queryByTestId } = renderInputField(config);
// Country code should not be rendered // Country code select should have a 0 width
expect(container.querySelector('select')).toBeNull(); expect(queryByTestId('prefix')?.style.width).toBe('0px');
const input = container.querySelector('input'); const input = container.querySelector('input');
@ -116,11 +125,11 @@ describe('SmartInputField Component', () => {
} }
}); });
test('should return email type with @ char', () => { test('should return email type with @ char', async () => {
const { container } = renderInputField(config); const { container, queryByTestId } = renderInputField(config);
// Country code should not be rendered // Country code select should have a 0 width
expect(container.querySelector('select')).toBeNull(); expect(queryByTestId('prefix')?.style.width).toBe('0px');
const input = container.querySelector('input'); const input = container.querySelector('input');

View file

@ -1,6 +1,6 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { animated, config, useSpring } from '@react-spring/web';
import type { Nullable } from '@silverhand/essentials'; import type { Nullable } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import type { HTMLProps, Ref } from 'react'; import type { HTMLProps, Ref } from 'react';
import { useEffect, useImperativeHandle, useRef, forwardRef } from 'react'; import { useEffect, useImperativeHandle, useRef, forwardRef } from 'react';
@ -28,6 +28,8 @@ type Props = Omit<HTMLProps<HTMLInputElement>, 'onChange' | 'prefix' | 'value'>
onChange?: (data: IdentifierInputValue) => void; onChange?: (data: IdentifierInputValue) => void;
}; };
const AnimatedInputField = animated(InputField);
const SmartInputField = ( const SmartInputField = (
{ defaultValue, defaultType, enabledTypes = [], onChange, ...rest }: Props, { defaultValue, defaultType, enabledTypes = [], onChange, ...rest }: Props,
ref: Ref<Nullable<HTMLInputElement>> ref: Ref<Nullable<HTMLInputElement>>
@ -48,29 +50,29 @@ const SmartInputField = (
enabledTypes, enabledTypes,
}); });
const isPhoneEnabled = enabledTypes.includes(SignInIdentifier.Phone); const isPrefixVisible = identifierType === SignInIdentifier.Phone;
const { paddingLeft } = useSpring({
paddingLeft: isPrefixVisible ? 4 : 16,
config: { ...config.default, clamp: true },
});
useEffect(() => { useEffect(() => {
onChange?.({ onChange?.({
type: identifierType, type: identifierType,
value: value: isPrefixVisible && inputValue ? `${countryCode}${inputValue}` : inputValue,
identifierType === SignInIdentifier.Phone && inputValue
? `${countryCode}${inputValue}`
: inputValue,
}); });
}, [countryCode, identifierType, inputValue, onChange]); }, [countryCode, identifierType, inputValue, isPrefixVisible, onChange]);
return ( return (
<InputField <AnimatedInputField
{...getInputHtmlProps(enabledTypes, identifierType)}
{...rest} {...rest}
{...getInputHtmlProps(enabledTypes, identifierType)}
ref={innerRef} ref={innerRef}
isSuffixFocusVisible={Boolean(inputValue)} isSuffixFocusVisible={Boolean(inputValue)}
isPrefixVisible={identifierType === SignInIdentifier.Phone} style={{ zIndex: 1, paddingLeft }} // Give <input /> z-index to override country selector
value={inputValue} value={inputValue}
prefix={conditional( prefix={
isPhoneEnabled && ( <AnimatedPrefix isVisible={isPrefixVisible}>
<AnimatedPrefix isVisible={identifierType === SignInIdentifier.Phone}>
<CountryCodeSelector <CountryCodeSelector
value={countryCode} value={countryCode}
onChange={(event) => { onChange={(event) => {
@ -79,8 +81,7 @@ const SmartInputField = (
}} }}
/> />
</AnimatedPrefix> </AnimatedPrefix>
) }
)}
suffix={ suffix={
<IconButton <IconButton
onMouseDown={(event) => { onMouseDown={(event) => {

View file

@ -1,4 +1,5 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { Globals } from '@react-spring/web';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import { MemoryRouter, useLocation } from 'react-router-dom'; import { MemoryRouter, useLocation } from 'react-router-dom';
@ -38,6 +39,12 @@ describe('ForgotPassword', () => {
</MemoryRouter> </MemoryRouter>
); );
beforeAll(() => {
Globals.assign({
skipAnimation: true,
});
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -69,9 +76,9 @@ describe('ForgotPassword', () => {
test.each(stateCases)('render the forgot password page with state %o', async (state) => { test.each(stateCases)('render the forgot password page with state %o', async (state) => {
mockUseLocation.mockImplementation(() => ({ state })); mockUseLocation.mockImplementation(() => ({ state }));
const { queryByText, queryAllByText, container } = renderPage(settings); const { queryByText, queryAllByText, container, queryByTestId } = renderPage(settings);
const inputField = container.querySelector('input[name="identifier"]'); const inputField = container.querySelector('input[name="identifier"]');
const countryCodeSelector = container.querySelector('select[name="countryCode"]'); const countryCodeSelectorPrefix = queryByTestId('prefix');
assert(inputField, new Error('input field not found')); assert(inputField, new Error('input field not found'));
expect(queryByText('description.reset_password')).not.toBeNull(); expect(queryByText('description.reset_password')).not.toBeNull();
@ -81,27 +88,35 @@ describe('ForgotPassword', () => {
if (state.identifier === SignInIdentifier.Phone && settings.phone) { if (state.identifier === SignInIdentifier.Phone && settings.phone) {
expect(inputField.getAttribute('value')).toBe(phone); expect(inputField.getAttribute('value')).toBe(phone);
expect(countryCodeSelector).not.toBeNull();
// Country code select should have a >0 width.
// The React Spring acquires the child element's width ahead of elementRef is properly set.
// So the value returns null. Assert style is null to represent the width is >0.
expect(countryCodeSelectorPrefix?.getAttribute('style')).toBeNull();
expect(queryAllByText(`+${countryCode}`)).toHaveLength(2); expect(queryAllByText(`+${countryCode}`)).toHaveLength(2);
} else if (state.identifier === SignInIdentifier.Phone) { } else if (state.identifier === SignInIdentifier.Phone) {
// Phone Number not enabled
expect(inputField.getAttribute('value')).toBe(''); expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelector).toBeNull(); expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
} }
if (state.identifier === SignInIdentifier.Email && settings.email) { if (state.identifier === SignInIdentifier.Email && settings.email) {
expect(inputField.getAttribute('value')).toBe(email); expect(inputField.getAttribute('value')).toBe(email);
expect(countryCodeSelector).toBeNull(); expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
} else if (state.identifier === SignInIdentifier.Email) { } else if (state.identifier === SignInIdentifier.Email) {
// Only PhoneNumber is enabled
expect(inputField.getAttribute('value')).toBe(''); expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelector).not.toBeNull(); expect(countryCodeSelectorPrefix?.getAttribute('style')).toBeNull();
} }
if (state.identifier === SignInIdentifier.Username && settings.email) { if (state.identifier === SignInIdentifier.Username && settings.email) {
expect(inputField.getAttribute('value')).toBe(''); expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelector).toBeNull(); expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
} else if (state.identifier === SignInIdentifier.Username) { } else if (state.identifier === SignInIdentifier.Username) {
// Only PhoneNumber is enabled
expect(inputField.getAttribute('value')).toBe(''); expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelector).not.toBeNull(); expect(countryCodeSelectorPrefix?.getAttribute('style')).toBeNull();
} }
}); });
}); });

View file

@ -848,6 +848,7 @@ importers:
'@parcel/transformer-sass': 2.8.3 '@parcel/transformer-sass': 2.8.3
'@parcel/transformer-svg-react': 2.8.3 '@parcel/transformer-svg-react': 2.8.3
'@peculiar/webcrypto': ^1.3.3 '@peculiar/webcrypto': ^1.3.3
'@react-spring/shared': ^9.6.1
'@react-spring/web': ^9.6.1 '@react-spring/web': ^9.6.1
'@silverhand/eslint-config': 2.0.1 '@silverhand/eslint-config': 2.0.1
'@silverhand/eslint-config-react': 2.0.1 '@silverhand/eslint-config-react': 2.0.1
@ -905,6 +906,7 @@ importers:
'@parcel/transformer-sass': 2.8.3_@parcel+core@2.8.3 '@parcel/transformer-sass': 2.8.3_@parcel+core@2.8.3
'@parcel/transformer-svg-react': 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 '@peculiar/webcrypto': 1.3.3
'@react-spring/shared': 9.6.1_react@18.2.0
'@react-spring/web': 9.6.1_biqbaboplfbrettd7655fr4n2y '@react-spring/web': 9.6.1_biqbaboplfbrettd7655fr4n2y
'@silverhand/eslint-config': 2.0.1_kjzxg5porcw5dx54sezsklj5cy '@silverhand/eslint-config': 2.0.1_kjzxg5porcw5dx54sezsklj5cy
'@silverhand/eslint-config-react': 2.0.1_kz2ighe3mj4zdkvq5whtl3dq4u '@silverhand/eslint-config-react': 2.0.1_kz2ighe3mj4zdkvq5whtl3dq4u