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
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ vi.mock('@base-ui/utils/platform', async (importOriginal) => {
describe('<Combobox.Input /> on Android', () => {
const { render } = createRenderer();

it('propagates changes during Android composition', async () => {
it('propagates changes and the final Android composition commit', async () => {
const onInputValueChange = vi.fn();
await render(
<Combobox.Root onInputValueChange={onInputValueChange}>
Expand All @@ -33,5 +33,14 @@ describe('<Combobox.Input /> on Android', () => {
fireEvent.change(input, { target: { value: 'a' } });

expect(onInputValueChange).toHaveBeenCalledWith('a', expect.anything());

onInputValueChange.mockClear();
fireEvent.compositionEnd(input, { data: 'a' });

expect(onInputValueChange).toHaveBeenCalledTimes(1);
expect(onInputValueChange).toHaveBeenCalledWith(
'a',
expect.objectContaining({ event: expect.objectContaining({ type: 'compositionend' }) }),
);
});
});
68 changes: 68 additions & 0 deletions packages/react/src/combobox/input/ComboboxInput.ios.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expect, vi, describe, it } from 'vitest';
import { Combobox } from '@base-ui/react/combobox';
import { createRenderer } from '#test-utils';
import { fireEvent, screen } from '@mui/internal-test-utils';

vi.mock('@base-ui/utils/platform', async (importOriginal) => {
const actual = await importOriginal<typeof import('@base-ui/utils/platform')>();
return {
...actual,
platform: {
...actual.platform,
os: { ...actual.platform.os, ios: true, apple: true },
},
};
});

describe('<Combobox.Input /> on iOS', () => {
const { render } = createRenderer();

it('preserves the composition preview during an item press', async () => {
const onValueChange = vi.fn();
const onInputValueChange = vi.fn();
const { user } = await render(
<Combobox.Root
items={['Apple', 'Banana']}
onValueChange={onValueChange}
onInputValueChange={onInputValueChange}
>
<Combobox.Input />
<Combobox.Portal>
<Combobox.Positioner>
<Combobox.Popup>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>,
);

const input = screen.getByRole('combobox');
await user.click(input);
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 'App' } });
expect(input).toHaveValue('App');

await user.click(screen.getByRole('option', { name: 'Apple' }));

expect(onValueChange).toHaveBeenCalledWith('Apple', expect.anything());
// Replacing the native composition on iOS risks WebKit bug 255857.
expect(input).toHaveValue('App');

onInputValueChange.mockClear();
fireEvent.change(input, { target: { value: 'Appl' } });
expect(input).toHaveValue('Appl');
expect(onInputValueChange).not.toHaveBeenCalled();

fireEvent.compositionEnd(input, { data: 'Appl' });

expect(onInputValueChange).toHaveBeenCalledTimes(1);
expect(onInputValueChange).toHaveBeenCalledWith('Appl', expect.anything());
});
});
185 changes: 185 additions & 0 deletions packages/react/src/combobox/input/ComboboxInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,191 @@ describe('<Combobox.Input />', () => {
},
}));

describe('IME composition', () => {
function TestCombobox(props: Combobox.Root.Props<string>) {
return (
<Combobox.Root items={['Apple', 'Banana']} defaultOpen filter={null} {...props}>
<Combobox.Input />
<Combobox.Portal>
<Combobox.Positioner>
<Combobox.Popup>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
);
}

it.each([
{
name: 'fills the selected label after a pointer selection',
defaultValue: null,
cancelSelection: false,
cancelInput: false,
expectedInput: 'Apple',
},
{
name: 'restores the label when reselecting the current value',
defaultValue: 'Apple',
cancelSelection: false,
cancelInput: false,
expectedInput: 'Apple',
},
{
name: 'preserves the composition when the selection is canceled',
defaultValue: null,
cancelSelection: true,
cancelInput: false,
expectedInput: 'App',
},
{
name: 'preserves the composition when filling the input is canceled',
defaultValue: null,
cancelSelection: false,
cancelInput: true,
expectedInput: 'App',
},
])('$name', async ({ defaultValue, cancelSelection, cancelInput, expectedInput }) => {
const onValueChange = vi.fn();
const { user } = await render(
<TestCombobox
defaultValue={defaultValue}
onValueChange={(value, details) => {
onValueChange(value, details);
if (cancelSelection) {
details.cancel();
}
}}
onInputValueChange={(_value, details) => {
if (cancelInput && details.reason === REASONS.itemPress) {
details.cancel();
}
}}
/>,
);

const input = screen.getByRole('combobox');
expect(input).toHaveValue(defaultValue ?? '');
await act(async () => input.focus());
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 'App' } });
expect(input).toHaveValue('App');

await user.click(screen.getByRole('option', { name: 'Apple' }));

expect(onValueChange).toHaveBeenCalledWith(
'Apple',
expect.objectContaining({ reason: REASONS.itemPress }),
);
expect(input).toHaveValue(expectedInput);
expect(input).toHaveFocus();
});

it('ignores a composition end after an accepted item press has replaced the preview', async () => {
const onInputValueChange = vi.fn();
const { user } = await render(<TestCombobox onInputValueChange={onInputValueChange} />);

const input = screen.getByRole('combobox');
await act(async () => input.focus());
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 'App' } });
expect(input).toHaveValue('App');
expect(onInputValueChange).not.toHaveBeenCalled();

await user.click(screen.getByRole('option', { name: 'Apple' }));
expect(input).toHaveValue('Apple');
expect(onInputValueChange).toHaveBeenCalledTimes(1);
expect(onInputValueChange).toHaveBeenLastCalledWith(
'Apple',
expect.objectContaining({ reason: REASONS.itemPress }),
);

fireEvent.compositionEnd(input, { data: 'App' });

