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

feat(ui): add passcode input component (#384)

* feat(ui): add passcode input component

add passcode input component

* fix(ui): cr fix

code review fix

* refactor(ui): refactor style sheets

refactor style sheets
This commit is contained in:
simeng-li 2022-03-16 16:04:27 +08:00 committed by GitHub
parent ee175e70fc
commit 0d76b91271
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 457 additions and 31 deletions

View file

@ -1,5 +1,20 @@
@use '@/scss/underscore' as _;
/* Foundation */
$color-neutral-100: #111;
$color-neutral-90: #666;
$color-neutral-70: #999;
$color-neutral-50: #aeaeae;
$color-neutral-30: #d8d8d8;
$color-neutral-10: #f4f4f4;
$color-neutral-0: #fff;
$color-primary: #6139f6;
$color-primary-tint-60: #a48dfa;
$color-primary-tint-70: #b09bfa;
$font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
.content {
position: absolute;
inset: 0;
@ -8,6 +23,8 @@
}
.universal {
--color-error: #ea0000;
/* ===== Legacy Styling ===== */
/* Color */
--color-button-background: #3c4ce3;
@ -15,7 +32,6 @@
--color-button-background-hover: #2234df;
--color-button-text: #f5f5f5;
--color-button-text-disabled: #eee;
--color-error: #ea0000;
--color-error-background: #{rgba(#ff6b66, 0.15)};
--color-error-border: #{rgba(#ff6b66, 0.35)};
@ -47,27 +63,23 @@
.light {
/* Color */
--color-primary: #6139f6;
--color-primary-tint-70: #b09bfa;
--color-gradient: linear-gradient(69.73deg, #492ef3 5.52%, #cf69ff 94.22%);
--color-background: #fdfdff;
--color-control-background: #f4f4f4;
--color-control-focus: #{rgba(#a48dfa, 0.8)};
--color-primary: #{$color-primary};
--color-background: #{$color-neutral-0};
--color-border: #{$color-neutral-100};
--color-border-active: #{$color-neutral-70};
--color-control-background: #{$color-neutral-10};
--color-control-focus: #{$color-primary-tint-60};
--color-control-action: #{$color-neutral-70};
--color-control-action-focus: #{$color-primary-tint-70};
--color-neutral-100: #111;
--color-neutral-90: #666;
--color-neutral-70: #999;
--color-neutral-50: #aeaeae;
--color-neutral-30: #d8d8d8;
--color-neutral-10: #f4f4f4;
--color-neutral-0: #fff;
/* Font Color */
--color-font-primary: #111;
--color-font-primary: #{$color-neutral-100};
--color-font-secondary: #444;
--color-font-tertiary-1: #777;
--color-font-tertiary-2: #999;
--color-font-tertiary-3: #aaa;
--color-font-placeholder: #aaa;
--color-font-button-text: #{$color-neutral-0};
--color-font-button-text-active: #{rgba($color-neutral-0, 0.4)};
--color-font-secondary-active: #{$color-neutral-70};
/* ===== Legacy Styling ===== */
--color-heading: #333;
@ -81,13 +93,12 @@
--shadow-control: 1px 1px 2px rgb(221, 221, 221, 25%);
}
$font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
.mobile {
--font-title: 600 32px/40px #{$font-family};
--font-heading-2: 400 18px/21px #{$font-family};
--font-heading-2-bold: 600 18px/21px #{$font-family};
--font-control: 400 18px/21px #{$font-family};
--font-heading-2: 400 18px/22px #{$font-family};
--font-heading-2-bold: 600 18px/22px #{$font-family};
--font-control: 500 18px/22px #{$font-family};
--font-button-text: 600 20px/24px #{$font-family};
/* ===== Legacy Styling ===== */
--font-headline: 600 40px/56px #{$font-family};

View file

@ -58,7 +58,7 @@ const PasswordInput = ({
/>
{value && onFocus && (
<PrivacyIcon
className={classNames(styles.actionButton, iconType === 'hide' && styles.highlight)}
className={styles.actionButton}
type={iconType}
onMouseDown={(event) => {
event.preventDefault();

View file

@ -7,6 +7,8 @@ import { ClearIcon, DownArrowIcon } from '../Icons';
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;
autoComplete?: AutoCompleteType;
@ -17,7 +19,7 @@ export type Props = {
nationalNumber: string;
countryList?: CountryMetaData[];
hasError?: boolean;
onChange: (value: { countryCallingCode?: CountryCallingCode; nationalNumber?: string }) => void;
onChange: (value: Value) => void;
};
const PhoneInput = ({

View file

@ -31,7 +31,7 @@
transition: var(--transition-default-control);
&::placeholder {
color: var(--color-font-tertiary-3);
color: var(--color-placeholder);
}
&:-webkit-autofill {
@ -42,9 +42,5 @@
}
.actionButton {
fill: var(--color-neutral-70);
&.highlight {
fill: var(--color-primary-tint-70);
}
fill: var(--color-control-action);
}

View file

@ -0,0 +1,38 @@
@use '@/scss/underscore' as _;
.passcode {
@include _.flex-row;
justify-content: space-between;
width: 100%;
max-width: 375px;
input {
border-radius: _.unit(2);
border: _.border();
background: var(--color-control-background);
caret-color: var(--color-primary);
width: _.unit(12);
height: _.unit(12);
text-align: center;
font: var(--font-control);
color: var(--color-font-primary);
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&:focus {
border: _.border(var(--color-control-focus));
}
&.error {
border: _.border(var(--color-error));
}
&::placeholder {
color: var(--color-font-placeholder);
}
}
}

View file

@ -0,0 +1,161 @@
import { render, fireEvent } from '@testing-library/react';
import React from 'react';
import Passcode, { defaultLength } from '.';
describe('Passcode Component', () => {
const onChange = jest.fn();
beforeEach(() => {
onChange.mockClear();
});
it('render with value', () => {
const input = ['1', '2', '3', '4', '5', '6'];
const { container } = render(<Passcode name="passcode" value={input} onChange={onChange} />);
const inputElements = container.querySelectorAll('input');
expect(inputElements).toHaveLength(defaultLength);
for (const [index, element] of inputElements.entries()) {
expect(element.value).toEqual(input[index]);
}
});
it('render with short value', () => {
const input = ['1', '2', '3'];
const { container } = render(<Passcode name="passcode" value={input} onChange={onChange} />);
const inputElements = container.querySelectorAll('input');
expect(inputElements).toHaveLength(defaultLength);
for (const [index, element] of inputElements.entries()) {
if (index < 3) {
expect(element.value).toEqual(input[index]);
} else {
expect(element.value).toEqual('');
}
}
});
it('render with long value', () => {
const input = ['1', '2', '3', '4', '5', '6', '7'];
const { container } = render(<Passcode name="passcode" value={input} onChange={onChange} />);
const inputElements = container.querySelectorAll('input');
expect(inputElements).toHaveLength(defaultLength);
for (const [index, element] of inputElements.entries()) {
expect(element.value).toEqual(input[index]);
}
});
it('on manual input', () => {
const input = ['1', '2', '3', '4', '5', '6'];
const { container } = render(<Passcode name="passcode" value={input} onChange={onChange} />);
const inputElements = container.querySelectorAll('input');
if (inputElements[2]) {
fireEvent.input(inputElements[2], { target: { value: '7' } });
expect(onChange).toBeCalledWith(['1', '2', '7', '4', '5', '6']);
expect(document.activeElement).toEqual(inputElements[3]);
}
});
it('on manual input with non-numric input', () => {
const input = ['1', '2', '3', '4', '5', '6'];
const { container } = render(<Passcode name="passcode" value={input} onChange={onChange} />);
const inputElements = container.querySelectorAll('input');
if (inputElements[2]) {
fireEvent.input(inputElements[2], { target: { value: 'a' } });
expect(onChange).not.toBeCalled();
}
});
it('replace old value with new input char', () => {
const input = ['1', '2', '3', '4', '5', '6'];
const { container } = render(<Passcode name="passcode" value={input} onChange={onChange} />);
const inputElements = container.querySelectorAll('input');
if (inputElements[2]) {
fireEvent.input(inputElements[2], { target: { value: '37' } });
expect(onChange).toBeCalledWith(['1', '2', '7', '4', '5', '6']);
}
});
it('onKeyDown handler', () => {
const input = ['1', '2', '3', '4', '5', ''];
const { container } = render(<Passcode name="passcode" value={input} onChange={onChange} />);
const inputElements = container.querySelectorAll('input');
// Backspace on empty input
if (inputElements[5]) {
fireEvent.keyDown(inputElements[5], { key: 'Backspace' });
expect(document.activeElement).toEqual(inputElements[4]);
}
// ArrowLeft
if (inputElements[5]) {
fireEvent.keyDown(inputElements[5], { key: 'ArrowLeft' });
expect(document.activeElement).toEqual(inputElements[4]);
}
// ArrowRight
if (inputElements[4]) {
fireEvent.keyDown(inputElements[4], { key: 'ArrowRight' });
expect(document.activeElement).toEqual(inputElements[5]);
}
});
it('onPasteHandler', () => {
const input = ['1', '2', '3', '4', '5', '6'];
const { container } = render(<Passcode name="passcode" value={input} onChange={onChange} />);
const inputElements = container.querySelectorAll('input');
// Full update
if (inputElements[0]) {
fireEvent.paste(inputElements[0], {
clipboardData: {
getData: () => '789012',
},
});
expect(onChange).toBeCalledWith(['7', '8', '9', '0', '1', '2']);
}
// Partial update
if (inputElements[2]) {
fireEvent.paste(inputElements[2], {
clipboardData: {
getData: () => '789',
},
});
expect(onChange).toBeCalledWith(['1', '2', '7', '8', '9', '6']);
}
// OverLength update
if (inputElements[4]) {
fireEvent.paste(inputElements[4], {
clipboardData: {
getData: () => '7890',
},
});
expect(onChange).toBeCalledWith(['1', '2', '3', '4', '7', '8']);
}
onChange.mockClear();
// Non-numeric past data
if (inputElements[0]) {
fireEvent.paste(inputElements[0], {
clipboardData: {
getData: () => 'test input',
},
});
expect(onChange).not.toBeCalled();
}
});
});

View file

@ -0,0 +1,217 @@
import classNames from 'classnames';
import React, {
useMemo,
useRef,
useCallback,
FormEventHandler,
KeyboardEventHandler,
FocusEventHandler,
ClipboardEventHandler,
} from 'react';
import * as styles from './index.module.scss';
export const defaultLength = 6;
export type Props = {
name: string;
isDisabled?: boolean;
className?: string;
length?: number;
value: string[];
hasError?: boolean;
onChange: (value: string[]) => void;
};
const isNumeric = (char: string) => /^\d*$/.test(char);
const normalize = (value: string[], length: number): string[] => {
if (value.length > length) {
return value.slice(0, length);
}
if (value.length < length) {
return value.concat(Array.from({ length: length - value.length }));
}
return value;
};
const trim = (oldValue: string | undefined, newValue: string) => {
// Trim oldValue from the latest input to get the updated char
if (newValue.length > 1 && oldValue) {
return newValue.replace(oldValue, '');
}
return newValue;
};
const Passcode = ({
name,
isDisabled,
className,
value,
length = defaultLength,
hasError,
onChange,
}: Props) => {
/* eslint-disable @typescript-eslint/ban-types */
const inputReferences = useRef<Array<HTMLInputElement | null>>(
Array.from<null>({ length }).fill(null)
);
/* eslint-enable @typescript-eslint/ban-types */
const codes = useMemo(() => normalize(value, length), [length, value]);
const onInputHandler: FormEventHandler<HTMLInputElement> = useCallback(
(event) => {
const { target } = event;
if (!(target instanceof HTMLInputElement)) {
return;
}
const { value, dataset } = target;
// Unrecognized target input field
if (dataset.id === undefined) {
return;
}
event.preventDefault();
// Filter non-numeric input
if (!isNumeric(value)) {
return;
}
const targetId = Number(dataset.id);
// Update the total input value
onChange(Object.assign([], codes, { [targetId]: trim(codes[targetId], value) }));
// Move to the next target
if (value) {
const nextTarget = inputReferences.current[targetId + 1];
nextTarget?.focus();
nextTarget?.select();
}
},
[codes, onChange]
);
const onKeyDownHandler: KeyboardEventHandler<HTMLInputElement> = useCallback((event) => {
const { key, target } = event;
if (!(target instanceof HTMLInputElement)) {
return;
}
const { value, dataset } = target;
if (!dataset.id) {
return;
}
const targetId = Number(dataset.id);
const nextTarget = inputReferences.current[targetId + 1];
const previousTarget = inputReferences.current[targetId - 1];
switch (key) {
case 'Backspace':
if (!value) {
previousTarget?.focus();
previousTarget?.select();
}
break;
case 'ArrowLeft':
event.preventDefault();
previousTarget?.focus();
previousTarget?.select();
break;
case 'ArrowRight':
event.preventDefault();
nextTarget?.focus();
nextTarget?.select();
break;
case 'ArrowUp':
case 'ArrowDown':
event.preventDefault();
break;
default:
break;
}
}, []);
const onFocusHandler: FocusEventHandler<HTMLInputElement> = useCallback(({ target }) => {
target.select();
}, []);
const onPasteHandler: ClipboardEventHandler<HTMLInputElement> = useCallback(
(event) => {
if (!(event.target instanceof HTMLInputElement)) {
return;
}
const {
target: { dataset },
clipboardData,
} = event;
if (!dataset.id) {
return;
}
event.preventDefault();
const data = clipboardData.getData('text');
if (!data || !isNumeric(data)) {
return;
}
const chars = data.split('');
const targetId = Number(dataset.id);
const trimmedChars = chars.slice(0, Math.min(chars.length, codes.length - targetId));
const value = [
...codes.slice(0, targetId),
...trimmedChars,
...codes.slice(targetId + trimmedChars.length, codes.length),
];
onChange(value);
},
[codes, onChange]
);
return (
<div className={classNames(styles.passcode, className)}>
{Array.from({ length }).map((_, index) => (
<input
ref={(element) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
inputReferences.current[index] = element;
}}
// eslint-disable-next-line react/no-array-index-key
key={`${name}_${index}`}
name={`${name}_${index}`}
data-id={index}
disabled={isDisabled}
value={codes[index]}
className={hasError ? styles.error : undefined}
type="text"
inputMode="numeric"
maxLength={2} // Allow overwrite input
onPaste={onPasteHandler}
onInput={onInputHandler}
onKeyDown={onKeyDownHandler}
onFocus={onFocusHandler}
/>
))}
</div>
);
};
export default Passcode;

View file

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logto</title>
</head>