diff --git a/packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx b/packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx index 4ab1920add0..b5325a9205c 100644 --- a/packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx +++ b/packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx @@ -97,6 +97,64 @@ describe('', () => { expect(input.value).toBe('beta'); }); + it('ignores hidden-input autofill when readOnly', async () => { + const onValueChange = vi.fn(); + await render( + + + + + + + + alpha + beta + + + + + + , + ); + + const hidden = screen.getByRole('textbox', { hidden: true }); + fireEvent.change(hidden, { target: { value: 'beta' } }); + await flushMicrotasks(); + + const input = screen.getByTestId('input'); + expect(onValueChange).not.toHaveBeenCalled(); + expect(input.value).toBe(''); + }); + + it('ignores hidden-input autofill when disabled', async () => { + const onValueChange = vi.fn(); + await render( + + + + + + + + alpha + beta + + + + + + , + ); + + const hidden = screen.getByRole('textbox', { hidden: true }); + fireEvent.change(hidden, { target: { value: 'beta' } }); + await flushMicrotasks(); + + const input = screen.getByTestId('input'); + expect(onValueChange).not.toHaveBeenCalled(); + expect(input.value).toBe(''); + }); + it('should pass autoComplete to the visible input', async () => { await render( diff --git a/packages/react/src/combobox/root/AriaCombobox.tsx b/packages/react/src/combobox/root/AriaCombobox.tsx index ccc46e19a30..4a89b82e032 100644 --- a/packages/react/src/combobox/root/AriaCombobox.tsx +++ b/packages/react/src/combobox/root/AriaCombobox.tsx @@ -41,7 +41,7 @@ import { createCollatorItemFilter, createSingleSelectionCollatorFilter } from '. import { useCoreFilter } from './utils/useFilter'; import { useTransitionStatus } from '../../internals/useTransitionStatus'; import { useOpenInteractionType } from '../../utils/useOpenInteractionType'; -import { HTMLProps } from '../../internals/types'; +import type { BaseUIEvent, HTMLProps } from '../../internals/types'; import { useValueChanged } from '../../internals/useValueChanged'; import { NOOP } from '../../internals/noop'; import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups'; @@ -1258,9 +1258,11 @@ export function AriaCombobox( (inputRef.current || triggerElement)?.focus(); }, // Handle browser autofill. - onChange(event: React.ChangeEvent) { + onChange(event: BaseUIEvent>) { // Workaround for https://github.com/facebook/react/issues/9023 - if (event.nativeEvent.defaultPrevented) { + if (event.nativeEvent.defaultPrevented || disabled || readOnly) { + // Outside Field.Root, the event is not wrapped by mergeProps. + event.preventBaseUIHandler?.(); return; } diff --git a/packages/react/src/combobox/root/ComboboxRoot.test.tsx b/packages/react/src/combobox/root/ComboboxRoot.test.tsx index c75dee895f8..0a62fbfc247 100644 --- a/packages/react/src/combobox/root/ComboboxRoot.test.tsx +++ b/packages/react/src/combobox/root/ComboboxRoot.test.tsx @@ -1456,6 +1456,75 @@ describe('', () => { }); }); + 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 onInputValueChange = vi.fn(); + const combobox = ( + + + + + + + a + b + + + + + + ); + + await render( + withField ? ( +
+ + {combobox} + + +
+ ) : ( + combobox + ), + ); + + const visibleInput = screen.getByTestId('input'); + const hiddenInput = screen + .getAllByDisplayValue('') + .find((el) => el.getAttribute('name') === 'test') as HTMLInputElement; + expect(hiddenInput).not.toBeUndefined(); + + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } + + fireEvent.change(hiddenInput, { target: { value: 'b' } }); + await flushMicrotasks(); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(onInputValueChange).not.toHaveBeenCalled(); + expect(visibleInput.value).toBe(''); + expect(hiddenInput.value).toBe(''); + + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } + }); + it('shows all items when opening after browser autofill', async () => { const items = ['a', 'b', 'c']; const { user } = await render( diff --git a/packages/react/src/number-field/root/NumberFieldRoot.test.tsx b/packages/react/src/number-field/root/NumberFieldRoot.test.tsx index dbc2b8d768b..9c9f425db5d 100644 --- a/packages/react/src/number-field/root/NumberFieldRoot.test.tsx +++ b/packages/react/src/number-field/root/NumberFieldRoot.test.tsx @@ -1320,6 +1320,62 @@ describe('', () => { expect(onValueChange.mock.calls[0][0]).toBe(42); expect(input).toHaveValue('42'); }); + + 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 numberField = ( + + + + ); + + await render( + withField ? ( +
+ + {numberField} + + +
+ ) : ( + numberField + ), + ); + + const input = screen.getByRole('textbox'); + const hiddenInput = document.querySelector( + 'input[type="number"][name="quantity"]', + ) as HTMLInputElement; + + expect(hiddenInput).not.toBe(null); + + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } + + fireEvent.change(hiddenInput, { target: { value: '42' } }); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(input).toHaveValue('1'); + + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } + }, + ); }); describe('Field', () => { diff --git a/packages/react/src/number-field/root/NumberFieldRoot.tsx b/packages/react/src/number-field/root/NumberFieldRoot.tsx index 9a40b9809c2..83a12e505ef 100644 --- a/packages/react/src/number-field/root/NumberFieldRoot.tsx +++ b/packages/react/src/number-field/root/NumberFieldRoot.tsx @@ -15,7 +15,7 @@ import { InputMode, NumberFieldRootContext } from './NumberFieldRootContext'; import { useFieldRootContext } from '../../internals/field-root-context/FieldRootContext'; import type { FieldRootState } from '../../field/root/FieldRoot'; import { useLabelableId } from '../../internals/labelable-provider/useLabelableId'; -import type { BaseUIComponentProps } from '../../internals/types'; +import type { BaseUIComponentProps, BaseUIEvent } from '../../internals/types'; import { stateAttributesMapping } from '../utils/stateAttributesMapping'; import { useRenderElement } from '../../internals/useRenderElement'; import { @@ -462,9 +462,11 @@ export const NumberFieldRoot = React.forwardRef(function NumberFieldRoot( onFocus() { inputRef.current?.focus(); }, - onChange(event) { + onChange(event: BaseUIEvent>) { // Workaround for https://github.com/facebook/react/issues/9023 - if (event.nativeEvent.defaultPrevented) { + if (event.nativeEvent.defaultPrevented || disabled || readOnly) { + // Outside Field.Root, the event is not wrapped by mergeProps. + event.preventBaseUIHandler?.(); return; } diff --git a/packages/react/src/select/root/SelectRoot.test.tsx b/packages/react/src/select/root/SelectRoot.test.tsx index 589a7dc1cd8..52cddecf849 100644 --- a/packages/react/src/select/root/SelectRoot.test.tsx +++ b/packages/react/src/select/root/SelectRoot.test.tsx @@ -733,6 +733,68 @@ describe('', () => { }); }); + 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 select = ( + + + + + + + + a + b + + + + + ); + + await render( + withField ? ( +
+ + {select} + + +
+ ) : ( + select + ), + ); + + const selectInput = screen.getByRole('textbox', { hidden: true }); + expect(selectInput).toHaveAttribute('name', 'select'); + + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } + + fireEvent.change(selectInput, { target: { value: 'b' } }); + await flushMicrotasks(); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(selectInput.value).toBe(''); + + if (withField) { + expect(screen.getByTestId('error')).toHaveTextContent('test'); + } + }); + describe('prop: modal', () => { it('should render an internal backdrop when `true`', async () => { const { user } = await render( diff --git a/packages/react/src/select/root/SelectRoot.tsx b/packages/react/src/select/root/SelectRoot.tsx index 159fc5f639e..e77e0a99350 100644 --- a/packages/react/src/select/root/SelectRoot.tsx +++ b/packages/react/src/select/root/SelectRoot.tsx @@ -34,6 +34,7 @@ import { useFormContext } from '../../internals/form-context/FormContext'; import { type Group, stringifyAsLabel, stringifyAsValue } from '../../internals/resolveValueLabel'; import { defaultItemEquality, findItemIndex } from '../../internals/itemEquality'; import { useValueChanged } from '../../internals/useValueChanged'; +import type { BaseUIEvent } from '../../internals/types'; import { useOpenInteractionType } from '../../utils/useOpenInteractionType'; import { getMaxScrollOffset, normalizeScrollOffset } from '../../utils/scrollEdges'; import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups'; @@ -559,9 +560,11 @@ export function SelectRoot( }); }, // Handle browser autofill. - onChange(event: React.ChangeEvent) { + onChange(event: BaseUIEvent>) { // Workaround for https://github.com/facebook/react/issues/9023 - if (event.nativeEvent.defaultPrevented) { + if (event.nativeEvent.defaultPrevented || disabled || readOnly) { + // Outside Field.Root, the event is not wrapped by mergeProps. + event.preventBaseUIHandler?.(); return; }