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:
parent
e8e623a2bf
commit
2ad285541f
10 changed files with 135 additions and 95 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/shared": "^9.6.1",
|
||||
"@react-spring/web": "^9.6.1",
|
||||
"@silverhand/eslint-config": "2.0.1",
|
||||
"@silverhand/eslint-config-react": "2.0.1",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
.prefix {
|
||||
overflow: hidden;
|
||||
|
||||
> div {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue