Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,64 @@ describe('<Autocomplete.Root />', () => {
expect(input.value).toBe('beta');
});

it('ignores hidden-input autofill when readOnly', async () => {
const onValueChange = vi.fn();
await render(
<Field.Root name="auto">
<Autocomplete.Root defaultValue="" readOnly onValueChange={onValueChange}>
<Autocomplete.Input data-testid="input" />
<Autocomplete.Portal>
<Autocomplete.Positioner>
<Autocomplete.Popup>
<Autocomplete.List>
<Autocomplete.Item value="alpha">alpha</Autocomplete.Item>
<Autocomplete.Item value="beta">beta</Autocomplete.Item>
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Portal>
</Autocomplete.Root>
</Field.Root>,
);

const hidden = screen.getByRole<HTMLInputElement>('textbox', { hidden: true });
fireEvent.change(hidden, { target: { value: 'beta' } });
await flushMicrotasks();

const input = screen.getByTestId<HTMLInputElement>('input');
expect(onValueChange).not.toHaveBeenCalled();
expect(input.value).toBe('');
});

it('ignores hidden-input autofill when disabled', async () => {
const onValueChange = vi.fn();
await render(
<Field.Root name="auto">
<Autocomplete.Root defaultValue="" disabled onValueChange={onValueChange}>
<Autocomplete.Input data-testid="input" />
<Autocomplete.Portal>
<Autocomplete.Positioner>
<Autocomplete.Popup>
<Autocomplete.List>
<Autocomplete.Item value="alpha">alpha</Autocomplete.Item>
<Autocomplete.Item value="beta">beta</Autocomplete.Item>
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Portal>
</Autocomplete.Root>
</Field.Root>,
);

const hidden = screen.getByRole<HTMLInputElement>('textbox', { hidden: true });
fireEvent.change(hidden, { target: { value: 'beta' } });
await flushMicrotasks();

const input = screen.getByTestId<HTMLInputElement>('input');
expect(onValueChange).not.toHaveBeenCalled();
expect(input.value).toBe('');
});

it('should pass autoComplete to the visible input', async () => {
await render(
<Autocomplete.Root name="search">
Expand Down
8 changes: 5 additions & 3 deletions packages/react/src/combobox/root/AriaCombobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1258,9 +1258,11 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
(inputRef.current || triggerElement)?.focus();
},
// Handle browser autofill.
onChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange(event: BaseUIEvent<React.ChangeEvent<HTMLInputElement>>) {
// Workaround for https://github.com/facebook/react/issues/9023
if (event.nativeEvent.defaultPrevented) {
if (event.nativeEvent.defaultPrevented || disabled || readOnly) {
Comment thread
lunaxislu marked this conversation as resolved.
// Outside Field.Root, the event is not wrapped by mergeProps.
event.preventBaseUIHandler?.();
return;
}

Expand Down
69 changes: 69 additions & 0 deletions packages/react/src/combobox/root/ComboboxRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1456,6 +1456,75 @@ describe('<Combobox.Root />', () => {
});
});

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 = (
<Combobox.Root
name={withField ? undefined : 'test'}
readOnly={lockState === 'readOnly'}
disabled={lockState === 'disabled'}
onValueChange={onValueChange}
onInputValueChange={onInputValueChange}
>
<Combobox.Input data-testid="input" />
<Combobox.Portal>
<Combobox.Positioner>
<Combobox.Popup>
<Combobox.List>
<Combobox.Item value="a">a</Combobox.Item>
<Combobox.Item value="b">b</Combobox.Item>
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
);

await render(
withField ? (
<Form errors={{ test: 'test' }}>
<Field.Root name="test">
{combobox}
<Field.Error data-testid="error" />
</Field.Root>
</Form>
) : (
combobox
),
);

const visibleInput = screen.getByTestId<HTMLInputElement>('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(
Expand Down
56 changes: 56 additions & 0 deletions packages/react/src/number-field/root/NumberFieldRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,62 @@ describe('<NumberField />', () => {
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 = (
<NumberFieldBase.Root
name={withField ? undefined : 'quantity'}
defaultValue={1}
readOnly={lockState === 'readOnly'}
disabled={lockState === 'disabled'}
onValueChange={onValueChange}
>
<NumberFieldBase.Input />
</NumberFieldBase.Root>
);

await render(
withField ? (
<Form errors={{ quantity: 'test' }}>
<Field.Root name="quantity">
{numberField}
<Field.Error data-testid="error" />
</Field.Root>
</Form>
) : (
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', () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/react/src/number-field/root/NumberFieldRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -462,9 +462,11 @@ export const NumberFieldRoot = React.forwardRef(function NumberFieldRoot(
onFocus() {
inputRef.current?.focus();
},
onChange(event) {
onChange(event: BaseUIEvent<React.ChangeEvent<HTMLInputElement>>) {
// 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;
}

Expand Down
62 changes: 62 additions & 0 deletions packages/react/src/select/root/SelectRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,68 @@ describe('<Select.Root />', () => {
});
});

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 = (
<Select.Root
name={withField ? undefined : 'select'}
readOnly={lockState === 'readOnly'}
disabled={lockState === 'disabled'}
onValueChange={onValueChange}
>
<Select.Trigger data-testid="trigger">
<Select.Value />
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.Item value="a">a</Select.Item>
<Select.Item value="b">b</Select.Item>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
);

await render(
withField ? (
<Form errors={{ select: 'test' }}>
<Field.Root name="select">
{select}
<Field.Error data-testid="error" />
</Field.Root>
</Form>
) : (
select
),
);

const selectInput = screen.getByRole<HTMLInputElement>('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(
Expand Down
7 changes: 5 additions & 2 deletions packages/react/src/select/root/SelectRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -559,9 +560,11 @@ export function SelectRoot<Value, Multiple extends boolean | undefined = false>(
});
},
// Handle browser autofill.
onChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange(event: BaseUIEvent<React.ChangeEvent<HTMLInputElement>>) {
// 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;
}

Expand Down
Loading