From 95983c934754af4aa389f3aa3cff105c0b3afa6f Mon Sep 17 00:00:00 2001 From: HyeryongChoi Date: Sat, 4 Jul 2026 01:08:40 +0900 Subject: [PATCH] feat: add toHaveVirtualFocus matcher (#207) Adds a toHaveVirtualFocus() matcher that checks whether an element has virtual focus via aria-activedescendant, i.e. it is referenced by the aria-activedescendant attribute of the element that currently has DOM focus. Common in composite widgets (listbox, combobox, grid) where DOM focus stays on a container while aria-activedescendant tracks the active item. Scoped to toHaveVirtualFocus only; the related toBeActiveDescendant matcher discussed in the issue is left out so the two APIs can be reviewed independently. --- README.md | 39 ++++++++++++++++++ src/__tests__/to-have-virtual-focus.js | 56 ++++++++++++++++++++++++++ src/matchers.js | 1 + src/to-have-virtual-focus.js | 38 +++++++++++++++++ types/matchers.d.ts | 19 +++++++++ 5 files changed, 153 insertions(+) create mode 100644 src/__tests__/to-have-virtual-focus.js create mode 100644 src/to-have-virtual-focus.js diff --git a/README.md b/README.md index 1c07ae72..b20fe0bf 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ clear to read and to maintain. - [`toHaveAttribute`](#tohaveattribute) - [`toHaveClass`](#tohaveclass) - [`toHaveFocus`](#tohavefocus) + - [`toHaveVirtualFocus`](#tohavevirtualfocus) - [`toHaveFormValues`](#tohaveformvalues) - [`toHaveStyle`](#tohavestyle) - [`toHaveTextContent`](#tohavetextcontent) @@ -836,6 +837,44 @@ expect(input).not.toHaveFocus()
+### `toHaveVirtualFocus` + +```typescript +toHaveVirtualFocus() +``` + +This allows you to assert whether an element has "virtual focus", i.e. it is +referenced by the `aria-activedescendant` attribute of the element that +currently has DOM focus. This is common in composite widgets such as listboxes +and comboboxes, where DOM focus stays on a container element while +`aria-activedescendant` points at the item that is currently "active". + +#### Examples + +```html + +``` + +```javascript +const listbox = getByTestId('listbox') +const option1 = getByTestId('option1') +const option2 = getByTestId('option2') + +listbox.focus() +expect(option1).toHaveVirtualFocus() +expect(option2).not.toHaveVirtualFocus() +``` + +
+ ### `toHaveFormValues` ```typescript diff --git a/src/__tests__/to-have-virtual-focus.js b/src/__tests__/to-have-virtual-focus.js new file mode 100644 index 00000000..0f20e827 --- /dev/null +++ b/src/__tests__/to-have-virtual-focus.js @@ -0,0 +1,56 @@ +import {render} from './helpers/test-utils' +import document from './helpers/document' + +test('.toHaveVirtualFocus', () => { + const {container} = render(` + `) + + const listbox = container.querySelector('[data-testid="listbox"]') + const option1 = container.querySelector('#option1') + const option2 = container.querySelector('#option2') + + document.body.appendChild(container) + listbox.focus() + + expect(option1).toHaveVirtualFocus() + expect(option2).not.toHaveVirtualFocus() + + expect(() => expect(option1).not.toHaveVirtualFocus()).toThrowError() + expect(() => expect(option2).toHaveVirtualFocus()).toThrowError() +}) + +test('.toHaveVirtualFocus when the container does not have DOM focus', () => { + const {container} = render(` + `) + + const option1 = container.querySelector('#option1') + + expect(option1).not.toHaveVirtualFocus() +}) + +test('.toHaveVirtualFocus updates as aria-activedescendant changes', () => { + const {container} = render(` + `) + + const listbox = container.querySelector('[data-testid="listbox"]') + const option1 = container.querySelector('#option1') + const option2 = container.querySelector('#option2') + + document.body.appendChild(container) + listbox.focus() + + expect(option1).toHaveVirtualFocus() + + listbox.setAttribute('aria-activedescendant', 'option2') + + expect(option1).not.toHaveVirtualFocus() + expect(option2).toHaveVirtualFocus() +}) diff --git a/src/matchers.js b/src/matchers.js index f67054f4..a8e1f67c 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -13,6 +13,7 @@ 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 {toHaveVirtualFocus} from './to-have-virtual-focus' 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-virtual-focus.js b/src/to-have-virtual-focus.js new file mode 100644 index 00000000..529f82ce --- /dev/null +++ b/src/to-have-virtual-focus.js @@ -0,0 +1,38 @@ +import {checkHtmlElement} from './utils' + +export function toHaveVirtualFocus(element) { + checkHtmlElement(element, toHaveVirtualFocus, this) + + const {activeElement} = element.ownerDocument + const activeDescendantId = activeElement?.getAttribute( + 'aria-activedescendant', + ) + const virtuallyFocusedElement = activeDescendantId + ? element.ownerDocument.getElementById(activeDescendantId) + : null + + return { + pass: virtuallyFocusedElement === element, + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveVirtualFocus`, + 'element', + '', + ), + '', + ...(this.isNot + ? [ + 'Received element has virtual focus via aria-activedescendant:', + ` ${this.utils.printReceived(element)}`, + ] + : [ + 'Expected element to have virtual focus via aria-activedescendant:', + ` ${this.utils.printExpected(element)}`, + 'Received element with virtual focus:', + ` ${this.utils.printReceived(virtuallyFocusedElement)}`, + ]), + ].join('\n') + }, + } +} diff --git a/types/matchers.d.ts b/types/matchers.d.ts index 82de01dc..2f2e5a9b 100755 --- a/types/matchers.d.ts +++ b/types/matchers.d.ts @@ -319,6 +319,25 @@ declare namespace matchers { * [testing-library/jest-dom#tohavefocus](https://github.com/testing-library/jest-dom#tohavefocus) */ toHaveFocus(): R + /** + * @description + * Assert whether an element has virtual focus via `aria-activedescendant`, + * i.e. it is referenced by the `aria-activedescendant` attribute of the + * element that currently has DOM focus. + * @example + * + * + * const listbox = getByTestId('listbox') + * const option1 = getByTestId('option1') + * 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.