diff --git a/apps/site/src/app/(home)/page.tsx b/apps/site/src/app/(home)/page.tsx index ba4cd8fdd..65097a201 100644 --- a/apps/site/src/app/(home)/page.tsx +++ b/apps/site/src/app/(home)/page.tsx @@ -5,7 +5,6 @@ import { type ArticleRowsProps } from './components/home-page/home-page.types'; export default async function Homepage() { const [urls, home] = await Promise.all([reader().singletons.url.read(), reader().singletons.homePage.readOrThrow()]); - const articleRows = await Promise.all( home.articleRows.map(row => { return new Promise((resolve, reject) => { @@ -30,7 +29,7 @@ export default async function Homepage() { ); return ( -
+
diff --git a/packages/ui/src/components/pass-code/pass-code.component.tsx b/packages/ui/src/components/pass-code/pass-code.component.tsx index dbcd78ebf..8f3f300b9 100644 --- a/packages/ui/src/components/pass-code/pass-code.component.tsx +++ b/packages/ui/src/components/pass-code/pass-code.component.tsx @@ -1,97 +1,157 @@ +/* eslint-disable sonarjs/cognitive-complexity */ 'use client'; -import React, { ChangeEvent, ClipboardEvent, KeyboardEvent, useCallback, useRef, useState } from 'react'; +import React, { + ChangeEvent, + ClipboardEvent, + FocusEvent, + KeyboardEvent, + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, +} from 'react'; import { Input } from '../index.js'; import { styles as passCodeStyles } from './pass-code.styles.js'; -import { type PassCodeProps } from './pass-code.types.js'; +import { PassCodeProps, PassCodeRef } from './pass-code.types.js'; -export function PassCode({ length, onComplete, className, ...props }: PassCodeProps) { - const [passcode, setPasscode] = useState<(string | undefined)[]>(Array.from({ length }).map(() => undefined)); - const inputRefs = useRef>([]); +export const PassCode = forwardRef( + ({ length, value, onChange, onComplete, className, type = 'alphanumeric', onBlur, ...props }, ref) => { + const [internalPasscode, setInternalPasscode] = useState(Array.from({ length }).map(() => '')); + const passcode = value ? value : internalPasscode; + const inputRefs = useRef>([]); - const styles = passCodeStyles({}); + const styles = passCodeStyles({}); - const handleChange = useCallback( - (index: number, event: ChangeEvent) => { - const value = event.target.value.slice(-1); + useImperativeHandle(ref, () => ({ + focus: () => { + inputRefs.current[0]?.focus(); + }, + clear: () => { + setInternalPasscode(Array.from({ length }).map(() => '')); + }, + })); - // Update the passcode state - const newPasscode = [...passcode.slice(0, index), value, ...passcode.slice(index + 1)]; - setPasscode(newPasscode); + const handleChange = useCallback( + (index: number, event: ChangeEvent) => { + const inputValue = event.target.value.slice(-1); + if ( + (type === 'numbers' && /^\d$/.test(inputValue)) || + (type === 'letters' && /^[a-zA-Z]$/.test(inputValue)) || + (type === 'alphanumeric' && /^[a-zA-Z0-9]$/.test(inputValue)) + ) { + const newPasscode = [...passcode.slice(0, index), inputValue, ...passcode.slice(index + 1)]; + if (onChange) { + onChange(newPasscode); + } else { + setInternalPasscode(newPasscode); + } - // Move to the next input if available - if (index < length - 1 && value !== '') { - inputRefs.current[index + 1]?.focus(); - } - - // Call onComplete when passcode is complete - if (newPasscode.filter(passcode => !passcode).length === 0) { - onComplete(newPasscode.join('')); - } - }, - [passcode, length, onComplete], - ); - - const handlePaste = useCallback( - (index: number, event: ClipboardEvent) => { - event.preventDefault(); - const pastedData = event.clipboardData.getData('text'); - const validData = pastedData.slice(0, length - index).split(''); - const previousSlice = passcode.slice(0, index); - const afterSlice = passcode.slice(index); - const newPasscode = [...previousSlice, ...[...validData, ...afterSlice.slice(validData.length)]].slice(0, length); - setPasscode(newPasscode); - if (newPasscode.filter(passcode => !passcode).length === 0) { - onComplete(newPasscode.join('')); - } - }, - [length, onComplete, passcode], - ); + // Move to the next input if available + if (index < length - 1 && inputValue !== '') { + inputRefs.current[index + 1]?.focus(); + } + if (newPasscode.filter(passcode => !passcode).length === 0 && onComplete) { + onComplete(newPasscode.join('')); + } + } + }, + [passcode, length, onChange, onComplete, type], + ); - const handleKeyDown = useCallback( - (index: number, event: KeyboardEvent) => { - if (event.key === 'Backspace' && index > 0) { + const handlePaste = useCallback( + (index: number, event: ClipboardEvent) => { event.preventDefault(); - const newPasscode = [...passcode.slice(0, index), undefined, ...passcode.slice(index + 1)]; - setPasscode(newPasscode); - const previousInput = inputRefs.current[index - 1]; - const currentInput = inputRefs.current[index]; - if (previousInput) { - previousInput.focus(); + const pastedData = event.clipboardData.getData('text'); + const validData = pastedData + .slice(0, length - index) + .split('') + .filter(char => { + if (type === 'numbers') return /^\d$/.test(char); + if (type === 'letters') return /^[a-zA-Z]$/.test(char); + return /^[a-zA-Z0-9]$/.test(char); + }); + const previousSlice = passcode.slice(0, index); + const afterSlice = passcode.slice(index); + const newPasscode = [...previousSlice, ...[...validData, ...afterSlice.slice(validData.length)]].slice( + 0, + length, + ); + if (onChange) { + onChange(newPasscode); + } else { + setInternalPasscode(newPasscode); + } + if (newPasscode.filter(passcode => !passcode).length === 0 && onComplete) { + onComplete(newPasscode.join('')); } - if (currentInput) { - currentInput.value = ''; + }, + [passcode, length, onChange, onComplete, type], + ); + + const handleKeyDown = useCallback( + (index: number, event: KeyboardEvent) => { + if (event.key === 'Backspace') { + event.preventDefault(); + const newPasscode = [...passcode.slice(0, index), '', ...passcode.slice(index + 1)]; + if (onChange) { + onChange(newPasscode); + } else { + setInternalPasscode(newPasscode); + } + const previousInput = inputRefs.current[index - 1]; + const currentInput = inputRefs.current[index]; + if (previousInput) { + previousInput.focus(); + } + if (currentInput) { + currentInput.value = ''; + } + } + }, + [passcode, onChange], + ); + + const handleFocus = useCallback( + (index: number) => { + inputRefs.current[index]?.select(); + }, + [inputRefs], + ); + + const handleBlur = useCallback( + (index: number, event: FocusEvent) => { + if (onBlur) { + onBlur(index, event); } - } - }, - [passcode], - ); + }, + [onBlur], + ); - const handleFocus = useCallback( - (index: number) => { - inputRefs.current[index]?.select(); - }, - [inputRefs], - ); + return ( +
+ {Array.from({ length }).map((_, index) => ( + handleChange(index, e)} + onPaste={e => handlePaste(index, e)} + onKeyDown={e => handleKeyDown(index, e)} + onFocus={() => handleFocus(index)} + onBlur={e => handleBlur(index, e)} + ref={input => (inputRefs.current[index] = input)} + className={styles.input({})} + aria-label={`Passcode digit ${index + 1}`} + inputMode={type === 'numbers' ? 'numeric' : 'text'} + /> + ))} +
+ ); + }, +); - return ( -
- {passcode.map((digit, index) => ( - handleChange(index, e)} - onPaste={e => handlePaste(index, e)} - onKeyDown={e => handleKeyDown(index, e)} - onFocus={() => handleFocus(index)} - ref={input => (inputRefs.current[index] = input)} - className={styles.input({})} - aria-label={`Passcode digit ${index + 1}`} - /> - ))} -
- ); -} +PassCode.displayName = 'PassCode'; diff --git a/packages/ui/src/components/pass-code/pass-code.patterns.stories.tsx b/packages/ui/src/components/pass-code/pass-code.patterns.stories.tsx new file mode 100644 index 000000000..310988f5e --- /dev/null +++ b/packages/ui/src/components/pass-code/pass-code.patterns.stories.tsx @@ -0,0 +1,156 @@ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable no-console */ +import { type Meta, StoryFn } from '@storybook/react'; +import { useRef, useState } from 'react'; + +import { PadlockIcon } from '../icon/index.js'; +import { Alert, Button } from '../index.js'; + +import { PassCode } from './pass-code.component.js'; +import { PassCodeRef } from './pass-code.types.js'; + +const meta: Meta = { + title: 'Components/PassCode/Patterns', + component: PassCode, + tags: ['autodocs'], + decorators: [(Story: StoryFn) => ], + parameters: { + layout: 'centered', + }, +}; + +export default meta; + +/** + * > SMS + */ +export const SMS = () => { + const [value, setValue] = useState(Array.from({ length: 4 }).map(() => '')); + const [error, setError] = useState(false); + const [alert, setAlert] = useState(false); + + const ref = useRef(null); + + const reset = () => { + setValue(Array.from({ length: 4 }).map(() => '')); // clear + ref.current?.focus(); + }; + + const handleChange = (val: string[]) => { + if (alert && val.some(Boolean)) { + setAlert(false); + } + + if (error) { + setError(false); + } + + setValue(val); + }; + + const handleResend = () => { + reset(); + setAlert(true); + }; + + const handleComplete = (code: string) => { + if (code === '1234') { + console.log('Correct code inputted'); + } else { + setError(true); + reset(); + } + }; + + return ( +
+ +

Enter SMS code

+

Your security code (1234) has been sent to your mobile number **** **** *XXXX

+ {error && ( + + Enter a valid 6 digit code using only numbers, you have X remaining challenge attempts. + + )} + + {alert && A text message with a new verification code was just sent to your mobile.} +

+ Didn't receive your code? + +

+
+ ); +}; + +export const SMSWithSubmit = () => { + const [value, setValue] = useState(Array.from({ length: 4 }).map(() => '')); + const [error, setError] = useState(false); + const [alert, setAlert] = useState(false); + + const ref = useRef(null); + + const reset = () => { + setValue(Array.from({ length: 4 }).map(() => '')); // clear + ref.current?.focus(); + }; + + const handleChange = (val: string[]) => { + if (alert && val.some(Boolean)) { + setAlert(false); + } + + if (error) { + setError(false); + } + + setValue(val); + }; + + const handleResend = () => { + reset(); + setAlert(true); + }; + + const handleSubmit = () => { + console.log(value.join('')); + if (value.join('') === '1234') { + console.log('Correct code inputted'); + } else { + setError(true); + reset(); + } + }; + + return ( +
+ +

Enter SMS code

+

Your security code (1234) has been sent to your mobile number **** **** *XXXX

+ {error && ( + + Enter a valid 6 digit code using only numbers, you have X remaining challenge attempts. + + )} + + {alert && A text message with a new verification code was just sent to your mobile.} +

+ Didn't receive your code? + +

+ +
+ ); +}; diff --git a/packages/ui/src/components/pass-code/pass-code.stories.tsx b/packages/ui/src/components/pass-code/pass-code.stories.tsx index a2f16d01e..66ddf1c3a 100644 --- a/packages/ui/src/components/pass-code/pass-code.stories.tsx +++ b/packages/ui/src/components/pass-code/pass-code.stories.tsx @@ -1,7 +1,6 @@ /* eslint-disable no-console */ import { type Meta, StoryFn, type StoryObj } from '@storybook/react'; - -import { Button, Link } from '../index.js'; +import { useState } from 'react'; import { PassCode } from './pass-code.component.js'; @@ -21,39 +20,34 @@ type Story = StoryObj; /** * > Default usage example */ -export const DefaultStory: Story = { - args: { - length: 4, - }, -}; +export const Default = () => console.log(val)} />; + +export const Types = () => ( +
+

Alphanumeric

+ console.log(val)} /> +

Numbers only

+ console.log(val)} /> +

Letters only

+ console.log(val)} /> +
+); /** - * > SMS usage example + * > Controlled Input example */ -export const SMSStory = () => { +export const Controlled = () => { + const [value, setValue] = useState(Array.from({ length: 4 }).map(() => '')); + return ( -
-

Enter SMS code

-

- Send to mobile ending ...XXXX{' '} - { - console.log('update'); - }} - > - update - -

- { - console.log('passcode', passcode); - }} - /> - -
+ setValue(val)} + onComplete={(passcode: string) => { + console.log('passcode', passcode); + }} + /> ); }; diff --git a/packages/ui/src/components/pass-code/pass-code.types.ts b/packages/ui/src/components/pass-code/pass-code.types.ts index 422e831ed..a07b822ce 100644 --- a/packages/ui/src/components/pass-code/pass-code.types.ts +++ b/packages/ui/src/components/pass-code/pass-code.types.ts @@ -1,10 +1,46 @@ -import { HTMLAttributes } from 'react'; +import { FocusEvent, HTMLAttributes } from 'react'; import { type VariantProps } from 'tailwind-variants'; import { styles } from './pass-code.styles.js'; export type PassCodeProps = { + /** + * Number of passcode inputs + */ length: number; - onComplete: (passcode: string) => void; + /** + * Callback when the input is blurred + */ + onBlur?: (index: number, event: FocusEvent) => void; + /** + * Callback when the input value changes + */ + onChange?: (passcode: string[]) => void; + /** + * Callback when the passcode is completely typed + */ + onComplete?: (passcode: string) => void; + /** + * Type of passcode input + */ + type?: 'numbers' | 'letters' | 'alphanumeric'; + /** + * Value of the passcode input + */ + value?: string[]; } & VariantProps & - HTMLAttributes; + Omit, 'onChange'>; + +/* + * Passcode input ref used to access the passcode input functions via useImperativeHandle hook + */ +export type PassCodeRef = { + /** + * Clear the passcode input, for non-controlled component only + */ + clear: () => void; + /** + * Focus on the first input + */ + focus: () => void; +};