diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index b88094f5d3b..0c020575c22 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -528,6 +528,65 @@ describe('Select', () => { expect(trigger).toHaveTextContent('Northern Territory'); expect(trigger).not.toHaveAttribute('data-pressed'); }); + + it('should move to the next matching item when the same letter is typed again after timeout', async function () { + let {getByTestId} = render( + + ); + + let wrapper = getByTestId('select'); + await user.tab(); + await user.keyboard('B'); + + let selectTester = testUtilUser.createTester('Select', {root: wrapper, interactionType: 'keyboard'}); + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Banana'); + + act(() => { + jest.advanceTimersByTime(1001); + }); + + await user.keyboard('B'); + expect(trigger).toHaveTextContent('Blackberry'); + }); + + it('should cycle to the next matching item when the same letter is typed twice quickly', async function () { + let {getByTestId} = render( + + ); + + let wrapper = getByTestId('select'); + await user.tab(); + await user.keyboard('bb'); + + let selectTester = testUtilUser.createTester('Select', {root: wrapper, interactionType: 'keyboard'}); + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Blackberry'); + }); }); it('should support autoFocus', () => { diff --git a/packages/react-aria/src/selection/ListKeyboardDelegate.ts b/packages/react-aria/src/selection/ListKeyboardDelegate.ts index 65533fe7044..46a6f30a57a 100644 --- a/packages/react-aria/src/selection/ListKeyboardDelegate.ts +++ b/packages/react-aria/src/selection/ListKeyboardDelegate.ts @@ -272,7 +272,8 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } let collection = this.collection; - let key = fromKey || this.getFirstKey(); + let key = fromKey != null ? this.getNextKey(fromKey) : this.getFirstKey(); + let hasWrapped = false; while (key != null) { let item = collection.getItem(key); if (!item) { @@ -284,6 +285,11 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } key = this.getNextKey(key); + + if (key == null && !hasWrapped) { + key = this.getFirstKey(); + hasWrapped = true; + } } return null; diff --git a/packages/react-aria/src/selection/useTypeSelect.ts b/packages/react-aria/src/selection/useTypeSelect.ts index 75b3569bb55..6dfa8d7e0de 100644 --- a/packages/react-aria/src/selection/useTypeSelect.ts +++ b/packages/react-aria/src/selection/useTypeSelect.ts @@ -47,9 +47,14 @@ export interface TypeSelectAria { */ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { let {keyboardDelegate, selectionManager, onTypeSelect} = options; - let state = useRef<{search: string, timeout: ReturnType | undefined}>({ + let state = useRef<{ + search: string, + timeout: ReturnType | undefined, + startKey: Key | null + }>({ search: '', - timeout: undefined + timeout: undefined, + startKey: null }).current; let onKeyDown = (e: KeyboardEvent) => { @@ -69,12 +74,42 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { } } - state.search += character; + let isFreshSearch = state.search.length === 0; + + if (isFreshSearch || state.search.split('').every(c => c === character)) { + state.search = character; + state.startKey = selectionManager.focusedKey; + } else { + state.search += character; + } if (keyboardDelegate.getKeyForSearch != null) { // Use the delegate to find a key to focus. - // Prioritize items after the currently focused item, falling back to searching the whole list. - let key = keyboardDelegate.getKeyForSearch(state.search, selectionManager.focusedKey); + // Prioritize items after the starting focused item for the active search, + // falling back to searching the whole list. + let key: Key | null = null; + + if ( + selectionManager.focusedKey != null && + selectionManager.isFocused && + ( + state.search.length > 1 || + isFreshSearch + ) + ) { + let focusedItem = selectionManager.collection.getItem(selectionManager.focusedKey); + if (focusedItem?.textValue) { + let searchValue = state.search.toLowerCase(); + let itemValue = focusedItem.textValue.slice(0, state.search.length).toLowerCase(); + if (itemValue === searchValue) { + key = selectionManager.focusedKey; + } + } + } + + if (key == null) { + key = keyboardDelegate.getKeyForSearch(state.search, state.startKey ?? selectionManager.focusedKey); + } // If no key found, search from the top. if (key == null) { @@ -92,6 +127,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { clearTimeout(state.timeout); state.timeout = setTimeout(() => { state.search = ''; + state.startKey = null; }, TYPEAHEAD_DEBOUNCE_WAIT_MS); };