diff --git a/packages/react/src/combobox/input/ComboboxInput.android.test.tsx b/packages/react/src/combobox/input/ComboboxInput.android.test.tsx index 35045add490..3b9a89bfe29 100644 --- a/packages/react/src/combobox/input/ComboboxInput.android.test.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.android.test.tsx @@ -20,7 +20,7 @@ vi.mock('@base-ui/utils/platform', async (importOriginal) => { describe(' 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( @@ -33,5 +33,14 @@ describe(' 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' }) }), + ); }); }); diff --git a/packages/react/src/combobox/input/ComboboxInput.ios.test.tsx b/packages/react/src/combobox/input/ComboboxInput.ios.test.tsx new file mode 100644 index 00000000000..a6b23e0e4cf --- /dev/null +++ b/packages/react/src/combobox/input/ComboboxInput.ios.test.tsx @@ -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(); + return { + ...actual, + platform: { + ...actual.platform, + os: { ...actual.platform.os, ios: true, apple: true }, + }, + }; +}); + +describe(' 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( + + + + + + + {(item: string) => ( + + {item} + + )} + + + + + , + ); + + 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()); + }); +}); diff --git a/packages/react/src/combobox/input/ComboboxInput.test.tsx b/packages/react/src/combobox/input/ComboboxInput.test.tsx index 69a8c42d954..a297d59383f 100644 --- a/packages/react/src/combobox/input/ComboboxInput.test.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.test.tsx @@ -16,6 +16,191 @@ describe('', () => { }, })); + describe('IME composition', () => { + function TestCombobox(props: Combobox.Root.Props) { + return ( + + + + + + + {(item: string) => ( + + {item} + + )} + + + + + + ); + } + + 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( + { + 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(); + + 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(); + + 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( + { + if (cancelClear && details.isItemPress) { + details.cancel(); + } + }} + > + + + + + + Apple + + + + + , + ); + + 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( diff --git a/packages/react/src/combobox/input/ComboboxInput.tsx b/packages/react/src/combobox/input/ComboboxInput.tsx index a52b4626203..24ad1e8ff15 100644 --- a/packages/react/src/combobox/input/ComboboxInput.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.tsx @@ -101,6 +101,15 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( const isComposingRef = React.useRef(false); const lastActiveIndexRef = React.useRef(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) { @@ -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), diff --git a/packages/react/src/combobox/root/AriaCombobox.tsx b/packages/react/src/combobox/root/AriaCombobox.tsx index c25a5173c61..ef6ba5277bc 100644 --- a/packages/react/src/combobox/root/AriaCombobox.tsx +++ b/packages/react/src/combobox/root/AriaCombobox.tsx @@ -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'; @@ -256,6 +257,7 @@ export function AriaCombobox(null); const clearRef = React.useRef(null); const selectionEventRef = React.useRef(null); + const resetInputCompositionRef = React.useRef<(() => void) | null>(null); const lastHighlightRef = React.useRef(INITIAL_LAST_HIGHLIGHT); const pendingQueryHighlightRef = React.useRef; /** Native event that triggered the in-flight selection. */ readonly selectionEventRef: React.RefObject; + /** 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`. diff --git a/test/e2e/fixtures/combobox/ImeComposition.tsx b/test/e2e/fixtures/combobox/ImeComposition.tsx new file mode 100644 index 00000000000..d66c36dddfa --- /dev/null +++ b/test/e2e/fixtures/combobox/ImeComposition.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { Combobox } from '@base-ui/react/combobox'; + +const items = ['Apple', 'Banana', 'Apricot']; + +export default function ImeComposition() { + const [value, setValue] = React.useState(() => + new URLSearchParams(window.location.search).get('value'), + ); + + return ( +
+ + + + + + + {(item: string) => ( + + {item} + + )} + + + + + + {value ?? 'null'} +
+ ); +} diff --git a/test/e2e/index.test.ts b/test/e2e/index.test.ts index b9203992ec3..9a80eae3b92 100644 --- a/test/e2e/index.test.ts +++ b/test/e2e/index.test.ts @@ -165,6 +165,52 @@ describe('e2e', () => { }); }); + describe('', () => { + it.each([ + { name: 'selects a new value', initialValue: null }, + { name: 'reselects the current value', initialValue: 'Apple' }, + ])('$name with the mouse during IME composition', async ({ initialValue }) => { + const query = new URLSearchParams(); + if (initialValue !== null) { + query.set('value', initialValue); + } + await renderFixture(`combobox/ImeComposition?${query}`); + + const input = page.getByRole('combobox'); + const selected = page.getByTestId('selected'); + await expect(input).toHaveValue(initialValue ?? ''); + await expect(selected).toHaveText(initialValue ?? 'null'); + await input.focus(); + await input.selectText(); + + const session = await page.context().newCDPSession(page); + try { + for (const text of ['A', 'Ap', 'App']) { + // eslint-disable-next-line no-await-in-loop + await session.send('Input.imeSetComposition', { + text, + selectionStart: text.length, + selectionEnd: text.length, + }); + } + + const option = page.getByRole('option', { name: 'Apple' }); + await expect(option).toBeVisible(); + await expect(input).toHaveValue('App'); + await expect(selected).toHaveText(initialValue ?? 'null'); + await expect(input).toBeFocused(); + + await option.click(); + + await expect(input).toHaveValue('Apple'); + await expect(selected).toHaveText('Apple'); + await expect(input).toBeFocused(); + } finally { + await session.detach(); + } + }); + }); + describe('', () => { it('loops focus by default', async () => { await renderFixture('Radio');