diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index df4906037d2..b28b78c061e 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -549,6 +549,134 @@ 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'); + + let selectTester = testUtilUser.createTester('Select', { + root: wrapper, + interactionType: 'keyboard' + }); + let trigger = selectTester.getTrigger(); + + await user.tab(); + await user.keyboard('B'); + 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'); + + let selectTester = testUtilUser.createTester('Select', { + root: wrapper, + interactionType: 'keyboard' + }); + let trigger = selectTester.getTrigger(); + + await user.tab(); + await user.keyboard('bb'); + expect(trigger).toHaveTextContent('Blackberry'); + }); + + it('should wrap if typeahead is not found after the current key', async function () { + let {getByTestId} = render( + + ); + + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', { + root: wrapper, + interactionType: 'keyboard' + }); + let trigger = selectTester.getTrigger(); + + await user.tab(); + await user.keyboard('b'); + expect(trigger).toHaveTextContent('Banana'); + }); + + // This matches the three main browsers behavior. Even though it seems like it should go to double "b" blackberry, + // it's just cycling through the items that start with "b". + it('searches the next item that starts with the same letter, not the next item that starts with the same letter twice', async function () { + let {getByTestId} = render( + + ); + + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', { + root: wrapper, + interactionType: 'keyboard' + }); + let trigger = selectTester.getTrigger(); + + await user.tab(); + await user.keyboard('bb'); + expect(trigger).toHaveTextContent('Boisenberry'); + }); }); it('should support autoFocus', () => { diff --git a/packages/react-aria/src/selection/ListKeyboardDelegate.ts b/packages/react-aria/src/selection/ListKeyboardDelegate.ts index 06e736447c6..709289989da 100644 --- a/packages/react-aria/src/selection/ListKeyboardDelegate.ts +++ b/packages/react-aria/src/selection/ListKeyboardDelegate.ts @@ -370,7 +370,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } let collection = this.collection; - let key = fromKey || this.getFirstKey(); + let key = fromKey != null ? this.getNextKey(fromKey) : this.getFirstKey(); while (key != null) { let item = collection.getItem(key); if (!item) { diff --git a/packages/react-aria/src/selection/useTypeSelect.ts b/packages/react-aria/src/selection/useTypeSelect.ts index a1ef7c36e9f..b1eaa9f9f4c 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 }); let onKeyDownCapture = (e: KeyboardEvent) => { @@ -106,12 +111,42 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { return; } - state.current.search += character; + let isFreshSearch = state.current.search.length === 0; + + if (isFreshSearch || state.current.search.split('').every(c => c === character)) { + state.current.search = character; + state.current.startKey = selectionManager.focusedKey; + } else { + state.current.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.current.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.current.search.length > 1 || isFreshSearch) + ) { + let focusedItem = selectionManager.collection.getItem(selectionManager.focusedKey); + if (focusedItem?.textValue) { + let searchValue = state.current.search.toLowerCase(); + let itemValue = focusedItem.textValue.slice(0, state.current.search.length).toLowerCase(); + if (itemValue === searchValue) { + key = selectionManager.focusedKey; + } + } + } + + if (key == null) { + key = keyboardDelegate.getKeyForSearch( + state.current.search, + state.current.startKey ?? selectionManager.focusedKey + ); + } if (key == null) { key = keyboardDelegate.getKeyForSearch(state.current.search); @@ -138,6 +173,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { clearTimeout(state.current.timeout); state.current.timeout = setTimeout(() => { state.current.search = ''; + state.current.startKey = null; }, TYPEAHEAD_DEBOUNCE_WAIT_MS); };