testing-library / testing-library/user-event

`selectOptions` doesn't work when 'listbox' lives outside of select element

Open
#522 2 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

accuracy needs investigation
Dominant language
TypeScript
Stars
2.3k
Forks
258
PR merge metrics
No merged PRs in 30d

Description

Environment
  • @testing-library/user-event version: 12.2.2
  • Testing Framework and version: jest@24.8.0 testing-library/react@11.1.2
  • DOM Environment: default
Problem description

selectOptions and deselectOptions does not work if the dropdown menu is a child of some other element, say document.body for example. It's because the current logic looks for options inside the given select element, and in the given scenario, it can't find any and it throws the Value [X] not found in options error.

Suggested solution

Instead of looking for options "inside" the select element straight up, we could first check if the given select element is "pseudo-select" by checking the aria-haspopup attribute and if it has a linked listbox through a shared labelledby pointer. downshift for one, has been tested to work with the proposed setup below.

If both the assertions are positive, then we could handle the selection and deselection like so:

Notice the call signature of selectOptions and deselectOptions; it's 100% compliant and backwards compatible with current signature

const getOptions = (selectToggle: HTMLElement, value: Matcher | Matcher[]): HTMLElement[] => {
  userEvent.click(selectToggle);
  const labelledByIds = selectToggle.getAttribute('aria-labelledby')?.split(/\s/) || [];
  const listboxes = labelledByIds.map(labelledById => document.querySelector(`[role="listbox"][aria-labelledby="${labelledById}"]`));
  const matchingListbox = listboxes.find(listbox => !!listbox) as HTMLElement;
  const values = arrify(value) as Matcher[];
  return values.map(v => within(matchingListbox).getByText(v, { selector: '[role="option"]' }));
};

export const selectOptions = (selectToggle: HTMLElement, value: Matcher | Matcher[]): void =>
  getOptions(selectToggle, value)
    .filter(option => option.getAttribute('aria-selected') !== 'true')
    .forEach(option => option.isConnected && userEvent.click(option));

export const deselectOptions = (selectToggle: HTMLElement, value: Matcher | Matcher[]): void =>
  getOptions(selectToggle, value)
    .filter(option => option.getAttribute('aria-selected') === 'true')
    .forEach(option => option.isConnected && userEvent.click(option));

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at the selectOptions and deselectOptions entry points and reproduce the reported case where the listbox is outside the select element. Check the aria-haspopup and aria-labelledby relationship described in the issue; done means selection and deselection work with an externally mounted listbox while preserving the current call signature.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
testing-qa
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.