Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -836,6 +838,70 @@ expect(input).not.toHaveFocus()

<hr />

### `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
<ul role="listbox" tabindex="0" aria-activedescendant="option-1" data-testid="listbox">
<li role="option" id="option-1" data-testid="option-1">Option 1</li>
<li role="option" id="option-2" data-testid="option-2">Option 2</li>
</ul>
```

```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)
```

<hr />

### `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
<ul role="listbox" tabindex="0" aria-activedescendant="option-1" data-testid="listbox">
<li role="option" id="option-1" data-testid="option-1">Option 1</li>
<li role="option" id="option-2" data-testid="option-2">Option 2</li>
</ul>
```

```javascript
const listbox = getByTestId('listbox')
const option1 = getByTestId('option-1')
const option2 = getByTestId('option-2')

listbox.focus()
expect(option1).toHaveVirtualFocus()
expect(option2).not.toHaveVirtualFocus()
```

<hr />

### `toHaveFormValues`

```typescript
Expand Down
140 changes: 140 additions & 0 deletions src/__tests__/to-have-active-descendant.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {render} from './helpers/test-utils'

describe('aria-activedescendant matchers', () => {
test('.toHaveActiveDescendant', () => {
const {queryByTestId} = render(`
<div>
<ul
aria-activedescendant="option-1"
data-testid="listbox"
role="listbox"
tabindex="0"
>
<li data-testid="option-1" id="option-1" role="option">Option 1</li>
<li data-testid="option-2" id="option-2" role="option">Option 2</li>
<li data-testid="no-id" role="option">No id</li>
</ul>
<button data-testid="button">Button</button>
</div>
`)
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(`
<div>
<ul
aria-activedescendant="option-1"
data-testid="listbox"
role="listbox"
tabindex="0"
>
<li data-testid="option-1" id="option-1" role="option">Option 1</li>
<li data-testid="option-2" id="option-2" role="option">Option 2</li>
<li data-testid="no-id" role="option">No id</li>
</ul>
<button data-testid="button">Button</button>
</div>
`)
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(`
<ul
aria-activedescendant="duplicate-id"
data-testid="listbox"
role="listbox"
tabindex="0"
>
<li data-testid="first" id="duplicate-id" role="option">First</li>
<li data-testid="second" id="duplicate-id" role="option">Second</li>
</ul>
`)
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(`
<ul
aria-activedescendant="option-1"
data-testid="listbox"
role="listbox"
tabindex="0"
>
<li data-testid="option-1" id="option-1" role="option">Option 1</li>
</ul>
`)

expect(() => expect({}).toHaveActiveDescendant()).toThrowError()
expect(() => expect({}).toHaveVirtualFocus()).toThrowError()
expect(() =>
expect(queryByTestId('listbox')).toHaveActiveDescendant({}),
).toThrowError()
})
})
4 changes: 4 additions & 0 deletions src/matchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
146 changes: 146 additions & 0 deletions src/to-have-active-descendant.js
Original file line number Diff line number Diff line change
@@ -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')
},
}
}
4 changes: 4 additions & 0 deletions types/__tests__/bun/bun-custom-expect-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
Loading
Loading