mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(ui): add new passwordInputField component
add new passwordInputFieldComponent
This commit is contained in:
parent
4a84162722
commit
26b09b7f54
9 changed files with 192 additions and 3 deletions
43
packages/ui/src/components/InputFields/InputField.test.tsx
Normal file
43
packages/ui/src/components/InputFields/InputField.test.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import InputField from './InputField';
|
||||
|
||||
describe('InputField Component', () => {
|
||||
const text = 'foo';
|
||||
const onChange = jest.fn();
|
||||
|
||||
test('render plain text input with value', () => {
|
||||
const { container } = render(
|
||||
<InputField
|
||||
name="foo"
|
||||
value={text}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
onChange(target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputElement = container.querySelector('input');
|
||||
expect(inputElement).not.toBeNull();
|
||||
expect(inputElement?.value).toEqual(text);
|
||||
|
||||
if (inputElement) {
|
||||
fireEvent.change(inputElement, { target: { value: 'update' } });
|
||||
expect(onChange).toBeCalledWith('update');
|
||||
}
|
||||
});
|
||||
|
||||
test('render error message', () => {
|
||||
const errorCode = 'invalid_email';
|
||||
const { queryByText } = render(<InputField error={errorCode} />);
|
||||
expect(queryByText(errorCode)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('render suffix', () => {
|
||||
const text = 'clearBtn';
|
||||
const { queryByText } = render(<InputField suffix={<button>{text}</button>} />);
|
||||
expect(queryByText(text)).not.toBeNull();
|
||||
});
|
||||
});
|
|
@ -5,9 +5,9 @@ import { forwardRef, cloneElement } from 'react';
|
|||
import type { ErrorType } from '@/components/ErrorMessage';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
import * as styles from './InputField.module.scss';
|
||||
|
||||
type Props = HTMLProps<HTMLInputElement> & {
|
||||
export type Props = HTMLProps<HTMLInputElement> & {
|
||||
className?: string;
|
||||
error?: ErrorType;
|
||||
isDanger?: boolean;
|
|
@ -0,0 +1,59 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import PasswordInputField from './PasswordInputField';
|
||||
|
||||
describe('Input Field UI Component', () => {
|
||||
const text = 'foo';
|
||||
const onChange = jest.fn();
|
||||
|
||||
test('render password input', () => {
|
||||
const { container } = render(
|
||||
<PasswordInputField
|
||||
name="foo"
|
||||
value={text}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
onChange(target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputElement = container.querySelector('input');
|
||||
expect(inputElement?.value).toEqual(text);
|
||||
expect(inputElement?.type).toEqual('password');
|
||||
|
||||
if (inputElement) {
|
||||
fireEvent.change(inputElement, { target: { value: 'update' } });
|
||||
expect(onChange).toBeCalledWith('update');
|
||||
}
|
||||
});
|
||||
|
||||
test('render error message', () => {
|
||||
const errorCode = 'username_password_mismatch';
|
||||
const { queryByText } = render(<PasswordInputField error={errorCode} />);
|
||||
expect(queryByText(errorCode)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('click on toggle visibility button', () => {
|
||||
const { container } = render(
|
||||
<PasswordInputField name="foo" value={text} onChange={onChange} />
|
||||
);
|
||||
|
||||
const inputElement = container.querySelector('input');
|
||||
|
||||
if (!inputElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(inputElement.type).toEqual('password');
|
||||
|
||||
const visibilityButton = container.querySelector('svg');
|
||||
expect(visibilityButton).not.toBeNull();
|
||||
|
||||
if (visibilityButton) {
|
||||
fireEvent.click(visibilityButton);
|
||||
expect(inputElement.type).toEqual('text');
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,46 @@
|
|||
import type { Nullable } from '@silverhand/essentials';
|
||||
import type { Ref } from 'react';
|
||||
import { forwardRef, useRef, useImperativeHandle } from 'react';
|
||||
|
||||
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 type { Props as InputFieldProps } from './InputField';
|
||||
import InputField from './InputField';
|
||||
|
||||
type Props = Omit<InputFieldProps, 'type' | 'suffix' | 'isSuffixFocusVisible'>;
|
||||
|
||||
const PasswordInputField = (props: Props, forwardRef: Ref<Nullable<HTMLInputElement>>) => {
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const innerRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
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}>
|
||||
{showPassword ? <PasswordShowIcon /> : <PasswordHideIcon />}
|
||||
</IconButton>
|
||||
}
|
||||
{...props}
|
||||
ref={innerRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(PasswordInputField);
|
2
packages/ui/src/components/InputFields/index.tsx
Normal file
2
packages/ui/src/components/InputFields/index.tsx
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default as InputField } from './InputField';
|
||||
export { default as PasswordInputField } from './PasswordInputField';
|
|
@ -7,7 +7,7 @@ import ClearIcon from '@/assets/icons/clear-icon.svg';
|
|||
import Button from '@/components/Button';
|
||||
import IconButton from '@/components/Button/IconButton';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import InputField from '@/components/InputField';
|
||||
import { InputField } from '@/components/InputFields';
|
||||
import { passwordErrorWatcher } from '@/utils/form';
|
||||
|
||||
import TogglePassword from './TogglePassword';
|
||||
|
|
13
packages/ui/src/hooks/use-toggle.ts
Normal file
13
packages/ui/src/hooks/use-toggle.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
const useToggle = (defaultValue = false) => {
|
||||
const [value, setValue] = useState<boolean>(defaultValue);
|
||||
|
||||
const toggle = () => {
|
||||
setValue((previous) => !previous);
|
||||
};
|
||||
|
||||
return [value, toggle] as const;
|
||||
};
|
||||
|
||||
export default useToggle;
|
26
packages/ui/src/hooks/use-update-effect.ts
Normal file
26
packages/ui/src/hooks/use-update-effect.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
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