Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve listbox support in selectOptions #1231

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
3 changes: 2 additions & 1 deletion src/utility/selectOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {getConfig} from '@testing-library/dom'
import {hasPointerEvents, isDisabled, isElementType, wait} from '../utils'
import {type Instance} from '../setup'
import {focusElement} from '../event'
import {getVisibleText} from '../utils/misc/getVisibleText'

export async function selectOptions(
this: Instance,
Expand Down Expand Up @@ -43,7 +44,7 @@ async function selectOptionsBase(
const matchingOption = allOptions.find(
o =>
(o as HTMLInputElement | HTMLTextAreaElement).value === val ||
o.innerHTML === val,
getVisibleText(o as HTMLElement) === val,
)
if (matchingOption) {
return matchingOption
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export * from './misc/findClosest'
export * from './misc/getDocumentFromNode'
export * from './misc/getTreeDiff'
export * from './misc/getWindow'
export * from './misc/getVisibleText'
export * from './misc/isDescendantOrSelf'
export * from './misc/isElementType'
export * from './misc/isVisible'
Expand Down
25 changes: 25 additions & 0 deletions src/utils/misc/getVisibleText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {getWindow} from './getWindow'

const removeNotVisibleChildren = (element: Element) => {
const window = getWindow(element)
for (const child of Array.from(element.children)) {
const {display, visibility} = window.getComputedStyle(child)
if (
child.getAttribute('aria-hidden') ||
display === 'none' ||
visibility === 'hidden'
) {
child.remove()
} else {
removeNotVisibleChildren(child)
}
}
}

export const getVisibleText = (element: Element | null) => {
if (!element) return

const clone = element.cloneNode(true) as Element
removeNotVisibleChildren(clone)
return clone.textContent?.trim().replace(/\s+/gm, ' ')
}
8 changes: 5 additions & 3 deletions tests/_helpers/listeners.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {eventMap} from '#src/event/eventMap'
import {isElementType} from '#src/utils'
import {isElementType, getVisibleText} from '#src/utils'
import {MouseButton, MouseButtonFlip} from '#src/system/pointer/buttons'

let eventListeners: Array<{
Expand Down Expand Up @@ -215,10 +215,12 @@ function getElementValue(element: Element) {
return JSON.stringify(Array.from(element.selectedOptions).map(o => o.value))
} else if (element.getAttribute('role') === 'listbox') {
return JSON.stringify(
element.querySelector('[aria-selected="true"]')?.innerHTML,
getVisibleText(
element.querySelector('[aria-selected="true"]') as Element,
),
)
} else if (element.getAttribute('role') === 'option') {
return JSON.stringify(element.innerHTML)
return JSON.stringify(getVisibleText(element))
} else if ('value' in element) {
return JSON.stringify((element as HTMLInputElement).value)
}
Expand Down
53 changes: 53 additions & 0 deletions tests/utility/selectOptions/_setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,56 @@ export function setupListbox() {
user: userEvent.setup(),
}
}

export function setupListboxWithComplexOptions() {
const wrapper = document.createElement('div')
wrapper.innerHTML = `
<button id="button" aria-haspopup="listbox">
Some label
</button>
<ul
role="listbox"
name="listbox"
aria-labelledby="button"
>
<li id="1" role="option" aria-selected="false">
<span>1</span>
</li>
<li id="2" role="option" aria-selected="false">
<span>2</span>
<span>is the best option</span>
<span aria-hidden="true">Not visible 1</span>
<span style="display:none">Not visible 2</span>
<span style="visibility:hidden">Not visible 3</span>
</li>
<li id="3" role="option" aria-selected="false">
<span>3</span>
</li>
</ul>
`
document.body.append(wrapper)
const listbox = wrapper.querySelector('[role="listbox"]') as HTMLUListElement
const options = Array.from(
wrapper.querySelectorAll<HTMLElement>('[role="option"]'),
)

// the user is responsible for handling aria-selected on listbox options
options.forEach(el =>
el.addEventListener('click', e => {
const target = e.currentTarget as HTMLElement
target.setAttribute(
'aria-selected',
JSON.stringify(
!JSON.parse(String(target.getAttribute('aria-selected'))),
),
)
}),
)

return {
...addListeners(listbox),
listbox,
options,
user: userEvent.setup(),
}
}
35 changes: 34 additions & 1 deletion tests/utility/selectOptions/select.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {setupListbox, setupSelect} from './_setup'
import {
setupListbox,
setupListboxWithComplexOptions,
setupSelect,
} from './_setup'
import {PointerEventsCheckLevel} from '#src'
import {addListeners, setup} from '#testHelpers'

Expand Down Expand Up @@ -65,6 +69,35 @@ test('fires correct events on listBox select', async () => {
expect(o3).toHaveAttribute('aria-selected', 'false')
})

test('fires correct events on listBox select with complex options', async () => {
const {listbox, options, getEventSnapshot, user} =
setupListboxWithComplexOptions()
await user.selectOptions(listbox, '2 is the best option')
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: ul[value="2 is the best option"]

li#2[value="2 is the best option"][aria-selected=false] - pointerover
ul - pointerenter
li#2[value="2 is the best option"][aria-selected=false] - mouseover
ul - mouseenter
li#2[value="2 is the best option"][aria-selected=false] - pointermove
li#2[value="2 is the best option"][aria-selected=false] - mousemove
li#2[value="2 is the best option"][aria-selected=false] - pointerdown
li#2[value="2 is the best option"][aria-selected=false] - mousedown: primary
li#2[value="2 is the best option"][aria-selected=false] - pointerup
li#2[value="2 is the best option"][aria-selected=false] - mouseup: primary
li#2[value="2 is the best option"][aria-selected=true] - click: primary
li#2[value="2 is the best option"][aria-selected=true] - pointerout
ul[value="2 is the best option"] - pointerleave
li#2[value="2 is the best option"][aria-selected=true] - mouseout
ul[value="2 is the best option"] - mouseleave
`)
const [o1, o2, o3] = options
expect(o1).toHaveAttribute('aria-selected', 'false')
expect(o2).toHaveAttribute('aria-selected', 'true')
expect(o3).toHaveAttribute('aria-selected', 'false')
})

test('fires correct events on multi-selects', async () => {
const {select, options, getEventSnapshot, user} = setupSelect({
multiple: true,
Expand Down