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..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,8 +6,8 @@ import styles from './index.module.css'; const CODE_LENGTH = 6; -function sanitizeTierCode(value: string) { - return value.replace(/[^0-3]/g, ''); +function normalizeRecoveryCode(value: string) { + return value.toUpperCase(); } function getInvalidClassName(invalidPulse: number, evenClassName: string, oddClassName: string) { @@ -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`; @@ -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/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 7220cbf6bf7..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,16 +106,18 @@ import { DemoOTPFieldFocusedPlaceholder } from './demos/focused-placeholder'; -### Custom sanitization +### Custom normalization -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 `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 normalizer should provide the +full validation rule. -import { DemoOTPFieldCustomSanitize } from './demos/custom-sanitize'; +Pair custom rules with `inputMode` for keyboard hints and `onValueInvalid` for rejected characters. - +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 7400efe564e..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 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. | +| 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,10 +363,17 @@ type OTPFieldRootProps = { */ validationType?: OTPFieldRoot.ValidationType; /** - * Function for custom sanitization when `validationType` is set to `'none'`. - * 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 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 @@ -399,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 8dbae4527bf..1e6c51e461c 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. @@ -62,7 +57,7 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( reportValueInvalid, readOnly, required, - sanitizeValue, + normalizeValue, setValue, state, validationType, @@ -143,15 +138,14 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( } const rawValue = event.currentTarget.value; - const nextDigits = normalizeOTPValue( - event.currentTarget.value, + const [nextDigits, didRejectCharacters] = normalizeOTPValueWithDetails( + rawValue, length, validationType, - sanitizeValue, + normalizeValue, ); - const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; - if (didSanitize) { + if (didRejectCharacters) { reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputChange, event.nativeEvent), @@ -177,7 +171,7 @@ export const OTPFieldInput = React.forwardRef(function OTPFieldInput( nextDigits, length, validationType, - sanitizeValue, + normalizeValue, ); const committedValue = setValue( @@ -291,10 +285,14 @@ 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, didRejectCharacters] = normalizeOTPValueWithDetails( + rawValue, + length, + validationType, + normalizeValue, + ); - if (didSanitize) { + if (didRejectCharacters) { reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputPaste, event.nativeEvent), @@ -306,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 af5c80d9da6..f561cccfbb1 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()} />, ); @@ -229,20 +229,62 @@ 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 normalization for pasted values', async () => { + await render( + value.toUpperCase()} + />, + ); + + const [firstInput] = screen.getAllByRole('textbox'); + pasteText(firstInput, 'ab-12 cd!'); + + expect(getValues()).toBe('AB12CD'); + }); + + it('composes built-in validation and custom normalization 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', () => { @@ -274,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(); @@ -288,14 +330,34 @@ 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} + />, + ); + + 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 normalization removes characters after built-in validation', async () => { + const onValueInvalid = vi.fn(); + + await render( + value.replace(/[^0-3]/g, '')} onValueInvalid={onValueInvalid} />, ); @@ -309,7 +371,47 @@ 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 when built-in validation removes characters before custom normalization 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 normalization 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 `input-paste` when pasted text is normalized before the OTP value updates', async () => { const onValueInvalid = vi.fn(); await render(); @@ -322,6 +424,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 normalization 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', () => { @@ -900,6 +1022,43 @@ describe('', () => { expect(onValueComplete.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); + it('composes validation and custom normalization 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.each([ { lockState: 'readOnly', label: 'inside Field', withField: true }, { lockState: 'disabled', label: 'inside Field', withField: true }, diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.tsx index 19633c0efe3..42008960829 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'; @@ -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 !== ''; @@ -155,8 +155,6 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( useOTPFieldRootDevWarnings({ inputCount, length, - sanitizeValue, - validationType, }); } @@ -229,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) @@ -344,7 +342,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( reportValueInvalid, readOnly, required, - sanitizeValue, + normalizeValue, setValue, state, validationType, @@ -369,7 +367,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( readOnly, reportValueInvalid, required, - sanitizeValue, + normalizeValue, setValue, state, validationType, @@ -414,14 +412,14 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( } const rawValue = event.currentTarget.value; - const normalizedValue = normalizeOTPValue( + const [normalizedValue, didRejectCharacters] = normalizeOTPValueWithDetails( rawValue, length, validationType, - sanitizeValue, + normalizeValue, ); - if (stripOTPWhitespace(rawValue).length > normalizedValue.length) { + if (didRejectCharacters) { reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputChange, event.nativeEvent), @@ -519,10 +517,17 @@ export interface OTPFieldRootProps extends Omit< */ validationType?: OTPFieldRoot.ValidationType | undefined; /** - * Function for custom sanitization when `validationType` is set to `'none'`. - * 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 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 @@ -563,10 +568,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) @@ -650,12 +655,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) { @@ -681,16 +684,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/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 8619b33e52f..62d74cd83fc 100644 --- a/packages/react/src/otp-field/utils/otp.test.ts +++ b/packages/react/src/otp-field/utils/otp.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { normalizeOTPValue, removeOTPCharacter, replaceOTPValue, stripOTPWhitespace } from './otp'; describe('otp utils', () => { @@ -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,6 +36,22 @@ describe('otp utils', () => { ).toBe('AB12CD'); }); + it('applies custom normalization after built-in validation', () => { + const normalizeValue = vi.fn((value: string) => value.toUpperCase()); + + expect(normalizeOTPValue('ab-12 cd!', 6, 'alphanumeric', normalizeValue)).toBe('AB12CD'); + expect(normalizeValue).toHaveBeenCalledTimes(1); + expect(normalizeValue).toHaveBeenCalledWith('ab12cd'); + }); + + it('filters custom normalization output through built-in validation', () => { + expect(normalizeOTPValue('12', 6, 'numeric', (value) => `${value}AB`)).toBe('12'); + }); + + it('clamps values after custom normalization', () => { + expect(normalizeOTPValue('123456', 4, 'numeric', (value) => `${value}789`)).toBe('1234'); + }); + it('returns an empty string for negative lengths', () => { expect(normalizeOTPValue('1234', -1, 'none')).toBe(''); }); @@ -52,6 +68,18 @@ describe('otp utils', () => { expect(replaceOTPValue('123456', 5, '9', 6, 'numeric')).toBe('123459'); }); + it('applies custom normalization when replacing OTP values', () => { + const normalizeValue = vi.fn((value: string) => value.toUpperCase()); + + expect(replaceOTPValue('123456', 2, 'ab', 6, 'alphanumeric', normalizeValue)).toBe('12AB56'); + }); + + 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'); + }); + 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..073dad415d6 100644 --- a/packages/react/src/otp-field/utils/otp.ts +++ b/packages/react/src/otp-field/utils/otp.ts @@ -40,27 +40,49 @@ 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; +} + /** - * Normalizes user-entered OTP text by stripping whitespace, applying validation or custom - * sanitization, and clamping the final value to the configured slot count. + * Normalizes user-entered OTP text by stripping whitespace, applying validation and custom + * normalization, 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); + normalizeValue?: ((value: string) => string) | undefined, +): readonly [value: string, didRejectCharacters: boolean] { + const strippedValue = stripOTPWhitespace(value); const validation = getOTPValidationConfig(validationType); + let normalizedValue = applyOTPValidation(strippedValue, validation); + let didRejectCharacters = strippedValue.length > normalizedValue.length; - if (validation) { - sanitizedValue = sanitizedValue.replace(validation.regexp, ''); - } else if (sanitizeValue) { - sanitizedValue = sanitizeValue(sanitizedValue); + 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. - return Array.from(sanitizedValue).slice(0, Math.max(length, 0)).join(''); + const maxLength = length < 0 ? 0 : length; + const normalizedCharacters = Array.from(normalizedValue); + + return [ + normalizedCharacters.slice(0, maxLength).join(''), + didRejectCharacters || normalizedCharacters.length > maxLength, + ]; +} + +export function normalizeOTPValue( + value: string | null | undefined, + length: number, + validationType: OTPValidationType, + normalizeValue?: ((value: string) => string) | undefined, +) { + return normalizeOTPValueWithDetails(value, length, validationType, normalizeValue)[0]; } /** @@ -73,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); @@ -83,7 +105,7 @@ export function replaceOTPValue( `${prefix}${normalizedValue}${suffix}`, length, validationType, - sanitizeValue, + normalizeValue, ); }