Skip to content
Closed
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
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ clear to read and to maintain.
- [`toBeEmpty`](#tobeempty)
- [`toBeInTheDocument`](#tobeinthedocument)
- [`toBeInvalid`](#tobeinvalid)
- [`toBeLabelled`](#tobelabelled)
- [`toBeRequired`](#toberequired)
- [`toBeValid`](#tobevalid)
- [`toBeVisible`](#tobevisible)
Expand Down Expand Up @@ -312,6 +313,78 @@ expect(getByTestId(container, 'invalid-form')).toBeInvalid()

<hr />

### `toBeLabelled`

```typescript
toBeLabelled()
```

This allows you to check if a HTML element is correctly labelled.
Labelling element is important for accessibility reason, [especially for non-text content](https://www.w3.org/TR/WCAG20/#text-equiv).

An element is labelled if it is having at least one of the following:

- the element is an image having a [not empty `alt` attribute](https://www.w3.org/TR/WCAG20-TECHS/H37.html);
- the element is a SVG element having [a not empty `title` element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title);
- the element is having [an `aria-label` attribute](https://www.w3.org/TR/WCAG20-TECHS/ARIA6.html);
- the element is [referencing a labelling element via `aria-labelledby`](https://www.w3.org/TR/WCAG20-TECHS/ARIA10.html);
- the element is [a form input having a `title` attribute](https://www.w3.org/TR/WCAG20-TECHS/H65.html);
- the element is [an image associated to a short content](https://www.w3.org/TR/WCAG20-TECHS/H2.html) within a button or a link;
- the element is a link, a button or [an object having a text content](https://www.w3.org/TR/WCAG20-TECHS/H53.html).

#### Examples

```html
<img data-testid="img-alt" src="" alt="Test alt" />
<img data-testid="img-label" src="" alt="" aria-label="Test alt" />
<img data-testid="img-labelledby" src="" alt="" aria-labelledby="testId" />
<img data-testid="img-empty-alt" src="" alt="" />
<svg data-testid="svg-title"><title>Test title</title></svg>
<button><img data-testid="img-text-sibling" src="" alt="" /><span>Test content</span></button>
<button data-testid="button-img-alt"><img src="" alt="Test" /></button>
<object data-testid="object" data="companylogo.gif" type="image/gif"><p>Company Name</p></object>
<p><img data-testid="img-paragraph" src="" alt="" />Test content</p>
<button data-testid="svg-button"><svg><title>Test</title></svg></p>
<div><svg data-testid="svg-without-title"></svg></div>
<input data-testid="input-title" title="test" />
```

##### Using document.querySelector

```javascript
expect(queryByTestId('img-alt')).toBeLabelled()
expect(queryByTestId('img-label')).toBeLabelled()
expect(queryByTestId('img-labelledby')).toBeLabelled()
expect(queryByTestId('img-empty-alt')).not.toBeLabelled()
expect(queryByTestId('svg-title')).toBeLabelled()
expect(queryByTestId('img-text-sibling')).toBeLabelled()
expect(queryByTestId('button-img-alt')).toBeLabelled()
expect(queryByTestId('object')).toBeLabelled()
expect(queryByTestId('img-paragraph')).not.toBeLabelled()
expect(queryByTestId('svg-button')).toBeLabelled()
expect(queryByTestId('svg-without-title')).not.toBeLabelled()
expect(queryByTestId('input-title')).toBeLabelled()
```

##### Using dom-testing-library

```javascript
expect(getByTestId(container, 'img-alt')).toBeLabelled()
expect(getByTestId(container, 'img-label')).toBeLabelled()
expect(getByTestId(container, 'img-labelledby')).toBeLabelled()
expect(getByTestId(container, 'img-empty-alt')).not.toBeLabelled()
expect(getByTestId(container, 'svg-title')).toBeLabelled()
expect(getByTestId(container, 'img-text-sibling')).toBeLabelled()
expect(getByTestId(container, 'button-img-alt')).toBeLabelled()
expect(getByTestId(container, 'object')).toBeLabelled()
expect(getByTestId(container, 'img-paragraph')).not.toBeLabelled()
expect(getByTestId(container, 'svg-button')).toBeLabelled()
expect(getByTestId(container, 'svg-without-title')).not.toBeLabelled()
expect(getByTestId(container, 'input-title')).toBeLabelled()
```

<hr />

### `toBeRequired`

```typescript
Expand Down
1 change: 1 addition & 0 deletions extend-expect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare namespace jest {
toBeEmpty(): R
toBeDisabled(): R
toBeEnabled(): R
toBeLabelled(element: HTMLElement | SVGElement | null): R
toBeInvalid(): R
toBeRequired(): R
toBeValid(): R
Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/to-be-labelled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {render} from './helpers/test-utils'

test('.toBeLabelled', () => {
const {queryByTestId} = render(`
<div>
<img data-testid="img-alt" src="" alt="Test alt" />
<img data-testid="img-label" src="" alt="" aria-label="Test alt" />
<img data-testid="img-labelledby" src="" alt="" aria-labelledby="testId" />
<img data-testid="img-empty-alt" src="" alt="" />
<svg data-testid="svg-title"><title>Test title</title></svg>
<button><img data-testid="img-text-sibling" src="" alt="" /><span>Test content</span></button>
<button data-testid="button-img-alt"><img src="" alt="Test" /></button>
<object data-testid="object" data="companylogo.gif" type="image/gif"><p>Company Name</p></object>
<p><img data-testid="img-paragraph" src="" alt="" />Test content</p>
<button data-testid="svg-button"><svg><title>Test</title></svg></p>
<div><svg data-testid="svg-without-title"></svg></div>
<input data-testid="input-title" title="test" />
</div>
`)

expect(queryByTestId('img-alt')).toBeLabelled()
expect(queryByTestId('img-label')).toBeLabelled()
expect(queryByTestId('img-labelledby')).toBeLabelled()
expect(queryByTestId('img-empty-alt')).not.toBeLabelled()
expect(queryByTestId('svg-title')).toBeLabelled()
expect(queryByTestId('img-text-sibling')).toBeLabelled()
expect(queryByTestId('button-img-alt')).toBeLabelled()
expect(queryByTestId('object')).toBeLabelled()
expect(queryByTestId('img-paragraph')).not.toBeLabelled()
expect(queryByTestId('svg-button')).toBeLabelled()
expect(queryByTestId('svg-without-title')).not.toBeLabelled()
expect(queryByTestId('input-title')).toBeLabelled()

expect(() =>
expect(queryByTestId('img-alt')).not.toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('img-label')).not.toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('img-labelledby')).not.toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('img-empty-alt')).toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('svg-title')).not.toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('img-text-sibling')).not.toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('button-img-alt')).not.toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('object')).not.toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('img-paragraph')).toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('svg-button')).not.toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('svg-without-title')).toBeLabelled(),
).toThrowError()
expect(() =>
expect(queryByTestId('input-title')).not.toBeLabelled(),
).toThrowError()
})
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {toBeDisabled, toBeEnabled} from './to-be-disabled'
import {toBeRequired} from './to-be-required'
import {toBeInvalid, toBeValid} from './to-be-invalid'
import {toHaveValue} from './to-have-value'
import {toBeLabelled} from './to-be-labelled'

export {
toBeInTheDOM,
Expand All @@ -34,4 +35,5 @@ export {
toBeInvalid,
toBeValid,
toHaveValue,
toBeLabelled,
}
148 changes: 148 additions & 0 deletions src/to-be-labelled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import {matcherHint, printReceived} from 'jest-matcher-utils'
import {checkHtmlElement, getTag} from './utils'

/*
* Lists all tags accepting alt as an attribute
* @link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes
*/
const TAGS_WITH_ALT = ['applet', 'area', 'img']

const TAGS_WITH_TITLE = ['svg']

const TAGS_WITH_TITLE_ATTR = ['input', 'select', 'textarea', 'abbr']

const TAGS_IMAGES = ['img', 'svg']

const TAGS_WITH_TEXT_CONTENT = ['a', 'button', 'object']

/*
* Checks if the element is labelled by `aria-label`
*
* e.g. `<button aria-label="Add new item"></button>`
*
* @link https://www.w3.org/TR/WCAG20-TECHS/ARIA6.html
*/
function isHavingARIALabel(element) {
return element.hasAttribute('aria-label')
}

/*
* Checks if the element is labelled by `aria-labelledby`
*
* e.g. `<svg aria-labelledby="refId"></svg>`
*
* @link https://www.w3.org/TR/WCAG20-TECHS/ARIA10.html
*/
function isHavingARIALabelledBy(element) {
return element.hasAttribute('aria-labelledby')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about this checking if the referred element really exist?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I though about that a bit but I do not know if we should do that all the time.
I am thinking about my own usage for example where I am doing a series of check on a UI library's components. This UI library is using the atomic design philosophy and for example, I want to check that my atoms can be labelled, even if the label is not existing in the tested DOM.
In the case of my atoms & molecules components, it could complexify the test scenarii.

But at the same time, aria-labelledby is only a valid way to label an element if the target is existing. So I would agree with you that the test cannot really returns true if we are not sure the target is existing.

Should this check be an option?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that this should indeed do the additional check. If users merely wanted to check for the presence of the aria-labelledby attribute, they can easily do so with .toHaveAttribute('aria-labelledby'). The real value proposition of this matcher is to actually check the label actually exists. And as I mentioned in another comment, to eventually even allow to make assertions on the label text as well. It aligns much better with the overall value proposition of the testing libraries ecosystem to promote testing the end result that users see.

If a UI library that you're testing has components that receive the aria-labelledby but they do not render the target element themselves, you can always in you tests render an outer div that includes the target element with a meaningful label text, alongside your component. Then when you test it, it will actually have a label. For example in react it would be something like this:

render(
  <div>
    <h1 id="my-component-title">Hello world</h1>
    <MyComponent aria-labelledby="my-component-title" />
  </div>
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The real value proposition of this matcher is to actually check the label actually exists

This would be really useful - you can add this before doing 'label' queries so that you can tell what is going wrong - the label or the reference. Also ensures the aria relationships don't get stale or broken over time.

}

/*
* Checks if meaningful images are having an alt attribute
*
* e.g. `<img src="" alt="Text alternative" />`
*
* @link https://www.w3.org/TR/WCAG20-TECHS/H37.html
*/
function isHavingMeaningfulAlt(element) {
return (
TAGS_WITH_ALT.includes(getTag(element)) &&
element.hasAttribute('alt') &&
element.getAttribute('alt') !== ''
)
}

/*
* Checks if there is a title tag as a child
*
* e.g. `<svg><title>Title for this non-text content</title></svg>`
*
* @link https://www.w3.org/TR/WCAG20-TECHS/H65.html
*/
function isHavingMeaningfulTitle(element) {
const titleTag = element.querySelector('title')

return (
TAGS_WITH_TITLE.includes(getTag(element)) &&
titleTag !== null &&
titleTag.textContent !== null
)
}

/*
* Checks if there is a title attribute
*
* e.g. `<abbr title="United Nations">UN</abbr>`
*/
function isHavingMeaningfulTitleAttribute(element) {
return (
TAGS_WITH_TITLE_ATTR.includes(getTag(element)) &&
element.hasAttribute('title') &&
element.getAttribute('title') !== null
)
}

/*
* Checks if there is an adjacent image and text
*
* e.g. `<button><img src="" alt="">Add new item</button>`
*
* @link https://www.w3.org/TR/WCAG20-TECHS/G95.html
*/
function isHavingASiblingLabel(element) {
return (
TAGS_IMAGES.includes(getTag(element)) &&
TAGS_WITH_TEXT_CONTENT.includes(getTag(element.parentElement)) &&
element.parentElement.textContent !== ''
)
}

/*
* Checks if the image is having a meaningful content with ARIA, an alt attribute or a title attribute
*
* @link https://www.w3.org/TR/WCAG20-TECHS/H2.html
*/
function isHavingAContentText(element) {
const isHavingLabelledImg =
element.querySelector('img') &&
(isHavingARIALabel(element.querySelector('img')) ||
isHavingARIALabelledBy(element.querySelector('img')) ||
isHavingMeaningfulAlt(element.querySelector('img')))

const isHavingLabelledSVG =
element.querySelector('svg') &&
(isHavingARIALabel(element.querySelector('svg')) ||
isHavingARIALabelledBy(element.querySelector('svg')) ||
isHavingMeaningfulTitle(element.querySelector('svg')))

return (
TAGS_WITH_TEXT_CONTENT.includes(getTag(element)) &&
(element.textContent !== '' || isHavingLabelledImg || isHavingLabelledSVG)
)
}

export function toBeLabelled(element) {
checkHtmlElement(element, toBeLabelled, this)

const isLabelled =
isHavingARIALabel(element) ||
isHavingARIALabelledBy(element) ||
isHavingMeaningfulAlt(element) ||
isHavingMeaningfulTitle(element) ||
isHavingMeaningfulTitleAttribute(element) ||
isHavingASiblingLabel(element) ||
isHavingAContentText(element)

return {
pass: isLabelled,
message: () => {
const is = isLabelled ? 'is' : 'is not'
return [
matcherHint(`${this.isNot ? '.not' : ''}.toBeLabelled`, 'element', ''),
'',
`Received element ${is} labelled:`,
` ${printReceived(element.cloneNode(false))}`,
].join('\n')
},
}
}