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(` +
+ + +
+ `) + 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(` +
+ + +
+ `) + 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 + * + * + * 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 + * + * + * 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.