diff --git a/src/__tests__/ariaAttributes.js b/src/__tests__/ariaAttributes.js
new file mode 100644
index 00000000..56590aac
--- /dev/null
+++ b/src/__tests__/ariaAttributes.js
@@ -0,0 +1,85 @@
+import {render} from './helpers/test-utils'
+
+test('`selected` throws on unsupported roles', () => {
+ const {getByRole} = render(``)
+ expect(() =>
+ getByRole('textbox', {selected: true}),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"\\"aria-selected\\" is not supported on role \\"textbox\\"."`,
+ )
+})
+
+test('`selected: true` matches `aria-selected="true"` on supported roles', () => {
+ const {getAllByRole} = render(`
+
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+ |
+ |
+
+
+
+ |
+ |
+
+
+
+
+
+`)
+
+ expect(
+ getAllByRole('columnheader', {selected: true}).map(({id}) => id),
+ ).toEqual(['selected-native-columnheader', 'selected-columnheader'])
+
+ expect(getAllByRole('gridcell', {selected: true}).map(({id}) => id)).toEqual([
+ 'selected-gridcell',
+ ])
+
+ expect(getAllByRole('option', {selected: true}).map(({id}) => id)).toEqual([
+ 'selected-native-option',
+ 'selected-listbox-option',
+ ])
+
+ expect(
+ getAllByRole('rowheader', {selected: true}).map(({id}) => id),
+ ).toEqual(['selected-rowheader', 'selected-native-rowheader'])
+
+ expect(getAllByRole('treeitem', {selected: true}).map(({id}) => id)).toEqual([
+ 'selected-treeitem',
+ ])
+
+ expect(getAllByRole('tab', {selected: true}).map(({id}) => id)).toEqual([
+ 'selected-tab',
+ ])
+})
diff --git a/src/queries/role.js b/src/queries/role.js
index a442bfd9..6cae446d 100644
--- a/src/queries/role.js
+++ b/src/queries/role.js
@@ -1,5 +1,7 @@
import {computeAccessibleName} from 'dom-accessibility-api'
+import {roles as allRoles} from 'aria-query'
import {
+ computeAriaSelected,
getImplicitAriaRoles,
prettyRoles,
isInaccessible,
@@ -24,11 +26,19 @@ function queryAllByRole(
trim,
normalizer,
queryFallbacks = false,
+ selected,
} = {},
) {
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
+ if (selected !== undefined) {
+ // guard against unknown roles
+ if (allRoles.get(role)?.props['aria-selected'] === undefined) {
+ throw new Error(`"aria-selected" is not supported on role "${role}".`)
+ }
+ }
+
const subtreeIsInaccessibleCache = new WeakMap()
function cachedIsSubtreeInaccessible(element) {
if (!subtreeIsInaccessibleCache.has(element)) {
@@ -65,6 +75,13 @@ function queryAllByRole(
matcher(implicitRole, node, role, matchNormalizer),
)
})
+ .filter(element => {
+ if (selected !== undefined) {
+ return selected === computeAriaSelected(element)
+ }
+ // don't care if aria attributes are unspecified
+ return true
+ })
.filter(element => {
return hidden === false
? isInaccessible(element, {
diff --git a/src/role-helpers.js b/src/role-helpers.js
index eee0010a..e7627b29 100644
--- a/src/role-helpers.js
+++ b/src/role-helpers.js
@@ -168,6 +168,27 @@ function prettyRoles(dom, {hidden}) {
const logRoles = (dom, {hidden = false} = {}) =>
console.log(prettyRoles(dom, {hidden}))
+/**
+ * @param {Element} element -
+ * @returns {boolean | undefined} - false/true if (not)selected, undefined if not selectable
+ */
+function computeAriaSelected(element) {
+ // implicit value from html-aam mappings: https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
+ // https://www.w3.org/TR/html-aam-1.0/#details-id-97
+ if (element.tagName === 'OPTION') {
+ return element.selected
+ }
+ // explicit value
+ const attributeValue = element.getAttribute('aria-selected')
+ if (attributeValue === 'true') {
+ return true
+ }
+ if (attributeValue === 'false') {
+ return false
+ }
+ return undefined
+}
+
export {
getRoles,
logRoles,
@@ -175,4 +196,5 @@ export {
isSubtreeInaccessible,
prettyRoles,
isInaccessible,
+ computeAriaSelected,
}