From caaabf1af757729a6e1bdee8eda311441c5e3bea Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Tue, 11 Aug 2026 15:41:31 +0530 Subject: [PATCH] Enhance OTP field functionality with length and numeric-only options --- .../src/components/factories/FieldFactory.tsx | 18 ++- .../presentation/auth/AuthOptionFactory.tsx | 9 ++ .../primitives/OtpField/OtpField.tsx | 12 +- .../OtpField/__tests__/OtpField.test.tsx | 112 ++++++++++++++++++ .../src/components/factories/FieldFactory.ts | 6 + .../primitives/OtpField/OtpField.ts | 14 ++- 6 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 packages/react/src/components/primitives/OtpField/__tests__/OtpField.test.tsx diff --git a/packages/react/src/components/factories/FieldFactory.tsx b/packages/react/src/components/factories/FieldFactory.tsx index 1209e0dd..0c256291 100644 --- a/packages/react/src/components/factories/FieldFactory.tsx +++ b/packages/react/src/components/factories/FieldFactory.tsx @@ -34,10 +34,18 @@ export interface FieldConfig { * Display name for the field. */ label: string; + /** + * Number of characters for OTP fields. + */ + length?: number; /** * The name of the field. */ name: string; + /** + * Whether an OTP field accepts digits only. Defaults to true. + */ + numericOnly?: boolean; /** * Callback function when the field loses focus. */ @@ -139,6 +147,8 @@ export const createField = (config: FieldConfig): ReactElement => { options = [], touched = false, placeholder, + length, + numericOnly = true, } = config; const validationError: string | null = error || validateFieldValue(value, type, required, touched); @@ -203,7 +213,13 @@ export const createField = (config: FieldConfig): ReactElement => { } case FieldType.Otp: return ( - ): void => onChange(e.target.value)} /> + ): void => onChange(e.target.value)} + /> ); case FieldType.Number: return ( diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index cd32552d..e5bcacb3 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -342,12 +342,21 @@ const createAuthComponentFromFlow = ( const isTouched: boolean = touchedFields[identifier] || false; const error: string = isTouched ? formErrors[identifier] : undefined!; + // The server reports the length and character set of the code it generated, so the field + // matches the OTP the user received. An older server omits both and the defaults apply. + const reportedLength: number = Number(options.additionalData?.['otpLength']); + const otpLength: number | undefined = + Number.isInteger(reportedLength) && reportedLength > 0 ? reportedLength : undefined; + const numericOnly: boolean = options.additionalData?.['otpNumericOnly'] !== 'false'; + const field: any = createField({ className: cx(options.inputClassName, component.classes), error, id: component.id, label: resolve(component.label) || '', + length: otpLength, name: identifier, + numericOnly, onBlur: () => options.onInputBlur?.(identifier), onChange: (newValue: string) => onInputChange(identifier, newValue), placeholder: resolve(component.placeholder) || '', diff --git a/packages/react/src/components/primitives/OtpField/OtpField.tsx b/packages/react/src/components/primitives/OtpField/OtpField.tsx index a3858bdd..a2e6da39 100644 --- a/packages/react/src/components/primitives/OtpField/OtpField.tsx +++ b/packages/react/src/components/primitives/OtpField/OtpField.tsx @@ -78,6 +78,10 @@ export interface OtpInputProps { * Type of input (text, number, password) */ type?: OtpFieldType; + /** + * Upper-case entered characters before emitting them + */ + uppercase?: boolean; /** * Current OTP value */ @@ -100,6 +104,7 @@ const OtpField: FC = ({ style = {}, autoFocus = false, pattern, + uppercase = false, }: OtpInputProps) => { const {theme, colorScheme}: ReturnType = useTheme(); const styles: Record = useStyles(theme, colorScheme, !!disabled, !!error, length); @@ -129,7 +134,7 @@ const OtpField: FC = ({ }, [autoFocus]); const handleChange = (index: number, event: ChangeEvent): void => { - const newValue: string = event.target.value; + const newValue: string = uppercase ? event.target.value.toUpperCase() : event.target.value; if (newValue.length > 1) return; @@ -186,7 +191,10 @@ const OtpField: FC = ({ const handlePaste = (event: ClipboardEvent): void => { event.preventDefault(); - const pastedData: string = event.clipboardData.getData('text').slice(0, length); + const rawData: string = event.clipboardData.getData('text'); + // Filter first and let the copy loop below bound the result, so surrounding text such as + // "Your code is 123456" does not push the code itself past the cut-off. + const pastedData: string = uppercase ? rawData.toUpperCase() : rawData; let validData = ''; diff --git a/packages/react/src/components/primitives/OtpField/__tests__/OtpField.test.tsx b/packages/react/src/components/primitives/OtpField/__tests__/OtpField.test.tsx new file mode 100644 index 00000000..f1a11c97 --- /dev/null +++ b/packages/react/src/components/primitives/OtpField/__tests__/OtpField.test.tsx @@ -0,0 +1,112 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {render, screen, cleanup, fireEvent} from '@testing-library/react'; +import {createTheme} from '@thunderid/browser'; +import {ReactElement} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import ThemeContext, {ThemeContextValue} from '../../../../contexts/Theme/ThemeContext'; +import OtpField from '../OtpField'; + +const themeContextValue: ThemeContextValue = { + colorScheme: 'light', + direction: 'ltr', + theme: createTheme(), + toggleTheme: vi.fn(), +}; + +const withTheme = (ui: ReactElement): ReactElement => ( + {ui} +); + +const boxes = (): HTMLInputElement[] => screen.getAllByRole('textbox'); + +const paste = (target: HTMLElement, text: string): void => { + const event: Event = new Event('paste', {bubbles: true, cancelable: true}); + Object.defineProperty(event, 'clipboardData', {value: {getData: () => text}}); + fireEvent(target, event); +}; + +describe('OtpField', () => { + afterEach(() => { + cleanup(); + }); + + it('renders six boxes by default', () => { + render(withTheme()); + expect(boxes()).toHaveLength(6); + }); + + it('renders the requested number of boxes', () => { + render(withTheme()); + expect(boxes()).toHaveLength(8); + }); + + it('rejects a letter when the field is numeric', () => { + const onChange = vi.fn(); + render(withTheme()); + + fireEvent.change(boxes()[0], {target: {value: 'a'}}); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('accepts a digit when the field is numeric', () => { + const onChange = vi.fn(); + render(withTheme()); + + fireEvent.change(boxes()[0], {target: {value: '7'}}); + + expect(onChange).toHaveBeenCalledWith({target: {value: '7'}}); + }); + + it('marks a numeric field with the numeric input mode', () => { + render(withTheme()); + expect(boxes()[0]).toHaveAttribute('inputmode', 'numeric'); + }); + + it('accepts a letter when the field is alphanumeric', () => { + const onChange = vi.fn(); + render(withTheme()); + + fireEvent.change(boxes()[0], {target: {value: 'K'}}); + + expect(onChange).toHaveBeenCalledWith({target: {value: 'K'}}); + }); + + it('upper-cases entered characters when asked to', () => { + const onChange = vi.fn(); + render(withTheme()); + + fireEvent.change(boxes()[0], {target: {value: 'k'}}); + + expect(onChange).toHaveBeenCalledWith({target: {value: 'K'}}); + }); + + it('upper-cases a pasted code when asked to', () => { + const onChange = vi.fn(); + render(withTheme()); + + paste(boxes()[0], 'k7gx2m'); + + expect(onChange).toHaveBeenCalledWith({target: {value: 'K7GX2M'}}); + }); + + it('keeps a pasted code intact when surrounded by other text', () => { + const onChange = vi.fn(); + render(withTheme()); + + paste(boxes()[0], 'Your code is 123456'); + + expect(onChange).toHaveBeenCalledWith({target: {value: '123456'}}); + }); + + it('calls onComplete once every box is filled', () => { + const onComplete = vi.fn(); + render(withTheme()); + + paste(boxes()[0], '123456'); + + expect(onComplete).toHaveBeenCalledWith('123456'); + }); +}); diff --git a/packages/vue/src/components/factories/FieldFactory.ts b/packages/vue/src/components/factories/FieldFactory.ts index d48a6b74..83aa0321 100644 --- a/packages/vue/src/components/factories/FieldFactory.ts +++ b/packages/vue/src/components/factories/FieldFactory.ts @@ -18,7 +18,9 @@ export interface FieldConfig { disabled?: boolean; error?: string; label: string; + length?: number; name: string; + numericOnly?: boolean; onBlur?: () => void; onChange: (value: string) => void; options?: SelectOption[]; @@ -93,6 +95,8 @@ export const createField = (config: FieldConfig): VNode => { options = [], touched = false, placeholder, + length, + numericOnly = true, } = config; const validationError: string | null | undefined = error || validateFieldValue(value, type, required, touched); @@ -152,6 +156,8 @@ export const createField = (config: FieldConfig): VNode => { case FieldType.Otp: return h(OtpField, { ...commonProps, + ...(length ? {length} : {}), + numericOnly, 'onUpdate:modelValue': onChange, } as Record); diff --git a/packages/vue/src/components/primitives/OtpField/OtpField.ts b/packages/vue/src/components/primitives/OtpField/OtpField.ts index 65f52dd5..0fc11717 100644 --- a/packages/vue/src/components/primitives/OtpField/OtpField.ts +++ b/packages/vue/src/components/primitives/OtpField/OtpField.ts @@ -4,6 +4,11 @@ import {withVendorCSSClassPrefix} from '@thunderid/browser'; import {type Component, type Ref, type SetupContext, type VNode, defineComponent, h, nextTick, ref} from 'vue'; +// Alphanumeric OTPs are minted from an uppercase charset, so the accepted characters are digits +// and uppercase letters only. +const NON_NUMERIC_OTP_CHARS = /[^0-9]/g; +const NON_ALPHANUMERIC_OTP_CHARS = /[^0-9A-Z]/g; + type OtpFieldProps = Readonly<{ disabled: boolean; error: string | undefined; @@ -11,6 +16,7 @@ type OtpFieldProps = Readonly<{ length: number; modelValue: string; name: string | undefined; + numericOnly: boolean; required: boolean; }>; @@ -23,6 +29,7 @@ const OtpField: Component = defineComponent({ length: {default: 6, type: Number}, modelValue: {default: '', type: String}, name: {default: undefined, type: String}, + numericOnly: {default: true, type: Boolean}, required: {default: false, type: Boolean}, }, emits: ['update:modelValue'], @@ -35,7 +42,10 @@ const OtpField: Component = defineComponent({ const handleInput = (index: number, e: Event): void => { const target: HTMLInputElement = e.target as HTMLInputElement; - const val: string = target.value.replace(/\D/g, '').slice(0, 1); + // Alphanumeric codes are minted from an uppercase charset and verified case-sensitively. + const val: string = props.numericOnly + ? target.value.replace(NON_NUMERIC_OTP_CHARS, '').slice(0, 1) + : target.value.toUpperCase().replace(NON_ALPHANUMERIC_OTP_CHARS, '').slice(0, 1); target.value = val; const current: string[] = (props.modelValue || '').split(''); @@ -79,7 +89,7 @@ const OtpField: Component = defineComponent({ 'aria-label': `Digit ${i + 1}`, class: withVendorCSSClassPrefix('otp-field__digit'), disabled: props.disabled, - inputmode: 'numeric', + inputmode: props.numericOnly ? 'numeric' : 'text', key: i, maxlength: 1, onInput: (e: Event) => handleInput(i, e),