From 26b09b7f541a3925108837026b024c5fbd1343d9 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 6 Feb 2023 17:04:44 +0800 Subject: [PATCH] feat(ui): add new passwordInputField component add new passwordInputFieldComponent --- .../InputField.module.scss} | 0 .../InputFields/InputField.test.tsx | 43 ++++++++++++++ .../index.tsx => InputFields/InputField.tsx} | 4 +- .../InputFields/PasswordInputField.test.tsx | 59 +++++++++++++++++++ .../InputFields/PasswordInputField.tsx | 46 +++++++++++++++ .../ui/src/components/InputFields/index.tsx | 2 + .../ui/src/containers/SetPassword/index.tsx | 2 +- packages/ui/src/hooks/use-toggle.ts | 13 ++++ packages/ui/src/hooks/use-update-effect.ts | 26 ++++++++ 9 files changed, 192 insertions(+), 3 deletions(-) rename packages/ui/src/components/{InputField/index.module.scss => InputFields/InputField.module.scss} (100%) create mode 100644 packages/ui/src/components/InputFields/InputField.test.tsx rename packages/ui/src/components/{InputField/index.tsx => InputFields/InputField.tsx} (91%) create mode 100644 packages/ui/src/components/InputFields/PasswordInputField.test.tsx create mode 100644 packages/ui/src/components/InputFields/PasswordInputField.tsx create mode 100644 packages/ui/src/components/InputFields/index.tsx create mode 100644 packages/ui/src/hooks/use-toggle.ts create mode 100644 packages/ui/src/hooks/use-update-effect.ts diff --git a/packages/ui/src/components/InputField/index.module.scss b/packages/ui/src/components/InputFields/InputField.module.scss similarity index 100% rename from packages/ui/src/components/InputField/index.module.scss rename to packages/ui/src/components/InputFields/InputField.module.scss diff --git a/packages/ui/src/components/InputFields/InputField.test.tsx b/packages/ui/src/components/InputFields/InputField.test.tsx new file mode 100644 index 000000000..308bc0904 --- /dev/null +++ b/packages/ui/src/components/InputFields/InputField.test.tsx @@ -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( + { + 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(); + expect(queryByText(errorCode)).not.toBeNull(); + }); + + test('render suffix', () => { + const text = 'clearBtn'; + const { queryByText } = render({text}} />); + expect(queryByText(text)).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/components/InputField/index.tsx b/packages/ui/src/components/InputFields/InputField.tsx similarity index 91% rename from packages/ui/src/components/InputField/index.tsx rename to packages/ui/src/components/InputFields/InputField.tsx index 74dc2f558..b722c726a 100644 --- a/packages/ui/src/components/InputField/index.tsx +++ b/packages/ui/src/components/InputFields/InputField.tsx @@ -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 & { +export type Props = HTMLProps & { className?: string; error?: ErrorType; isDanger?: boolean; diff --git a/packages/ui/src/components/InputFields/PasswordInputField.test.tsx b/packages/ui/src/components/InputFields/PasswordInputField.test.tsx new file mode 100644 index 000000000..f48dc6221 --- /dev/null +++ b/packages/ui/src/components/InputFields/PasswordInputField.test.tsx @@ -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( + { + 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(); + expect(queryByText(errorCode)).not.toBeNull(); + }); + + test('click on toggle visibility button', () => { + const { container } = render( + + ); + + 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'); + } + }); +}); diff --git a/packages/ui/src/components/InputFields/PasswordInputField.tsx b/packages/ui/src/components/InputFields/PasswordInputField.tsx new file mode 100644 index 000000000..20929d308 --- /dev/null +++ b/packages/ui/src/components/InputFields/PasswordInputField.tsx @@ -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; + +const PasswordInputField = (props: Props, forwardRef: Ref>) => { + const [showPassword, toggleShowPassword] = useToggle(false); + const innerRef = useRef(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 ( + + {showPassword ? : } + + } + {...props} + ref={innerRef} + /> + ); +}; + +export default forwardRef(PasswordInputField); diff --git a/packages/ui/src/components/InputFields/index.tsx b/packages/ui/src/components/InputFields/index.tsx new file mode 100644 index 000000000..f07d76045 --- /dev/null +++ b/packages/ui/src/components/InputFields/index.tsx @@ -0,0 +1,2 @@ +export { default as InputField } from './InputField'; +export { default as PasswordInputField } from './PasswordInputField'; diff --git a/packages/ui/src/containers/SetPassword/index.tsx b/packages/ui/src/containers/SetPassword/index.tsx index 78dd1a78d..b74c0326d 100644 --- a/packages/ui/src/containers/SetPassword/index.tsx +++ b/packages/ui/src/containers/SetPassword/index.tsx @@ -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'; diff --git a/packages/ui/src/hooks/use-toggle.ts b/packages/ui/src/hooks/use-toggle.ts new file mode 100644 index 000000000..38f17f307 --- /dev/null +++ b/packages/ui/src/hooks/use-toggle.ts @@ -0,0 +1,13 @@ +import { useState } from 'react'; + +const useToggle = (defaultValue = false) => { + const [value, setValue] = useState(defaultValue); + + const toggle = () => { + setValue((previous) => !previous); + }; + + return [value, toggle] as const; +}; + +export default useToggle; diff --git a/packages/ui/src/hooks/use-update-effect.ts b/packages/ui/src/hooks/use-update-effect.ts new file mode 100644 index 000000000..ba1d5b23b --- /dev/null +++ b/packages/ui/src/hooks/use-update-effect.ts @@ -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;