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