Skip to content

Commit 828bd9c

Browse files
committed
feat: add semantic html elements implicit role support to queryByRole/getByRole selectors (testing-library#262)
* Add aria-query library as a dependency * Extend queryByRole to use aria-query ARIA roles to identify semantic HTML elements
1 parent e0eed7d commit 828bd9c

File tree

3 files changed

+126
-2
lines changed

3 files changed

+126
-2
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"dependencies": {
4545
"@babel/runtime": "^7.4.5",
4646
"@sheerun/mutationobserver-shim": "^0.3.2",
47+
"aria-query": "3.0.0",
4748
"pretty-format": "^24.8.0",
4849
"wait-for-expect": "^1.2.0"
4950
},

src/__tests__/element-queries.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,61 @@ describe('query by test id', () => {
374374
})
375375
})
376376

377+
test('queryAllByRole returns semantic html elements', () => {
378+
const {queryAllByRole} = render(`
379+
<form>
380+
<h1>Heading 1</h1>
381+
<h2>Heading 2</h2>
382+
<h3>Heading 3</h3>
383+
<h4>Heading 4</h4>
384+
<h5>Heading 5</h5>
385+
<h6>Heading 6</h6>
386+
<ol>
387+
<li></li>
388+
<li></li>
389+
</ol>
390+
<ul>
391+
<li></li>
392+
</ul>
393+
<input>
394+
<input type="text">
395+
<input type="checkbox">
396+
<input type="radio">
397+
<table>
398+
<thead>
399+
<tr>
400+
<th></th>
401+
<th scope="row"></th>
402+
</tr>
403+
</thead>
404+
<tbody>
405+
<tr></tr>
406+
<tr></tr>
407+
</tbody>
408+
</table>
409+
<table role="grid"></table>
410+
<button>Button</button>
411+
</form>
412+
`)
413+
414+
expect(queryAllByRole(/table/i)).toHaveLength(1)
415+
expect(queryAllByRole(/tabl/i, {exact: false})).toHaveLength(1)
416+
expect(queryAllByRole(/columnheader/i)).toHaveLength(1)
417+
expect(queryAllByRole(/rowheader/i)).toHaveLength(1)
418+
expect(queryAllByRole(/grid/i)).toHaveLength(1)
419+
expect(queryAllByRole(/form/i)).toHaveLength(1)
420+
expect(queryAllByRole(/button/i)).toHaveLength(1)
421+
expect(queryAllByRole(/heading/i)).toHaveLength(6)
422+
expect(queryAllByRole('list')).toHaveLength(2)
423+
expect(queryAllByRole(/listitem/i)).toHaveLength(3)
424+
expect(queryAllByRole(/textbox/i)).toHaveLength(2)
425+
expect(queryAllByRole(/checkbox/i)).toHaveLength(1)
426+
expect(queryAllByRole(/radio/i)).toHaveLength(1)
427+
expect(queryAllByRole('row')).toHaveLength(3)
428+
expect(queryAllByRole(/rowgroup/i)).toHaveLength(2)
429+
expect(queryAllByRole(/(table)|(textbox)/i)).toHaveLength(3)
430+
})
431+
377432
test('getAll* matchers return an array', () => {
378433
const {
379434
getAllByAltText,

src/queries/role.js

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,74 @@
1-
import {queryAllByAttribute, buildQueries} from './all-utils'
1+
import {buildQueries, fuzzyMatches, makeNormalizer, matches} from './all-utils'
2+
import {elementRoles} from 'aria-query'
23

3-
const queryAllByRole = queryAllByAttribute.bind(null, 'role')
4+
function buildElementRoleList(elementRolesMap) {
5+
function makeElementSelector({name, attributes = []}) {
6+
return `${name}${attributes
7+
.map(({name: attributeName, value}) => `[${attributeName}=${value}]`)
8+
.join('')}`
9+
}
10+
11+
function getSelectorSpecificity({attributes = []}) {
12+
return attributes.length
13+
}
14+
15+
function bySelectorSpecificity(
16+
{specificity: leftSpecificity},
17+
{specificity: rightSpecificity},
18+
) {
19+
return rightSpecificity - leftSpecificity
20+
}
21+
22+
let result = []
23+
24+
for (const [element, roles] of elementRolesMap.entries()) {
25+
result = [
26+
...result,
27+
{
28+
selector: makeElementSelector(element),
29+
roles: Array.from(roles),
30+
specificity: getSelectorSpecificity(element),
31+
},
32+
]
33+
}
34+
35+
return result.sort(bySelectorSpecificity)
36+
}
37+
38+
const elementRoleList = buildElementRoleList(elementRoles)
39+
40+
function queryAllByRole(
41+
container,
42+
role,
43+
{exact = true, collapseWhitespace, trim, normalizer} = {},
44+
) {
45+
const matcher = exact ? matches : fuzzyMatches
46+
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
47+
48+
function getImplicitAriaRole(currentNode) {
49+
for (const {selector, roles} of elementRoleList) {
50+
if (currentNode.matches(selector)) {
51+
return [...roles]
52+
}
53+
}
54+
55+
return []
56+
}
57+
58+
return Array.from(container.querySelectorAll('*')).filter(node => {
59+
const isRoleSpecifiedExplicitly = node.hasAttribute('role')
60+
61+
if (isRoleSpecifiedExplicitly) {
62+
return matcher(node.getAttribute('role'), node, role, matchNormalizer)
63+
}
64+
65+
const implicitRoles = getImplicitAriaRole(node)
66+
67+
return implicitRoles.some(implicitRole =>
68+
matcher(implicitRole, node, role, matchNormalizer),
69+
)
70+
})
71+
}
472

573
const getMultipleError = (c, id) => `Found multiple elements by [role=${id}]`
674
const getMissingError = (c, id) => `Unable to find an element by [role=${id}]`

0 commit comments

Comments
 (0)