mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #3113 from logto-io/simeng-ui-clean-up
refactor(ui): clean up legacy code
This commit is contained in:
commit
43ecf01ce8
9 changed files with 0 additions and 642 deletions
|
@ -1,57 +0,0 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import PasswordInput from './PasswordInput';
|
||||
|
||||
describe('Input Field UI Component', () => {
|
||||
const text = 'foo';
|
||||
const onChange = jest.fn();
|
||||
|
||||
test('render password input', () => {
|
||||
const { container } = render(
|
||||
<PasswordInput
|
||||
name="foo"
|
||||
value={text}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
onChange(target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputEle = container.querySelector('input');
|
||||
expect(inputEle?.value).toEqual(text);
|
||||
expect(inputEle?.type).toEqual('password');
|
||||
|
||||
if (inputEle) {
|
||||
fireEvent.change(inputEle, { target: { value: 'update' } });
|
||||
expect(onChange).toBeCalledWith('update');
|
||||
}
|
||||
});
|
||||
|
||||
test('render error message', () => {
|
||||
const errorCode = 'password_required';
|
||||
const { queryByText } = render(<PasswordInput error={errorCode} />);
|
||||
expect(queryByText(errorCode)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('click on toggle visibility button', () => {
|
||||
const { container } = render(<PasswordInput name="foo" value={text} onChange={onChange} />);
|
||||
|
||||
const inputEle = container.querySelector('input');
|
||||
|
||||
if (!inputEle) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireEvent.focus(inputEle);
|
||||
|
||||
const visibilityButton = container.querySelector('svg');
|
||||
expect(visibilityButton).not.toBeNull();
|
||||
|
||||
if (visibilityButton) {
|
||||
fireEvent.mouseDown(visibilityButton);
|
||||
expect(inputEle.type).toEqual('text');
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1,64 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import type { HTMLProps } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
import PasswordHideIcon from '@/assets/icons/password-hide-icon.svg';
|
||||
import PasswordShowIcon from '@/assets/icons/password-show-icon.svg';
|
||||
import type { ErrorType } from '@/components/ErrorMessage';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = Omit<HTMLProps<HTMLInputElement>, 'type'> & {
|
||||
className?: string;
|
||||
error?: ErrorType;
|
||||
};
|
||||
|
||||
const PasswordInput = ({ className, value, error, onFocus, onBlur, ...rest }: Props) => {
|
||||
// Toggle the password visibility
|
||||
const [type, setType] = useState('password');
|
||||
const [onInputFocus, setOnInputFocus] = useState(false);
|
||||
const inputElement = useRef<HTMLInputElement>(null);
|
||||
const Icon = type === 'password' ? PasswordHideIcon : PasswordShowIcon;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={classNames(styles.wrapper, error && styles.error)}>
|
||||
<input
|
||||
ref={inputElement}
|
||||
type={type}
|
||||
value={value}
|
||||
onFocus={(event) => {
|
||||
setOnInputFocus(true);
|
||||
onFocus?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setOnInputFocus(false);
|
||||
onBlur?.(event);
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
{value && onInputFocus && (
|
||||
<Icon
|
||||
className={styles.actionButton}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setType(type === 'password' ? 'text' : 'password');
|
||||
|
||||
if (inputElement.current) {
|
||||
const { length } = inputElement.current.value;
|
||||
// Force async render, move cursor to the end of the input
|
||||
setTimeout(() => {
|
||||
inputElement.current?.setSelectionRange(length, length);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error && <ErrorMessage className={styles.errorMessage} error={error} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordInput;
|
|
@ -1,97 +0,0 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { getCountryList, getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import PhoneInput from './PhoneInput';
|
||||
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
}));
|
||||
|
||||
describe('Phone Input Field UI Component', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onChange.mockClear();
|
||||
});
|
||||
|
||||
const defaultCountryCallingCode = getDefaultCountryCallingCode();
|
||||
|
||||
it('render empty PhoneInput', () => {
|
||||
const { queryByText, container } = render(
|
||||
<PhoneInput name="PhoneInput" nationalNumber="" onChange={onChange} />
|
||||
);
|
||||
expect(queryByText(`+${defaultCountryCallingCode}`)).toBeNull();
|
||||
expect(container.querySelector('input')?.value).toBe('');
|
||||
});
|
||||
|
||||
it('render with country list', () => {
|
||||
const { queryAllByText, container } = render(
|
||||
<PhoneInput
|
||||
name="PhoneInput"
|
||||
nationalNumber=""
|
||||
countryList={getCountryList()}
|
||||
countryCallingCode={defaultCountryCallingCode}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const countryCode = queryAllByText(`+${defaultCountryCallingCode}`);
|
||||
expect(countryCode).toHaveLength(2);
|
||||
|
||||
const selector = container.querySelector('select');
|
||||
expect(selector).not.toBeNull();
|
||||
|
||||
if (selector) {
|
||||
fireEvent.change(selector, { target: { value: '1' } });
|
||||
expect(onChange).toBeCalledWith({ countryCallingCode: '1' });
|
||||
}
|
||||
});
|
||||
|
||||
it('render input update', () => {
|
||||
const { container } = render(
|
||||
<PhoneInput name="PhoneInput" nationalNumber="911" onChange={onChange} />
|
||||
);
|
||||
|
||||
const inputField = container.querySelector('input');
|
||||
expect(inputField?.value).toBe('911');
|
||||
|
||||
if (inputField) {
|
||||
fireEvent.change(inputField, { target: { value: '110' } });
|
||||
expect(onChange).toBeCalledWith({ nationalNumber: '110' });
|
||||
fireEvent.focus(inputField);
|
||||
}
|
||||
});
|
||||
|
||||
it('render input error', () => {
|
||||
const { queryByText } = render(
|
||||
<PhoneInput
|
||||
name="PhoneInput"
|
||||
nationalNumber="110"
|
||||
error="invalid_phone"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
expect(queryByText('invalid_phone')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('render input clear', () => {
|
||||
const { container } = render(
|
||||
<PhoneInput name="PhoneInput" nationalNumber="911" onChange={onChange} />
|
||||
);
|
||||
|
||||
const inputField = container.querySelector('input');
|
||||
|
||||
if (inputField) {
|
||||
fireEvent.focus(inputField);
|
||||
}
|
||||
|
||||
const clearButton = container.querySelector('svg');
|
||||
expect(clearButton).not.toBeNull();
|
||||
|
||||
if (clearButton) {
|
||||
fireEvent.mouseDown(clearButton);
|
||||
expect(onChange).toBeCalledWith({ nationalNumber: '' });
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1,112 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
|
||||
import DownArrowIcon from '@/assets/icons/arrow-down.svg';
|
||||
import ClearIcon from '@/assets/icons/clear-icon.svg';
|
||||
import type { ErrorType } from '@/components/ErrorMessage';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import type { CountryCallingCode, CountryMetaData } from '@/hooks/use-phone-number';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
import * as phoneInputStyles from './phoneInput.module.scss';
|
||||
|
||||
type Value = { countryCallingCode?: CountryCallingCode; nationalNumber?: string };
|
||||
|
||||
export type Props = {
|
||||
name: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
countryCallingCode?: CountryCallingCode;
|
||||
nationalNumber: string;
|
||||
countryList?: CountryMetaData[];
|
||||
error?: ErrorType;
|
||||
onChange: (value: Value) => void;
|
||||
};
|
||||
|
||||
const PhoneInput = ({
|
||||
name,
|
||||
autoFocus,
|
||||
className,
|
||||
placeholder,
|
||||
countryCallingCode,
|
||||
nationalNumber,
|
||||
countryList,
|
||||
error,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const [onFocus, setOnFocus] = useState(false);
|
||||
const inputReference = useRef<HTMLInputElement>(null);
|
||||
|
||||
const countrySelector = useMemo(() => {
|
||||
if (countryCallingCode === undefined || !countryList?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={phoneInputStyles.countryCodeSelector}>
|
||||
<span>{`+${countryCallingCode}`}</span>
|
||||
<DownArrowIcon />
|
||||
<select
|
||||
autoComplete="tel-country-code"
|
||||
onChange={({ target: { value } }) => {
|
||||
onChange({ countryCallingCode: value });
|
||||
|
||||
// Auto Focus to the input
|
||||
if (inputReference.current) {
|
||||
inputReference.current.focus();
|
||||
const { length } = inputReference.current.value;
|
||||
inputReference.current.setSelectionRange(length, length);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{countryList.map(({ countryCallingCode, countryCode }) => (
|
||||
<option key={countryCode} value={countryCallingCode}>
|
||||
{`+${countryCallingCode}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}, [countryCallingCode, countryList, onChange]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={classNames(styles.wrapper, error && styles.error)}>
|
||||
{countrySelector}
|
||||
<input
|
||||
ref={inputReference}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel-national"
|
||||
autoFocus={autoFocus}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={nationalNumber}
|
||||
onFocus={() => {
|
||||
setOnFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOnFocus(false);
|
||||
}}
|
||||
onChange={({ target: { value } }) => {
|
||||
onChange({ nationalNumber: value.replace(/\D/g, '') });
|
||||
}}
|
||||
/>
|
||||
{nationalNumber && onFocus && (
|
||||
<ClearIcon
|
||||
className={styles.actionButton}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onChange({ nationalNumber: '' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhoneInput;
|
|
@ -1,91 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
@include _.flex-row;
|
||||
border: _.border(var(--color-line-border));
|
||||
border-radius: var(--radius);
|
||||
// fix in safari input field line-height issue
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
transition-property: outline, border;
|
||||
transition-timing-function: ease-in-out;
|
||||
transition-duration: 0.2s;
|
||||
|
||||
.actionButton {
|
||||
position: absolute;
|
||||
right: _.unit(4);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
color: var(--color-type-secondary);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
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;
|
||||
|
||||
&:not(:first-child) {
|
||||
padding-left: _.unit(1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-type-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border: _.border(var(--color-brand-default));
|
||||
|
||||
.actionButton {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input {
|
||||
padding-right: calc(24px + _.unit(4));
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
border: _.border(var(--color-danger-default));
|
||||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin-left: _.unit(0.5);
|
||||
margin-top: _.unit(1);
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.wrapper {
|
||||
border-radius: 6px;
|
||||
outline: 3px solid transparent;
|
||||
|
||||
input {
|
||||
font: var(--font-body-2);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border: _.border(var(--color-danger-default));
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border: _.border(var(--color-brand-default));
|
||||
outline-color: var(--color-overlay-brand-focused);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import Input from '.';
|
||||
|
||||
describe('Input Field UI Component', () => {
|
||||
const text = 'foo';
|
||||
const onChange = jest.fn();
|
||||
const onClear = jest.fn();
|
||||
|
||||
test('render plain text input with value', () => {
|
||||
const { container } = render(
|
||||
<Input
|
||||
name="foo"
|
||||
value={text}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
onChange(target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const inputEle = container.querySelector('input');
|
||||
expect(inputEle).not.toBeNull();
|
||||
expect(inputEle?.value).toEqual(text);
|
||||
|
||||
if (inputEle) {
|
||||
fireEvent.change(inputEle, { target: { value: 'update' } });
|
||||
expect(onChange).toBeCalledWith('update');
|
||||
}
|
||||
});
|
||||
|
||||
test('render error message', () => {
|
||||
const errorCode = 'invalid_email';
|
||||
const { queryByText } = render(<Input error={errorCode} />);
|
||||
expect(queryByText(errorCode)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('click on clear button', () => {
|
||||
const { container } = render(
|
||||
<Input name="foo" value={text} onChange={onChange} onClear={onClear} />
|
||||
);
|
||||
const inputField = container.querySelector('input');
|
||||
|
||||
if (inputField) {
|
||||
fireEvent.focus(inputField);
|
||||
}
|
||||
|
||||
const clearIcon = container.querySelector('svg');
|
||||
expect(clearIcon).not.toBeNull();
|
||||
|
||||
if (clearIcon) {
|
||||
fireEvent.mouseDown(clearIcon);
|
||||
expect(onClear).toBeCalledWith();
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1,48 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import type { HTMLProps } from 'react';
|
||||
|
||||
import ClearIcon from '@/assets/icons/clear-icon.svg';
|
||||
import type { ErrorType } from '@/components/ErrorMessage';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export { default as PasswordInput } from './PasswordInput';
|
||||
export { default as PhoneInput } from './PhoneInput';
|
||||
|
||||
export type Props = HTMLProps<HTMLInputElement> & {
|
||||
className?: string;
|
||||
error?: ErrorType;
|
||||
onClear?: () => void;
|
||||
isErrorStyling?: boolean;
|
||||
};
|
||||
|
||||
const Input = ({
|
||||
className,
|
||||
type = 'text',
|
||||
value,
|
||||
error,
|
||||
isErrorStyling = true,
|
||||
onClear,
|
||||
...rest
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={classNames(styles.wrapper, error && isErrorStyling && styles.error)}>
|
||||
<input type={type} value={value} {...rest} />
|
||||
{value && onClear && (
|
||||
<ClearIcon
|
||||
className={styles.actionButton}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
|
@ -1,62 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.countryCodeSelector {
|
||||
color: var(--color-type-primary);
|
||||
border: none;
|
||||
background: none;
|
||||
width: auto;
|
||||
@include _.flex-row;
|
||||
position: relative;
|
||||
margin-right: _.unit(1);
|
||||
margin-left: _.unit(4);
|
||||
|
||||
> select {
|
||||
appearance: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.mobile) {
|
||||
.countryCodeSelector {
|
||||
font: var(--font-label-1);
|
||||
|
||||
> select option {
|
||||
font: var(--font-label-1);
|
||||
}
|
||||
|
||||
> svg {
|
||||
color: var(--color-type-secondary);
|
||||
margin-left: _.unit(1);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
+ input {
|
||||
// hot fix unknown android bug of input width
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.countryCodeSelector {
|
||||
font: var(--font-body-2);
|
||||
|
||||
> select option {
|
||||
font: var(--font-body-2);
|
||||
}
|
||||
|
||||
> svg {
|
||||
color: var(--color-type-secondary);
|
||||
margin-left: _.unit(2);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
/**
|
||||
* Provide PhoneNumber Format support
|
||||
* Reference [libphonenumber-js](https://gitlab.com/catamphetamine/libphonenumber-js)
|
||||
*/
|
||||
|
||||
import type { CountryCallingCode, CountryCode } from 'libphonenumber-js/mobile';
|
||||
import { parsePhoneNumberWithError, ParseError } from 'libphonenumber-js/mobile';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
getDefaultCountryCallingCode,
|
||||
getCountryList,
|
||||
parseE164Number,
|
||||
} from '@/utils/country-code';
|
||||
|
||||
export type { CountryCallingCode } from 'libphonenumber-js/mobile';
|
||||
|
||||
export type CountryMetaData = {
|
||||
countryCode: CountryCode;
|
||||
countryCallingCode: CountryCallingCode;
|
||||
};
|
||||
|
||||
type PhoneNumberData = {
|
||||
countryCallingCode: string;
|
||||
nationalNumber: string;
|
||||
};
|
||||
|
||||
const isValidPhoneNumber = (value: string): boolean => {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
|
||||
|
||||
return phoneNumber.isValid();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ParseError) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const usePhoneNumber = () => {
|
||||
const [phoneNumber, setPhoneNumber] = useState<PhoneNumberData>({
|
||||
countryCallingCode: getDefaultCountryCallingCode(),
|
||||
nationalNumber: '',
|
||||
});
|
||||
|
||||
return {
|
||||
countryList: getCountryList(),
|
||||
phoneNumber,
|
||||
setPhoneNumber,
|
||||
isValidPhoneNumber,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePhoneNumber;
|
Loading…
Reference in a new issue