0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

feat(ui): add smart input field (#3038)

This commit is contained in:
simeng-li 2023-02-07 17:05:29 +08:00 committed by GitHub
parent f804e06252
commit 8bf28df796
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 776 additions and 28 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/web": "^9.6.1",
"@silverhand/eslint-config": "1.3.0",
"@silverhand/eslint-config-react": "1.3.0",
"@silverhand/essentials": "2.1.0",

View file

@ -9,26 +9,23 @@
transition-property: outline, border;
transition-timing-function: ease-in-out;
transition-duration: 0.2s;
align-items: stretch;
// fix in safari input field line-height issue
height: 44px;
input {
transition: width 0.3s ease-in;
padding: 0 _.unit(4);
flex: 1;
background: none;
caret-color: var(--color-brand-default);
font: var(--font-body-1);
color: var(--color-type-primary);
align-self: stretch;
&::placeholder {
color: var(--color-type-secondary);
}
&:not(:last-child) {
padding-right: _.unit(10);
}
}
.suffix {
@ -39,22 +36,15 @@
width: _.unit(8);
height: _.unit(8);
display: none;
&.visible {
display: flex;
}
}
&:focus-within {
border: _.border(var(--color-brand-default));
input {
outline: none;
}
.focusVisible {
display: flex;
}
}
&.danger {
@ -64,6 +54,23 @@
caret-color: var(--color-danger-default);
}
}
&.isPrefixVisible {
input {
padding-left: _.unit(1);
}
}
&.isSuffixVisible,
&.isSuffixFocusVisible:focus-within {
input {
padding-right: _.unit(10);
}
.suffix {
display: flex;
}
}
}
.errorMessage {
@ -75,6 +82,7 @@
.inputField {
outline: 3px solid transparent;
/* stylelint-disable-next-line no-descending-specificity */
input {
font: var(--font-body-2);
}

View file

@ -1,6 +1,6 @@
import { render, fireEvent } from '@testing-library/react';
import InputField from './InputField';
import InputField from '.';
describe('InputField Component', () => {
const text = 'foo';

View file

@ -5,32 +5,48 @@ import { forwardRef, cloneElement } from 'react';
import type { ErrorType } from '@/components/ErrorMessage';
import ErrorMessage from '@/components/ErrorMessage';
import * as styles from './InputField.module.scss';
import * as styles from './index.module.scss';
export type Props = HTMLProps<HTMLInputElement> & {
export type Props = Omit<HTMLProps<HTMLInputElement>, 'prefix'> & {
className?: string;
error?: ErrorType;
isDanger?: boolean;
prefix?: ReactElement;
isPrefixVisible?: boolean;
suffix?: ReactElement;
isSuffixFocusVisible?: boolean;
isSuffixVisible?: boolean;
isSuffixFocusVisible?: boolean;
};
const InputField = (
{ className, error, isDanger, suffix, isSuffixFocusVisible, isSuffixVisible, ...props }: Props,
{
className,
error,
isDanger,
prefix,
suffix,
isPrefixVisible,
isSuffixFocusVisible,
isSuffixVisible,
...props
}: Props,
reference: ForwardedRef<HTMLInputElement>
) => (
<div className={className}>
<div className={classNames(styles.inputField, isDanger && styles.danger)}>
<div
className={classNames(
styles.inputField,
isDanger && styles.danger,
isPrefixVisible && styles.isPrefixVisible,
isSuffixFocusVisible && styles.isSuffixFocusVisible,
isSuffixVisible && styles.isSuffixVisible
)}
>
{prefix}
<input {...props} ref={reference} />
{suffix &&
cloneElement(suffix, {
className: classNames([
suffix.props.className,
styles.suffix,
isSuffixFocusVisible && styles.focusVisible,
isSuffixVisible && styles.visible,
]),
className: classNames([suffix.props.className, styles.suffix]),
})}
</div>
{error && <ErrorMessage error={error} className={styles.errorMessage} />}

View file

@ -1,6 +1,6 @@
import { render, fireEvent } from '@testing-library/react';
import PasswordInputField from './PasswordInputField';
import PasswordInputField from '.';
describe('Input Field UI Component', () => {
const text = 'foo';

View file

@ -8,8 +8,8 @@ import IconButton from '@/components/Button/IconButton';
import useToggle from '@/hooks/use-toggle';
import useUpdateEffect from '@/hooks/use-update-effect';
import type { Props as InputFieldProps } from './InputField';
import InputField from './InputField';
import InputField from '../InputField';
import type { Props as InputFieldProps } from '../InputField';
type Props = Omit<InputFieldProps, 'type' | 'suffix' | 'isSuffixFocusVisible'>;

View file

@ -0,0 +1,3 @@
.prefix {
overflow: hidden;
}

View file

@ -0,0 +1,40 @@
import { useSpring, animated } from '@react-spring/web';
import { useState } from 'react';
import * as styles from './index.module.scss';
type Props = {
children: React.ReactNode;
isVisible: boolean;
};
const AnimatedPrefix = ({ children, isVisible }: Props) => {
const [show, setShow] = useState(isVisible);
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);
}
},
}),
[isVisible]
);
return (
<animated.div className={styles.prefix} style={animation}>
{show && children}
</animated.div>
);
};
export default AnimatedPrefix;

View file

@ -0,0 +1,53 @@
@use '@/scss/underscore' as _;
.countryCodeSelector {
font: var(--font-label-1);
color: var(--color-type-primary);
border: none;
background: none;
position: relative;
height: 100%;
padding-right: _.unit(1);
padding-left: _.unit(4);
@include _.flex-row;
overflow: hidden;
white-space: nowrap;
> select {
appearance: none;
border: none;
outline: none;
background: none;
position: absolute;
width: 100%;
height: 100%;
font-size: 0;
option {
font: var(--font-label-1);
}
}
> svg {
color: var(--color-type-secondary);
margin-left: _.unit(1);
width: 16px;
height: 16px;
}
}
:global(body.desktop) {
.countryCodeSelector {
font: var(--font-body-2);
> select option {
font: var(--font-body-2);
}
> svg {
margin-left: _.unit(2);
width: 20px;
height: 20px;
}
}
}

View file

@ -0,0 +1,42 @@
import classNames from 'classnames';
import type { ChangeEventHandler, ForwardedRef } from 'react';
import { useMemo, forwardRef } from 'react';
import DownArrowIcon from '@/assets/icons/arrow-down.svg';
import { getCountryList, getDefaultCountryCallingCode } from '@/utils/country-code';
import * as styles from './index.module.scss';
type Props = {
className?: string;
value?: string;
onChange?: ChangeEventHandler<HTMLSelectElement>;
};
const CountryCodeSelector = (
{ className, value, onChange }: Props,
ref: ForwardedRef<HTMLDivElement>
) => {
const countryList = useMemo(getCountryList, []);
const defaultCountCode = useMemo(getDefaultCountryCallingCode, []);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const countryCode = value || defaultCountCode;
return (
<div ref={ref} className={classNames(styles.countryCodeSelector, className)}>
<span>{`+${countryCode}`}</span>
<DownArrowIcon />
<select autoComplete="region" onChange={onChange}>
{countryList.map(({ countryCallingCode, countryCode }) => (
<option key={countryCode} value={countryCallingCode}>
{`+${countryCallingCode}`}
</option>
))}
</select>
</div>
);
};
export default forwardRef(CountryCodeSelector);

View file

@ -0,0 +1,333 @@
import { SignInIdentifier } from '@logto/schemas';
import { fireEvent, render } from '@testing-library/react';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import type { EnabledIdentifierTypes, IdentifierInputType } from '.';
import SmartInputField from '.';
jest.mock('i18next', () => ({
language: 'en',
}));
describe('SmartInputField Component', () => {
const onChange = jest.fn();
const onTypeChange = jest.fn();
const defaultCountryCallingCode = getDefaultCountryCallingCode();
const renderInputField = (props: {
currentType: IdentifierInputType;
enabledTypes?: EnabledIdentifierTypes;
}) => render(<SmartInputField {...props} onTypeChange={onTypeChange} onChange={onChange} />);
afterEach(() => {
jest.clearAllMocks();
});
describe('standard input field', () => {
it.each([SignInIdentifier.Username, SignInIdentifier.Email])(
`should render %s input field`,
(currentType) => {
const { container } = renderInputField({ currentType });
// Country code should not be rendered
expect(container.querySelector('select')).toBeNull();
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: 'foo' } });
expect(onChange).toBeCalledWith('foo');
expect(onTypeChange).not.toBeCalled();
fireEvent.change(input, { target: { value: 'foo@' } });
expect(onChange).toBeCalledWith('foo@');
expect(onTypeChange).not.toBeCalled();
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith('12315');
expect(onTypeChange).not.toBeCalled();
}
}
);
it('phone', async () => {
const { container, queryAllByText } = renderInputField({
currentType: SignInIdentifier.Phone,
});
const countryCode = queryAllByText(`+${defaultCountryCallingCode}`);
expect(countryCode).toHaveLength(2);
const selector = container.querySelector('select');
expect(selector).not.toBeNull();
const newCountryCode = '86';
if (selector) {
fireEvent.change(selector, { target: { value: newCountryCode } });
expect(onChange).toBeCalledWith(newCountryCode);
}
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith(`${newCountryCode}12315`);
expect(onTypeChange).not.toBeCalled();
}
});
});
describe('username with email', () => {
const config = {
currentType: SignInIdentifier.Username,
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Username],
};
it('should not update inputType if no @ char present', () => {
const { container } = renderInputField(config);
// Country code should not be rendered
expect(container.querySelector('select')).toBeNull();
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: 'foo' } });
expect(onChange).toBeCalledWith('foo');
expect(onTypeChange).not.toBeCalled();
}
});
it('should not update inputType to phone as phone is not enabled', () => {
const { container } = renderInputField(config);
// Country code should not be rendered
expect(container.querySelector('select')).toBeNull();
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith('12315');
expect(onTypeChange).not.toBeCalled();
}
});
it('should update inputType to email with @ char', () => {
const { container } = renderInputField(config);
// Country code should not be rendered
expect(container.querySelector('select')).toBeNull();
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: 'foo@' } });
expect(onChange).toBeCalledWith('foo@');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Email);
}
});
});
describe('username with phone', () => {
const config = {
currentType: SignInIdentifier.Username,
enabledTypes: [SignInIdentifier.Username, SignInIdentifier.Phone],
};
it('should not update inputType if non digit chars present', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12345@' } });
expect(onChange).toBeCalledWith('12345@');
expect(onTypeChange).not.toBeCalled();
}
});
it('should not update inputType if less than 3 digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12' } });
expect(onChange).toBeCalledWith('12');
expect(onTypeChange).not.toBeCalled();
}
});
it('should update inputType to phone with more than 3 digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}12315`);
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Phone);
}
});
});
describe('email with phone', () => {
const config = {
currentType: SignInIdentifier.Email,
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Phone],
};
it('should not update inputType non digit char present', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315@' } });
expect(onChange).toBeCalledWith('12315@');
expect(onTypeChange).not.toBeCalled();
}
});
it('should not update inputType if less than 3 digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12' } });
expect(onChange).toBeCalledWith('12');
expect(onTypeChange).not.toBeCalled();
}
});
it('should update inputType to phone with more than 3 digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}12315`);
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Phone);
}
});
it('should not update inputType if @ present', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315@' } });
expect(onChange).toBeCalledWith('12315@');
expect(onTypeChange).not.toBeCalled();
}
});
});
describe('email with username', () => {
const config = {
currentType: SignInIdentifier.Email,
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Username],
};
it('should not update inputType if @ present', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: 'foo@' } });
expect(onChange).toBeCalledWith('foo@');
expect(onTypeChange).not.toBeCalled();
}
});
it('should update inputType to username with pure digits as phone is not enabled', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith('12315');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Username);
}
});
it('should update inputType to username with no @ present', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: 'foo' } });
expect(onChange).toBeCalledWith('foo');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Username);
}
});
});
describe('phone with username', () => {
const config = {
currentType: SignInIdentifier.Phone,
enabledTypes: [SignInIdentifier.Phone, SignInIdentifier.Username],
};
it('should not update inputType if all chars are digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '123' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}123`);
expect(onTypeChange).not.toBeCalled();
}
});
it('should update inputType if non digit char found', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '123@' } });
expect(onChange).toBeCalledWith('123@');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Username);
}
});
});
describe('phone with email', () => {
const config = {
currentType: SignInIdentifier.Phone,
enabledTypes: [SignInIdentifier.Phone, SignInIdentifier.Email],
};
it('should not update inputType if all chars are digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '123' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}123`);
expect(onTypeChange).not.toBeCalled();
}
});
it('should not update inputType if no all chars are digits and no @', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '123a' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}123a`);
expect(onTypeChange).not.toBeCalled();
}
});
it('should update inputType if @ char found', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315@' } });
expect(onChange).toBeCalledWith('12315@');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Email);
}
});
});
});

View file

@ -0,0 +1,76 @@
import { SignInIdentifier } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { ForwardedRef, HTMLProps } from 'react';
import { forwardRef } from 'react';
import ClearIcon from '@/assets/icons/clear-icon.svg';
import IconButton from '@/components/Button/IconButton';
import type { ErrorType } from '@/components/ErrorMessage';
import InputField from '../InputField';
import AnimatedPrefix from './AnimatedPrefix';
import CountryCodeSelector from './CountryCodeSelector';
import type { EnabledIdentifierTypes, IdentifierInputType } from './use-smart-input-field';
import useSmartInputField from './use-smart-input-field';
export type { IdentifierInputType, EnabledIdentifierTypes } from './use-smart-input-field';
type Props = Omit<HTMLProps<HTMLInputElement>, 'onChange' | 'prefix'> & {
className?: string;
error?: ErrorType;
isDanger?: boolean;
enabledTypes?: EnabledIdentifierTypes;
currentType?: IdentifierInputType;
onTypeChange?: (type: IdentifierInputType) => void;
onChange?: (value: string) => void;
};
const SmartInputField = (
{
value,
onChange,
type = 'text',
currentType = SignInIdentifier.Username,
enabledTypes = [currentType],
onTypeChange,
...rest
}: Props,
ref: ForwardedRef<HTMLInputElement>
) => {
const { countryCode, onCountryCodeChange, inputValue, onInputValueChange, onInputValueClear } =
useSmartInputField({
onChange,
enabledTypes,
currentType,
onTypeChange,
});
const isPhoneEnabled = enabledTypes.includes(SignInIdentifier.Phone);
return (
<InputField
{...rest}
ref={ref}
isSuffixFocusVisible
isPrefixVisible={isPhoneEnabled && currentType === SignInIdentifier.Phone}
type={type}
value={inputValue}
prefix={conditional(
isPhoneEnabled && (
<AnimatedPrefix isVisible={currentType === SignInIdentifier.Phone}>
<CountryCodeSelector value={countryCode} onChange={onCountryCodeChange} />
</AnimatedPrefix>
)
)}
suffix={
<IconButton onClick={onInputValueClear}>
<ClearIcon />
</IconButton>
}
onChange={onInputValueChange}
/>
);
};
export default forwardRef(SmartInputField);

View file

@ -0,0 +1,115 @@
import { SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { useState, useCallback, useMemo } from 'react';
import type { ChangeEventHandler } from 'react';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
export type IdentifierInputType =
| SignInIdentifier.Email
| SignInIdentifier.Phone
| SignInIdentifier.Username;
export type EnabledIdentifierTypes = IdentifierInputType[];
const digitsRegex = /^\d*$/;
type Props = {
onChange?: (value: string) => void;
enabledTypes: EnabledIdentifierTypes;
currentType: IdentifierInputType;
onTypeChange?: (type: IdentifierInputType) => void;
};
const useSmartInputField = ({ onChange, currentType, enabledTypes, onTypeChange }: Props) => {
const [countryCode, setCountryCode] = useState<string>(getDefaultCountryCallingCode());
const [inputValue, setInputValue] = useState<string>('');
const enabledTypeSet = useMemo(() => new Set(enabledTypes), [enabledTypes]);
assert(enabledTypeSet.has(currentType), new Error('Invalid input type'));
const detectInputType = useCallback(
(value: string) => {
if (!value || enabledTypeSet.size === 1) {
return currentType;
}
const hasAtSymbol = value.includes('@');
const isAllDigits = digitsRegex.test(value);
const isEmailDetected = enabledTypeSet.has(SignInIdentifier.Email) && hasAtSymbol;
const isPhoneDetected =
enabledTypeSet.has(SignInIdentifier.Phone) && value.length > 3 && isAllDigits;
if (isPhoneDetected) {
return SignInIdentifier.Phone;
}
if (isEmailDetected) {
return SignInIdentifier.Email;
}
if (
currentType === SignInIdentifier.Email &&
enabledTypeSet.has(SignInIdentifier.Username) &&
!hasAtSymbol
) {
return SignInIdentifier.Username;
}
if (
currentType === SignInIdentifier.Phone &&
enabledTypeSet.has(SignInIdentifier.Username) &&
!isAllDigits
) {
return SignInIdentifier.Username;
}
return currentType;
},
[currentType, enabledTypeSet]
);
const onCountryCodeChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
({ target: { value } }) => {
if (currentType === SignInIdentifier.Phone) {
const code = value.replace(/\D/g, '');
setCountryCode(code);
onChange?.(`${code}${inputValue}`);
}
},
[currentType, inputValue, onChange]
);
const onInputValueChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
({ target: { value } }) => {
const trimValue = value.trim();
setInputValue(trimValue);
const type = detectInputType(trimValue);
if (type !== currentType) {
onTypeChange?.(type);
}
onChange?.(type === SignInIdentifier.Phone ? `${countryCode}${trimValue}` : trimValue);
},
[countryCode, currentType, detectInputType, onChange, onTypeChange]
);
const onInputValueClear = useCallback(() => {
setInputValue('');
onChange?.('');
}, [onChange]);
return {
countryCode,
onCountryCodeChange,
inputValue,
onInputValueChange,
onInputValueClear,
};
};
export default useSmartInputField;

View file

@ -1,2 +1,7 @@
export { default as InputField } from './InputField';
export { default as PasswordInputField } from './PasswordInputField';
export {
default as SmartInputField,
type IdentifierInputType,
type EnabledIdentifierTypes,
} from './SmartInputField';

56
pnpm-lock.yaml generated
View file

@ -779,6 +779,7 @@ importers:
'@parcel/transformer-sass': 2.8.3
'@parcel/transformer-svg-react': 2.8.3
'@peculiar/webcrypto': ^1.3.3
'@react-spring/web': ^9.6.1
'@silverhand/eslint-config': 1.3.0
'@silverhand/eslint-config-react': 1.3.0
'@silverhand/essentials': 2.1.0
@ -835,6 +836,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/web': 9.6.1_biqbaboplfbrettd7655fr4n2y
'@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4
'@silverhand/eslint-config-react': 1.3.0_kvwdjz5hpaxioiksuaratzdsqy
'@silverhand/essentials': 2.1.0
@ -3340,6 +3342,60 @@ packages:
resolution: {integrity: sha512-Yykovind6xzqAqd0t5umrdAGPlGLTE80cy80UkEnbt8Zv5zEYTFzJSNPQ81TY8BSpRreubu1oE54iHBv2UVnTQ==}
dev: true
/@react-spring/animated/9.6.1_react@18.2.0:
resolution: {integrity: sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@react-spring/shared': 9.6.1_react@18.2.0
'@react-spring/types': 9.6.1
react: 18.2.0
dev: true
/@react-spring/core/9.6.1_react@18.2.0:
resolution: {integrity: sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@react-spring/animated': 9.6.1_react@18.2.0
'@react-spring/rafz': 9.6.1
'@react-spring/shared': 9.6.1_react@18.2.0
'@react-spring/types': 9.6.1
react: 18.2.0
dev: true
/@react-spring/rafz/9.6.1:
resolution: {integrity: sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==}
dev: true
/@react-spring/shared/9.6.1_react@18.2.0:
resolution: {integrity: sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@react-spring/rafz': 9.6.1
'@react-spring/types': 9.6.1
react: 18.2.0
dev: true
/@react-spring/types/9.6.1:
resolution: {integrity: sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==}
dev: true
/@react-spring/web/9.6.1_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-X2zR6q2Z+FjsWfGAmAXlQaoUHbPmfuCaXpuM6TcwXPpLE1ZD4A1eys/wpXboFQmDkjnrlTmKvpVna1MjWpZ5Hw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@react-spring/animated': 9.6.1_react@18.2.0
'@react-spring/core': 9.6.1_react@18.2.0
'@react-spring/shared': 9.6.1_react@18.2.0
'@react-spring/types': 9.6.1
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: true
/@sideway/address/4.1.4:
resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==}
dependencies: