From 84ff0906033b4f2a167f78ae8b80d8a0524357c9 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 1 Dec 2025 20:32:56 +1100 Subject: [PATCH 1/2] [select] Fix remembered typeahead value when reset while closed --- .../floating-ui-react/hooks/useTypeahead.ts | 24 ++-- .../react/src/select/root/SelectRoot.test.tsx | 131 ++++++++++++++++++ 2 files changed, 146 insertions(+), 9 deletions(-) diff --git a/packages/react/src/floating-ui-react/hooks/useTypeahead.ts b/packages/react/src/floating-ui-react/hooks/useTypeahead.ts index 4c5386c9bf..c5eab46339 100644 --- a/packages/react/src/floating-ui-react/hooks/useTypeahead.ts +++ b/packages/react/src/floating-ui-react/hooks/useTypeahead.ts @@ -1,9 +1,7 @@ import * as React from 'react'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; -import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { useTimeout } from '@base-ui-components/utils/useTimeout'; import { stopEvent } from '../utils'; - import type { ElementProps, FloatingContext, FloatingRootContext } from '../types'; import { EMPTY_ARRAY } from '../../utils/constants'; @@ -69,6 +67,7 @@ export function useTypeahead( const store = 'rootStore' in context ? context.rootStore : context; const open = store.useState('open'); const dataRef = store.context.dataRef; + const { listRef, activeIndex, @@ -86,15 +85,19 @@ export function useTypeahead( const prevIndexRef = React.useRef(selectedIndex ?? activeIndex ?? -1); const matchIndexRef = React.useRef(null); - useIsoLayoutEffect(() => { + const clear = useStableCallback(() => { + timeout.clear(); + matchIndexRef.current = null; + stringRef.current = ''; + }); + + React.useEffect(() => { if (open) { - timeout.clear(); - matchIndexRef.current = null; - stringRef.current = ''; + clear(); } - }, [open, timeout]); + }, [open, clear]); - useIsoLayoutEffect(() => { + React.useEffect(() => { // Sync arrow key navigation but not typeahead navigation. if (open && stringRef.current === '') { prevIndexRef.current = selectedIndex ?? activeIndex ?? -1; @@ -193,7 +196,10 @@ export function useTypeahead( } }); - const reference: ElementProps['reference'] = React.useMemo(() => ({ onKeyDown }), [onKeyDown]); + const reference: ElementProps['reference'] = React.useMemo( + () => ({ onKeyDown, onBlur: clear }), + [onKeyDown, clear], + ); const floating: ElementProps['floating'] = React.useMemo(() => { return { diff --git a/packages/react/src/select/root/SelectRoot.test.tsx b/packages/react/src/select/root/SelectRoot.test.tsx index ad1af6313a..ba0604804f 100644 --- a/packages/react/src/select/root/SelectRoot.test.tsx +++ b/packages/react/src/select/root/SelectRoot.test.tsx @@ -2583,4 +2583,135 @@ describe('', () => { }); }); }); + + describe('typeahead', () => { + it('should reset typeahead string when the value is cleared while the trigger is focused', async () => { + function App() { + const [value, setValue] = React.useState('A1'); + + return ( +
+ + + + + + + + + A1 + A2 + + + + +
+ ); + } + + const { user } = await render(); + const trigger = screen.getByTestId('trigger'); + + act(() => { + trigger.focus(); + }); + + await waitFor(() => { + expect(screen.queryByRole('listbox', { hidden: true })).not.to.equal(null); + }); + + fireEvent.keyDown(trigger, { key: 'A' }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'A1', hidden: true })).to.have.attribute( + 'data-selected', + ); + }); + + await user.click(screen.getByText('Reset')); + await waitFor(() => { + expect(trigger).to.have.text(''); + }); + + act(() => { + trigger.focus(); + }); + + fireEvent.keyDown(trigger, { key: 'A' }); + await waitFor(() => { + expect(screen.getByRole('option', { name: 'A1', hidden: true })).to.have.attribute( + 'data-selected', + ); + }); + expect(screen.getByRole('option', { name: 'A2', hidden: true })).not.to.have.attribute( + 'data-selected', + ); + }); + + it('should reset typeahead when value changes to a null-value item in the list', async () => { + function App() { + const [value, setValue] = React.useState('A1'); + + return ( +
+ + + + + + + + + Select... + A1 + A2 + + + + +
+ ); + } + + const { user } = await render(); + const trigger = screen.getByTestId('trigger'); + + act(() => { + trigger.focus(); + }); + + await waitFor(() => { + expect(screen.queryByRole('listbox', { hidden: true })).not.to.equal(null); + }); + + fireEvent.keyDown(trigger, { key: 'A' }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'A1', hidden: true })).to.have.attribute( + 'data-selected', + ); + }); + + await user.click(screen.getByText('Reset')); + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Select...', hidden: true })).to.have.attribute( + 'data-selected', + ); + }); + + act(() => { + trigger.focus(); + }); + + fireEvent.keyDown(trigger, { key: 'A' }); + await waitFor(() => { + expect(screen.getByRole('option', { name: 'A1', hidden: true })).to.have.attribute( + 'data-selected', + ); + }); + expect(screen.getByRole('option', { name: 'A2', hidden: true })).not.to.have.attribute( + 'data-selected', + ); + }); + }); }); From d99decb473dfec32b3c5f4a2660cf6f023ce5ff6 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 2 Dec 2025 20:23:00 +1100 Subject: [PATCH 2/2] lint --- .../src/floating-ui-react/hooks/useTypeahead.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/react/src/floating-ui-react/hooks/useTypeahead.ts b/packages/react/src/floating-ui-react/hooks/useTypeahead.ts index c5eab46339..ec8e9f961f 100644 --- a/packages/react/src/floating-ui-react/hooks/useTypeahead.ts +++ b/packages/react/src/floating-ui-react/hooks/useTypeahead.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useTimeout } from '@base-ui-components/utils/useTimeout'; -import { stopEvent } from '../utils'; +import { contains, stopEvent } from '../utils'; import type { ElementProps, FloatingContext, FloatingRootContext } from '../types'; import { EMPTY_ARRAY } from '../../utils/constants'; @@ -197,8 +197,16 @@ export function useTypeahead( }); const reference: ElementProps['reference'] = React.useMemo( - () => ({ onKeyDown, onBlur: clear }), - [onKeyDown, clear], + () => ({ + onKeyDown, + onBlur(event) { + if (contains(store.select('floatingElement'), event.relatedTarget as Element | null)) { + return; + } + clear(); + }, + }), + [onKeyDown, store, clear], ); const floating: ElementProps['floating'] = React.useMemo(() => {