diff --git a/README.md b/README.md index d6f1f613..9762210e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ clear to read and to maintain. - [`toBeVisible`](#tobevisible) - [`toContainElement`](#tocontainelement) - [`toContainHTML`](#tocontainhtml) + - [`toHaveAccessibleDescription`](#tohaveaccessibledescription) + - [`toHaveAccessibleName`](#tohaveaccessiblename) - [`toHaveAttribute`](#tohaveattribute) - [`toHaveClass`](#tohaveclass) - [`toHaveFocus`](#tohavefocus) @@ -155,8 +157,7 @@ toBeDisabled() ``` This allows you to check whether an element is disabled from the user's -perspective. -According to the specification, the following elements can be +perspective. According to the specification, the following elements can be [disabled](https://html.spec.whatwg.org/multipage/semantics-other.html#disabled-elements): `button`, `input`, `select`, `textarea`, `optgroup`, `option`, `fieldset`. @@ -526,6 +527,94 @@ expect(getByTestId('parent')).toContainHTML('')
+### `toHaveAccessibleDescription` + +```typescript +toHaveAccessibleDescription(expectedAccessibleDescription?: string | RegExp) +``` + +This allows to assert that an element has the expected +[accessible description](https://w3c.github.io/accname/). + +You can pass the exact string of the expected accessible description, or you can +make a partial match passing a regular expression, or by using +[expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring)/[expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp). + +#### Examples + +```html +Start +About +User profile pic +Company logo +The logo of Our Company +``` + +```js +expect(getByTestId('link')).toHaveAccessibleDescription() +expect(getByTestId('link')).toHaveAccessibleDescription('A link to start over') +expect(getByTestId('link')).not.toHaveAccessibleDescription('Home page') +expect(getByTestId('extra-link')).not.toHaveAccessibleDescription() +expect(getByTestId('avatar')).not.toHaveAccessibleDescription() +expect(getByTestId('logo')).not.toHaveAccessibleDescription('Company logo') +expect(getByTestId('logo')).toHaveAccessibleDescription( + 'The logo of Our Company', +) +``` + +
+ +### `toHaveAccessibleName` + +```typescript +toHaveAccessibleName(expectedAccessibleName?: string | RegExp) +``` + +This allows to assert that an element is has the expected +[accessible name](https://w3c.github.io/accname/). It is useful, for instance, +to assert that form elements and buttons are properly labelled. + +You can pass the exact string of the expected accessible name, or you can make a +partial match passing a regular expression, or by using +[expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring)/[expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp). + +#### Examples + +```html +Test alt + +Test title + +

Test content

+ + + `) + + const list = queryByTestId('my-list') + expect(list).not.toHaveAccessibleName() + expect(() => { + expect(list).toHaveAccessibleName() + }).toThrow(/expected element to have accessible name/i) + + expect(queryByTestId('first')).toHaveAccessibleName('First element') + expect(queryByTestId('second')).toHaveAccessibleName('Second element') + + const button = queryByTestId('my-button') + expect(button).toHaveAccessibleName() + expect(button).toHaveAccessibleName('Continue to the next step') + expect(button).toHaveAccessibleName(/continue to the next step/i) + expect(button).toHaveAccessibleName( + expect.stringContaining('Continue to the next'), + ) + expect(button).not.toHaveAccessibleName('Next step') + expect(() => { + expect(button).toHaveAccessibleName('Next step') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(button).not.toHaveAccessibleName('Continue to the next step') + }).toThrow(/expected element not to have accessible name/i) + expect(() => { + expect(button).not.toHaveAccessibleName() + }).toThrow(/expected element not to have accessible name/i) + }) + + it('works with label elements', () => { + const {queryByTestId} = render(` +
+ + + + +
+ `) + + const firstNameField = queryByTestId('first-name-field') + expect(firstNameField).toHaveAccessibleName('First name') + expect(queryByTestId('first-name-field')).toHaveAccessibleName( + /first name/i, + ) + expect(firstNameField).toHaveAccessibleName( + expect.stringContaining('First'), + ) + expect(() => { + expect(firstNameField).toHaveAccessibleName('Last name') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(firstNameField).not.toHaveAccessibleName('First name') + }).toThrow(/expected element not to have accessible name/i) + + const checkboxField = queryByTestId('checkbox-field') + expect(checkboxField).toHaveAccessibleName('Accept terms and conditions') + expect(checkboxField).toHaveAccessibleName(/accept terms/i) + expect(checkboxField).toHaveAccessibleName( + expect.stringContaining('Accept terms'), + ) + expect(() => { + expect(checkboxField).toHaveAccessibleName( + 'Accept our terms and conditions', + ) + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(checkboxField).not.toHaveAccessibleName( + 'Accept terms and conditions', + ) + }).toThrow(/expected element not to have accessible name/i) + }) + + it('works with aria-label attributes', () => { + const {queryByTestId} = render(` +
+ + + + + + +
+ `) + + const firstNameField = queryByTestId('first-name-field') + expect(firstNameField).not.toHaveAccessibleName('First name') + expect(firstNameField).toHaveAccessibleName('Enter your name') + expect(firstNameField).toHaveAccessibleName(/enter your name/i) + expect(firstNameField).toHaveAccessibleName( + expect.stringContaining('your name'), + ) + expect(() => { + expect(firstNameField).toHaveAccessibleName('First name') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(firstNameField).not.toHaveAccessibleName('Enter your name') + }).toThrow(/expected element not to have accessible name/i) + + const checkboxField = queryByTestId('checkbox-field') + expect(checkboxField).not.toHaveAccessibleName( + 'Accept terms and conditions', + ) + expect(checkboxField).toHaveAccessibleName( + 'Accept our terms and conditions', + ) + expect(checkboxField).toHaveAccessibleName(/accept our terms/i) + expect(checkboxField).toHaveAccessibleName(expect.stringContaining('terms')) + expect(() => { + expect(checkboxField).toHaveAccessibleName('Accept terms and conditions') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(checkboxField).not.toHaveAccessibleName( + 'Accept our terms and conditions', + ) + }).toThrow(/expected element not to have accessible name/i) + + const submitButton = queryByTestId('submit-button') + expect(submitButton).not.toHaveAccessibleName('Continue') + expect(submitButton).toHaveAccessibleName('Submit this form') + expect(submitButton).toHaveAccessibleName(/submit this form/i) + expect(submitButton).toHaveAccessibleName(expect.stringContaining('form')) + expect(() => { + expect(submitButton).toHaveAccessibleName('Continue') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(submitButton).not.toHaveAccessibleName('Submit this form') + }).toThrow(/expected element not to have accessible name/i) + }) + + it('works with aria-labelledby attributes', () => { + const {queryByTestId} = render(` +
+ + +

Enter your name

+ + +

Accept our terms and conditions

+ + +

Submit this form

+
+ `) + + const firstNameField = queryByTestId('first-name-field') + expect(firstNameField).not.toHaveAccessibleName('First name') + expect(firstNameField).toHaveAccessibleName('Enter your name') + expect(firstNameField).toHaveAccessibleName(/enter your name/i) + expect(firstNameField).toHaveAccessibleName( + expect.stringContaining('your name'), + ) + expect(() => { + expect(firstNameField).toHaveAccessibleName('First name') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(firstNameField).not.toHaveAccessibleName('Enter your name') + }).toThrow(/expected element not to have accessible name/i) + + const checkboxField = queryByTestId('checkbox-field') + expect(checkboxField).not.toHaveAccessibleName( + 'Accept terms and conditions', + ) + expect(checkboxField).toHaveAccessibleName( + 'Accept our terms and conditions', + ) + expect(checkboxField).toHaveAccessibleName(/accept our terms/i) + expect(checkboxField).toHaveAccessibleName(expect.stringContaining('terms')) + expect(() => { + expect(checkboxField).toHaveAccessibleName('Accept terms and conditions') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(checkboxField).not.toHaveAccessibleName( + 'Accept our terms and conditions', + ) + }).toThrow(/expected element not to have accessible name/i) + + const submitButton = queryByTestId('submit-button') + expect(submitButton).not.toHaveAccessibleName('Continue') + expect(submitButton).toHaveAccessibleName('Submit this form') + expect(submitButton).toHaveAccessibleName(/submit this form/i) + expect(submitButton).toHaveAccessibleName(expect.stringContaining('form')) + expect(() => { + expect(submitButton).toHaveAccessibleName('Continue') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(submitButton).not.toHaveAccessibleName('Submit this form') + }).toThrow(/expected element not to have accessible name/i) + }) + + it('works with image alt attributes', () => { + const {queryByTestId} = render(` +
+ Company logo + +
+ `) + + const logoImage = queryByTestId('logo-img') + expect(logoImage).toHaveAccessibleName('Company logo') + expect(logoImage).toHaveAccessibleName(/company logo/i) + expect(logoImage).toHaveAccessibleName(expect.stringContaining('logo')) + expect(() => { + expect(logoImage).toHaveAccessibleName('Our company logo') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(logoImage).not.toHaveAccessibleName('Company logo') + }).toThrow(/expected element not to have accessible name/i) + + const closeButton = queryByTestId('close-button') + expect(closeButton).toHaveAccessibleName('Close modal') + expect(closeButton).toHaveAccessibleName(/close modal/i) + expect(closeButton).toHaveAccessibleName(expect.stringContaining('modal')) + expect(() => { + expect(closeButton).toHaveAccessibleName('Close window') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(closeButton).not.toHaveAccessibleName('Close modal') + }).toThrow(/expected element not to have accessible name/i) + }) + + it('works with svg title attributes', () => { + const {queryByTestId} = render(` + Test title + `) + + const svgElement = queryByTestId('svg-title') + expect(svgElement).toHaveAccessibleName('Test title') + expect(svgElement).toHaveAccessibleName(/test title/i) + expect(svgElement).toHaveAccessibleName(expect.stringContaining('Test')) + expect(() => { + expect(svgElement).toHaveAccessibleName('Another title') + }).toThrow(/expected element to have accessible name/i) + expect(() => { + expect(svgElement).not.toHaveAccessibleName('Test title') + }).toThrow(/expected element not to have accessible name/i) + }) + + it('works as in the examples in the README', () => { + const {queryByTestId: getByTestId} = render(` +
+ Test alt + + Test title + +

Test content

+
+ `) + + expect(getByTestId('img-alt')).toHaveAccessibleName('Test alt') + expect(getByTestId('img-empty-alt')).not.toHaveAccessibleName() + expect(getByTestId('svg-title')).toHaveAccessibleName('Test title') + expect(getByTestId('button-img-alt')).toHaveAccessibleName() + expect(getByTestId('img-paragraph')).not.toHaveAccessibleName() + expect(getByTestId('svg-button')).toHaveAccessibleName() + expect(getByTestId('svg-without-title')).not.toHaveAccessibleName() + expect(getByTestId('input-title')).toHaveAccessibleName() + }) +}) diff --git a/src/matchers.js b/src/matchers.js index 1dbbec77..c90945d5 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -5,6 +5,8 @@ import {toBeEmptyDOMElement} from './to-be-empty-dom-element' import {toContainElement} from './to-contain-element' import {toContainHTML} from './to-contain-html' import {toHaveTextContent} from './to-have-text-content' +import {toHaveAccessibleDescription} from './to-have-accessible-description' +import {toHaveAccessibleName} from './to-have-accessible-name' import {toHaveAttribute} from './to-have-attribute' import {toHaveClass} from './to-have-class' import {toHaveStyle} from './to-have-style' @@ -29,6 +31,8 @@ export { toContainElement, toContainHTML, toHaveTextContent, + toHaveAccessibleDescription, + toHaveAccessibleName, toHaveAttribute, toHaveClass, toHaveStyle, diff --git a/src/to-have-accessible-description.js b/src/to-have-accessible-description.js new file mode 100644 index 00000000..8d77a10e --- /dev/null +++ b/src/to-have-accessible-description.js @@ -0,0 +1,46 @@ +import {computeAccessibleDescription} from 'dom-accessibility-api' +import {checkHtmlElement, getMessage} from './utils' + +export function toHaveAccessibleDescription( + htmlElement, + expectedAccessibleDescription, +) { + checkHtmlElement(htmlElement, toHaveAccessibleDescription, this) + const actualAccessibleDescription = computeAccessibleDescription(htmlElement) + const missingExpectedValue = arguments.length === 1 + + let pass = false + if (missingExpectedValue) { + // When called without an expected value we only want to validate that the element has an + // accessible description, whatever it may be. + pass = actualAccessibleDescription !== '' + } else { + pass = + expectedAccessibleDescription instanceof RegExp + ? expectedAccessibleDescription.test(actualAccessibleDescription) + : this.equals( + actualAccessibleDescription, + expectedAccessibleDescription, + ) + } + + return { + pass, + + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.${toHaveAccessibleDescription.name}`, + 'element', + '', + ), + `Expected element ${to} have accessible description`, + expectedAccessibleDescription, + 'Received', + actualAccessibleDescription, + ) + }, + } +} diff --git a/src/to-have-accessible-name.js b/src/to-have-accessible-name.js new file mode 100644 index 00000000..9cd2f39b --- /dev/null +++ b/src/to-have-accessible-name.js @@ -0,0 +1,40 @@ +import {computeAccessibleName} from 'dom-accessibility-api' +import {checkHtmlElement, getMessage} from './utils' + +export function toHaveAccessibleName(htmlElement, expectedAccessibleName) { + checkHtmlElement(htmlElement, toHaveAccessibleName, this) + const actualAccessibleName = computeAccessibleName(htmlElement) + const missingExpectedValue = arguments.length === 1 + + let pass = false + if (missingExpectedValue) { + // When called without an expected value we only want to validate that the element has an + // accessible name, whatever it may be. + pass = actualAccessibleName !== '' + } else { + pass = + expectedAccessibleName instanceof RegExp + ? expectedAccessibleName.test(actualAccessibleName) + : this.equals(actualAccessibleName, expectedAccessibleName) + } + + return { + pass, + + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.${toHaveAccessibleName.name}`, + 'element', + '', + ), + `Expected element ${to} have accessible name`, + expectedAccessibleName, + 'Received', + actualAccessibleName, + ) + }, + } +}