Skip to content

Commit e5e5273

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 ed0541d commit e5e5273

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.3",
4646
"@sheerun/mutationobserver-shim": "^0.3.2",
47+
"aria-query": "3.0.0",
4748
"pretty-format": "^24.7.0",
4849
"wait-for-expect": "^1.1.1"
4950
},

src/__tests__/element-queries.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,61 @@ describe('query by test id', () => {
361361
})
362362
})
363363

364+
test('queryAllByRole returns semantic html elements', () => {
365+
const {queryAllByRole} = render(`
366+
<form>
367+
<h1>Heading 1</h1>
368+
<h2>Heading 2</h2>
369+
<h3>Heading 3</h3>
370+
<h4>Heading 4</h4>
371+
<h5>Heading 5</h5>
372+
<h6>Heading 6</h6>
373+
<ol>
374+
<li></li>
375+
<li></li>
376+
</ol>
377+
<ul>
378+
<li></li>
379+
</ul>
380+
<input>
381+
<input type="text">
382+
<input type="checkbox">
383+
<input type="radio">
384+
<table>
385+
<thead>
386+
<tr>
387+
<th></th>
388+
<th scope="row"></th>
389+
</tr>
390+
</thead>
391+
<tbody>
392+
<tr></tr>
393+
<tr></tr>
394+
</tbody>
395+
</table>
396+
<table role="grid"></table>
397+
<button>Button</button>
398+
</form>
399+
`)
400+
401+
expect(queryAllByRole(/table/i)).toHaveLength(1)
402+
expect(queryAllByRole(/tabl/i, {exact: false})).toHaveLength(1)
403+
expect(queryAllByRole(/columnheader/i)).toHaveLength(1)
404+
expect(queryAllByRole(/rowheader/i)).toHaveLength(1)
405+
expect(queryAllByRole(/grid/i)).toHaveLength(1)
406+
expect(queryAllByRole(/form/i)).toHaveLength(1)
407+
expect(queryAllByRole(/button/i)).toHaveLength(1)
408+
expect(queryAllByRole(/heading/i)).toHaveLength(6)
409+
expect(queryAllByRole('list')).toHaveLength(2)
410+
expect(queryAllByRole(/listitem/i)).toHaveLength(3)
411+
expect(queryAllByRole(/textbox/i)).toHaveLength(2)
412+
expect(queryAllByRole(/checkbox/i)).toHaveLength(1)
413+
expect(queryAllByRole(/radio/i)).toHaveLength(1)
414+
expect(queryAllByRole('row')).toHaveLength(3)
415+
expect(queryAllByRole(/rowgroup/i)).toHaveLength(2)
416+
expect(queryAllByRole(/(table)|(textbox)/i)).toHaveLength(3)
417+
})
418+
364419
test('getAll* matchers return an array', () => {
365420
const {
366421
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)