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:
parent
ee175e70fc
commit
0d76b91271
8 changed files with 457 additions and 31 deletions
|
@ -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};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 = ({
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
38
packages/ui/src/components/Passcode/index.module.scss
Normal file
38
packages/ui/src/components/Passcode/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
161
packages/ui/src/components/Passcode/index.test.tsx
Normal file
161
packages/ui/src/components/Passcode/index.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
217
packages/ui/src/components/Passcode/index.tsx
Normal file
217
packages/ui/src/components/Passcode/index.tsx
Normal 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;
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Logto</title>
|
||||
</head>
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue