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-svg-react": "2.8.3",
"@peculiar/webcrypto": "^1.3.3",
"@react-spring/shared": "^9.6.1",
"@react-spring/web": "^9.6.1",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/eslint-config-react": "2.0.1",

View file

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

View file

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

View file

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

View file

@ -1,38 +1,50 @@
import { useSpring, animated } from '@react-spring/web';
import { useState } from 'react';
import { onResize, useIsomorphicLayoutEffect } from '@react-spring/shared';
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';
type Props = {
children: React.ReactNode;
children: JSX.Element; // Limit to one element
isVisible: boolean;
};
const AnimatedPrefix = ({ children, isVisible }: Props) => {
const [show, setShow] = useState(isVisible);
const elementRef = useRef<Nullable<HTMLElement>>(null);
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);
}
},
}),
// Get target width for the children
const getTargetWidth = useCallback(
() => (isVisible ? elementRef.current?.getBoundingClientRect().width : 0),
[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 (
<animated.div className={styles.prefix} style={animation}>
{show && children}
<animated.div className={styles.prefix} style={animation} data-testid="prefix">
{cloneElement(children, { ref: elementRef })}
</animated.div>
);
};

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { SignInIdentifier } from '@logto/schemas';
import { animated, config, useSpring } from '@react-spring/web';
import type { Nullable } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import type { HTMLProps, Ref } 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;
};
const AnimatedInputField = animated(InputField);
const SmartInputField = (
{ defaultValue, defaultType, enabledTypes = [], onChange, ...rest }: Props,
ref: Ref<Nullable<HTMLInputElement>>
@ -48,39 +50,38 @@ const SmartInputField = (
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(() => {
onChange?.({
type: identifierType,
value:
identifierType === SignInIdentifier.Phone && inputValue
? `${countryCode}${inputValue}`
: inputValue,
value: isPrefixVisible && inputValue ? `${countryCode}${inputValue}` : inputValue,
});
}, [countryCode, identifierType, inputValue, onChange]);
}, [countryCode, identifierType, inputValue, isPrefixVisible, onChange]);
return (
<InputField
{...getInputHtmlProps(enabledTypes, identifierType)}
<AnimatedInputField
{...rest}
{...getInputHtmlProps(enabledTypes, identifierType)}
ref={innerRef}
isSuffixFocusVisible={Boolean(inputValue)}
isPrefixVisible={identifierType === SignInIdentifier.Phone}
style={{ zIndex: 1, paddingLeft }} // Give <input /> z-index to override country selector
value={inputValue}
prefix={conditional(
isPhoneEnabled && (
<AnimatedPrefix isVisible={identifierType === SignInIdentifier.Phone}>
<CountryCodeSelector
value={countryCode}
onChange={(event) => {
onCountryCodeChange(event);
innerRef.current?.focus();
}}
/>
</AnimatedPrefix>
)
)}
prefix={
<AnimatedPrefix isVisible={isPrefixVisible}>
<CountryCodeSelector
value={countryCode}
onChange={(event) => {
onCountryCodeChange(event);
innerRef.current?.focus();
}}
/>
</AnimatedPrefix>
}
suffix={
<IconButton
onMouseDown={(event) => {

View file

@ -1,4 +1,5 @@
import { SignInIdentifier } from '@logto/schemas';
import { Globals } from '@react-spring/web';
import { assert } from '@silverhand/essentials';
import { MemoryRouter, useLocation } from 'react-router-dom';
@ -38,6 +39,12 @@ describe('ForgotPassword', () => {
</MemoryRouter>
);
beforeAll(() => {
Globals.assign({
skipAnimation: true,
});
});
afterEach(() => {
jest.clearAllMocks();
});
@ -69,9 +76,9 @@ describe('ForgotPassword', () => {
test.each(stateCases)('render the forgot password page with state %o', async (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 countryCodeSelector = container.querySelector('select[name="countryCode"]');
const countryCodeSelectorPrefix = queryByTestId('prefix');
assert(inputField, new Error('input field not found'));
expect(queryByText('description.reset_password')).not.toBeNull();
@ -81,27 +88,35 @@ describe('ForgotPassword', () => {
if (state.identifier === SignInIdentifier.Phone && settings.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);
} else if (state.identifier === SignInIdentifier.Phone) {
// Phone Number not enabled
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelector).toBeNull();
expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
}
if (state.identifier === SignInIdentifier.Email && settings.email) {
expect(inputField.getAttribute('value')).toBe(email);
expect(countryCodeSelector).toBeNull();
expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
} else if (state.identifier === SignInIdentifier.Email) {
// Only PhoneNumber is enabled
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelector).not.toBeNull();
expect(countryCodeSelectorPrefix?.getAttribute('style')).toBeNull();
}
if (state.identifier === SignInIdentifier.Username && settings.email) {
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelector).toBeNull();
expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
} else if (state.identifier === SignInIdentifier.Username) {
// Only PhoneNumber is enabled
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-svg-react': 2.8.3
'@peculiar/webcrypto': ^1.3.3
'@react-spring/shared': ^9.6.1
'@react-spring/web': ^9.6.1
'@silverhand/eslint-config': 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-svg-react': 2.8.3_@parcel+core@2.8.3
'@peculiar/webcrypto': 1.3.3
'@react-spring/shared': 9.6.1_react@18.2.0
'@react-spring/web': 9.6.1_biqbaboplfbrettd7655fr4n2y
'@silverhand/eslint-config': 2.0.1_kjzxg5porcw5dx54sezsklj5cy
'@silverhand/eslint-config-react': 2.0.1_kz2ighe3mj4zdkvq5whtl3dq4u