From 138a24f285ef1c83015ca1c74b0c50237e23851a Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 1 May 2026 14:55:28 +1000 Subject: [PATCH 1/4] [otp field] Compose sanitizeValue with validation --- .../css-modules/index.module.css | 5 - .../custom-sanitize/css-modules/index.tsx | 13 +-- .../react/components/otp-field/page.mdx | 10 +- .../react/components/otp-field/types.md | 53 +++++---- docs/src/error-codes.json | 3 +- .../src/otp-field/input/OTPFieldInput.tsx | 45 ++++---- .../src/otp-field/root/OTPFieldRoot.test.tsx | 109 +++++++++++++++++- .../react/src/otp-field/root/OTPFieldRoot.tsx | 47 ++++---- .../react/src/otp-field/utils/otp.test.ts | 64 ++++++++-- packages/react/src/otp-field/utils/otp.ts | 101 +++++++++++++--- 10 files changed, 330 insertions(+), 120 deletions(-) diff --git a/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.module.css b/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.module.css index 89b23f66487..6b0d480f505 100644 --- a/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.module.css +++ b/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.module.css @@ -72,11 +72,6 @@ color: var(--color-gray-600); } -.Code { - font-family: - ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; -} - .ScreenReaderOnly { position: absolute; width: 1px; diff --git a/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.tsx b/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.tsx index 1245015033f..484c2547760 100644 --- a/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.tsx @@ -6,8 +6,8 @@ import styles from './index.module.css'; const CODE_LENGTH = 6; -function sanitizeTierCode(value: string) { - return value.replace(/[^0-3]/g, ''); +function sanitizeRecoveryCode(value: string) { + return value.toUpperCase(); } function getInvalidClassName(invalidPulse: number, evenClassName: string, oddClassName: string) { @@ -40,14 +40,13 @@ export default function OTPFieldCustomSanitizeDemo() { return (

- Digits 0-3 only. + Letters and digits only. Letters are converted to uppercase.

{statusMessage} diff --git a/docs/src/app/(docs)/react/components/otp-field/page.mdx b/docs/src/app/(docs)/react/components/otp-field/page.mdx index 7220cbf6bf7..93e8d1a838d 100644 --- a/docs/src/app/(docs)/react/components/otp-field/page.mdx +++ b/docs/src/app/(docs)/react/components/otp-field/page.mdx @@ -108,10 +108,12 @@ import { DemoOTPFieldFocusedPlaceholder } from './demos/focused-placeholder'; ### Custom sanitization -Set `validationType="none"` with `sanitizeValue` when you need to normalize pasted values before -they reach state or apply custom validation rules. Use `inputMode` when a custom rule still needs a -specific virtual keyboard hint, and `onValueInvalid` when you want to react to rejected -characters. +Use `sanitizeValue` to normalize accepted values before state updates, such as converting +alphanumeric codes to uppercase. It runs after `validationType` filtering, and the result is checked +against `validationType` again. Use `validationType="none"` when the sanitizer should provide the +full validation rule. + +Pair custom rules with `inputMode` for keyboard hints and `onValueInvalid` for rejected characters. import { DemoOTPFieldCustomSanitize } from './demos/custom-sanitize'; diff --git a/docs/src/app/(docs)/react/components/otp-field/types.md b/docs/src/app/(docs)/react/components/otp-field/types.md index 7400efe564e..a3665ff5b0f 100644 --- a/docs/src/app/(docs)/react/components/otp-field/types.md +++ b/docs/src/app/(docs)/react/components/otp-field/types.md @@ -11,29 +11,29 @@ Renders a `
` element. **Root Props:** -| Prop | Type | Default | Description | -| :-------------- | :---------------------------------------------------------------------------------------- | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| name | `string` | - | Identifies the field when a form is submitted. | -| defaultValue | `string` | - | The uncontrolled OTP value when the component is initially rendered. | -| value | `string` | - | The OTP value. | -| onValueChange | `((value: string, eventDetails: OTPFieldPreview.Root.ChangeEventDetails) => void)` | - | Callback fired when the OTP value changes. The `eventDetails.reason` indicates what triggered the change: `'input-change'` for typing or autofill`'input-clear'` when a character is removed by text input`'input-paste'` for paste interactions`'keyboard'` for keyboard interactions that change the value | -| autoComplete | `string` | `'one-time-code'` | The input autocomplete attribute. Applied to the first slot and hidden validation input. | -| autoSubmit | `boolean` | `false` | Whether to submit the owning form when the OTP becomes complete. | -| form | `string` | - | A string specifying the `form` element with which the hidden input is associated. This string's value must match the id of a `form` element in the same document. | -| inputMode | `'none' \| 'text' \| 'tel' \| 'url' \| 'email' \| 'numeric' \| 'decimal' \| 'search'` | - | The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed. | -| length\* | `number` | - | The number of OTP input slots. Required so the root can clamp values, detect completion, and generate consistent validation markup before all slots hydrate. | -| mask | `boolean` | `false` | Whether the slot inputs should mask entered characters. Pass `type` directly to individual `` parts to use a custom input type. | -| onValueComplete | `((value: string, eventDetails: OTPFieldPreview.Root.CompleteEventDetails) => void)` | - | Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than `onValueChange`, after the internal value update is applied. If a complete pasted value matches the current value, `onValueChange` does not fire. If `autoSubmit` is enabled, it runs immediately before the owning form is submitted. | -| onValueInvalid | `((value: string, eventDetails: OTPFieldPreview.Root.InvalidEventDetails) => void)` | - | Callback fired when entered text contains characters that are rejected by sanitization, before the OTP value updates. The `value` argument is the attempted user-entered string before sanitization. | -| sanitizeValue | `((value: string) => string)` | - | Function for custom sanitization when `validationType` is set to `'none'`. This function runs before updating the OTP value from user interactions. | -| validationType | `OTPFieldPreview.Root.ValidationType` | `'numeric'` | The type of input validation to apply to the OTP value. | -| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | -| readOnly | `boolean` | `false` | Whether the user should be unable to change the field value. | -| required | `boolean` | `false` | Whether the user must enter a value before submitting a form. | -| id | `string` | - | The id of the first input element. Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on). | -| className | `string \| ((state: OTPFieldRootState) => string \| undefined)` | - | CSS class applied to the element, or a function that returns a class based on the component's state. | -| style | `React.CSSProperties \| ((state: OTPFieldRootState) => React.CSSProperties \| undefined)` | - | Style applied to the element, or a function that returns a style object based on the component's state. | -| render | `ReactElement \| ((props: HTMLProps, state: OTPFieldRootState) => ReactElement)` | - | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | +| Prop | Type | Default | Description | +| :-------------- | :---------------------------------------------------------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| name | `string` | - | Identifies the field when a form is submitted. | +| defaultValue | `string` | - | The uncontrolled OTP value when the component is initially rendered. | +| value | `string` | - | The OTP value. | +| onValueChange | `((value: string, eventDetails: OTPFieldPreview.Root.ChangeEventDetails) => void)` | - | Callback fired when the OTP value changes. The `eventDetails.reason` indicates what triggered the change: `'input-change'` for typing or autofill`'input-clear'` when a character is removed by text input`'input-paste'` for paste interactions`'keyboard'` for keyboard interactions that change the value | +| autoComplete | `string` | `'one-time-code'` | The input autocomplete attribute. Applied to the first slot and hidden validation input. | +| autoSubmit | `boolean` | `false` | Whether to submit the owning form when the OTP becomes complete. | +| form | `string` | - | A string specifying the `form` element with which the hidden input is associated. This string's value must match the id of a `form` element in the same document. | +| inputMode | `'none' \| 'text' \| 'tel' \| 'url' \| 'email' \| 'numeric' \| 'decimal' \| 'search'` | - | The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed. | +| length\* | `number` | - | The number of OTP input slots. Required so the root can clamp values, detect completion, and generate consistent validation markup before all slots hydrate. | +| mask | `boolean` | `false` | Whether the slot inputs should mask entered characters. Pass `type` directly to individual `` parts to use a custom input type. | +| onValueComplete | `((value: string, eventDetails: OTPFieldPreview.Root.CompleteEventDetails) => void)` | - | Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than `onValueChange`, after the internal value update is applied. If a complete pasted value matches the current value, `onValueChange` does not fire. If `autoSubmit` is enabled, it runs immediately before the owning form is submitted. | +| onValueInvalid | `((value: string, eventDetails: OTPFieldPreview.Root.InvalidEventDetails) => void)` | - | Callback fired when entered text contains characters that are rejected by sanitization, before the OTP value updates. The `value` argument is the attempted user-entered string before sanitization. | +| sanitizeValue | `((value: string) => string)` | - | Function for custom sanitization after whitespace and `validationType` filtering. This function runs before updating the OTP value from user interactions. The returned value is filtered by `validationType` again, then clamped to `length`. It should be idempotent because the component may normalize controlled values on every render and normalize again for each user edit. Characters removed by this function are reported through `onValueInvalid`. | +| validationType | `OTPFieldPreview.Root.ValidationType` | `'numeric'` | The type of input validation to apply to the OTP value. | +| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | +| readOnly | `boolean` | `false` | Whether the user should be unable to change the field value. | +| required | `boolean` | `false` | Whether the user must enter a value before submitting a form. | +| id | `string` | - | The id of the first input element. Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on). | +| className | `string \| ((state: OTPFieldRootState) => string \| undefined)` | - | CSS class applied to the element, or a function that returns a class based on the component's state. | +| style | `React.CSSProperties \| ((state: OTPFieldRootState) => React.CSSProperties \| undefined)` | - | Style applied to the element, or a function that returns a style object based on the component's state. | +| render | `ReactElement \| ((props: HTMLProps, state: OTPFieldRootState) => ReactElement)` | - | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | **Root Data Attributes:** @@ -363,8 +363,13 @@ type OTPFieldRootProps = { */ validationType?: OTPFieldRoot.ValidationType; /** - * Function for custom sanitization when `validationType` is set to `'none'`. + * Function for custom sanitization after whitespace and `validationType` filtering. * This function runs before updating the OTP value from user interactions. + * + * The returned value is filtered by `validationType` again, then clamped to `length`. + * It should be idempotent because the component may normalize controlled values on every render + * and normalize again for each user edit. Characters removed by this function are reported + * through `onValueInvalid`. */ sanitizeValue?: (value: string) => string; /** diff --git a/docs/src/error-codes.json b/docs/src/error-codes.json index f879c383ecf..336482ebc29 100644 --- a/docs/src/error-codes.json +++ b/docs/src/error-codes.json @@ -95,5 +95,6 @@ "95": "Base UI: SharedCalendarDayGridBodyContext is missing. must be placed within and must be placed within .", "96": "Base UI: SharedCalendarDayGridCellContext is missing. must be placed within and must be placed within .", "97": "Base UI: SharedCalendarRootContext is missing. Calendar parts must be placed within and Range Calendar parts must be placed within .", - "98": "Base UI: OTPFieldRootContext is missing. OTPField parts must be placed within ." + "98": "Base UI: OTPFieldRootContext is missing. OTPField parts must be placed within .", + "99": "Base UI: `sanitizeValue` must return a string. Returning a non-string value prevents the OTP value from being normalized. Ensure `sanitizeValue` returns the sanitized string." } diff --git a/packages/react/src/otp-field/input/OTPFieldInput.tsx b/packages/react/src/otp-field/input/OTPFieldInput.tsx index 8dbae4527bf..f9182d135f1 100644 --- a/packages/react/src/otp-field/input/OTPFieldInput.tsx +++ b/packages/react/src/otp-field/input/OTPFieldInput.tsx @@ -17,12 +17,7 @@ import { REASONS } from '../../internals/reasons'; import { useOTPFieldRootContext, getOTPFieldInputState } from '../root/OTPFieldRootContext'; import type { OTPFieldRootState } from '../root/OTPFieldRoot'; import { inputStateAttributesMapping } from '../utils/stateAttributesMapping'; -import { - normalizeOTPValue, - removeOTPCharacter, - replaceOTPValue, - stripOTPWhitespace, -} from '../utils/otp'; +import { removeOTPCharacter, replaceOTPValueWithDetails } from '../utils/otp'; /** * An individual OTP character input. @@ -143,13 +138,15 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( } const rawValue = event.currentTarget.value; - const nextDigits = normalizeOTPValue( - event.currentTarget.value, + const replacementDetails = replaceOTPValueWithDetails( + value, + index, + rawValue, length, validationType, sanitizeValue, ); - const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; + const { value: nextValue, didSanitize, insertionLength } = replacementDetails; if (didSanitize) { reportValueInvalid( @@ -158,7 +155,7 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( ); } - if (nextDigits === '') { + if (insertionLength === 0) { if (rawValue === '') { setValue( removeOTPCharacter(value, index), @@ -171,22 +168,13 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( return; } - const nextValue = replaceOTPValue( - value, - index, - nextDigits, - length, - validationType, - sanitizeValue, - ); - const committedValue = setValue( nextValue, createChangeEventDetails(REASONS.inputChange, event.nativeEvent), ); if (committedValue != null) { - const nextInput = Math.min(index + nextDigits.length, length - 1); + const nextInput = Math.min(index + insertionLength, length - 1); queueFocusInput(nextInput, committedValue); } }, @@ -291,8 +279,15 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( event.preventDefault(); - const nextDigits = normalizeOTPValue(rawValue, length, validationType, sanitizeValue); - const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; + const replacementDetails = replaceOTPValueWithDetails( + value, + index, + rawValue, + length, + validationType, + sanitizeValue, + ); + const { value: nextValue, didSanitize, insertionLength } = replacementDetails; if (didSanitize) { reportValueInvalid( @@ -301,17 +296,17 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( ); } - if (nextDigits === '') { + if (insertionLength === 0) { return; } const committedValue = setValue( - replaceOTPValue(value, index, nextDigits, length, validationType, sanitizeValue), + nextValue, createChangeEventDetails(REASONS.inputPaste, event.nativeEvent), ); if (committedValue != null) { - const nextInput = Math.min(index + nextDigits.length, length - 1); + const nextInput = Math.min(index + insertionLength, length - 1); queueFocusInput(nextInput, committedValue); } }, diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx index 4a83f528292..8a4df3fcd4e 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx @@ -229,20 +229,38 @@ describe('', () => { expect(getValues()).toBe('AB12CD'); }); - it('warns when `sanitizeValue` is used without `validationType="none"`', async () => { + it('composes with built-in validation and advances focus', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - await render( value.toUpperCase()} />); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0]?.[0]).toContain( - 'Base UI: `sanitizeValue` is only used when `validationType="none"`.', + await render( + value.toUpperCase()} + />, ); + + const inputs = screen.getAllByRole('textbox'); + fireEvent.change(inputs[0], { target: { value: 'a!' } }); + + expect(getValues()).toBe('A'); + expect(inputs[1]).toHaveFocus(); + expect(warnSpy).not.toHaveBeenCalled(); } finally { warnSpy.mockRestore(); } }); + + it('composes built-in validation and custom sanitization for pasted values', async () => { + await render( + value.toUpperCase()} />, + ); + + const [firstInput] = screen.getAllByRole('textbox'); + pasteText(firstInput, 'ab-12 cd!'); + + expect(getValues()).toBe('AB12CD'); + }); }); describe('prop: onValueChange', () => { @@ -309,6 +327,65 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); + it('fires when custom sanitization removes characters after built-in validation', async () => { + const onValueInvalid = vi.fn(); + + await render( + value.replace(/[^0-3]/g, '')} + onValueInvalid={onValueInvalid} + />, + ); + + const [firstInput] = screen.getAllByRole('textbox'); + fireEvent.change(firstInput, { target: { value: '1209' } }); + + expect(getValues()).toBe('120'); + expect(onValueInvalid).toHaveBeenCalledTimes(1); + expect(onValueInvalid.mock.calls[0]?.[0]).toBe('1209'); + expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); + }); + + it('fires when custom sanitization removes all characters after built-in validation', async () => { + const onValueInvalid = vi.fn(); + + await render( + ''} + onValueInvalid={onValueInvalid} + />, + ); + + const [firstInput] = screen.getAllByRole('textbox'); + fireEvent.change(firstInput, { target: { value: '1' } }); + + expect(getValues()).toBe(''); + expect(onValueInvalid).toHaveBeenCalledTimes(1); + expect(onValueInvalid.mock.calls[0]?.[0]).toBe('1'); + expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); + }); + + it('fires when built-in validation removes characters before custom sanitization expands the value', async () => { + const onValueInvalid = vi.fn(); + + await render( + (value === '1' ? '12' : value)} + onValueInvalid={onValueInvalid} + />, + ); + + const [firstInput] = screen.getAllByRole('textbox'); + fireEvent.change(firstInput, { target: { value: '1a' } }); + + expect(getValues()).toBe('12'); + expect(onValueInvalid).toHaveBeenCalledTimes(1); + expect(onValueInvalid.mock.calls[0]?.[0]).toBe('1a'); + expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); + }); + it('fires `input-paste` when pasted text is sanitized before the OTP value updates', async () => { const onValueInvalid = vi.fn(); @@ -322,6 +399,26 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[0]).toBe('12a34'); expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputPaste); }); + + it('fires `input-paste` when custom sanitization removes characters after built-in validation', async () => { + const onValueInvalid = vi.fn(); + + await render( + value.replace(/[^0-3]/g, '')} + onValueInvalid={onValueInvalid} + />, + ); + + const [firstInput] = screen.getAllByRole('textbox'); + pasteText(firstInput, '1209'); + + expect(getValues()).toBe('120'); + expect(onValueInvalid).toHaveBeenCalledTimes(1); + expect(onValueInvalid.mock.calls[0]?.[0]).toBe('1209'); + expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputPaste); + }); }); describe('prop: onValueComplete', () => { diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.tsx index 713433d67c1..14f164fe746 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.tsx @@ -31,8 +31,7 @@ import { OTPFieldRootContext } from './OTPFieldRootContext'; import { rootStateAttributesMapping } from '../utils/stateAttributesMapping'; import { getOTPValidationConfig, - normalizeOTPValue, - stripOTPWhitespace, + normalizeOTPValueWithDetails, type OTPValidationType, } from '../utils/otp'; @@ -134,7 +133,12 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( const inputMode = inputModeProp ?? validationConfig?.inputMode; const hasValidLength = Number.isInteger(length) && length > 0; - const value = normalizeOTPValue(valueUnwrapped, length, validationType, sanitizeValue); + const value = normalizeOTPValueWithDetails( + valueUnwrapped, + length, + validationType, + sanitizeValue, + ).value; const valueRef = useValueAsRef(value); const filled = value !== ''; @@ -155,8 +159,6 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( useOTPFieldRootDevWarnings({ inputCount, length, - sanitizeValue, - validationType, }); } @@ -229,7 +231,12 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( const setValue = useStableCallback( (nextValue: string, details: OTPFieldRoot.ChangeEventDetails) => { - const normalizedValue = normalizeOTPValue(nextValue, length, validationType, sanitizeValue); + const normalizedValue = normalizeOTPValueWithDetails( + nextValue, + length, + validationType, + sanitizeValue, + ).value; const completeEventDetails = normalizedValue.length === length && (valueRef.current.length !== length || details.reason === REASONS.inputPaste) @@ -412,14 +419,15 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( } const rawValue = event.currentTarget.value; - const normalizedValue = normalizeOTPValue( + const normalizedValueDetails = normalizeOTPValueWithDetails( rawValue, length, validationType, sanitizeValue, ); + const { value: normalizedValue, didSanitize } = normalizedValueDetails; - if (stripOTPWhitespace(rawValue).length > normalizedValue.length) { + if (didSanitize) { reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputChange, event.nativeEvent), @@ -517,8 +525,13 @@ export interface OTPFieldRootProps extends Omit< */ validationType?: OTPFieldRoot.ValidationType | undefined; /** - * Function for custom sanitization when `validationType` is set to `'none'`. + * Function for custom sanitization after whitespace and `validationType` filtering. * This function runs before updating the OTP value from user interactions. + * + * The returned value is filtered by `validationType` again, then clamped to `length`. + * It should be idempotent because the component may normalize controlled values on every render + * and normalize again for each user edit. Characters removed by this function are reported + * through `onValueInvalid`. */ sanitizeValue?: ((value: string) => string) | undefined; /** @@ -648,12 +661,10 @@ function mergeAriaIds(...values: Array) { interface UseOTPFieldRootDevWarningsParameters { inputCount: number; length: number; - sanitizeValue: ((value: string) => string) | undefined; - validationType: OTPFieldRoot.ValidationType; } function useOTPFieldRootDevWarnings(parameters: UseOTPFieldRootDevWarningsParameters) { - const { inputCount, length, sanitizeValue, validationType } = parameters; + const { inputCount, length } = parameters; React.useEffect(() => { if (!Number.isInteger(length) || length <= 0 || inputCount === 0 || inputCount === length) { @@ -679,16 +690,4 @@ function useOTPFieldRootDevWarnings(parameters: UseOTPFieldRootDevWarningsParame ownerStackMessage, ); }, [length]); - - React.useEffect(() => { - if (sanitizeValue == null || validationType === 'none') { - return; - } - - const ownerStackMessage = SafeReact.captureOwnerStack?.() || ''; - warn( - ' `sanitizeValue` is only used when `validationType="none"`.', - ownerStackMessage, - ); - }, [sanitizeValue, validationType]); } diff --git a/packages/react/src/otp-field/utils/otp.test.ts b/packages/react/src/otp-field/utils/otp.test.ts index 8619b33e52f..45639d79d91 100644 --- a/packages/react/src/otp-field/utils/otp.test.ts +++ b/packages/react/src/otp-field/utils/otp.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from 'vitest'; -import { normalizeOTPValue, removeOTPCharacter, replaceOTPValue, stripOTPWhitespace } from './otp'; +import { describe, expect, it, vi } from 'vitest'; +import { + normalizeOTPValueWithDetails, + removeOTPCharacter, + replaceOTPValue, + stripOTPWhitespace, +} from './otp'; + +function normalizeValue(...args: Parameters) { + return normalizeOTPValueWithDetails(...args).value; +} describe('otp utils', () => { it('removes whitespace from pasted values', () => { @@ -12,32 +21,61 @@ describe('otp utils', () => { }); it('normalizes, filters, and clamps numeric values', () => { - expect(normalizeOTPValue('1a 2b34c56', 4, 'numeric')).toBe('1234'); + expect(normalizeValue('1a 2b34c56', 4, 'numeric')).toBe('1234'); }); it('normalizes alphabetic values', () => { - expect(normalizeOTPValue('1a 2b3C4', 6, 'alpha')).toBe('abC'); + expect(normalizeValue('1a 2b3C4', 6, 'alpha')).toBe('abC'); }); it('normalizes alphanumeric values', () => { - expect(normalizeOTPValue('A1-B2 c3!', 6, 'alphanumeric')).toBe('A1B2c3'); + expect(normalizeValue('A1-B2 c3!', 6, 'alphanumeric')).toBe('A1B2c3'); }); it('returns an empty string for nullish input values', () => { - expect(normalizeOTPValue(null, 6, 'numeric')).toBe(''); - expect(normalizeOTPValue(undefined, 6, 'alpha')).toBe(''); + expect(normalizeValue(null, 6, 'numeric')).toBe(''); + expect(normalizeValue(undefined, 6, 'alpha')).toBe(''); }); it('uses custom sanitization when validationType is none', () => { expect( - normalizeOTPValue('ab-12 cd', 6, 'none', (value) => + normalizeValue('ab-12 cd', 6, 'none', (value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase(), ), ).toBe('AB12CD'); }); + it('applies custom sanitization after built-in validation', () => { + expect(normalizeValue('ab-12 cd!', 6, 'alphanumeric', (value) => value.toUpperCase())).toBe( + 'AB12CD', + ); + }); + + it('filters custom sanitization output through built-in validation', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + expect(normalizeValue('12', 6, 'numeric', (value) => `${value}AB`)).toBe('12'); + expect(warnSpy.mock.calls[0]?.[0]).toContain( + '`sanitizeValue` returned characters that are not allowed by `validationType`', + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it('throws when custom sanitization returns a non-string value', () => { + expect(() => normalizeValue('12', 6, 'numeric', () => 12 as unknown as string)).toThrow( + 'Base UI: `sanitizeValue` must return a string. Returning a non-string value prevents the OTP value from being normalized. Ensure `sanitizeValue` returns the sanitized string.', + ); + }); + + it('clamps values after custom sanitization', () => { + expect(normalizeValue('123456', 4, 'numeric', (value) => `${value}789`)).toBe('1234'); + }); + it('returns an empty string for negative lengths', () => { - expect(normalizeOTPValue('1234', -1, 'none')).toBe(''); + expect(normalizeValue('1234', -1, 'none')).toBe(''); }); it('replaces values from the middle of the OTP', () => { @@ -52,6 +90,14 @@ describe('otp utils', () => { expect(replaceOTPValue('123456', 5, '9', 6, 'numeric')).toBe('123459'); }); + it('applies custom sanitization once when replacing OTP values', () => { + const sanitizeValue = vi.fn((value: string) => value.toUpperCase()); + + expect(replaceOTPValue('123456', 2, 'ab', 6, 'alphanumeric', sanitizeValue)).toBe('12AB56'); + expect(sanitizeValue).toHaveBeenCalledTimes(1); + expect(sanitizeValue).toHaveBeenCalledWith('12ab56'); + }); + it('removes a character from the first slot', () => { expect(removeOTPCharacter('1234', 0)).toBe('234'); }); diff --git a/packages/react/src/otp-field/utils/otp.ts b/packages/react/src/otp-field/utils/otp.ts index d59384be7a2..0dcc06e7d19 100644 --- a/packages/react/src/otp-field/utils/otp.ts +++ b/packages/react/src/otp-field/utils/otp.ts @@ -1,3 +1,5 @@ +import { warn } from '@base-ui/utils/warn'; + interface OTPValidationConfig { slotPattern: string; getRootPattern: (length: number) => string; @@ -40,27 +42,98 @@ export function stripOTPWhitespace(value: string | null | undefined) { return (value ?? '').replace(/\s/g, ''); } +function applyOTPValidation(value: string, validation: OTPValidationConfig | null) { + return validation ? value.replace(validation.regexp, '') : value; +} + +function getStringLength(value: string) { + return Array.from(value).length; +} + /** - * Normalizes user-entered OTP text by stripping whitespace, applying validation or custom + * Normalizes user-entered OTP text by stripping whitespace, applying validation and custom * sanitization, and clamping the final value to the configured slot count. */ -export function normalizeOTPValue( +export function normalizeOTPValueWithDetails( value: string | null | undefined, length: number, validationType: OTPValidationType, sanitizeValue?: ((value: string) => string) | undefined, ) { - let sanitizedValue = stripOTPWhitespace(value); + const strippedValue = stripOTPWhitespace(value); const validation = getOTPValidationConfig(validationType); + let sanitizedValue = applyOTPValidation(strippedValue, validation); + let didSanitize = getStringLength(strippedValue) > getStringLength(sanitizedValue); + + if (sanitizeValue) { + const customSanitizedValue = sanitizeValue(sanitizedValue); + + if (process.env.NODE_ENV !== 'production') { + if (typeof customSanitizedValue !== 'string') { + throw new Error( + 'Base UI: `sanitizeValue` must return a string. ' + + 'Returning a non-string value prevents the OTP value from being normalized. ' + + 'Ensure `sanitizeValue` returns the sanitized string.', + ); + } + } + + const validatedCustomValue = applyOTPValidation(customSanitizedValue, validation); - if (validation) { - sanitizedValue = sanitizedValue.replace(validation.regexp, ''); - } else if (sanitizeValue) { - sanitizedValue = sanitizeValue(sanitizedValue); + if (process.env.NODE_ENV !== 'production') { + if (validatedCustomValue !== customSanitizedValue) { + warn( + ' `sanitizeValue` returned characters that are not allowed by ' + + '`validationType`. These characters were removed before updating the OTP value. ' + + 'Ensure `sanitizeValue` returns only characters allowed by `validationType`.', + ); + } + } + + didSanitize ||= getStringLength(sanitizedValue) > getStringLength(validatedCustomValue); + sanitizedValue = validatedCustomValue; } // Slice by Unicode code points so multi-byte characters do not split across OTP slots. - return Array.from(sanitizedValue).slice(0, Math.max(length, 0)).join(''); + const clampedValue = Array.from(sanitizedValue).slice(0, Math.max(length, 0)).join(''); + + return { + value: clampedValue, + didSanitize: didSanitize || getStringLength(sanitizedValue) > getStringLength(clampedValue), + }; +} + +export function replaceOTPValueWithDetails( + currentValue: string, + index: number, + nextValue: string, + length: number, + validationType: OTPValidationType, + sanitizeValue?: ((value: string) => string) | undefined, +) { + const strippedValue = stripOTPWhitespace(nextValue); + const validation = getOTPValidationConfig(validationType); + const normalizedValue = applyOTPValidation(strippedValue, validation); + const prefix = currentValue.slice(0, index); + const suffix = currentValue.slice(index + normalizedValue.length); + const nextOTPValue = `${prefix}${normalizedValue}${suffix}`; + const normalizedDetails = normalizeOTPValueWithDetails( + nextOTPValue, + length, + validationType, + sanitizeValue, + ); + + return { + ...normalizedDetails, + didSanitize: + normalizedDetails.didSanitize || + getStringLength(strippedValue) > getStringLength(normalizedValue), + insertionLength: Math.max( + getStringLength(normalizedDetails.value) - getStringLength(prefix) - getStringLength(suffix), + 0, + ), + }; } /** @@ -75,16 +148,14 @@ export function replaceOTPValue( validationType: OTPValidationType, sanitizeValue?: ((value: string) => string) | undefined, ) { - const normalizedValue = normalizeOTPValue(nextValue, length, validationType, sanitizeValue); - const prefix = currentValue.slice(0, index); - const suffix = currentValue.slice(index + normalizedValue.length); - - return normalizeOTPValue( - `${prefix}${normalizedValue}${suffix}`, + return replaceOTPValueWithDetails( + currentValue, + index, + nextValue, length, validationType, sanitizeValue, - ); + ).value; } export function removeOTPCharacter(currentValue: string, index: number) { From 68e7cba1c471e75bfc0ac1c9daecd1d529abee30 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 4 May 2026 18:51:53 +1000 Subject: [PATCH 2/4] [otp field] Fix sanitizer replacement span --- .../react/components/otp-field/types.md | 48 +++++----- docs/src/error-codes.json | 3 +- .../src/otp-field/input/OTPFieldInput.tsx | 48 +++++----- .../src/otp-field/root/OTPFieldRoot.test.tsx | 19 ---- .../react/src/otp-field/root/OTPFieldRoot.tsx | 24 ++--- .../react/src/otp-field/utils/otp.test.ts | 40 +++----- packages/react/src/otp-field/utils/otp.ts | 92 +++---------------- 7 files changed, 80 insertions(+), 194 deletions(-) diff --git a/docs/src/app/(docs)/react/components/otp-field/types.md b/docs/src/app/(docs)/react/components/otp-field/types.md index a3665ff5b0f..26be3fe8138 100644 --- a/docs/src/app/(docs)/react/components/otp-field/types.md +++ b/docs/src/app/(docs)/react/components/otp-field/types.md @@ -11,29 +11,29 @@ Renders a `
` element. **Root Props:** -| Prop | Type | Default | Description | -| :-------------- | :---------------------------------------------------------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| name | `string` | - | Identifies the field when a form is submitted. | -| defaultValue | `string` | - | The uncontrolled OTP value when the component is initially rendered. | -| value | `string` | - | The OTP value. | -| onValueChange | `((value: string, eventDetails: OTPFieldPreview.Root.ChangeEventDetails) => void)` | - | Callback fired when the OTP value changes. The `eventDetails.reason` indicates what triggered the change: `'input-change'` for typing or autofill`'input-clear'` when a character is removed by text input`'input-paste'` for paste interactions`'keyboard'` for keyboard interactions that change the value | -| autoComplete | `string` | `'one-time-code'` | The input autocomplete attribute. Applied to the first slot and hidden validation input. | -| autoSubmit | `boolean` | `false` | Whether to submit the owning form when the OTP becomes complete. | -| form | `string` | - | A string specifying the `form` element with which the hidden input is associated. This string's value must match the id of a `form` element in the same document. | -| inputMode | `'none' \| 'text' \| 'tel' \| 'url' \| 'email' \| 'numeric' \| 'decimal' \| 'search'` | - | The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed. | -| length\* | `number` | - | The number of OTP input slots. Required so the root can clamp values, detect completion, and generate consistent validation markup before all slots hydrate. | -| mask | `boolean` | `false` | Whether the slot inputs should mask entered characters. Pass `type` directly to individual `` parts to use a custom input type. | -| onValueComplete | `((value: string, eventDetails: OTPFieldPreview.Root.CompleteEventDetails) => void)` | - | Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than `onValueChange`, after the internal value update is applied. If a complete pasted value matches the current value, `onValueChange` does not fire. If `autoSubmit` is enabled, it runs immediately before the owning form is submitted. | -| onValueInvalid | `((value: string, eventDetails: OTPFieldPreview.Root.InvalidEventDetails) => void)` | - | Callback fired when entered text contains characters that are rejected by sanitization, before the OTP value updates. The `value` argument is the attempted user-entered string before sanitization. | -| sanitizeValue | `((value: string) => string)` | - | Function for custom sanitization after whitespace and `validationType` filtering. This function runs before updating the OTP value from user interactions. The returned value is filtered by `validationType` again, then clamped to `length`. It should be idempotent because the component may normalize controlled values on every render and normalize again for each user edit. Characters removed by this function are reported through `onValueInvalid`. | -| validationType | `OTPFieldPreview.Root.ValidationType` | `'numeric'` | The type of input validation to apply to the OTP value. | -| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | -| readOnly | `boolean` | `false` | Whether the user should be unable to change the field value. | -| required | `boolean` | `false` | Whether the user must enter a value before submitting a form. | -| id | `string` | - | The id of the first input element. Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on). | -| className | `string \| ((state: OTPFieldRootState) => string \| undefined)` | - | CSS class applied to the element, or a function that returns a class based on the component's state. | -| style | `React.CSSProperties \| ((state: OTPFieldRootState) => React.CSSProperties \| undefined)` | - | Style applied to the element, or a function that returns a style object based on the component's state. | -| render | `ReactElement \| ((props: HTMLProps, state: OTPFieldRootState) => ReactElement)` | - | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | +| Prop | Type | Default | Description | +| :-------------- | :---------------------------------------------------------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| name | `string` | - | Identifies the field when a form is submitted. | +| defaultValue | `string` | - | The uncontrolled OTP value when the component is initially rendered. | +| value | `string` | - | The OTP value. | +| onValueChange | `((value: string, eventDetails: OTPFieldPreview.Root.ChangeEventDetails) => void)` | - | Callback fired when the OTP value changes. The `eventDetails.reason` indicates what triggered the change: `'input-change'` for typing or autofill`'input-clear'` when a character is removed by text input`'input-paste'` for paste interactions`'keyboard'` for keyboard interactions that change the value | +| autoComplete | `string` | `'one-time-code'` | The input autocomplete attribute. Applied to the first slot and hidden validation input. | +| autoSubmit | `boolean` | `false` | Whether to submit the owning form when the OTP becomes complete. | +| form | `string` | - | A string specifying the `form` element with which the hidden input is associated. This string's value must match the id of a `form` element in the same document. | +| inputMode | `'none' \| 'text' \| 'tel' \| 'url' \| 'email' \| 'numeric' \| 'decimal' \| 'search'` | - | The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed. | +| length\* | `number` | - | The number of OTP input slots. Required so the root can clamp values, detect completion, and generate consistent validation markup before all slots hydrate. | +| mask | `boolean` | `false` | Whether the slot inputs should mask entered characters. Pass `type` directly to individual `` parts to use a custom input type. | +| onValueComplete | `((value: string, eventDetails: OTPFieldPreview.Root.CompleteEventDetails) => void)` | - | Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than `onValueChange`, after the internal value update is applied. If a complete pasted value matches the current value, `onValueChange` does not fire. If `autoSubmit` is enabled, it runs immediately before the owning form is submitted. | +| onValueInvalid | `((value: string, eventDetails: OTPFieldPreview.Root.InvalidEventDetails) => void)` | - | Callback fired when entered text contains characters that are rejected by sanitization, before the OTP value updates. The `value` argument is the attempted user-entered string before sanitization. | +| sanitizeValue | `((value: string) => string)` | - | Function for custom sanitization after whitespace and `validationType` filtering. This function runs before updating the OTP value from user interactions. The returned value is filtered by `validationType` again, then clamped to `length`. It should be idempotent because the component may normalize controlled values on every render and normalize again for each user edit. Characters removed from user-entered text are reported through `onValueInvalid`. | +| validationType | `OTPFieldPreview.Root.ValidationType` | `'numeric'` | The type of input validation to apply to the OTP value. | +| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | +| readOnly | `boolean` | `false` | Whether the user should be unable to change the field value. | +| required | `boolean` | `false` | Whether the user must enter a value before submitting a form. | +| id | `string` | - | The id of the first input element. Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on). | +| className | `string \| ((state: OTPFieldRootState) => string \| undefined)` | - | CSS class applied to the element, or a function that returns a class based on the component's state. | +| style | `React.CSSProperties \| ((state: OTPFieldRootState) => React.CSSProperties \| undefined)` | - | Style applied to the element, or a function that returns a style object based on the component's state. | +| render | `ReactElement \| ((props: HTMLProps, state: OTPFieldRootState) => ReactElement)` | - | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | **Root Data Attributes:** @@ -368,7 +368,7 @@ type OTPFieldRootProps = { * * The returned value is filtered by `validationType` again, then clamped to `length`. * It should be idempotent because the component may normalize controlled values on every render - * and normalize again for each user edit. Characters removed by this function are reported + * and normalize again for each user edit. Characters removed from user-entered text are reported * through `onValueInvalid`. */ sanitizeValue?: (value: string) => string; diff --git a/docs/src/error-codes.json b/docs/src/error-codes.json index 336482ebc29..f879c383ecf 100644 --- a/docs/src/error-codes.json +++ b/docs/src/error-codes.json @@ -95,6 +95,5 @@ "95": "Base UI: SharedCalendarDayGridBodyContext is missing. must be placed within and must be placed within .", "96": "Base UI: SharedCalendarDayGridCellContext is missing. must be placed within and must be placed within .", "97": "Base UI: SharedCalendarRootContext is missing. Calendar parts must be placed within and Range Calendar parts must be placed within .", - "98": "Base UI: OTPFieldRootContext is missing. OTPField parts must be placed within .", - "99": "Base UI: `sanitizeValue` must return a string. Returning a non-string value prevents the OTP value from being normalized. Ensure `sanitizeValue` returns the sanitized string." + "98": "Base UI: OTPFieldRootContext is missing. OTPField parts must be placed within ." } diff --git a/packages/react/src/otp-field/input/OTPFieldInput.tsx b/packages/react/src/otp-field/input/OTPFieldInput.tsx index f9182d135f1..14e75ade6a0 100644 --- a/packages/react/src/otp-field/input/OTPFieldInput.tsx +++ b/packages/react/src/otp-field/input/OTPFieldInput.tsx @@ -17,7 +17,12 @@ import { REASONS } from '../../internals/reasons'; import { useOTPFieldRootContext, getOTPFieldInputState } from '../root/OTPFieldRootContext'; import type { OTPFieldRootState } from '../root/OTPFieldRoot'; import { inputStateAttributesMapping } from '../utils/stateAttributesMapping'; -import { removeOTPCharacter, replaceOTPValueWithDetails } from '../utils/otp'; +import { + normalizeOTPValue, + removeOTPCharacter, + replaceOTPValue, + stripOTPWhitespace, +} from '../utils/otp'; /** * An individual OTP character input. @@ -138,15 +143,8 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( } const rawValue = event.currentTarget.value; - const replacementDetails = replaceOTPValueWithDetails( - value, - index, - rawValue, - length, - validationType, - sanitizeValue, - ); - const { value: nextValue, didSanitize, insertionLength } = replacementDetails; + const nextDigits = normalizeOTPValue(rawValue, length, validationType, sanitizeValue); + const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; if (didSanitize) { reportValueInvalid( @@ -155,7 +153,7 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( ); } - if (insertionLength === 0) { + if (nextDigits === '') { if (rawValue === '') { setValue( removeOTPCharacter(value, index), @@ -168,13 +166,22 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( return; } + const nextValue = replaceOTPValue( + value, + index, + nextDigits, + length, + validationType, + sanitizeValue, + ); + const committedValue = setValue( nextValue, createChangeEventDetails(REASONS.inputChange, event.nativeEvent), ); if (committedValue != null) { - const nextInput = Math.min(index + insertionLength, length - 1); + const nextInput = Math.min(index + nextDigits.length, length - 1); queueFocusInput(nextInput, committedValue); } }, @@ -279,15 +286,8 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( event.preventDefault(); - const replacementDetails = replaceOTPValueWithDetails( - value, - index, - rawValue, - length, - validationType, - sanitizeValue, - ); - const { value: nextValue, didSanitize, insertionLength } = replacementDetails; + const nextDigits = normalizeOTPValue(rawValue, length, validationType, sanitizeValue); + const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; if (didSanitize) { reportValueInvalid( @@ -296,17 +296,17 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( ); } - if (insertionLength === 0) { + if (nextDigits === '') { return; } const committedValue = setValue( - nextValue, + replaceOTPValue(value, index, nextDigits, length, validationType, sanitizeValue), createChangeEventDetails(REASONS.inputPaste, event.nativeEvent), ); if (committedValue != null) { - const nextInput = Math.min(index + insertionLength, length - 1); + const nextInput = Math.min(index + nextDigits.length, length - 1); queueFocusInput(nextInput, committedValue); } }, diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx index 8a4df3fcd4e..52929f44ccc 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx @@ -367,25 +367,6 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); - it('fires when built-in validation removes characters before custom sanitization expands the value', async () => { - const onValueInvalid = vi.fn(); - - await render( - (value === '1' ? '12' : value)} - onValueInvalid={onValueInvalid} - />, - ); - - const [firstInput] = screen.getAllByRole('textbox'); - fireEvent.change(firstInput, { target: { value: '1a' } }); - - expect(getValues()).toBe('12'); - expect(onValueInvalid).toHaveBeenCalledTimes(1); - expect(onValueInvalid.mock.calls[0]?.[0]).toBe('1a'); - expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); - }); - it('fires `input-paste` when pasted text is sanitized before the OTP value updates', async () => { const onValueInvalid = vi.fn(); diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.tsx index 14f164fe746..97a737ac63c 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.tsx @@ -31,7 +31,8 @@ import { OTPFieldRootContext } from './OTPFieldRootContext'; import { rootStateAttributesMapping } from '../utils/stateAttributesMapping'; import { getOTPValidationConfig, - normalizeOTPValueWithDetails, + normalizeOTPValue, + stripOTPWhitespace, type OTPValidationType, } from '../utils/otp'; @@ -133,12 +134,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( const inputMode = inputModeProp ?? validationConfig?.inputMode; const hasValidLength = Number.isInteger(length) && length > 0; - const value = normalizeOTPValueWithDetails( - valueUnwrapped, - length, - validationType, - sanitizeValue, - ).value; + const value = normalizeOTPValue(valueUnwrapped, length, validationType, sanitizeValue); const valueRef = useValueAsRef(value); const filled = value !== ''; @@ -231,12 +227,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( const setValue = useStableCallback( (nextValue: string, details: OTPFieldRoot.ChangeEventDetails) => { - const normalizedValue = normalizeOTPValueWithDetails( - nextValue, - length, - validationType, - sanitizeValue, - ).value; + const normalizedValue = normalizeOTPValue(nextValue, length, validationType, sanitizeValue); const completeEventDetails = normalizedValue.length === length && (valueRef.current.length !== length || details.reason === REASONS.inputPaste) @@ -419,15 +410,14 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( } const rawValue = event.currentTarget.value; - const normalizedValueDetails = normalizeOTPValueWithDetails( + const normalizedValue = normalizeOTPValue( rawValue, length, validationType, sanitizeValue, ); - const { value: normalizedValue, didSanitize } = normalizedValueDetails; - if (didSanitize) { + if (stripOTPWhitespace(rawValue).length > normalizedValue.length) { reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputChange, event.nativeEvent), @@ -530,7 +520,7 @@ export interface OTPFieldRootProps extends Omit< * * The returned value is filtered by `validationType` again, then clamped to `length`. * It should be idempotent because the component may normalize controlled values on every render - * and normalize again for each user edit. Characters removed by this function are reported + * and normalize again for each user edit. Characters removed from user-entered text are reported * through `onValueInvalid`. */ sanitizeValue?: ((value: string) => string) | undefined; diff --git a/packages/react/src/otp-field/utils/otp.test.ts b/packages/react/src/otp-field/utils/otp.test.ts index 45639d79d91..697cdb1109a 100644 --- a/packages/react/src/otp-field/utils/otp.test.ts +++ b/packages/react/src/otp-field/utils/otp.test.ts @@ -1,13 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import { - normalizeOTPValueWithDetails, - removeOTPCharacter, - replaceOTPValue, - stripOTPWhitespace, -} from './otp'; - -function normalizeValue(...args: Parameters) { - return normalizeOTPValueWithDetails(...args).value; +import { normalizeOTPValue, removeOTPCharacter, replaceOTPValue, stripOTPWhitespace } from './otp'; + +function normalizeValue(...args: Parameters) { + return normalizeOTPValue(...args); } describe('otp utils', () => { @@ -52,22 +47,7 @@ describe('otp utils', () => { }); it('filters custom sanitization output through built-in validation', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - try { - expect(normalizeValue('12', 6, 'numeric', (value) => `${value}AB`)).toBe('12'); - expect(warnSpy.mock.calls[0]?.[0]).toContain( - '`sanitizeValue` returned characters that are not allowed by `validationType`', - ); - } finally { - warnSpy.mockRestore(); - } - }); - - it('throws when custom sanitization returns a non-string value', () => { - expect(() => normalizeValue('12', 6, 'numeric', () => 12 as unknown as string)).toThrow( - 'Base UI: `sanitizeValue` must return a string. Returning a non-string value prevents the OTP value from being normalized. Ensure `sanitizeValue` returns the sanitized string.', - ); + expect(normalizeValue('12', 6, 'numeric', (value) => `${value}AB`)).toBe('12'); }); it('clamps values after custom sanitization', () => { @@ -90,12 +70,16 @@ describe('otp utils', () => { expect(replaceOTPValue('123456', 5, '9', 6, 'numeric')).toBe('123459'); }); - it('applies custom sanitization once when replacing OTP values', () => { + it('applies custom sanitization when replacing OTP values', () => { const sanitizeValue = vi.fn((value: string) => value.toUpperCase()); expect(replaceOTPValue('123456', 2, 'ab', 6, 'alphanumeric', sanitizeValue)).toBe('12AB56'); - expect(sanitizeValue).toHaveBeenCalledTimes(1); - expect(sanitizeValue).toHaveBeenCalledWith('12ab56'); + }); + + it('preserves suffix characters when custom sanitization removes part of a middle replacement', () => { + expect( + replaceOTPValue('1303', 1, '29', 4, 'numeric', (value) => value.replace(/[^0-3]/g, '')), + ).toBe('1203'); }); it('removes a character from the first slot', () => { diff --git a/packages/react/src/otp-field/utils/otp.ts b/packages/react/src/otp-field/utils/otp.ts index 0dcc06e7d19..2d9f08a5548 100644 --- a/packages/react/src/otp-field/utils/otp.ts +++ b/packages/react/src/otp-field/utils/otp.ts @@ -1,5 +1,3 @@ -import { warn } from '@base-ui/utils/warn'; - interface OTPValidationConfig { slotPattern: string; getRootPattern: (length: number) => string; @@ -46,94 +44,26 @@ function applyOTPValidation(value: string, validation: OTPValidationConfig | nul return validation ? value.replace(validation.regexp, '') : value; } -function getStringLength(value: string) { - return Array.from(value).length; -} - /** * Normalizes user-entered OTP text by stripping whitespace, applying validation and custom * sanitization, and clamping the final value to the configured slot count. */ -export function normalizeOTPValueWithDetails( +export function normalizeOTPValue( value: string | null | undefined, length: number, validationType: OTPValidationType, sanitizeValue?: ((value: string) => string) | undefined, ) { - const strippedValue = stripOTPWhitespace(value); + let sanitizedValue = stripOTPWhitespace(value); const validation = getOTPValidationConfig(validationType); - let sanitizedValue = applyOTPValidation(strippedValue, validation); - let didSanitize = getStringLength(strippedValue) > getStringLength(sanitizedValue); + sanitizedValue = applyOTPValidation(sanitizedValue, validation); if (sanitizeValue) { - const customSanitizedValue = sanitizeValue(sanitizedValue); - - if (process.env.NODE_ENV !== 'production') { - if (typeof customSanitizedValue !== 'string') { - throw new Error( - 'Base UI: `sanitizeValue` must return a string. ' + - 'Returning a non-string value prevents the OTP value from being normalized. ' + - 'Ensure `sanitizeValue` returns the sanitized string.', - ); - } - } - - const validatedCustomValue = applyOTPValidation(customSanitizedValue, validation); - - if (process.env.NODE_ENV !== 'production') { - if (validatedCustomValue !== customSanitizedValue) { - warn( - ' `sanitizeValue` returned characters that are not allowed by ' + - '`validationType`. These characters were removed before updating the OTP value. ' + - 'Ensure `sanitizeValue` returns only characters allowed by `validationType`.', - ); - } - } - - didSanitize ||= getStringLength(sanitizedValue) > getStringLength(validatedCustomValue); - sanitizedValue = validatedCustomValue; + sanitizedValue = applyOTPValidation(sanitizeValue(sanitizedValue), validation); } // Slice by Unicode code points so multi-byte characters do not split across OTP slots. - const clampedValue = Array.from(sanitizedValue).slice(0, Math.max(length, 0)).join(''); - - return { - value: clampedValue, - didSanitize: didSanitize || getStringLength(sanitizedValue) > getStringLength(clampedValue), - }; -} - -export function replaceOTPValueWithDetails( - currentValue: string, - index: number, - nextValue: string, - length: number, - validationType: OTPValidationType, - sanitizeValue?: ((value: string) => string) | undefined, -) { - const strippedValue = stripOTPWhitespace(nextValue); - const validation = getOTPValidationConfig(validationType); - const normalizedValue = applyOTPValidation(strippedValue, validation); - const prefix = currentValue.slice(0, index); - const suffix = currentValue.slice(index + normalizedValue.length); - const nextOTPValue = `${prefix}${normalizedValue}${suffix}`; - const normalizedDetails = normalizeOTPValueWithDetails( - nextOTPValue, - length, - validationType, - sanitizeValue, - ); - - return { - ...normalizedDetails, - didSanitize: - normalizedDetails.didSanitize || - getStringLength(strippedValue) > getStringLength(normalizedValue), - insertionLength: Math.max( - getStringLength(normalizedDetails.value) - getStringLength(prefix) - getStringLength(suffix), - 0, - ), - }; + return Array.from(sanitizedValue).slice(0, Math.max(length, 0)).join(''); } /** @@ -148,14 +78,16 @@ export function replaceOTPValue( validationType: OTPValidationType, sanitizeValue?: ((value: string) => string) | undefined, ) { - return replaceOTPValueWithDetails( - currentValue, - index, - nextValue, + const normalizedValue = normalizeOTPValue(nextValue, length, validationType, sanitizeValue); + const prefix = currentValue.slice(0, index); + const suffix = currentValue.slice(index + normalizedValue.length); + + return normalizeOTPValue( + `${prefix}${normalizedValue}${suffix}`, length, validationType, sanitizeValue, - ).value; + ); } export function removeOTPCharacter(currentValue: string, index: number) { From 74ecb1874ae0d8980457724b093ffcd53689fa16 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 13 May 2026 18:10:27 +1000 Subject: [PATCH 3/4] [otp-field] Cover sanitizer composition --- .../react/components/otp-field/page.mdx | 2 +- .../react/components/otp-field/types.md | 50 ++++++------ .../src/otp-field/input/OTPFieldInput.tsx | 23 +++--- .../src/otp-field/root/OTPFieldRoot.test.tsx | 78 +++++++++++++++++++ .../react/src/otp-field/root/OTPFieldRoot.tsx | 10 +-- .../react/src/otp-field/utils/otp.test.ts | 30 ++++--- packages/react/src/otp-field/utils/otp.ts | 31 ++++++-- 7 files changed, 161 insertions(+), 63 deletions(-) diff --git a/docs/src/app/(docs)/react/components/otp-field/page.mdx b/docs/src/app/(docs)/react/components/otp-field/page.mdx index 93e8d1a838d..c7a9093b83a 100644 --- a/docs/src/app/(docs)/react/components/otp-field/page.mdx +++ b/docs/src/app/(docs)/react/components/otp-field/page.mdx @@ -109,7 +109,7 @@ import { DemoOTPFieldFocusedPlaceholder } from './demos/focused-placeholder'; ### Custom sanitization Use `sanitizeValue` to normalize accepted values before state updates, such as converting -alphanumeric codes to uppercase. It runs after `validationType` filtering, and the result is checked +alphanumeric codes to uppercase. It runs after `validationType` filtering, and the result is filtered against `validationType` again. Use `validationType="none"` when the sanitizer should provide the full validation rule. diff --git a/docs/src/app/(docs)/react/components/otp-field/types.md b/docs/src/app/(docs)/react/components/otp-field/types.md index 26be3fe8138..0939397318f 100644 --- a/docs/src/app/(docs)/react/components/otp-field/types.md +++ b/docs/src/app/(docs)/react/components/otp-field/types.md @@ -11,29 +11,29 @@ Renders a `
` element. **Root Props:** -| Prop | Type | Default | Description | -| :-------------- | :---------------------------------------------------------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| name | `string` | - | Identifies the field when a form is submitted. | -| defaultValue | `string` | - | The uncontrolled OTP value when the component is initially rendered. | -| value | `string` | - | The OTP value. | -| onValueChange | `((value: string, eventDetails: OTPFieldPreview.Root.ChangeEventDetails) => void)` | - | Callback fired when the OTP value changes. The `eventDetails.reason` indicates what triggered the change: `'input-change'` for typing or autofill`'input-clear'` when a character is removed by text input`'input-paste'` for paste interactions`'keyboard'` for keyboard interactions that change the value | -| autoComplete | `string` | `'one-time-code'` | The input autocomplete attribute. Applied to the first slot and hidden validation input. | -| autoSubmit | `boolean` | `false` | Whether to submit the owning form when the OTP becomes complete. | -| form | `string` | - | A string specifying the `form` element with which the hidden input is associated. This string's value must match the id of a `form` element in the same document. | -| inputMode | `'none' \| 'text' \| 'tel' \| 'url' \| 'email' \| 'numeric' \| 'decimal' \| 'search'` | - | The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed. | -| length\* | `number` | - | The number of OTP input slots. Required so the root can clamp values, detect completion, and generate consistent validation markup before all slots hydrate. | -| mask | `boolean` | `false` | Whether the slot inputs should mask entered characters. Pass `type` directly to individual `` parts to use a custom input type. | -| onValueComplete | `((value: string, eventDetails: OTPFieldPreview.Root.CompleteEventDetails) => void)` | - | Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than `onValueChange`, after the internal value update is applied. If a complete pasted value matches the current value, `onValueChange` does not fire. If `autoSubmit` is enabled, it runs immediately before the owning form is submitted. | -| onValueInvalid | `((value: string, eventDetails: OTPFieldPreview.Root.InvalidEventDetails) => void)` | - | Callback fired when entered text contains characters that are rejected by sanitization, before the OTP value updates. The `value` argument is the attempted user-entered string before sanitization. | -| sanitizeValue | `((value: string) => string)` | - | Function for custom sanitization after whitespace and `validationType` filtering. This function runs before updating the OTP value from user interactions. The returned value is filtered by `validationType` again, then clamped to `length`. It should be idempotent because the component may normalize controlled values on every render and normalize again for each user edit. Characters removed from user-entered text are reported through `onValueInvalid`. | -| validationType | `OTPFieldPreview.Root.ValidationType` | `'numeric'` | The type of input validation to apply to the OTP value. | -| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | -| readOnly | `boolean` | `false` | Whether the user should be unable to change the field value. | -| required | `boolean` | `false` | Whether the user must enter a value before submitting a form. | -| id | `string` | - | The id of the first input element. Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on). | -| className | `string \| ((state: OTPFieldRootState) => string \| undefined)` | - | CSS class applied to the element, or a function that returns a class based on the component's state. | -| style | `React.CSSProperties \| ((state: OTPFieldRootState) => React.CSSProperties \| undefined)` | - | Style applied to the element, or a function that returns a style object based on the component's state. | -| render | `ReactElement \| ((props: HTMLProps, state: OTPFieldRootState) => ReactElement)` | - | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | +| Prop | Type | Default | Description | +| :-------------- | :---------------------------------------------------------------------------------------- | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | `string` | - | Identifies the field when a form is submitted. | +| defaultValue | `string` | - | The uncontrolled OTP value when the component is initially rendered. | +| value | `string` | - | The OTP value. | +| onValueChange | `((value: string, eventDetails: OTPFieldPreview.Root.ChangeEventDetails) => void)` | - | Callback fired when the OTP value changes. The `eventDetails.reason` indicates what triggered the change: `'input-change'` for typing or autofill`'input-clear'` when a character is removed by text input`'input-paste'` for paste interactions`'keyboard'` for keyboard interactions that change the value | +| autoComplete | `string` | `'one-time-code'` | The input autocomplete attribute. Applied to the first slot and hidden validation input. | +| autoSubmit | `boolean` | `false` | Whether to submit the owning form when the OTP becomes complete. | +| form | `string` | - | A string specifying the `form` element with which the hidden input is associated. This string's value must match the id of a `form` element in the same document. | +| inputMode | `'none' \| 'text' \| 'tel' \| 'url' \| 'email' \| 'numeric' \| 'decimal' \| 'search'` | - | The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed. | +| length\* | `number` | - | The number of OTP input slots. Required so the root can clamp values, detect completion, and generate consistent validation markup before all slots hydrate. | +| mask | `boolean` | `false` | Whether the slot inputs should mask entered characters. Pass `type` directly to individual `` parts to use a custom input type. | +| onValueComplete | `((value: string, eventDetails: OTPFieldPreview.Root.CompleteEventDetails) => void)` | - | Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than `onValueChange`, after the internal value update is applied. If a complete pasted value matches the current value, `onValueChange` does not fire. If `autoSubmit` is enabled, it runs immediately before the owning form is submitted. | +| onValueInvalid | `((value: string, eventDetails: OTPFieldPreview.Root.InvalidEventDetails) => void)` | - | Callback fired when entered text contains characters that are rejected by sanitization, before the OTP value updates. The `value` argument is the attempted user-entered string before sanitization. | +| sanitizeValue | `((value: string) => string)` | - | Function for custom sanitization after whitespace and `validationType` filtering. This function runs before updating the OTP value from user interactions. The returned value is filtered by `validationType` again, then clamped to `length`. It should be idempotent because the component may normalize controlled values on every render and normalize again for each user edit. Characters rejected while normalizing typed or pasted text are reported through `onValueInvalid`. | +| validationType | `OTPFieldPreview.Root.ValidationType` | `'numeric'` | The type of input validation to apply to the OTP value. | +| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | +| readOnly | `boolean` | `false` | Whether the user should be unable to change the field value. | +| required | `boolean` | `false` | Whether the user must enter a value before submitting a form. | +| id | `string` | - | The id of the first input element. Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on). | +| className | `string \| ((state: OTPFieldRootState) => string \| undefined)` | - | CSS class applied to the element, or a function that returns a class based on the component's state. | +| style | `React.CSSProperties \| ((state: OTPFieldRootState) => React.CSSProperties \| undefined)` | - | Style applied to the element, or a function that returns a style object based on the component's state. | +| render | `ReactElement \| ((props: HTMLProps, state: OTPFieldRootState) => ReactElement)` | - | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | **Root Data Attributes:** @@ -368,8 +368,8 @@ type OTPFieldRootProps = { * * The returned value is filtered by `validationType` again, then clamped to `length`. * It should be idempotent because the component may normalize controlled values on every render - * and normalize again for each user edit. Characters removed from user-entered text are reported - * through `onValueInvalid`. + * and normalize again for each user edit. Characters rejected while normalizing typed or pasted + * text are reported through `onValueInvalid`. */ sanitizeValue?: (value: string) => string; /** diff --git a/packages/react/src/otp-field/input/OTPFieldInput.tsx b/packages/react/src/otp-field/input/OTPFieldInput.tsx index 14e75ade6a0..8fd77fbd5ac 100644 --- a/packages/react/src/otp-field/input/OTPFieldInput.tsx +++ b/packages/react/src/otp-field/input/OTPFieldInput.tsx @@ -17,12 +17,7 @@ import { REASONS } from '../../internals/reasons'; import { useOTPFieldRootContext, getOTPFieldInputState } from '../root/OTPFieldRootContext'; import type { OTPFieldRootState } from '../root/OTPFieldRoot'; import { inputStateAttributesMapping } from '../utils/stateAttributesMapping'; -import { - normalizeOTPValue, - removeOTPCharacter, - replaceOTPValue, - stripOTPWhitespace, -} from '../utils/otp'; +import { normalizeOTPValueWithDetails, removeOTPCharacter, replaceOTPValue } from '../utils/otp'; /** * An individual OTP character input. @@ -143,8 +138,12 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( } const rawValue = event.currentTarget.value; - const nextDigits = normalizeOTPValue(rawValue, length, validationType, sanitizeValue); - const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; + const [nextDigits, didSanitize] = normalizeOTPValueWithDetails( + rawValue, + length, + validationType, + sanitizeValue, + ); if (didSanitize) { reportValueInvalid( @@ -286,8 +285,12 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( event.preventDefault(); - const nextDigits = normalizeOTPValue(rawValue, length, validationType, sanitizeValue); - const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; + const [nextDigits, didSanitize] = normalizeOTPValueWithDetails( + rawValue, + length, + validationType, + sanitizeValue, + ); if (didSanitize) { reportValueInvalid( diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx index 52929f44ccc..1afefef4263 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx @@ -261,6 +261,27 @@ describe('', () => { expect(getValues()).toBe('AB12CD'); }); + + it('composes built-in validation and custom sanitization from a non-first slot', async () => { + await render( + value.toUpperCase()} + />, + ); + + const inputs = screen.getAllByRole('textbox'); + + await act(async () => { + inputs[2].focus(); + }); + + fireEvent.change(inputs[2], { target: { value: 'a!' } }); + + expect(getValues()).toBe('12A'); + expect(inputs[3]).toHaveFocus(); + }); }); describe('prop: onValueChange', () => { @@ -347,6 +368,26 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); + it('fires when built-in validation removes characters before custom sanitization expands the value', async () => { + const onValueInvalid = vi.fn(); + + await render( + (value === '1' ? '12' : value)} + onValueInvalid={onValueInvalid} + />, + ); + + const [firstInput] = screen.getAllByRole('textbox'); + fireEvent.change(firstInput, { target: { value: '1a' } }); + + expect(getValues()).toBe('12'); + expect(onValueInvalid).toHaveBeenCalledTimes(1); + expect(onValueInvalid.mock.calls[0]?.[0]).toBe('1a'); + expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); + }); + it('fires when custom sanitization removes all characters after built-in validation', async () => { const onValueInvalid = vi.fn(); @@ -978,6 +1019,43 @@ describe('', () => { expect(onValueComplete.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); + it('composes validation and custom sanitization during hidden input autofill', async () => { + const onValueChange = vi.fn(); + const onValueInvalid = vi.fn(); + const onValueComplete = vi.fn(); + + await render( + value.toUpperCase()} + onValueChange={onValueChange} + onValueInvalid={onValueInvalid} + onValueComplete={onValueComplete} + />, + ); + + const hiddenInput = document.querySelector('input[name="otp"]'); + + expect(hiddenInput).not.toBeNull(); + + fireEvent.change(hiddenInput!, { target: { value: 'ab-12 cd!' } }); + + const inputs = screen.getAllByRole('textbox'); + + expect(inputs.map((input) => input.value)).toEqual(['A', 'B', '1', '2', 'C', 'D']); + expect(document.activeElement).toBe(inputs[5]); + expect(onValueChange.mock.calls.length).toBe(1); + expect(onValueChange.mock.calls[0]?.[0]).toBe('AB12CD'); + expect(onValueChange.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); + expect(onValueInvalid).toHaveBeenCalledTimes(1); + expect(onValueInvalid.mock.calls[0]?.[0]).toBe('ab-12 cd!'); + expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); + expect(onValueComplete.mock.calls.length).toBe(1); + expect(onValueComplete.mock.calls[0]?.[0]).toBe('AB12CD'); + expect(onValueComplete.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); + }); + it('ignores hidden-input autofill when the field is readonly', async () => { const onValueChange = vi.fn(); const onValueInvalid = vi.fn(); diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.tsx index 97a737ac63c..1f13f4c7791 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.tsx @@ -32,7 +32,7 @@ import { rootStateAttributesMapping } from '../utils/stateAttributesMapping'; import { getOTPValidationConfig, normalizeOTPValue, - stripOTPWhitespace, + normalizeOTPValueWithDetails, type OTPValidationType, } from '../utils/otp'; @@ -410,14 +410,14 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( } const rawValue = event.currentTarget.value; - const normalizedValue = normalizeOTPValue( + const [normalizedValue, didSanitize] = normalizeOTPValueWithDetails( rawValue, length, validationType, sanitizeValue, ); - if (stripOTPWhitespace(rawValue).length > normalizedValue.length) { + if (didSanitize) { reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputChange, event.nativeEvent), @@ -520,8 +520,8 @@ export interface OTPFieldRootProps extends Omit< * * The returned value is filtered by `validationType` again, then clamped to `length`. * It should be idempotent because the component may normalize controlled values on every render - * and normalize again for each user edit. Characters removed from user-entered text are reported - * through `onValueInvalid`. + * and normalize again for each user edit. Characters rejected while normalizing typed or pasted + * text are reported through `onValueInvalid`. */ sanitizeValue?: ((value: string) => string) | undefined; /** diff --git a/packages/react/src/otp-field/utils/otp.test.ts b/packages/react/src/otp-field/utils/otp.test.ts index 697cdb1109a..ebb81514fce 100644 --- a/packages/react/src/otp-field/utils/otp.test.ts +++ b/packages/react/src/otp-field/utils/otp.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { normalizeOTPValue, removeOTPCharacter, replaceOTPValue, stripOTPWhitespace } from './otp'; -function normalizeValue(...args: Parameters) { - return normalizeOTPValue(...args); -} - describe('otp utils', () => { it('removes whitespace from pasted values', () => { expect(stripOTPWhitespace(' 12 3\t4\n5 ')).toBe('12345'); @@ -16,46 +12,48 @@ describe('otp utils', () => { }); it('normalizes, filters, and clamps numeric values', () => { - expect(normalizeValue('1a 2b34c56', 4, 'numeric')).toBe('1234'); + expect(normalizeOTPValue('1a 2b34c56', 4, 'numeric')).toBe('1234'); }); it('normalizes alphabetic values', () => { - expect(normalizeValue('1a 2b3C4', 6, 'alpha')).toBe('abC'); + expect(normalizeOTPValue('1a 2b3C4', 6, 'alpha')).toBe('abC'); }); it('normalizes alphanumeric values', () => { - expect(normalizeValue('A1-B2 c3!', 6, 'alphanumeric')).toBe('A1B2c3'); + expect(normalizeOTPValue('A1-B2 c3!', 6, 'alphanumeric')).toBe('A1B2c3'); }); it('returns an empty string for nullish input values', () => { - expect(normalizeValue(null, 6, 'numeric')).toBe(''); - expect(normalizeValue(undefined, 6, 'alpha')).toBe(''); + expect(normalizeOTPValue(null, 6, 'numeric')).toBe(''); + expect(normalizeOTPValue(undefined, 6, 'alpha')).toBe(''); }); it('uses custom sanitization when validationType is none', () => { expect( - normalizeValue('ab-12 cd', 6, 'none', (value) => + normalizeOTPValue('ab-12 cd', 6, 'none', (value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase(), ), ).toBe('AB12CD'); }); it('applies custom sanitization after built-in validation', () => { - expect(normalizeValue('ab-12 cd!', 6, 'alphanumeric', (value) => value.toUpperCase())).toBe( - 'AB12CD', - ); + const sanitizeValue = vi.fn((value: string) => value.toUpperCase()); + + expect(normalizeOTPValue('ab-12 cd!', 6, 'alphanumeric', sanitizeValue)).toBe('AB12CD'); + expect(sanitizeValue).toHaveBeenCalledTimes(1); + expect(sanitizeValue).toHaveBeenCalledWith('ab12cd'); }); it('filters custom sanitization output through built-in validation', () => { - expect(normalizeValue('12', 6, 'numeric', (value) => `${value}AB`)).toBe('12'); + expect(normalizeOTPValue('12', 6, 'numeric', (value) => `${value}AB`)).toBe('12'); }); it('clamps values after custom sanitization', () => { - expect(normalizeValue('123456', 4, 'numeric', (value) => `${value}789`)).toBe('1234'); + expect(normalizeOTPValue('123456', 4, 'numeric', (value) => `${value}789`)).toBe('1234'); }); it('returns an empty string for negative lengths', () => { - expect(normalizeValue('1234', -1, 'none')).toBe(''); + expect(normalizeOTPValue('1234', -1, 'none')).toBe(''); }); it('replaces values from the middle of the OTP', () => { diff --git a/packages/react/src/otp-field/utils/otp.ts b/packages/react/src/otp-field/utils/otp.ts index 2d9f08a5548..6349aa8bb3f 100644 --- a/packages/react/src/otp-field/utils/otp.ts +++ b/packages/react/src/otp-field/utils/otp.ts @@ -48,22 +48,41 @@ function applyOTPValidation(value: string, validation: OTPValidationConfig | nul * Normalizes user-entered OTP text by stripping whitespace, applying validation and custom * sanitization, and clamping the final value to the configured slot count. */ -export function normalizeOTPValue( +export function normalizeOTPValueWithDetails( value: string | null | undefined, length: number, validationType: OTPValidationType, sanitizeValue?: ((value: string) => string) | undefined, -) { - let sanitizedValue = stripOTPWhitespace(value); +): readonly [value: string, didSanitize: boolean] { + const strippedValue = stripOTPWhitespace(value); const validation = getOTPValidationConfig(validationType); - sanitizedValue = applyOTPValidation(sanitizedValue, validation); + let sanitizedValue = applyOTPValidation(strippedValue, validation); + let didSanitize = strippedValue.length > sanitizedValue.length; if (sanitizeValue) { - sanitizedValue = applyOTPValidation(sanitizeValue(sanitizedValue), validation); + const customSanitizedValue = sanitizeValue(sanitizedValue); + didSanitize ||= sanitizedValue.length > customSanitizedValue.length; + sanitizedValue = applyOTPValidation(customSanitizedValue, validation); + didSanitize ||= customSanitizedValue.length > sanitizedValue.length; } // Slice by Unicode code points so multi-byte characters do not split across OTP slots. - return Array.from(sanitizedValue).slice(0, Math.max(length, 0)).join(''); + const maxLength = length < 0 ? 0 : length; + const sanitizedCharacters = Array.from(sanitizedValue); + + return [ + sanitizedCharacters.slice(0, maxLength).join(''), + didSanitize || sanitizedCharacters.length > maxLength, + ]; +} + +export function normalizeOTPValue( + value: string | null | undefined, + length: number, + validationType: OTPValidationType, + sanitizeValue?: ((value: string) => string) | undefined, +) { + return normalizeOTPValueWithDetails(value, length, validationType, sanitizeValue)[0]; } /** From 4721b912a634747a6614e7ee5ccb8a53533247ba Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 15 May 2026 15:32:44 +1000 Subject: [PATCH 4/4] [otp field] Rename normalize value prop --- .../custom-sanitize/css-modules/index.tsx | 6 +- .../otp-field/demos/custom-sanitize/index.ts | 2 +- .../react/components/otp-field/page.mdx | 10 +-- .../react/components/otp-field/types.md | 66 ++++++++++--------- docs/src/app/(docs)/react/components/page.mdx | 4 +- .../src/otp-field/input/OTPFieldInput.tsx | 18 ++--- .../src/otp-field/root/OTPFieldRoot.spec.tsx | 13 +++- .../src/otp-field/root/OTPFieldRoot.test.tsx | 47 ++++++------- .../react/src/otp-field/root/OTPFieldRoot.tsx | 36 +++++----- .../src/otp-field/root/OTPFieldRootContext.ts | 2 +- .../react/src/otp-field/utils/otp.test.ts | 24 +++---- packages/react/src/otp-field/utils/otp.ts | 36 +++++----- 12 files changed, 141 insertions(+), 123 deletions(-) diff --git a/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.tsx b/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.tsx index 484c2547760..6051b802d1e 100644 --- a/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/css-modules/index.tsx @@ -6,7 +6,7 @@ import styles from './index.module.css'; const CODE_LENGTH = 6; -function sanitizeRecoveryCode(value: string) { +function normalizeRecoveryCode(value: string) { return value.toUpperCase(); } @@ -18,7 +18,7 @@ function getInvalidClassName(invalidPulse: number, evenClassName: string, oddCla return invalidPulse % 2 === 0 ? evenClassName : oddClassName; } -export default function OTPFieldCustomSanitizeDemo() { +export default function OTPFieldCustomNormalizeDemo() { const id = React.useId(); const descriptionId = `${id}-description`; @@ -46,7 +46,7 @@ export default function OTPFieldCustomSanitizeDemo() { id={id} length={CODE_LENGTH} validationType="alphanumeric" - sanitizeValue={sanitizeRecoveryCode} + normalizeValue={normalizeRecoveryCode} onValueChange={handleValueChange} onValueInvalid={handleValueInvalid} aria-describedby={descriptionId} diff --git a/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/index.ts b/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/index.ts index 83dcddc8729..6593c6878cd 100644 --- a/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/index.ts +++ b/docs/src/app/(docs)/react/components/otp-field/demos/custom-sanitize/index.ts @@ -1,6 +1,6 @@ import { createDemoWithVariants } from 'docs/src/utils/createDemo'; import CssModules from './css-modules'; -export const DemoOTPFieldCustomSanitize = createDemoWithVariants(import.meta.url, { +export const DemoOTPFieldCustomNormalize = createDemoWithVariants(import.meta.url, { CssModules, }); diff --git a/docs/src/app/(docs)/react/components/otp-field/page.mdx b/docs/src/app/(docs)/react/components/otp-field/page.mdx index c7a9093b83a..aaf9046465b 100644 --- a/docs/src/app/(docs)/react/components/otp-field/page.mdx +++ b/docs/src/app/(docs)/react/components/otp-field/page.mdx @@ -106,18 +106,18 @@ import { DemoOTPFieldFocusedPlaceholder } from './demos/focused-placeholder'; -### Custom sanitization +### Custom normalization -Use `sanitizeValue` to normalize accepted values before state updates, such as converting +Use `normalizeValue` to normalize accepted values before state updates, such as converting alphanumeric codes to uppercase. It runs after `validationType` filtering, and the result is filtered -against `validationType` again. Use `validationType="none"` when the sanitizer should provide the +against `validationType` again. Use `validationType="none"` when the normalizer should provide the full validation rule. Pair custom rules with `inputMode` for keyboard hints and `onValueInvalid` for rejected characters. -import { DemoOTPFieldCustomSanitize } from './demos/custom-sanitize'; +import { DemoOTPFieldCustomNormalize } from './demos/custom-sanitize'; - + ### Masked entry diff --git a/docs/src/app/(docs)/react/components/otp-field/types.md b/docs/src/app/(docs)/react/components/otp-field/types.md index 0939397318f..32a547839fa 100644 --- a/docs/src/app/(docs)/react/components/otp-field/types.md +++ b/docs/src/app/(docs)/react/components/otp-field/types.md @@ -11,29 +11,29 @@ Renders a `
` element. **Root Props:** -| Prop | Type | Default | Description | -| :-------------- | :---------------------------------------------------------------------------------------- | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| name | `string` | - | Identifies the field when a form is submitted. | -| defaultValue | `string` | - | The uncontrolled OTP value when the component is initially rendered. | -| value | `string` | - | The OTP value. | -| onValueChange | `((value: string, eventDetails: OTPFieldPreview.Root.ChangeEventDetails) => void)` | - | Callback fired when the OTP value changes. The `eventDetails.reason` indicates what triggered the change: `'input-change'` for typing or autofill`'input-clear'` when a character is removed by text input`'input-paste'` for paste interactions`'keyboard'` for keyboard interactions that change the value | -| autoComplete | `string` | `'one-time-code'` | The input autocomplete attribute. Applied to the first slot and hidden validation input. | -| autoSubmit | `boolean` | `false` | Whether to submit the owning form when the OTP becomes complete. | -| form | `string` | - | A string specifying the `form` element with which the hidden input is associated. This string's value must match the id of a `form` element in the same document. | -| inputMode | `'none' \| 'text' \| 'tel' \| 'url' \| 'email' \| 'numeric' \| 'decimal' \| 'search'` | - | The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed. | -| length\* | `number` | - | The number of OTP input slots. Required so the root can clamp values, detect completion, and generate consistent validation markup before all slots hydrate. | -| mask | `boolean` | `false` | Whether the slot inputs should mask entered characters. Pass `type` directly to individual `` parts to use a custom input type. | -| onValueComplete | `((value: string, eventDetails: OTPFieldPreview.Root.CompleteEventDetails) => void)` | - | Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than `onValueChange`, after the internal value update is applied. If a complete pasted value matches the current value, `onValueChange` does not fire. If `autoSubmit` is enabled, it runs immediately before the owning form is submitted. | -| onValueInvalid | `((value: string, eventDetails: OTPFieldPreview.Root.InvalidEventDetails) => void)` | - | Callback fired when entered text contains characters that are rejected by sanitization, before the OTP value updates. The `value` argument is the attempted user-entered string before sanitization. | -| sanitizeValue | `((value: string) => string)` | - | Function for custom sanitization after whitespace and `validationType` filtering. This function runs before updating the OTP value from user interactions. The returned value is filtered by `validationType` again, then clamped to `length`. It should be idempotent because the component may normalize controlled values on every render and normalize again for each user edit. Characters rejected while normalizing typed or pasted text are reported through `onValueInvalid`. | -| validationType | `OTPFieldPreview.Root.ValidationType` | `'numeric'` | The type of input validation to apply to the OTP value. | -| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | -| readOnly | `boolean` | `false` | Whether the user should be unable to change the field value. | -| required | `boolean` | `false` | Whether the user must enter a value before submitting a form. | -| id | `string` | - | The id of the first input element. Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on). | -| className | `string \| ((state: OTPFieldRootState) => string \| undefined)` | - | CSS class applied to the element, or a function that returns a class based on the component's state. | -| style | `React.CSSProperties \| ((state: OTPFieldRootState) => React.CSSProperties \| undefined)` | - | Style applied to the element, or a function that returns a style object based on the component's state. | -| render | `ReactElement \| ((props: HTMLProps, state: OTPFieldRootState) => ReactElement)` | - | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | +| Prop | Type | Default | Description | +| :-------------- | :---------------------------------------------------------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | `string` | - | Identifies the field when a form is submitted. | +| defaultValue | `string` | - | The uncontrolled OTP value when the component is initially rendered. | +| value | `string` | - | The OTP value. | +| onValueChange | `((value: string, eventDetails: OTPFieldPreview.Root.ChangeEventDetails) => void)` | - | Callback fired when the OTP value changes. The `eventDetails.reason` indicates what triggered the change: `'input-change'` for typing or autofill`'input-clear'` when a character is removed by text input`'input-paste'` for paste interactions`'keyboard'` for keyboard interactions that change the value | +| autoComplete | `string` | `'one-time-code'` | The input autocomplete attribute. Applied to the first slot and hidden validation input. | +| autoSubmit | `boolean` | `false` | Whether to submit the owning form when the OTP becomes complete. | +| form | `string` | - | A string specifying the `form` element with which the hidden input is associated. This string's value must match the id of a `form` element in the same document. | +| inputMode | `'none' \| 'text' \| 'tel' \| 'url' \| 'email' \| 'numeric' \| 'decimal' \| 'search'` | - | The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed. | +| length\* | `number` | - | The number of OTP input slots. Required so the root can clamp values, detect completion, and generate consistent validation markup before all slots hydrate. | +| mask | `boolean` | `false` | Whether the slot inputs should mask entered characters. Pass `type` directly to individual `` parts to use a custom input type. | +| normalizeValue | `((value: string) => string)` | - | Function that normalizes the OTP value after whitespace and `validationType` filtering. It runs whenever OTP Field normalizes a value, including initial/default values, controlled values, and user edits. The returned value is filtered by `validationType` again, then clamped to `length`. It should be idempotent because OTP Field may normalize the same value more than once while handling edits, storing state, and rendering controlled or uncontrolled values. Non-idempotent normalizers can compound across those normalization passes. Characters rejected while normalizing typed or pasted text are reported through `onValueInvalid`. | +| onValueComplete | `((value: string, eventDetails: OTPFieldPreview.Root.CompleteEventDetails) => void)` | - | Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than `onValueChange`, after the internal value update is applied. If a complete pasted value matches the current value, `onValueChange` does not fire. If `autoSubmit` is enabled, it runs immediately before the owning form is submitted. | +| onValueInvalid | `((value: string, eventDetails: OTPFieldPreview.Root.InvalidEventDetails) => void)` | - | Callback fired when entered text contains characters that are rejected by validation or normalization before the OTP value updates. The `value` argument is the attempted user-entered string before normalization. | +| validationType | `OTPFieldPreview.Root.ValidationType` | `'numeric'` | The type of input validation to apply to the OTP value. | +| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | +| readOnly | `boolean` | `false` | Whether the user should be unable to change the field value. | +| required | `boolean` | `false` | Whether the user must enter a value before submitting a form. | +| id | `string` | - | The id of the first input element. Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on). | +| className | `string \| ((state: OTPFieldRootState) => string \| undefined)` | - | CSS class applied to the element, or a function that returns a class based on the component's state. | +| style | `React.CSSProperties \| ((state: OTPFieldRootState) => React.CSSProperties \| undefined)` | - | Style applied to the element, or a function that returns a style object based on the component's state. | +| render | `ReactElement \| ((props: HTMLProps, state: OTPFieldRootState) => ReactElement)` | - | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | **Root Data Attributes:** @@ -363,15 +363,17 @@ type OTPFieldRootProps = { */ validationType?: OTPFieldRoot.ValidationType; /** - * Function for custom sanitization after whitespace and `validationType` filtering. - * This function runs before updating the OTP value from user interactions. + * Function that normalizes the OTP value after whitespace and `validationType` filtering. + * It runs whenever OTP Field normalizes a value, including initial/default values, controlled + * values, and user edits. * * The returned value is filtered by `validationType` again, then clamped to `length`. - * It should be idempotent because the component may normalize controlled values on every render - * and normalize again for each user edit. Characters rejected while normalizing typed or pasted - * text are reported through `onValueInvalid`. + * It should be idempotent because OTP Field may normalize the same value more than once while + * handling edits, storing state, and rendering controlled or uncontrolled values. Non-idempotent + * normalizers can compound across those normalization passes. Characters rejected while + * normalizing typed or pasted text are reported through `onValueInvalid`. */ - sanitizeValue?: (value: string) => string; + normalizeValue?: (value: string) => string; /** * Whether the user must enter a value before submitting a form. * @default false @@ -404,10 +406,10 @@ type OTPFieldRootProps = { */ onValueChange?: (value: string, eventDetails: OTPFieldRoot.ChangeEventDetails) => void; /** - * Callback fired when entered text contains characters that are rejected by sanitization, - * before the OTP value updates. + * Callback fired when entered text contains characters that are rejected by validation or + * normalization before the OTP value updates. * - * The `value` argument is the attempted user-entered string before sanitization. + * The `value` argument is the attempted user-entered string before normalization. */ onValueInvalid?: (value: string, eventDetails: OTPFieldRoot.InvalidEventDetails) => void; /** diff --git a/docs/src/app/(docs)/react/components/page.mdx b/docs/src/app/(docs)/react/components/page.mdx index d8165f073f2..06c64375767 100644 --- a/docs/src/app/(docs)/react/components/page.mdx +++ b/docs/src/app/(docs)/react/components/page.mdx @@ -1199,7 +1199,7 @@ A one-time password input composed of individual character slots. - Alphanumeric verification codes - Grouped layouts - Placeholder hints - - Custom sanitization + - Custom normalization - Masked entry - API reference - Root @@ -1207,7 +1207,7 @@ A one-time password input composed of individual character slots. - Separator - Exports: - OTP Field - Root - - Props: autoComplete, autoSubmit, className, defaultValue, disabled, form, id, inputMode, length, mask, name, onValueChange, onValueComplete, onValueInvalid, readOnly, render, required, sanitizeValue, style, validationType, value + - Props: autoComplete, autoSubmit, className, defaultValue, disabled, form, id, inputMode, length, mask, name, normalizeValue, onValueChange, onValueComplete, onValueInvalid, readOnly, render, required, style, validationType, value - Data Attributes: data-complete, data-dirty, data-disabled, data-filled, data-focused, data-invalid, data-readonly, data-required, data-touched, data-valid - OTP Field - Input - Props: className, render, style diff --git a/packages/react/src/otp-field/input/OTPFieldInput.tsx b/packages/react/src/otp-field/input/OTPFieldInput.tsx index 8fd77fbd5ac..1e6c51e461c 100644 --- a/packages/react/src/otp-field/input/OTPFieldInput.tsx +++ b/packages/react/src/otp-field/input/OTPFieldInput.tsx @@ -57,7 +57,7 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( reportValueInvalid, readOnly, required, - sanitizeValue, + normalizeValue, setValue, state, validationType, @@ -138,14 +138,14 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( } const rawValue = event.currentTarget.value; - const [nextDigits, didSanitize] = normalizeOTPValueWithDetails( + const [nextDigits, didRejectCharacters] = normalizeOTPValueWithDetails( rawValue, length, validationType, - sanitizeValue, + normalizeValue, ); - if (didSanitize) { + if (didRejectCharacters) { reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputChange, event.nativeEvent), @@ -171,7 +171,7 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( nextDigits, length, validationType, - sanitizeValue, + normalizeValue, ); const committedValue = setValue( @@ -285,14 +285,14 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( event.preventDefault(); - const [nextDigits, didSanitize] = normalizeOTPValueWithDetails( + const [nextDigits, didRejectCharacters] = normalizeOTPValueWithDetails( rawValue, length, validationType, - sanitizeValue, + normalizeValue, ); - if (didSanitize) { + if (didRejectCharacters) { reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputPaste, event.nativeEvent), @@ -304,7 +304,7 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( } const committedValue = setValue( - replaceOTPValue(value, index, nextDigits, length, validationType, sanitizeValue), + replaceOTPValue(value, index, nextDigits, length, validationType, normalizeValue), createChangeEventDetails(REASONS.inputPaste, event.nativeEvent), ); diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.spec.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.spec.tsx index 20d33d093c2..10f08d1cd90 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.spec.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.spec.tsx @@ -79,7 +79,7 @@ const otpFieldEventNarrowing = ( form="verification-form" mask validationType="alphanumeric" - sanitizeValue={(value) => value.toUpperCase()} + normalizeValue={(value) => value.toUpperCase()} onValueChange={handleOTPFieldChange} onValueInvalid={handleOTPFieldInvalid} onValueComplete={handleOTPFieldComplete} @@ -100,3 +100,14 @@ const customInputModeWithBuiltInValidation = ( ); void customInputModeWithBuiltInValidation; + +const normalizesValue = ( + value} /> +); +void normalizesValue; + +const removedSanitizeValue = ( + // @ts-expect-error - sanitizeValue was renamed to normalizeValue + value} /> +); +void removedSanitizeValue; diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx index 1afefef4263..e69673389d5 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx @@ -214,12 +214,12 @@ describe('', () => { }); }); - describe('prop: sanitizeValue', () => { - it('supports custom sanitization when `validationType` is `none`', async () => { + describe('prop: normalizeValue', () => { + it('supports custom normalization when `validationType` is `none`', async () => { await render( value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()} + normalizeValue={(value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()} />, ); @@ -236,7 +236,7 @@ describe('', () => { await render( value.toUpperCase()} + normalizeValue={(value) => value.toUpperCase()} />, ); @@ -251,9 +251,12 @@ describe('', () => { } }); - it('composes built-in validation and custom sanitization for pasted values', async () => { + it('composes built-in validation and custom normalization for pasted values', async () => { await render( - value.toUpperCase()} />, + value.toUpperCase()} + />, ); const [firstInput] = screen.getAllByRole('textbox'); @@ -262,12 +265,12 @@ describe('', () => { expect(getValues()).toBe('AB12CD'); }); - it('composes built-in validation and custom sanitization from a non-first slot', async () => { + it('composes built-in validation and custom normalization from a non-first slot', async () => { await render( value.toUpperCase()} + normalizeValue={(value) => value.toUpperCase()} />, ); @@ -313,7 +316,7 @@ describe('', () => { }); describe('prop: onValueInvalid', () => { - it('fires when typing is sanitized before the OTP value updates', async () => { + it('fires when typing is normalized before the OTP value updates', async () => { const onValueInvalid = vi.fn(); await render(); @@ -327,14 +330,14 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); - it('fires when custom sanitization removes characters', async () => { + it('fires when custom normalization removes characters', async () => { const onValueInvalid = vi.fn(); await render( value.replace(/[^0-3]/g, '')} + normalizeValue={(value) => value.replace(/[^0-3]/g, '')} onValueInvalid={onValueInvalid} />, ); @@ -348,13 +351,13 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); - it('fires when custom sanitization removes characters after built-in validation', async () => { + it('fires when custom normalization removes characters after built-in validation', async () => { const onValueInvalid = vi.fn(); await render( value.replace(/[^0-3]/g, '')} + normalizeValue={(value) => value.replace(/[^0-3]/g, '')} onValueInvalid={onValueInvalid} />, ); @@ -368,13 +371,13 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); - it('fires when built-in validation removes characters before custom sanitization expands the value', async () => { + it('fires when built-in validation removes characters before custom normalization expands the value', async () => { const onValueInvalid = vi.fn(); await render( (value === '1' ? '12' : value)} + normalizeValue={(value) => (value === '1' ? '12' : value)} onValueInvalid={onValueInvalid} />, ); @@ -388,13 +391,13 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); - it('fires when custom sanitization removes all characters after built-in validation', async () => { + it('fires when custom normalization removes all characters after built-in validation', async () => { const onValueInvalid = vi.fn(); await render( ''} + normalizeValue={() => ''} onValueInvalid={onValueInvalid} />, ); @@ -408,7 +411,7 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); - it('fires `input-paste` when pasted text is sanitized before the OTP value updates', async () => { + it('fires `input-paste` when pasted text is normalized before the OTP value updates', async () => { const onValueInvalid = vi.fn(); await render(); @@ -422,13 +425,13 @@ describe('', () => { expect(onValueInvalid.mock.calls[0]?.[1].reason).toBe(REASONS.inputPaste); }); - it('fires `input-paste` when custom sanitization removes characters after built-in validation', async () => { + it('fires `input-paste` when custom normalization removes characters after built-in validation', async () => { const onValueInvalid = vi.fn(); await render( value.replace(/[^0-3]/g, '')} + normalizeValue={(value) => value.replace(/[^0-3]/g, '')} onValueInvalid={onValueInvalid} />, ); @@ -1019,7 +1022,7 @@ describe('', () => { expect(onValueComplete.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); - it('composes validation and custom sanitization during hidden input autofill', async () => { + it('composes validation and custom normalization during hidden input autofill', async () => { const onValueChange = vi.fn(); const onValueInvalid = vi.fn(); const onValueComplete = vi.fn(); @@ -1028,7 +1031,7 @@ describe('', () => { value.toUpperCase()} + normalizeValue={(value) => value.toUpperCase()} onValueChange={onValueChange} onValueInvalid={onValueInvalid} onValueComplete={onValueComplete} diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.tsx index 1f13f4c7791..0773d401161 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.tsx @@ -61,7 +61,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( mask = false, inputMode: inputModeProp, validationType = 'numeric', - sanitizeValue, + normalizeValue, disabled: disabledProp = false, readOnly = false, required = false, @@ -134,7 +134,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( const inputMode = inputModeProp ?? validationConfig?.inputMode; const hasValidLength = Number.isInteger(length) && length > 0; - const value = normalizeOTPValue(valueUnwrapped, length, validationType, sanitizeValue); + const value = normalizeOTPValue(valueUnwrapped, length, validationType, normalizeValue); const valueRef = useValueAsRef(value); const filled = value !== ''; @@ -227,7 +227,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( const setValue = useStableCallback( (nextValue: string, details: OTPFieldRoot.ChangeEventDetails) => { - const normalizedValue = normalizeOTPValue(nextValue, length, validationType, sanitizeValue); + const normalizedValue = normalizeOTPValue(nextValue, length, validationType, normalizeValue); const completeEventDetails = normalizedValue.length === length && (valueRef.current.length !== length || details.reason === REASONS.inputPaste) @@ -342,7 +342,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( reportValueInvalid, readOnly, required, - sanitizeValue, + normalizeValue, setValue, state, validationType, @@ -367,7 +367,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( readOnly, reportValueInvalid, required, - sanitizeValue, + normalizeValue, setValue, state, validationType, @@ -410,14 +410,14 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( } const rawValue = event.currentTarget.value; - const [normalizedValue, didSanitize] = normalizeOTPValueWithDetails( + const [normalizedValue, didRejectCharacters] = normalizeOTPValueWithDetails( rawValue, length, validationType, - sanitizeValue, + normalizeValue, ); - if (didSanitize) { + if (didRejectCharacters) { reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputChange, event.nativeEvent), @@ -515,15 +515,17 @@ export interface OTPFieldRootProps extends Omit< */ validationType?: OTPFieldRoot.ValidationType | undefined; /** - * Function for custom sanitization after whitespace and `validationType` filtering. - * This function runs before updating the OTP value from user interactions. + * Function that normalizes the OTP value after whitespace and `validationType` filtering. + * It runs whenever OTP Field normalizes a value, including initial/default values, controlled + * values, and user edits. * * The returned value is filtered by `validationType` again, then clamped to `length`. - * It should be idempotent because the component may normalize controlled values on every render - * and normalize again for each user edit. Characters rejected while normalizing typed or pasted - * text are reported through `onValueInvalid`. + * It should be idempotent because OTP Field may normalize the same value more than once while + * handling edits, storing state, and rendering controlled or uncontrolled values. Non-idempotent + * normalizers can compound across those normalization passes. Characters rejected while + * normalizing typed or pasted text are reported through `onValueInvalid`. */ - sanitizeValue?: ((value: string) => string) | undefined; + normalizeValue?: ((value: string) => string) | undefined; /** * Whether the user must enter a value before submitting a form. * @default false @@ -564,10 +566,10 @@ export interface OTPFieldRootProps extends Omit< | ((value: string, eventDetails: OTPFieldRoot.ChangeEventDetails) => void) | undefined; /** - * Callback fired when entered text contains characters that are rejected by sanitization, - * before the OTP value updates. + * Callback fired when entered text contains characters that are rejected by validation or + * normalization before the OTP value updates. * - * The `value` argument is the attempted user-entered string before sanitization. + * The `value` argument is the attempted user-entered string before normalization. */ onValueInvalid?: | ((value: string, eventDetails: OTPFieldRoot.InvalidEventDetails) => void) diff --git a/packages/react/src/otp-field/root/OTPFieldRootContext.ts b/packages/react/src/otp-field/root/OTPFieldRootContext.ts index 3833a5db933..97a425f2258 100644 --- a/packages/react/src/otp-field/root/OTPFieldRootContext.ts +++ b/packages/react/src/otp-field/root/OTPFieldRootContext.ts @@ -22,7 +22,7 @@ export interface OTPFieldRootContext { reportValueInvalid: (value: string, details: OTPFieldRoot.InvalidEventDetails) => void; readOnly: boolean; required: boolean; - sanitizeValue: ((value: string) => string) | undefined; + normalizeValue: ((value: string) => string) | undefined; setValue: (value: string, details: OTPFieldRoot.ChangeEventDetails) => string | null; state: OTPFieldRootState; validationType: OTPFieldRoot.ValidationType; diff --git a/packages/react/src/otp-field/utils/otp.test.ts b/packages/react/src/otp-field/utils/otp.test.ts index ebb81514fce..62d74cd83fc 100644 --- a/packages/react/src/otp-field/utils/otp.test.ts +++ b/packages/react/src/otp-field/utils/otp.test.ts @@ -28,7 +28,7 @@ describe('otp utils', () => { expect(normalizeOTPValue(undefined, 6, 'alpha')).toBe(''); }); - it('uses custom sanitization when validationType is none', () => { + it('uses custom normalization when validationType is none', () => { expect( normalizeOTPValue('ab-12 cd', 6, 'none', (value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase(), @@ -36,19 +36,19 @@ describe('otp utils', () => { ).toBe('AB12CD'); }); - it('applies custom sanitization after built-in validation', () => { - const sanitizeValue = vi.fn((value: string) => value.toUpperCase()); + it('applies custom normalization after built-in validation', () => { + const normalizeValue = vi.fn((value: string) => value.toUpperCase()); - expect(normalizeOTPValue('ab-12 cd!', 6, 'alphanumeric', sanitizeValue)).toBe('AB12CD'); - expect(sanitizeValue).toHaveBeenCalledTimes(1); - expect(sanitizeValue).toHaveBeenCalledWith('ab12cd'); + expect(normalizeOTPValue('ab-12 cd!', 6, 'alphanumeric', normalizeValue)).toBe('AB12CD'); + expect(normalizeValue).toHaveBeenCalledTimes(1); + expect(normalizeValue).toHaveBeenCalledWith('ab12cd'); }); - it('filters custom sanitization output through built-in validation', () => { + it('filters custom normalization output through built-in validation', () => { expect(normalizeOTPValue('12', 6, 'numeric', (value) => `${value}AB`)).toBe('12'); }); - it('clamps values after custom sanitization', () => { + it('clamps values after custom normalization', () => { expect(normalizeOTPValue('123456', 4, 'numeric', (value) => `${value}789`)).toBe('1234'); }); @@ -68,13 +68,13 @@ describe('otp utils', () => { expect(replaceOTPValue('123456', 5, '9', 6, 'numeric')).toBe('123459'); }); - it('applies custom sanitization when replacing OTP values', () => { - const sanitizeValue = vi.fn((value: string) => value.toUpperCase()); + it('applies custom normalization when replacing OTP values', () => { + const normalizeValue = vi.fn((value: string) => value.toUpperCase()); - expect(replaceOTPValue('123456', 2, 'ab', 6, 'alphanumeric', sanitizeValue)).toBe('12AB56'); + expect(replaceOTPValue('123456', 2, 'ab', 6, 'alphanumeric', normalizeValue)).toBe('12AB56'); }); - it('preserves suffix characters when custom sanitization removes part of a middle replacement', () => { + it('preserves suffix characters when custom normalization removes part of a middle replacement', () => { expect( replaceOTPValue('1303', 1, '29', 4, 'numeric', (value) => value.replace(/[^0-3]/g, '')), ).toBe('1203'); diff --git a/packages/react/src/otp-field/utils/otp.ts b/packages/react/src/otp-field/utils/otp.ts index 6349aa8bb3f..073dad415d6 100644 --- a/packages/react/src/otp-field/utils/otp.ts +++ b/packages/react/src/otp-field/utils/otp.ts @@ -46,33 +46,33 @@ function applyOTPValidation(value: string, validation: OTPValidationConfig | nul /** * Normalizes user-entered OTP text by stripping whitespace, applying validation and custom - * sanitization, and clamping the final value to the configured slot count. + * normalization, and clamping the final value to the configured slot count. */ export function normalizeOTPValueWithDetails( value: string | null | undefined, length: number, validationType: OTPValidationType, - sanitizeValue?: ((value: string) => string) | undefined, -): readonly [value: string, didSanitize: boolean] { + normalizeValue?: ((value: string) => string) | undefined, +): readonly [value: string, didRejectCharacters: boolean] { const strippedValue = stripOTPWhitespace(value); const validation = getOTPValidationConfig(validationType); - let sanitizedValue = applyOTPValidation(strippedValue, validation); - let didSanitize = strippedValue.length > sanitizedValue.length; + let normalizedValue = applyOTPValidation(strippedValue, validation); + let didRejectCharacters = strippedValue.length > normalizedValue.length; - if (sanitizeValue) { - const customSanitizedValue = sanitizeValue(sanitizedValue); - didSanitize ||= sanitizedValue.length > customSanitizedValue.length; - sanitizedValue = applyOTPValidation(customSanitizedValue, validation); - didSanitize ||= customSanitizedValue.length > sanitizedValue.length; + if (normalizeValue) { + const customNormalizedValue = normalizeValue(normalizedValue); + didRejectCharacters ||= normalizedValue.length > customNormalizedValue.length; + normalizedValue = applyOTPValidation(customNormalizedValue, validation); + didRejectCharacters ||= customNormalizedValue.length > normalizedValue.length; } // Slice by Unicode code points so multi-byte characters do not split across OTP slots. const maxLength = length < 0 ? 0 : length; - const sanitizedCharacters = Array.from(sanitizedValue); + const normalizedCharacters = Array.from(normalizedValue); return [ - sanitizedCharacters.slice(0, maxLength).join(''), - didSanitize || sanitizedCharacters.length > maxLength, + normalizedCharacters.slice(0, maxLength).join(''), + didRejectCharacters || normalizedCharacters.length > maxLength, ]; } @@ -80,9 +80,9 @@ export function normalizeOTPValue( value: string | null | undefined, length: number, validationType: OTPValidationType, - sanitizeValue?: ((value: string) => string) | undefined, + normalizeValue?: ((value: string) => string) | undefined, ) { - return normalizeOTPValueWithDetails(value, length, validationType, sanitizeValue)[0]; + return normalizeOTPValueWithDetails(value, length, validationType, normalizeValue)[0]; } /** @@ -95,9 +95,9 @@ export function replaceOTPValue( nextValue: string, length: number, validationType: OTPValidationType, - sanitizeValue?: ((value: string) => string) | undefined, + normalizeValue?: ((value: string) => string) | undefined, ) { - const normalizedValue = normalizeOTPValue(nextValue, length, validationType, sanitizeValue); + const normalizedValue = normalizeOTPValue(nextValue, length, validationType, normalizeValue); const prefix = currentValue.slice(0, index); const suffix = currentValue.slice(index + normalizedValue.length); @@ -105,7 +105,7 @@ export function replaceOTPValue( `${prefix}${normalizedValue}${suffix}`, length, validationType, - sanitizeValue, + normalizeValue, ); }