diff --git a/packages/react/src/autocomplete/value/AutocompleteValue.tsx b/packages/react/src/autocomplete/value/AutocompleteValue.tsx index f1e4439a1c..9b2865f271 100644 --- a/packages/react/src/autocomplete/value/AutocompleteValue.tsx +++ b/packages/react/src/autocomplete/value/AutocompleteValue.tsx @@ -1,8 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui-components/utils/store'; -import { useComboboxRootContext } from '../../combobox/root/ComboboxRootContext'; -import { selectors } from '../../combobox/store'; +import { useComboboxInputValueContext } from '../../combobox/root/ComboboxRootContext'; /** * The current value of the autocomplete. @@ -13,9 +11,7 @@ import { selectors } from '../../combobox/store'; export function AutocompleteValue(props: AutocompleteValue.Props) { const { children } = props; - const store = useComboboxRootContext(); - - const inputValue = useStore(store, selectors.inputValue); + const inputValue = useComboboxInputValueContext(); if (typeof children === 'function') { return children(String(inputValue)); diff --git a/packages/react/src/combobox/clear/ComboboxClear.tsx b/packages/react/src/combobox/clear/ComboboxClear.tsx index 8b3245c524..494277a42e 100644 --- a/packages/react/src/combobox/clear/ComboboxClear.tsx +++ b/packages/react/src/combobox/clear/ComboboxClear.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { useStore } from '@base-ui-components/utils/store'; -import { useComboboxRootContext } from '../root/ComboboxRootContext'; +import { useComboboxInputValueContext, useComboboxRootContext } from '../root/ComboboxRootContext'; import type { BaseUIComponentProps, NativeButtonProps } from '../../utils/types'; import { useRenderElement } from '../../utils/useRenderElement'; import { selectors } from '../store'; @@ -45,7 +45,8 @@ export const ComboboxClear = React.forwardRef(function ComboboxClear( const clearRef = useStore(store, selectors.clearRef); const open = useStore(store, selectors.open); const selectedValue = useStore(store, selectors.selectedValue); - const inputValue = useStore(store, selectors.inputValue); + + const inputValue = useComboboxInputValueContext(); let visible = false; if (selectionMode === 'none') { diff --git a/packages/react/src/combobox/input/ComboboxInput.test.tsx b/packages/react/src/combobox/input/ComboboxInput.test.tsx index a19d65a55c..7dc5b24705 100644 --- a/packages/react/src/combobox/input/ComboboxInput.test.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.test.tsx @@ -357,5 +357,38 @@ describe('', () => { expect(input.selectionStart).to.equal(input.value.length); expect(input.selectionEnd).to.equal(input.value.length); }); + + it('preserves caret position when controlled and inserting in the middle', async () => { + function Controlled() { + const [value, setValue] = React.useState(''); + return ( + + + + ); + } + + const { user } = await render(); + + const input = screen.getByRole('combobox'); + + await user.type(input, 'abcd'); + expect(input.value).to.equal('abcd'); + + // Move caret left twice to position after "ab" + await user.keyboard('{ArrowLeft}{ArrowLeft}'); + expect(input.selectionStart).to.equal(2); + expect(input.selectionEnd).to.equal(2); + + await user.keyboard('xxx'); + expect(input.value).to.equal('abxxxcd'); + expect(input.selectionStart).to.equal(5); + expect(input.selectionEnd).to.equal(5); + + await user.keyboard('y'); + expect(input.value).to.equal('abxxxycd'); + expect(input.selectionStart).to.equal(6); + expect(input.selectionEnd).to.equal(6); + }); }); }); diff --git a/packages/react/src/combobox/input/ComboboxInput.tsx b/packages/react/src/combobox/input/ComboboxInput.tsx index 5da6a6ccc8..a885673b2e 100644 --- a/packages/react/src/combobox/input/ComboboxInput.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.tsx @@ -4,7 +4,7 @@ import { useStore } from '@base-ui-components/utils/store'; import { useEventCallback } from '@base-ui-components/utils/useEventCallback'; import { BaseUIComponentProps } from '../../utils/types'; import { useRenderElement } from '../../utils/useRenderElement'; -import { useComboboxRootContext } from '../root/ComboboxRootContext'; +import { useComboboxInputValueContext, useComboboxRootContext } from '../root/ComboboxRootContext'; import { selectors } from '../store'; import { pressableTriggerOpenStateMapping } from '../../utils/popupStateMapping'; import { useFieldRootContext } from '../../field/root/FieldRootContext'; @@ -54,13 +54,16 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( const triggerProps = useStore(store, selectors.triggerProps); const open = useStore(store, selectors.open); const selectedValue = useStore(store, selectors.selectedValue); - const inputValue = useStore(store, selectors.inputValue); - const [composingValue, setComposingValue] = React.useState(null); - const isComposingRef = React.useRef(false); + // `inputValue` can't be placed in the store. + // https://github.com/mui/base-ui/issues/2703 + const inputValue = useComboboxInputValueContext(); const disabled = fieldDisabled || comboboxDisabled || disabledProp; + const [composingValue, setComposingValue] = React.useState(null); + const isComposingRef = React.useRef(false); + const setInputElement = useEventCallback((element) => { store.apply({ inputElement: element, @@ -194,7 +197,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( const trimmed = nextVal.trim(); if (trimmed !== '') { store.state.setOpen(true, createBaseUIEventDetails('none', event.nativeEvent)); - if (!(selectionMode === 'none' && autoHighlight)) { + if (!autoHighlight) { store.state.setIndices({ activeIndex: null, selectedIndex: null, @@ -207,7 +210,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( if ( open && store.state.activeIndex !== null && - !(selectionMode === 'none' && autoHighlight && nextVal.trim() !== '') + !(autoHighlight && nextVal.trim() !== '') ) { store.state.setIndices({ activeIndex: null, diff --git a/packages/react/src/combobox/root/ComboboxRootContext.tsx b/packages/react/src/combobox/root/ComboboxRootContext.tsx index 16b4a33712..01a88ea5d9 100644 --- a/packages/react/src/combobox/root/ComboboxRootContext.tsx +++ b/packages/react/src/combobox/root/ComboboxRootContext.tsx @@ -15,6 +15,10 @@ export const ComboboxFloatingContext = React.createContext(undefined); +// `inputValue` can't be placed in the store. +// https://github.com/mui/base-ui/issues/2703 +export const ComboboxInputValueContext = + React.createContext['value']>(''); export function useComboboxRootContext() { const context = React.useContext(ComboboxRootContext) as ComboboxStore | undefined; @@ -45,3 +49,7 @@ export function useComboboxDerivedItemsContext() { } return context; } + +export function useComboboxInputValueContext() { + return React.useContext(ComboboxInputValueContext); +} diff --git a/packages/react/src/combobox/root/ComboboxRootInternal.tsx b/packages/react/src/combobox/root/ComboboxRootInternal.tsx index de884d327f..b8bc56aeab 100644 --- a/packages/react/src/combobox/root/ComboboxRootInternal.tsx +++ b/packages/react/src/combobox/root/ComboboxRootInternal.tsx @@ -27,6 +27,7 @@ import { ComboboxFloatingContext, ComboboxDerivedItemsContext, ComboboxRootContext, + ComboboxInputValueContext, } from './ComboboxRootContext'; import { selectors, type State as StoreState } from '../store'; import { useOpenChangeComplete } from '../../utils/useOpenChangeComplete'; @@ -1045,13 +1046,15 @@ export function ComboboxRootInternal