From 08b9bfed40fd4424a8ce26959e69ccc3e0d494b8 Mon Sep 17 00:00:00 2001 From: coi Date: Sat, 16 May 2026 00:22:49 +0900 Subject: [PATCH 1/3] [otp field] Prevent locked hidden autofill validation --- .../src/otp-field/root/OTPFieldRoot.test.tsx | 68 +++++++++---------- .../react/src/otp-field/root/OTPFieldRoot.tsx | 6 +- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx index 4a83f528292..a2b9a14f38d 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx @@ -900,62 +900,60 @@ describe('', () => { expect(onValueComplete.mock.calls[0]?.[1].reason).toBe(REASONS.inputChange); }); - it('ignores hidden-input autofill when the field is readonly', async () => { + it.each([ + { lockState: 'readOnly', label: 'inside Field', withField: true }, + { lockState: 'disabled', label: 'inside Field', withField: true }, + { lockState: 'readOnly', label: 'outside Field', withField: false }, + { lockState: 'disabled', label: 'outside Field', withField: false }, + ] as const)('ignores hidden-input autofill when $lockState $label', async ({ + lockState, + withField, + }) => { const onValueChange = vi.fn(); const onValueInvalid = vi.fn(); const onValueComplete = vi.fn(); - - await render( + const otpField = ( , + /> ); - const hiddenInput = document.querySelector('input[name="otp"]'); - - expect(hiddenInput).not.toBeNull(); - - fireEvent.change(hiddenInput!, { target: { value: '12a34b56' } }); - - const inputs = screen.getAllByRole('textbox'); - - expect(inputs.map((input) => input.value)).toEqual(['', '', '', '', '', '']); - expect(onValueChange).not.toHaveBeenCalled(); - expect(onValueInvalid).not.toHaveBeenCalled(); - expect(onValueComplete).not.toHaveBeenCalled(); - }); - - it('ignores hidden-input autofill when the field is disabled', async () => { - const onValueChange = vi.fn(); - const onValueInvalid = vi.fn(); - const onValueComplete = vi.fn(); - await render( - , + withField ? ( +
+ + {otpField} + + +
+ ) : ( + otpField + ), ); const hiddenInput = document.querySelector('input[name="otp"]'); expect(hiddenInput).not.toBeNull(); - fireEvent.change(hiddenInput!, { target: { value: '12a34b56' } }); + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } - const inputs = screen.getAllByRole('textbox'); + fireEvent.change(hiddenInput!, { target: { value: '12a34b56' } }); - expect(inputs.map((input) => input.value)).toEqual(['', '', '', '', '', '']); + expect(getValues()).toBe(''); expect(onValueChange).not.toHaveBeenCalled(); expect(onValueInvalid).not.toHaveBeenCalled(); expect(onValueComplete).not.toHaveBeenCalled(); + + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } }); describe('prop: autoSubmit', () => { diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.tsx index 713433d67c1..113e8440fd1 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.tsx @@ -19,7 +19,7 @@ import { useAriaLabelledBy } from '../../internals/labelable-provider/useAriaLab import { useLabelableId } from '../../internals/labelable-provider/useLabelableId'; import { useRenderElement } from '../../internals/useRenderElement'; import { useValueChanged } from '../../internals/useValueChanged'; -import type { BaseUIComponentProps } from '../../internals/types'; +import type { BaseUIComponentProps, BaseUIEvent } from '../../internals/types'; import { createChangeEventDetails, createGenericEventDetails, @@ -406,8 +406,10 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( onFocus() { focusInput(0); }, - onChange(event) { + onChange(event: BaseUIEvent>) { if (event.nativeEvent.defaultPrevented || disabled || readOnly) { + // Outside Field.Root, the event is not wrapped by mergeProps. + event.preventBaseUIHandler?.(); return; } From 0a6053bb1a965c6b131a8ca4fd52d8d24368c23e Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 18 May 2026 15:23:12 +1000 Subject: [PATCH 2/3] [otp field] Use maybe Base UI event type --- packages/react/src/internals/types.ts | 3 +++ packages/react/src/otp-field/root/OTPFieldRoot.tsx | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react/src/internals/types.ts b/packages/react/src/internals/types.ts index 23c712a31c4..b8cd7a0331b 100644 --- a/packages/react/src/internals/types.ts +++ b/packages/react/src/internals/types.ts @@ -3,6 +3,9 @@ import type { BaseUIEvent, ComponentRenderFn, HTMLProps } from '../types'; export type { HTMLProps, BaseUIEvent, ComponentRenderFn }; +export type MaybeBaseUIEvent> = E & + Partial, 'preventBaseUIHandler' | 'baseUIHandlerPrevented'>>; + export interface FloatingUIOpenChangeDetails { open: boolean; reason: string; diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.tsx index 113e8440fd1..19633c0efe3 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.tsx @@ -19,7 +19,7 @@ import { useAriaLabelledBy } from '../../internals/labelable-provider/useAriaLab import { useLabelableId } from '../../internals/labelable-provider/useLabelableId'; import { useRenderElement } from '../../internals/useRenderElement'; import { useValueChanged } from '../../internals/useValueChanged'; -import type { BaseUIComponentProps, BaseUIEvent } from '../../internals/types'; +import type { BaseUIComponentProps, MaybeBaseUIEvent } from '../../internals/types'; import { createChangeEventDetails, createGenericEventDetails, @@ -406,7 +406,7 @@ export const OTPFieldRoot = React.forwardRef(function OTPFieldRoot( onFocus() { focusInput(0); }, - onChange(event: BaseUIEvent>) { + onChange(event: MaybeBaseUIEvent>) { if (event.nativeEvent.defaultPrevented || disabled || readOnly) { // Outside Field.Root, the event is not wrapped by mergeProps. event.preventBaseUIHandler?.(); From 437e858567ff1b1f0c4d44b5cde6aeaa73d187cf Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 18 May 2026 15:26:15 +1000 Subject: [PATCH 3/3] [otp field] Format locked autofill test --- .../src/otp-field/root/OTPFieldRoot.test.tsx | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx index a2b9a14f38d..af5c80d9da6 100644 --- a/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx +++ b/packages/react/src/otp-field/root/OTPFieldRoot.test.tsx @@ -905,56 +905,56 @@ describe('', () => { { lockState: 'disabled', label: 'inside Field', withField: true }, { lockState: 'readOnly', label: 'outside Field', withField: false }, { lockState: 'disabled', label: 'outside Field', withField: false }, - ] as const)('ignores hidden-input autofill when $lockState $label', async ({ - lockState, - withField, - }) => { - const onValueChange = vi.fn(); - const onValueInvalid = vi.fn(); - const onValueComplete = vi.fn(); - const otpField = ( - - ); + ] as const)( + 'ignores hidden-input autofill when $lockState $label', + async ({ lockState, withField }) => { + const onValueChange = vi.fn(); + const onValueInvalid = vi.fn(); + const onValueComplete = vi.fn(); + const otpField = ( + + ); - await render( - withField ? ( -
- - {otpField} - - -
- ) : ( - otpField - ), - ); + await render( + withField ? ( +
+ + {otpField} + + +
+ ) : ( + otpField + ), + ); - const hiddenInput = document.querySelector('input[name="otp"]'); + const hiddenInput = document.querySelector('input[name="otp"]'); - expect(hiddenInput).not.toBeNull(); + expect(hiddenInput).not.toBeNull(); - if (withField) { - expect(screen.getByTestId('error')).toHaveTextContent('test'); - } + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } - fireEvent.change(hiddenInput!, { target: { value: '12a34b56' } }); + fireEvent.change(hiddenInput!, { target: { value: '12a34b56' } }); - expect(getValues()).toBe(''); - expect(onValueChange).not.toHaveBeenCalled(); - expect(onValueInvalid).not.toHaveBeenCalled(); - expect(onValueComplete).not.toHaveBeenCalled(); + expect(getValues()).toBe(''); + expect(onValueChange).not.toHaveBeenCalled(); + expect(onValueInvalid).not.toHaveBeenCalled(); + expect(onValueComplete).not.toHaveBeenCalled(); - if (withField) { - expect(screen.getByTestId('error')).toHaveTextContent('test'); - } - }); + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } + }, + ); describe('prop: autoSubmit', () => { const flushSyncLifecycleError = 'flushSync was called from inside a lifecycle method';