Skip to content

Commit 752ff66

Browse files
authored
feat(ByRole): Allow filter by selected state (#540)
1 parent 7b3ca97 commit 752ff66

File tree

3 files changed

+124
-0
lines changed

3 files changed

+124
-0
lines changed

src/__tests__/ariaAttributes.js

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {render} from './helpers/test-utils'
2+
3+
test('`selected` throws on unsupported roles', () => {
4+
const {getByRole} = render(`<input aria-selected="true" type="text">`)
5+
expect(() =>
6+
getByRole('textbox', {selected: true}),
7+
).toThrowErrorMatchingInlineSnapshot(
8+
`"\\"aria-selected\\" is not supported on role \\"textbox\\"."`,
9+
)
10+
})
11+
12+
test('`selected: true` matches `aria-selected="true"` on supported roles', () => {
13+
const {getAllByRole} = render(`
14+
<select>
15+
<option selected id="selected-native-option" />
16+
<option id="unselected-native-option" />
17+
</select>
18+
<div role="listbox">
19+
<div role="option" aria-selected="true" id="selected-listbox-option" />
20+
<div role="option" aria-selected="false" id="unselected-listbox-option" />
21+
<div role="option" id="not-selectable-listbox-option" />
22+
</div>
23+
<div role="tree">
24+
<div role="treeitem" aria-selected="true" id="selected-treeitem" />
25+
<div role="treeitem" aria-selected="false" id="unselected-treeitem" />
26+
<div role="treeitem" id="not-selectable-treeitem" />
27+
</div>
28+
<table>
29+
<thead>
30+
<tr>
31+
<th scope="col" aria-selected="true" id="selected-native-columnheader" />
32+
<div role="columnheader" aria-selected="true" id="selected-columnheader" />
33+
<th scope="col" id="unselected-native-columnheader" />
34+
</tr>
35+
</thead>
36+
<tbody>
37+
<tr>
38+
<th scope="row" aria-selected="true" id="selected-native-rowheader" />
39+
<td />
40+
<td />
41+
</tr>
42+
<tr>
43+
<div role="rowheader" aria-selected="true" id="selected-rowheader" />
44+
<td />
45+
<td />
46+
</tr>
47+
</tbody>
48+
</table>
49+
<div role="grid">
50+
<div role="gridcell" aria-selected="true" id="selected-gridcell" />
51+
<div role="gridcell" aria-selected="false" id="unselected-gridcell" />
52+
<div role="gridcell" id="not-selectable-gridcell" />
53+
</div>
54+
<div role="tablist">
55+
<div role="tab" aria-selected="true" id="selected-tab" />
56+
<div role="tab" aria-selected="false" id= "unselected-tab" />
57+
<div role="tab" id="unselectable-tab" />
58+
</div>
59+
`)
60+
61+
expect(
62+
getAllByRole('columnheader', {selected: true}).map(({id}) => id),
63+
).toEqual(['selected-native-columnheader', 'selected-columnheader'])
64+
65+
expect(getAllByRole('gridcell', {selected: true}).map(({id}) => id)).toEqual([
66+
'selected-gridcell',
67+
])
68+
69+
expect(getAllByRole('option', {selected: true}).map(({id}) => id)).toEqual([
70+
'selected-native-option',
71+
'selected-listbox-option',
72+
])
73+
74+
expect(
75+
getAllByRole('rowheader', {selected: true}).map(({id}) => id),
76+
).toEqual(['selected-rowheader', 'selected-native-rowheader'])
77+
78+
expect(getAllByRole('treeitem', {selected: true}).map(({id}) => id)).toEqual([
79+
'selected-treeitem',
80+
])
81+
82+
expect(getAllByRole('tab', {selected: true}).map(({id}) => id)).toEqual([
83+
'selected-tab',
84+
])
85+
})

src/queries/role.js

+17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {computeAccessibleName} from 'dom-accessibility-api'
2+
import {roles as allRoles} from 'aria-query'
23
import {
4+
computeAriaSelected,
35
getImplicitAriaRoles,
46
prettyRoles,
57
isInaccessible,
@@ -24,11 +26,19 @@ function queryAllByRole(
2426
trim,
2527
normalizer,
2628
queryFallbacks = false,
29+
selected,
2730
} = {},
2831
) {
2932
const matcher = exact ? matches : fuzzyMatches
3033
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
3134

35+
if (selected !== undefined) {
36+
// guard against unknown roles
37+
if (allRoles.get(role)?.props['aria-selected'] === undefined) {
38+
throw new Error(`"aria-selected" is not supported on role "${role}".`)
39+
}
40+
}
41+
3242
const subtreeIsInaccessibleCache = new WeakMap()
3343
function cachedIsSubtreeInaccessible(element) {
3444
if (!subtreeIsInaccessibleCache.has(element)) {
@@ -65,6 +75,13 @@ function queryAllByRole(
6575
matcher(implicitRole, node, role, matchNormalizer),
6676
)
6777
})
78+
.filter(element => {
79+
if (selected !== undefined) {
80+
return selected === computeAriaSelected(element)
81+
}
82+
// don't care if aria attributes are unspecified
83+
return true
84+
})
6885
.filter(element => {
6986
return hidden === false
7087
? isInaccessible(element, {

src/role-helpers.js

+22
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,33 @@ function prettyRoles(dom, {hidden}) {
168168
const logRoles = (dom, {hidden = false} = {}) =>
169169
console.log(prettyRoles(dom, {hidden}))
170170

171+
/**
172+
* @param {Element} element -
173+
* @returns {boolean | undefined} - false/true if (not)selected, undefined if not selectable
174+
*/
175+
function computeAriaSelected(element) {
176+
// implicit value from html-aam mappings: https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
177+
// https://www.w3.org/TR/html-aam-1.0/#details-id-97
178+
if (element.tagName === 'OPTION') {
179+
return element.selected
180+
}
181+
// explicit value
182+
const attributeValue = element.getAttribute('aria-selected')
183+
if (attributeValue === 'true') {
184+
return true
185+
}
186+
if (attributeValue === 'false') {
187+
return false
188+
}
189+
return undefined
190+
}
191+
171192
export {
172193
getRoles,
173194
logRoles,
174195
getImplicitAriaRoles,
175196
isSubtreeInaccessible,
176197
prettyRoles,
177198
isInaccessible,
199+
computeAriaSelected,
178200
}

0 commit comments

Comments
 (0)