Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions packages/react-aria-components/test/Select.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Select data-testid="select">
<Label>Favorite Fruit</Label>
<Button>
<SelectValue />
</Button>
<Popover>
<ListBox>
<ListBoxItem>Banana</ListBoxItem>
<ListBoxItem>Blackberry</ListBoxItem>
<ListBoxItem>Blueberry</ListBoxItem>
</ListBox>
</Popover>
</Select>
);

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(
<Select data-testid="select">
<Label>Favorite Fruit</Label>
<Button>
<SelectValue />
</Button>
<Popover>
<ListBox>
<ListBoxItem>Banana</ListBoxItem>
<ListBoxItem>Blackberry</ListBoxItem>
<ListBoxItem>Blueberry</ListBoxItem>
</ListBox>
</Popover>
</Select>
);

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', () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/react-aria/src/selection/ListKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@ export class ListKeyboardDelegate<T> 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) {
Expand All @@ -284,6 +285,11 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
}

key = this.getNextKey(key);

if (key == null && !hasWrapped) {
key = this.getFirstKey();
hasWrapped = true;
}
}

return null;
Expand Down
46 changes: 41 additions & 5 deletions packages/react-aria/src/selection/useTypeSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | undefined}>({
let state = useRef<{
search: string,
timeout: ReturnType<typeof setTimeout> | undefined,
startKey: Key | null
}>({
search: '',
timeout: undefined
timeout: undefined,
startKey: null
}).current;

let onKeyDown = (e: KeyboardEvent) => {
Expand All @@ -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) {
Expand All @@ -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);
};

Expand Down
Loading