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);
};