diff --git a/README.md b/README.md
index 5770ccff..541faaa4 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ clear to read and to maintain.
- [`toBeEmpty`](#tobeempty)
- [`toBeInTheDocument`](#tobeinthedocument)
- [`toBeInvalid`](#tobeinvalid)
+ - [`toBeLabelled`](#tobelabelled)
- [`toBeRequired`](#toberequired)
- [`toBeValid`](#tobevalid)
- [`toBeVisible`](#tobevisible)
@@ -312,6 +313,78 @@ expect(getByTestId(container, 'invalid-form')).toBeInvalid()
+### `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
+
+
+
+
+Test title
+Test content
+
+Company Name
+ Test content
+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()
+```
+
+
+
### `toBeRequired`
```typescript
diff --git a/extend-expect.d.ts b/extend-expect.d.ts
index ee2ef31b..20033d62 100644
--- a/extend-expect.d.ts
+++ b/extend-expect.d.ts
@@ -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
diff --git a/src/__tests__/to-be-labelled.js b/src/__tests__/to-be-labelled.js
new file mode 100644
index 00000000..a104539c
--- /dev/null
+++ b/src/__tests__/to-be-labelled.js
@@ -0,0 +1,70 @@
+import {render} from './helpers/test-utils'
+
+test('.toBeLabelled', () => {
+ const {queryByTestId} = render(`
+
+
+
+
+
+
Test title
+
Test content
+
+
Company Name
+
Test content
+
Test
+
+
+
+ `)
+
+ 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()
+})
diff --git a/src/index.js b/src/index.js
index c5f2872b..e092d764 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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,
@@ -34,4 +35,5 @@ export {
toBeInvalid,
toBeValid,
toHaveValue,
+ toBeLabelled,
}
diff --git a/src/to-be-labelled.js b/src/to-be-labelled.js
new file mode 100644
index 00000000..76aa6366
--- /dev/null
+++ b/src/to-be-labelled.js
@@ -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. ` `
+ *
+ * @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. ` `
+ *
+ * @link https://www.w3.org/TR/WCAG20-TECHS/ARIA10.html
+ */
+function isHavingARIALabelledBy(element) {
+ return element.hasAttribute('aria-labelledby')
+}
+
+/*
+ * Checks if meaningful images are having an alt attribute
+ *
+ * e.g. ` `
+ *
+ * @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. `Title for this non-text content `
+ *
+ * @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. `UN `
+ */
+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. ` Add new item `
+ *
+ * @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')
+ },
+ }
+}