expect(input).toHaveValue('Apple');
expect(onInputValueChange).toHaveBeenCalledTimes(1);

fireEvent.change(input, { target: { value: 'Ba' } });
expect(input).toHaveValue('Ba');
expect(onInputValueChange).toHaveBeenLastCalledWith(
'Ba',
expect.objectContaining({ reason: REASONS.inputChange }),
);
});

it('keeps the preview local until a normal composition ends', async () => {
const onInputValueChange = vi.fn();
await render(<TestCombobox onInputValueChange={onInputValueChange} />);

const input = screen.getByRole('combobox');
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 'App' } });
expect(input).toHaveValue('App');
expect(onInputValueChange).not.toHaveBeenCalled();

fireEvent.compositionEnd(input, { data: 'App' });

expect(input).toHaveValue('App');
expect(onInputValueChange).toHaveBeenCalledTimes(1);
expect(onInputValueChange).toHaveBeenLastCalledWith(
'App',
expect.objectContaining({ reason: REASONS.inputChange }),
);
});

it.each([
{ name: 'clears', cancelClear: false, expectedInput: '' },
{ name: 'preserves a canceled clear of', cancelClear: true, expectedInput: 'App' },
])(
'$name the composition for a multiple selection inside the popup',
async ({ cancelClear, expectedInput }) => {
const onValueChange = vi.fn();
const { user } = await render(
<Combobox.Root
multiple
defaultOpen
onValueChange={onValueChange}
onInputValueChange={(_value, details) => {
if (cancelClear && details.isItemPress) {
details.cancel();
}
}}
>
<Combobox.Portal>
<Combobox.Positioner>
<Combobox.Popup>
<Combobox.Input data-testid="input" />
<Combobox.List>
<Combobox.Item value="Apple">Apple</Combobox.Item>
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>,
);

const input = screen.getByTestId('input');
expect(input).toHaveValue('');
await act(async () => input.focus());
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 'App' } });
expect(input).toHaveValue('App');

await user.click(screen.getByRole('option', { name: 'Apple' }));

expect(onValueChange).toHaveBeenCalledWith(['Apple'], expect.anything());
expect(input).toHaveValue(expectedInput);
},
);
});

describe('prop: disabled', () => {
it('should render aria-disabled attribute when disabled', async () => {
await render(
Expand Down
19 changes: 17 additions & 2 deletions packages/react/src/combobox/input/ComboboxInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
const isComposingRef = React.useRef(false);
const lastActiveIndexRef = React.useRef<number | null>(null);

const resetInputComposition = useStableCallback(() => {
isComposingRef.current = false;
setComposingValue(null);
});

React.useImperativeHandle(store.context.resetInputCompositionRef, () => resetInputComposition, [
resetInputComposition,
]);

// Restore the saved highlight on refocus only within the same open cycle.
useIsoLayoutEffect(() => {
if (!open) {
Expand Down Expand Up @@ -228,9 +237,15 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
setComposingValue(event.currentTarget.value);
},
onCompositionEnd(event) {
isComposingRef.current = false;
const wasComposing = isComposingRef.current;
const next = event.currentTarget.value;
setComposingValue(null);
resetInputComposition();
// An item press may have already replaced the preview. Do not report its label as
// typed input when the browser subsequently ends that composition. Android does not
// track composition state, so its commits must still propagate.
if (!wasComposing && !platform.os.android) {
return;
}
store.context.setInputValue(
next,
createChangeEventDetails(REASONS.inputChange, event.nativeEvent),
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/combobox/root/AriaCombobox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';
import * as React from 'react';
import { platform } from '@base-ui/utils/platform';
import { useControlled } from '@base-ui/utils/useControlled';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useOnFirstRender } from '@base-ui/utils/useOnFirstRender';
Expand Down Expand Up @@ -256,6 +257,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none', I
const chipsContainerRef = React.useRef<HTMLDivElement | null>(null);
const clearRef = React.useRef<HTMLButtonElement | null>(null);
const selectionEventRef = React.useRef<MouseEvent | PointerEvent | KeyboardEvent | null>(null);
const resetInputCompositionRef = React.useRef<(() => void) | null>(null);
const lastHighlightRef = React.useRef(INITIAL_LAST_HIGHLIGHT);
const pendingQueryHighlightRef = React.useRef<null | {
hasQuery: boolean;
Expand Down Expand Up @@ -423,6 +425,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none', I
valuesRef,
pointerDownItemRef,
selectionEventRef,
resetInputCompositionRef,
},
selectors,
),
Expand Down Expand Up @@ -670,6 +673,16 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none', I
return;
}

// The accepted fill or clear replaces the IME preview, even if the stored value is unchanged.
// On iOS, changing the value during composition can leave the native IME unable to accept
// further input until blur/refocus: https://bugs.webkit.org/show_bug.cgi?id=255857
if (
!platform.os.ios &&
(eventDetails.reason === REASONS.itemPress || eventDetails.isItemPress)
) {
resetInputCompositionRef.current?.();
}

// A canceled selection clear must not suppress close-completion cleanup.
hadInputClearRef.current = eventDetails.reason === REASONS.inputClear;

Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/combobox/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export type ComboboxStoreContext = ValuePopupContext<AriaCombobox.OpenChangeEven
readonly pointerDownItemRef: React.RefObject<Element | null>;
/** Native event that triggered the in-flight selection. */
readonly selectionEventRef: React.RefObject<MouseEvent | PointerEvent | KeyboardEvent | null>;
/** Dismisses the input's local composition preview after an accepted item press. */
readonly resetInputCompositionRef: React.RefObject<(() => void) | null>;

// Commands. Seeded with `NOOP` when the store is constructed and assigned during the root's
// first render, so they are not `readonly`.
Expand Down
Loading
Loading