testing-library / testing-library/user-event
`selectOptions` doesn't work when 'listbox' lives outside of select element
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 2.3k
- Forks
- 258
- PR merge metrics
- No merged PRs in 30d
Description
Environment
@testing-library/user-eventversion: 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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