diff --git a/packages/ui/src/components/AppContent/index.module.scss b/packages/ui/src/components/AppContent/index.module.scss index 362cf0a54..2f1fbae3a 100644 --- a/packages/ui/src/components/AppContent/index.module.scss +++ b/packages/ui/src/components/AppContent/index.module.scss @@ -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}; diff --git a/packages/ui/src/components/Input/PasswordInput.tsx b/packages/ui/src/components/Input/PasswordInput.tsx index 30daa6ac1..5ee7e1906 100644 --- a/packages/ui/src/components/Input/PasswordInput.tsx +++ b/packages/ui/src/components/Input/PasswordInput.tsx @@ -58,7 +58,7 @@ const PasswordInput = ({ /> {value && onFocus && ( { event.preventDefault(); diff --git a/packages/ui/src/components/Input/PhoneInput.tsx b/packages/ui/src/components/Input/PhoneInput.tsx index 0430fcfa3..874d0ee4a 100644 --- a/packages/ui/src/components/Input/PhoneInput.tsx +++ b/packages/ui/src/components/Input/PhoneInput.tsx @@ -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 = ({ diff --git a/packages/ui/src/components/Input/index.module.scss b/packages/ui/src/components/Input/index.module.scss index 367807f8d..bb4c819c9 100644 --- a/packages/ui/src/components/Input/index.module.scss +++ b/packages/ui/src/components/Input/index.module.scss @@ -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); } diff --git a/packages/ui/src/components/Passcode/index.module.scss b/packages/ui/src/components/Passcode/index.module.scss new file mode 100644 index 000000000..5f54d074d --- /dev/null +++ b/packages/ui/src/components/Passcode/index.module.scss @@ -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); + } + } +} diff --git a/packages/ui/src/components/Passcode/index.test.tsx b/packages/ui/src/components/Passcode/index.test.tsx new file mode 100644 index 000000000..aaec550d4 --- /dev/null +++ b/packages/ui/src/components/Passcode/index.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + } + }); +}); diff --git a/packages/ui/src/components/Passcode/index.tsx b/packages/ui/src/components/Passcode/index.tsx new file mode 100644 index 000000000..27663c773 --- /dev/null +++ b/packages/ui/src/components/Passcode/index.tsx @@ -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.from({ length }).fill(null) + ); + /* eslint-enable @typescript-eslint/ban-types */ + + const codes = useMemo(() => normalize(value, length), [length, value]); + + const onInputHandler: FormEventHandler = 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 = 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 = useCallback(({ target }) => { + target.select(); + }, []); + + const onPasteHandler: ClipboardEventHandler = 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 ( +
+ {Array.from({ length }).map((_, index) => ( + { + // 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} + /> + ))} +
+ ); +}; + +export default Passcode; diff --git a/packages/ui/src/index.html b/packages/ui/src/index.html index 67f86e2d9..50599d8f4 100644 --- a/packages/ui/src/index.html +++ b/packages/ui/src/index.html @@ -3,6 +3,7 @@ + Logto