From 86d25017e81d99d581c2aec5c766e61e08c23e13 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Thu, 30 Jul 2026 16:33:35 +0530 Subject: [PATCH 1/3] added autoFocusFirst props to autofocus on first list item when ListBox is filtered. --- .../test/combobox/ComboBox.test.js | 125 ++++++++++++++++++ .../src/combobox/useComboBoxState.ts | 19 ++- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js b/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js index 9c1ea49af91..9d40dd2bfc4 100644 --- a/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js +++ b/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js @@ -1496,6 +1496,131 @@ describe('ComboBox', function () { expect(items).toHaveLength(1); expect(combobox).not.toHaveAttribute('aria-activedescendant'); }); + + it("doesn't focus the first item when filtering by default", async function () { + let {getByRole} = renderComboBox(); + + let combobox = getByRole('combobox'); + act(() => { + combobox.focus(); + }); + await user.keyboard('o'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(2); + expect(combobox).not.toHaveAttribute('aria-activedescendant'); + }); + + it('focuses the first item when filtering if autoFocusFirst is true', async function () { + let {getByRole} = renderComboBox({autoFocusFirst: true}); + + let combobox = getByRole('combobox'); + act(() => { + combobox.focus(); + }); + await user.keyboard('o'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('One'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + + // Narrowing the filter further should keep moving focus to the new first match. + await user.keyboard('ne'); + act(() => { + jest.runAllTimers(); + }); + + items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveTextContent('One'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + }); + + it('still focuses the first item if the menu was already open before typing', async function () { + let {getByRole} = renderComboBox({autoFocusFirst: true}); + + let button = getByRole('button'); + await user.click(button); + act(() => { + jest.runAllTimers(); + }); + + let combobox = getByRole('combobox'); + let listbox = getByRole('listbox'); + await user.keyboard('o'); + act(() => { + jest.runAllTimers(); + }); + + let items = within(listbox).getAllByRole('option'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + }); + + it('does not get stuck on a stale selection when re-searching after selecting an item', async function () { + let {getByRole} = renderComboBox({autoFocusFirst: true}); + + let combobox = getByRole('combobox'); + act(() => { + combobox.focus(); + }); + await user.keyboard('One'); + act(() => { + jest.runAllTimers(); + }); + await user.keyboard('[Enter]'); + act(() => { + jest.runAllTimers(); + }); + + await user.clear(combobox); + await user.keyboard('T'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items[0]).toHaveTextContent('Two'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + }); + + it('clears the focused option when the input is cleared back to empty', async function () { + let {getByRole} = renderComboBox({autoFocusFirst: true}); + + let combobox = getByRole('combobox'); + act(() => { + combobox.focus(); + }); + await user.keyboard('One'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + + await user.clear(combobox); + act(() => { + jest.runAllTimers(); + }); + + // Clearing returns to the unfiltered list, which should not be treated as "a filter was + // performed," so no option should be focused until the user navigates explicitly. + listbox = getByRole('listbox'); + items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(3); + expect(combobox).not.toHaveAttribute('aria-activedescendant'); + }); }); describe('blur', function () { diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts index ec71270bc50..999056486ca 100644 --- a/packages/react-stately/src/combobox/useComboBoxState.ts +++ b/packages/react-stately/src/combobox/useComboBoxState.ts @@ -115,6 +115,13 @@ export interface ComboBoxProps * @default 'input' */ menuTrigger?: MenuTriggerAction; + /** + * Whether to automatically focus the first item in the ComboBox's listbox when the + * user filters the list by typing into the input. + * + * @default false + */ + autoFocusFirst?: boolean; } export interface ComboBoxState @@ -200,7 +207,8 @@ export function useComboBoxState( allowsEmptyCollection = false, allowsCustomValue, shouldCloseOnBlur = true, - selectionMode = 'single' as SelectionMode + selectionMode = 'single' as SelectionMode, + autoFocusFirst = false } = props; let [showAllItems, setShowAllItems] = useState(false); @@ -409,6 +417,9 @@ export function useComboBoxState( let lastSelectedKeyText = useRef( selectedKey != null ? (collection.getItem(selectedKey)?.textValue ?? '') : '' ); + // Clearing the input returns to the unfiltered list, so there is no "filter" to focus the + // first result of. + let shouldAutoFocusFirst = autoFocusFirst && inputValue !== ''; // intentional omit dependency array, want this to happen on every render // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { @@ -421,7 +432,7 @@ export function useComboBoxState( inputValue !== lastValue && menuTrigger !== 'manual' ) { - open(null, 'input'); + open(shouldAutoFocusFirst ? 'first' : null, 'input'); } // Close the menu if the collection is empty. Don't close menu if filtered collection size is 0 @@ -446,7 +457,9 @@ export function useComboBoxState( // Clear focused key when input value changes and display filtered collection again. if (inputValue !== lastValue) { - selectionManager.setFocusedKey(null); + selectionManager.setFocusedKey( + shouldAutoFocusFirst ? filteredCollection.getFirstKey() : null + ); setShowAllItems(false); // Set value to null when the user clears the input. From 17e84204f2a7fa59ae8ce5a82b10531b6e199b25 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Thu, 30 Jul 2026 19:40:18 +0530 Subject: [PATCH 2/3] fixed the lint error. --- .../src/combobox/useComboBoxState.ts | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts index afd596529d0..b41c0847315 100644 --- a/packages/react-stately/src/combobox/useComboBoxState.ts +++ b/packages/react-stately/src/combobox/useComboBoxState.ts @@ -26,13 +26,13 @@ import { Validation, ValueBase } from '@react-types/shared'; -import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; -import {getChildNodes} from '../collections/getChildNodes'; -import {ListCollection} from '../list/ListCollection'; -import {ListState, useListState} from '../list/useListState'; -import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {useControlledState} from '../utils/useControlledState'; +import { FormValidationState, useFormValidationState } from '../form/useFormValidationState'; +import { getChildNodes } from '../collections/getChildNodes'; +import { ListCollection } from '../list/ListCollection'; +import { ListState, useListState } from '../list/useListState'; +import { OverlayTriggerState, useOverlayTriggerState } from '../overlays/useOverlayTriggerState'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useControlledState } from '../utils/useControlledState'; export type MenuTriggerAction = 'focus' | 'input' | 'manual'; export type SelectionMode = 'single' | 'multiple'; @@ -55,14 +55,14 @@ export interface ComboBoxValidationValue { export interface ComboBoxProps extends - CollectionBase, - InputBase, - ValueBase, ChangeValueType>, - TextInputBase, - Validation>, - FocusableProps, - LabelableProps, - HelpTextProps { + CollectionBase, + InputBase, + ValueBase, ChangeValueType>, + TextInputBase, + Validation>, + FocusableProps, + LabelableProps, + HelpTextProps { /** The list of ComboBox items (uncontrolled). */ defaultItems?: Iterable; /** The list of ComboBox items (controlled). */ @@ -255,7 +255,7 @@ export function useComboBoxState( } }; - let {collection, selectionManager, disabledKeys} = useListState({ + let { collection, selectionManager, disabledKeys } = useListState({ ...props, items: props.items ?? props.defaultItems, selectionMode, @@ -417,8 +417,6 @@ export function useComboBoxState( let lastSelectedKeyText = useRef( selectedKey != null ? (collection.getItem(selectedKey)?.textValue ?? '') : '' ); - // Clearing the input returns to the unfiltered list, so there is no "filter" to focus the - // first result of. let shouldAutoFocusFirst = autoFocusFirst && inputValue !== ''; // intentional omit dependency array, want this to happen on every render // eslint-disable-next-line react-hooks/exhaustive-deps @@ -432,6 +430,7 @@ export function useComboBoxState( inputValue !== lastValue && menuTrigger !== 'manual' ) { + // oxlint-disable-next-line react/react-compiler open(shouldAutoFocusFirst ? 'first' : null, 'input'); } @@ -513,7 +512,7 @@ export function useComboBoxState( () => Array.isArray(displayValue) && displayValue.length === 0 ? null - : {inputValue, value: displayValue as any, selectedKey}, + : { inputValue, value: displayValue as any, selectedKey }, [inputValue, selectedKey, displayValue] ) }); @@ -672,12 +671,12 @@ function filterNodes( if (node.type === 'section' && node.hasChildNodes) { let filtered = filterNodes(collection, getChildNodes(node, collection), inputValue, filter); if ([...filtered].some(node => node.type === 'item')) { - filteredNode.push({...node, childNodes: filtered}); + filteredNode.push({ ...node, childNodes: filtered }); } } else if (node.type === 'item' && filter(node.textValue, inputValue)) { - filteredNode.push({...node}); + filteredNode.push({ ...node }); } else if (node.type !== 'item') { - filteredNode.push({...node}); + filteredNode.push({ ...node }); } } return filteredNode; From 9e4e8114bc2dfe50daa77372d9fa313461eb78a0 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Thu, 30 Jul 2026 19:44:55 +0530 Subject: [PATCH 3/3] fixed the lint error. --- .../src/combobox/useComboBoxState.ts | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts index b41c0847315..6b829845ce0 100644 --- a/packages/react-stately/src/combobox/useComboBoxState.ts +++ b/packages/react-stately/src/combobox/useComboBoxState.ts @@ -26,13 +26,13 @@ import { Validation, ValueBase } from '@react-types/shared'; -import { FormValidationState, useFormValidationState } from '../form/useFormValidationState'; -import { getChildNodes } from '../collections/getChildNodes'; -import { ListCollection } from '../list/ListCollection'; -import { ListState, useListState } from '../list/useListState'; -import { OverlayTriggerState, useOverlayTriggerState } from '../overlays/useOverlayTriggerState'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useControlledState } from '../utils/useControlledState'; +import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; +import {getChildNodes} from '../collections/getChildNodes'; +import {ListCollection} from '../list/ListCollection'; +import {ListState, useListState} from '../list/useListState'; +import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useControlledState} from '../utils/useControlledState'; export type MenuTriggerAction = 'focus' | 'input' | 'manual'; export type SelectionMode = 'single' | 'multiple'; @@ -55,14 +55,14 @@ export interface ComboBoxValidationValue { export interface ComboBoxProps extends - CollectionBase, - InputBase, - ValueBase, ChangeValueType>, - TextInputBase, - Validation>, - FocusableProps, - LabelableProps, - HelpTextProps { + CollectionBase, + InputBase, + ValueBase, ChangeValueType>, + TextInputBase, + Validation>, + FocusableProps, + LabelableProps, + HelpTextProps { /** The list of ComboBox items (uncontrolled). */ defaultItems?: Iterable; /** The list of ComboBox items (controlled). */ @@ -255,7 +255,7 @@ export function useComboBoxState( } }; - let { collection, selectionManager, disabledKeys } = useListState({ + let {collection, selectionManager, disabledKeys} = useListState({ ...props, items: props.items ?? props.defaultItems, selectionMode, @@ -512,7 +512,7 @@ export function useComboBoxState( () => Array.isArray(displayValue) && displayValue.length === 0 ? null - : { inputValue, value: displayValue as any, selectedKey }, + : {inputValue, value: displayValue as any, selectedKey}, [inputValue, selectedKey, displayValue] ) }); @@ -671,12 +671,12 @@ function filterNodes( if (node.type === 'section' && node.hasChildNodes) { let filtered = filterNodes(collection, getChildNodes(node, collection), inputValue, filter); if ([...filtered].some(node => node.type === 'item')) { - filteredNode.push({ ...node, childNodes: filtered }); + filteredNode.push({...node, childNodes: filtered}); } } else if (node.type === 'item' && filter(node.textValue, inputValue)) { - filteredNode.push({ ...node }); + filteredNode.push({...node}); } else if (node.type !== 'item') { - filteredNode.push({ ...node }); + filteredNode.push({...node}); } } return filteredNode;