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
67 changes: 67 additions & 0 deletions packages/react-aria-components/test/Select.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,73 @@ 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');

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(
<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');

let selectTester = testUtilUser.createTester('Select', {
root: wrapper,
interactionType: 'keyboard'
});
let trigger = selectTester.getTrigger();

await user.tab();
await user.keyboard('bb');
expect(trigger).toHaveTextContent('Blackberry');
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    it('should wrap if typeahead is not found after the current key', async function () {
      let {getByTestId} = render(
        <Select defaultSelectedKey="blueberry" data-testid="select">
          <Label>Favorite Fruit</Label>
          <Button>
            <SelectValue />
          </Button>
          <Popover>
            <ListBox>
              <ListBoxItem id="banana">Banana</ListBoxItem>
              <ListBoxItem id="blackberry">Blackberry</ListBoxItem>
              <ListBoxItem id="blueberry">Blueberry</ListBoxItem>
            </ListBox>
          </Popover>
        </Select>
      );

      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(
        <Select data-testid="select">
          <Label>Favorite Fruit</Label>
          <Button>
            <SelectValue />
          </Button>
          <Popover>
            <ListBox>
              <ListBoxItem id="banana">Banana</ListBoxItem>
              <ListBoxItem id="boisenberry">Boisenberry</ListBoxItem>
              <ListBoxItem id="bblackberry">Bblackberry</ListBoxItem>
              <ListBoxItem id="blueberry">Blueberry</ListBoxItem>
            </ListBox>
          </Popover>
        </Select>
      );

      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', () => {
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 @@ -333,7 +333,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();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be better if we can handle this in useTypeSelect instead, it may affect unrelated implementations, and we'd need to go make the change in every delegate and layout which implements this function probably

let hasWrapped = false;
while (key != null) {
let item = collection.getItem(key);
if (!item) {
Expand All @@ -345,6 +346,11 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
}

key = this.getNextKey(key);

if (key == null && !hasWrapped) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't need to do wrapping here, i think we already handle it in useTypeSelection, if a key wasn't found after the starting point, we try again from the top of the list

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 @@ -75,12 +80,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 @@ -98,6 +133,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria {
clearTimeout(state.timeout);
state.timeout = setTimeout(() => {
state.search = '';
state.startKey = null;
}, TYPEAHEAD_DEBOUNCE_WAIT_MS);
};

Expand Down