diff --git a/README.md b/README.md
index 1c07ae72..d7c08180 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,8 @@ clear to read and to maintain.
- [`toHaveAttribute`](#tohaveattribute)
- [`toHaveClass`](#tohaveclass)
- [`toHaveFocus`](#tohavefocus)
+ - [`toHaveActiveDescendant`](#tohaveactivedescendant)
+ - [`toHaveVirtualFocus`](#tohavevirtualfocus)
- [`toHaveFormValues`](#tohaveformvalues)
- [`toHaveStyle`](#tohavestyle)
- [`toHaveTextContent`](#tohavetextcontent)
@@ -836,6 +838,70 @@ expect(input).not.toHaveFocus()
+### `toHaveActiveDescendant`
+
+```typescript
+toHaveActiveDescendant(
+ activeDescendant?: HTMLElement | SVGElement | string
+)
+```
+
+This allows you to assert whether an element with DOM focus has an active
+descendant through its `aria-activedescendant` attribute.
+
+#### Examples
+
+```html
+
+```
+
+```javascript
+const listbox = getByTestId('listbox')
+const option1 = getByTestId('option-1')
+const option2 = getByTestId('option-2')
+
+listbox.focus()
+expect(listbox).toHaveActiveDescendant()
+expect(listbox).toHaveActiveDescendant(option1)
+expect(listbox).toHaveActiveDescendant('option-1')
+expect(listbox).not.toHaveActiveDescendant(option2)
+```
+
+
+
+### `toHaveVirtualFocus`
+
+```typescript
+toHaveVirtualFocus()
+```
+
+This allows you to assert whether an element has virtual focus through the
+current DOM focused element's `aria-activedescendant` attribute.
+
+#### Examples
+
+```html
+
+```
+
+```javascript
+const listbox = getByTestId('listbox')
+const option1 = getByTestId('option-1')
+const option2 = getByTestId('option-2')
+
+listbox.focus()
+expect(option1).toHaveVirtualFocus()
+expect(option2).not.toHaveVirtualFocus()
+```
+
+
+
### `toHaveFormValues`
```typescript
diff --git a/src/__tests__/to-have-active-descendant.js b/src/__tests__/to-have-active-descendant.js
new file mode 100644
index 00000000..14e9f57d
--- /dev/null
+++ b/src/__tests__/to-have-active-descendant.js
@@ -0,0 +1,140 @@
+import {render} from './helpers/test-utils'
+
+describe('aria-activedescendant matchers', () => {
+ test('.toHaveActiveDescendant', () => {
+ const {queryByTestId} = render(`
+
+
+ Option 1
+ Option 2
+ No id
+
+
Button
+
+ `)
+ const listbox = queryByTestId('listbox')
+ const option1 = queryByTestId('option-1')
+ const option2 = queryByTestId('option-2')
+ const noId = queryByTestId('no-id')
+ const button = queryByTestId('button')
+
+ listbox.focus()
+
+ expect(listbox).toHaveActiveDescendant()
+ expect(listbox).toHaveActiveDescendant(option1)
+ expect(listbox).toHaveActiveDescendant('option-1')
+ expect(listbox).not.toHaveActiveDescendant(option2)
+ expect(listbox).not.toHaveActiveDescendant(noId)
+ expect(listbox).not.toHaveActiveDescendant('option-2')
+ expect(listbox).not.toHaveActiveDescendant('')
+
+ expect(() => expect(listbox).not.toHaveActiveDescendant()).toThrowError()
+ expect(() =>
+ expect(listbox).not.toHaveActiveDescendant(option1),
+ ).toThrowError()
+ expect(() => expect(listbox).toHaveActiveDescendant(option2)).toThrowError()
+ expect(() => expect(listbox).toHaveActiveDescendant(noId)).toThrowError()
+ expect(() => expect(listbox).toHaveActiveDescendant('')).toThrowError()
+
+ listbox.setAttribute('aria-activedescendant', 'missing')
+
+ expect(listbox).not.toHaveActiveDescendant()
+ expect(listbox).not.toHaveActiveDescendant('missing')
+ expect(() => expect(listbox).toHaveActiveDescendant()).toThrowError()
+ expect(() =>
+ expect(listbox).toHaveActiveDescendant('missing'),
+ ).toThrowError()
+
+ button.focus()
+
+ expect(listbox).not.toHaveActiveDescendant()
+ expect(listbox).not.toHaveActiveDescendant(option1)
+ expect(() => expect(listbox).toHaveActiveDescendant()).toThrowError()
+ expect(() => expect(listbox).toHaveActiveDescendant(option1)).toThrowError()
+ })
+
+ test('.toHaveVirtualFocus', () => {
+ const {queryByTestId} = render(`
+
+
+ Option 1
+ Option 2
+ No id
+
+
Button
+
+ `)
+ const listbox = queryByTestId('listbox')
+ const option1 = queryByTestId('option-1')
+ const option2 = queryByTestId('option-2')
+ const noId = queryByTestId('no-id')
+ const button = queryByTestId('button')
+
+ listbox.focus()
+
+ expect(option1).toHaveVirtualFocus()
+ expect(option2).not.toHaveVirtualFocus()
+ expect(noId).not.toHaveVirtualFocus()
+
+ expect(() => expect(option1).not.toHaveVirtualFocus()).toThrowError()
+ expect(() => expect(option2).toHaveVirtualFocus()).toThrowError()
+
+ button.focus()
+
+ expect(option1).not.toHaveVirtualFocus()
+ expect(() => expect(option1).toHaveVirtualFocus()).toThrowError()
+ })
+
+ test('checks the resolved active descendant element', () => {
+ const {queryByTestId} = render(`
+
+ `)
+ const listbox = queryByTestId('listbox')
+ const first = queryByTestId('first')
+ const second = queryByTestId('second')
+
+ listbox.focus()
+
+ expect(listbox).toHaveActiveDescendant(first)
+ expect(listbox).not.toHaveActiveDescendant(second)
+ expect(first).toHaveVirtualFocus()
+ expect(second).not.toHaveVirtualFocus()
+ })
+
+ test('validates received and expected elements', () => {
+ const {queryByTestId} = render(`
+
+ `)
+
+ expect(() => expect({}).toHaveActiveDescendant()).toThrowError()
+ expect(() => expect({}).toHaveVirtualFocus()).toThrowError()
+ expect(() =>
+ expect(queryByTestId('listbox')).toHaveActiveDescendant({}),
+ ).toThrowError()
+ })
+})
diff --git a/src/matchers.js b/src/matchers.js
index f67054f4..5dfb64e5 100644
--- a/src/matchers.js
+++ b/src/matchers.js
@@ -13,6 +13,10 @@ export {toHaveAttribute} from './to-have-attribute'
export {toHaveClass} from './to-have-class'
export {toHaveStyle} from './to-have-style'
export {toHaveFocus} from './to-have-focus'
+export {
+ toHaveActiveDescendant,
+ toHaveVirtualFocus,
+} from './to-have-active-descendant'
export {toHaveFormValues} from './to-have-form-values'
export {toBeVisible} from './to-be-visible'
export {toBeDisabled, toBeEnabled} from './to-be-disabled'
diff --git a/src/to-have-active-descendant.js b/src/to-have-active-descendant.js
new file mode 100644
index 00000000..4ce3cb4c
--- /dev/null
+++ b/src/to-have-active-descendant.js
@@ -0,0 +1,146 @@
+import {checkHtmlElement} from './utils'
+
+const ARIA_ACTIVEDESCENDANT = 'aria-activedescendant'
+
+function getExpectedActiveDescendant(
+ expectedActiveDescendant,
+ matcher,
+ context,
+) {
+ if (typeof expectedActiveDescendant === 'string') {
+ return {id: expectedActiveDescendant}
+ }
+
+ checkHtmlElement(expectedActiveDescendant, matcher, context)
+ return {element: expectedActiveDescendant}
+}
+
+function getActiveDescendantId(element) {
+ return element.getAttribute(ARIA_ACTIVEDESCENDANT)
+}
+
+function getActiveDescendant(element) {
+ const activeDescendantId = getActiveDescendantId(element)
+
+ if (!activeDescendantId) {
+ return null
+ }
+
+ return element.ariaActiveDescendantElement
+ ? element.ariaActiveDescendantElement
+ : element.ownerDocument.getElementById(activeDescendantId)
+}
+
+function hasActiveDescendant(element, expectedActiveDescendant) {
+ const activeElement = element.ownerDocument.activeElement
+
+ if (activeElement !== element) {
+ return false
+ }
+
+ const activeDescendant = getActiveDescendant(element)
+
+ if (expectedActiveDescendant === undefined) {
+ return activeDescendant !== null
+ }
+
+ return expectedActiveDescendant.element
+ ? activeDescendant === expectedActiveDescendant.element
+ : activeDescendant?.id === expectedActiveDescendant.id
+}
+
+export function toHaveActiveDescendant(element, expectedActiveDescendant) {
+ checkHtmlElement(element, toHaveActiveDescendant, this)
+
+ const hasExpectedActiveDescendant = expectedActiveDescendant !== undefined
+ const expectedActiveDescendantResult = hasExpectedActiveDescendant
+ ? getExpectedActiveDescendant(
+ expectedActiveDescendant,
+ toHaveActiveDescendant,
+ this,
+ )
+ : undefined
+ const activeElement = element.ownerDocument.activeElement
+ const activeDescendantId = getActiveDescendantId(element)
+ const activeDescendant = getActiveDescendant(element)
+
+ return {
+ pass: hasActiveDescendant(element, expectedActiveDescendantResult),
+ message: () => {
+ return [
+ this.utils.matcherHint(
+ `${this.isNot ? '.not' : ''}.toHaveActiveDescendant`,
+ 'element',
+ hasExpectedActiveDescendant ? 'element | string' : '',
+ ),
+ '',
+ ...(this.isNot
+ ? [
+ 'Expected element not to have active descendant:',
+ ` ${this.utils.printExpected(
+ hasExpectedActiveDescendant
+ ? expectedActiveDescendant
+ : activeDescendantId,
+ )}`,
+ ]
+ : [
+ 'Expected element to have active descendant:',
+ ` ${this.utils.printExpected(
+ hasExpectedActiveDescendant
+ ? expectedActiveDescendant
+ : 'any element',
+ )}`,
+ ]),
+ 'Received element:',
+ ` ${this.utils.printReceived(element)}`,
+ 'Received focused element:',
+ ` ${this.utils.printReceived(activeElement)}`,
+ 'Received active descendant id:',
+ ` ${this.utils.printReceived(activeDescendantId)}`,
+ 'Received active descendant element:',
+ ` ${this.utils.printReceived(activeDescendant)}`,
+ ].join('\n')
+ },
+ }
+}
+
+export function toHaveVirtualFocus(element) {
+ checkHtmlElement(element, toHaveVirtualFocus, this)
+
+ const activeElement = element.ownerDocument.activeElement
+ const activeDescendantId = activeElement
+ ? getActiveDescendantId(activeElement)
+ : null
+ const activeDescendant = activeElement
+ ? getActiveDescendant(activeElement)
+ : null
+
+ return {
+ pass: activeDescendant === element,
+ message: () => {
+ return [
+ this.utils.matcherHint(
+ `${this.isNot ? '.not' : ''}.toHaveVirtualFocus`,
+ 'element',
+ '',
+ ),
+ '',
+ ...(this.isNot
+ ? [
+ 'Expected element not to have virtual focus:',
+ ` ${this.utils.printExpected(element)}`,
+ ]
+ : [
+ 'Expected element to have virtual focus:',
+ ` ${this.utils.printExpected(element)}`,
+ ]),
+ 'Received focused element:',
+ ` ${this.utils.printReceived(activeElement)}`,
+ 'Received active descendant id:',
+ ` ${this.utils.printReceived(activeDescendantId)}`,
+ 'Received active descendant element:',
+ ` ${this.utils.printReceived(activeDescendant)}`,
+ ].join('\n')
+ },
+ }
+}
diff --git a/types/__tests__/bun/bun-custom-expect-types.test.ts b/types/__tests__/bun/bun-custom-expect-types.test.ts
index 1467e913..f95978e8 100644
--- a/types/__tests__/bun/bun-custom-expect-types.test.ts
+++ b/types/__tests__/bun/bun-custom-expect-types.test.ts
@@ -47,6 +47,10 @@ customExpect(element).toHaveDisplayValue(['str1', 'str2'])
customExpect(element).toHaveDisplayValue(/str/)
customExpect(element).toHaveDisplayValue([/str1/, 'str2'])
customExpect(element).toHaveFocus()
+customExpect(element).toHaveActiveDescendant()
+customExpect(element).toHaveActiveDescendant(document.body)
+customExpect(element).toHaveActiveDescendant('id')
+customExpect(element).toHaveVirtualFocus()
customExpect(element).toHaveFormValues({foo: 'bar', baz: 1})
customExpect(element).toHaveStyle('display: block')
customExpect(element).toHaveStyle({display: 'block', width: 100})
diff --git a/types/__tests__/bun/bun-types.test.ts b/types/__tests__/bun/bun-types.test.ts
index 432dd867..d8cad675 100644
--- a/types/__tests__/bun/bun-types.test.ts
+++ b/types/__tests__/bun/bun-types.test.ts
@@ -36,6 +36,10 @@ expect(element).toHaveDisplayValue(['str1', 'str2'])
expect(element).toHaveDisplayValue(/str/)
expect(element).toHaveDisplayValue([/str1/, 'str2'])
expect(element).toHaveFocus()
+expect(element).toHaveActiveDescendant()
+expect(element).toHaveActiveDescendant(document.body)
+expect(element).toHaveActiveDescendant('id')
+expect(element).toHaveVirtualFocus()
expect(element).toHaveFormValues({foo: 'bar', baz: 1})
expect(element).toHaveStyle('display: block')
expect(element).toHaveStyle({display: 'block', width: 100})
@@ -94,6 +98,10 @@ expect(element).not.toHaveDisplayValue(['str1', 'str2'])
expect(element).not.toHaveDisplayValue(/str/)
expect(element).not.toHaveDisplayValue([/str1/, 'str2'])
expect(element).not.toHaveFocus()
+expect(element).not.toHaveActiveDescendant()
+expect(element).not.toHaveActiveDescendant(document.body)
+expect(element).not.toHaveActiveDescendant('id')
+expect(element).not.toHaveVirtualFocus()
expect(element).not.toHaveFormValues({foo: 'bar', baz: 1})
expect(element).not.toHaveStyle('display: block')
expect(element).not.toHaveTextContent('Text')
diff --git a/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts b/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts
index 96034dc1..4b974358 100644
--- a/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts
+++ b/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts
@@ -46,6 +46,10 @@ customExpect(element).toHaveDisplayValue(['str1', 'str2'])
customExpect(element).toHaveDisplayValue(/str/)
customExpect(element).toHaveDisplayValue([/str1/, 'str2'])
customExpect(element).toHaveFocus()
+customExpect(element).toHaveActiveDescendant()
+customExpect(element).toHaveActiveDescendant(document.body)
+customExpect(element).toHaveActiveDescendant('id')
+customExpect(element).toHaveVirtualFocus()
customExpect(element).toHaveFormValues({foo: 'bar', baz: 1})
customExpect(element).toHaveStyle('display: block')
customExpect(element).toHaveStyle({display: 'block', width: 100})
diff --git a/types/__tests__/jest-globals/jest-globals-types.test.ts b/types/__tests__/jest-globals/jest-globals-types.test.ts
index 150f8250..24b2fe9d 100644
--- a/types/__tests__/jest-globals/jest-globals-types.test.ts
+++ b/types/__tests__/jest-globals/jest-globals-types.test.ts
@@ -36,6 +36,10 @@ expect(element).toHaveDisplayValue(['str1', 'str2'])
expect(element).toHaveDisplayValue(/str/)
expect(element).toHaveDisplayValue([/str1/, 'str2'])
expect(element).toHaveFocus()
+expect(element).toHaveActiveDescendant()
+expect(element).toHaveActiveDescendant(document.body)
+expect(element).toHaveActiveDescendant('id')
+expect(element).toHaveVirtualFocus()
expect(element).toHaveFormValues({foo: 'bar', baz: 1})
expect(element).toHaveStyle('display: block')
expect(element).toHaveStyle({display: 'block', width: 100})
@@ -94,6 +98,10 @@ expect(element).not.toHaveDisplayValue(['str1', 'str2'])
expect(element).not.toHaveDisplayValue(/str/)
expect(element).not.toHaveDisplayValue([/str1/, 'str2'])
expect(element).not.toHaveFocus()
+expect(element).not.toHaveActiveDescendant()
+expect(element).not.toHaveActiveDescendant(document.body)
+expect(element).not.toHaveActiveDescendant('id')
+expect(element).not.toHaveVirtualFocus()
expect(element).not.toHaveFormValues({foo: 'bar', baz: 1})
expect(element).not.toHaveStyle('display: block')
expect(element).not.toHaveTextContent('Text')
diff --git a/types/__tests__/jest/jest-custom-expect-types.test.ts b/types/__tests__/jest/jest-custom-expect-types.test.ts
index 6b64b8e5..a3065596 100644
--- a/types/__tests__/jest/jest-custom-expect-types.test.ts
+++ b/types/__tests__/jest/jest-custom-expect-types.test.ts
@@ -46,6 +46,10 @@ customExpect(element).toHaveDisplayValue(['str1', 'str2'])
customExpect(element).toHaveDisplayValue(/str/)
customExpect(element).toHaveDisplayValue([/str1/, 'str2'])
customExpect(element).toHaveFocus()
+customExpect(element).toHaveActiveDescendant()
+customExpect(element).toHaveActiveDescendant(document.body)
+customExpect(element).toHaveActiveDescendant('id')
+customExpect(element).toHaveVirtualFocus()
customExpect(element).toHaveFormValues({foo: 'bar', baz: 1})
customExpect(element).toHaveStyle('display: block')
customExpect(element).toHaveStyle({display: 'block', width: 100})
diff --git a/types/__tests__/jest/jest-types.test.ts b/types/__tests__/jest/jest-types.test.ts
index d9596c67..ae059776 100644
--- a/types/__tests__/jest/jest-types.test.ts
+++ b/types/__tests__/jest/jest-types.test.ts
@@ -35,6 +35,10 @@ expect(element).toHaveDisplayValue(['str1', 'str2'])
expect(element).toHaveDisplayValue(/str/)
expect(element).toHaveDisplayValue([/str1/, 'str2'])
expect(element).toHaveFocus()
+expect(element).toHaveActiveDescendant()
+expect(element).toHaveActiveDescendant(document.body)
+expect(element).toHaveActiveDescendant('id')
+expect(element).toHaveVirtualFocus()
expect(element).toHaveFormValues({foo: 'bar', baz: 1})
expect(element).toHaveStyle('display: block')
expect(element).toHaveStyle({display: 'block', width: 100})
@@ -93,6 +97,10 @@ expect(element).not.toHaveDisplayValue(['str1', 'str2'])
expect(element).not.toHaveDisplayValue(/str/)
expect(element).not.toHaveDisplayValue([/str1/, 'str2'])
expect(element).not.toHaveFocus()
+expect(element).not.toHaveActiveDescendant()
+expect(element).not.toHaveActiveDescendant(document.body)
+expect(element).not.toHaveActiveDescendant('id')
+expect(element).not.toHaveVirtualFocus()
expect(element).not.toHaveFormValues({foo: 'bar', baz: 1})
expect(element).not.toHaveStyle('display: block')
expect(element).not.toHaveTextContent('Text')
diff --git a/types/__tests__/vitest/vitest-custom-expect-types.test.ts b/types/__tests__/vitest/vitest-custom-expect-types.test.ts
index e9008966..248adfae 100644
--- a/types/__tests__/vitest/vitest-custom-expect-types.test.ts
+++ b/types/__tests__/vitest/vitest-custom-expect-types.test.ts
@@ -46,6 +46,10 @@ customExpect(element).toHaveDisplayValue(['str1', 'str2'])
customExpect(element).toHaveDisplayValue(/str/)
customExpect(element).toHaveDisplayValue([/str1/, 'str2'])
customExpect(element).toHaveFocus()
+customExpect(element).toHaveActiveDescendant()
+customExpect(element).toHaveActiveDescendant(document.body)
+customExpect(element).toHaveActiveDescendant('id')
+customExpect(element).toHaveVirtualFocus()
customExpect(element).toHaveFormValues({foo: 'bar', baz: 1})
customExpect(element).toHaveStyle('display: block')
customExpect(element).toHaveStyle({display: 'block', width: 100})
diff --git a/types/__tests__/vitest/vitest-types.test.ts b/types/__tests__/vitest/vitest-types.test.ts
index 7a90be05..82f738b6 100644
--- a/types/__tests__/vitest/vitest-types.test.ts
+++ b/types/__tests__/vitest/vitest-types.test.ts
@@ -36,6 +36,10 @@ expect(element).toHaveDisplayValue(['str1', 'str2'])
expect(element).toHaveDisplayValue(/str/)
expect(element).toHaveDisplayValue([/str1/, 'str2'])
expect(element).toHaveFocus()
+expect(element).toHaveActiveDescendant()
+expect(element).toHaveActiveDescendant(document.body)
+expect(element).toHaveActiveDescendant('id')
+expect(element).toHaveVirtualFocus()
expect(element).toHaveFormValues({foo: 'bar', baz: 1})
expect(element).toHaveStyle('display: block')
expect(element).toHaveStyle({display: 'block', width: 100})
@@ -94,6 +98,10 @@ expect(element).not.toHaveDisplayValue(['str1', 'str2'])
expect(element).not.toHaveDisplayValue(/str/)
expect(element).not.toHaveDisplayValue([/str1/, 'str2'])
expect(element).not.toHaveFocus()
+expect(element).not.toHaveActiveDescendant()
+expect(element).not.toHaveActiveDescendant(document.body)
+expect(element).not.toHaveActiveDescendant('id')
+expect(element).not.toHaveVirtualFocus()
expect(element).not.toHaveFormValues({foo: 'bar', baz: 1})
expect(element).not.toHaveStyle('display: block')
expect(element).not.toHaveTextContent('Text')
diff --git a/types/matchers.d.ts b/types/matchers.d.ts
index 82de01dc..db0eee6e 100755
--- a/types/matchers.d.ts
+++ b/types/matchers.d.ts
@@ -319,6 +319,45 @@ declare namespace matchers {
* [testing-library/jest-dom#tohavefocus](https://github.com/testing-library/jest-dom#tohavefocus)
*/
toHaveFocus(): R
+ /**
+ * @description
+ * Assert whether an element with DOM focus has an active descendant, or an expected active descendant,
+ * through its `aria-activedescendant` attribute.
+ * @example
+ *
+ * Option 1
+ * Option 2
+ *
+ *
+ * const listbox = getByTestId('listbox')
+ * const option1 = getByTestId('option-1')
+ * listbox.focus()
+ * expect(listbox).toHaveActiveDescendant(option1)
+ * expect(listbox).toHaveActiveDescendant('option-1')
+ * @see
+ * [testing-library/jest-dom#tohaveactivedescendant](https://github.com/testing-library/jest-dom#tohaveactivedescendant)
+ */
+ toHaveActiveDescendant(
+ activeDescendant?: HTMLElement | SVGElement | string,
+ ): R
+ /**
+ * @description
+ * Assert whether an element has virtual focus from the current DOM focused element's
+ * `aria-activedescendant` attribute.
+ * @example
+ *
+ * Option 1
+ * Option 2
+ *
+ *
+ * const listbox = getByTestId('listbox')
+ * const option1 = getByTestId('option-1')
+ * listbox.focus()
+ * expect(option1).toHaveVirtualFocus()
+ * @see
+ * [testing-library/jest-dom#tohavevirtualfocus](https://github.com/testing-library/jest-dom#tohavevirtualfocus)
+ */
+ toHaveVirtualFocus(): R
/**
* @description
* Check if a form or fieldset contains form controls for each given name, and having the specified value.