Creating a Google Lighthouse custom audit that checks DOM elements

 |  Lighthouse, Custom Audit

Lighthouse provides a rich set of built-in audits but can also be extended to run your own custom audits. This can be extremely useful if you have your own specific checks that you want to perform on your site, such as checking all links to external sites have the rel="noopener" attribute or contain a specific class to visually indicate an external link etc.

Lighthouse report summary

In this article, we’re going to write a custom audit to check for the well known web accessibility issue of adding a click handler to a non-interactive element and not ensuring it’s accessible for keyboard and screen reader users. My article on Accessible JavaScript Click Handlers covers this web accessibility issue in more detail.


JavaScript modules for our custom audit

There are 3 JavaScript modules we’ll need to create for our custom audit.

  1. Gatherer — Collects information about the page. There are many standard gathers built-in to Lighthouse, including the AnchorElements gatherer, but this doesn’t collect all the details we require so we’ll create our own.
  2. Audit — Converts the gatherer data into a score and in our case, returns details to display for each element that has failed.
  3. Config — Provides the configuration for Lighthouse to register and run our custom gatherer and audit.

The code for this article is available on GitHub and contains a test HTML page with a <div> element containing a click handler and no other web accessibility considerations. This is what we want our custom audit to catch.

<div id="inaccessibleDiv" class="btn btn-primary">
This is an inaccessible div element with a click handler
</div>

This test page can be viewed locally via a static web server.

npm install
npm run serve

The command behind npm run serve is:

http-server ./test-site --port 8085 -o

Creating the custom gatherer

Within the folder gatherers, create a new JavaScript file non-interactive-click-handlers-gatherer.js.

const { Gatherer } = require('lighthouse');
const pageFunctions = require('lighthouse/lighthouse-core/lib/page-functions');

/* global getNodeDetails */

class NonInteractiveClickHandlersGatherer extends Gatherer {
/**
* @param {LH.Gatherer.PassContext} options
* @param {LH.Gatherer.LoadData} loadData
*/

async afterPass(options, loadData) {
const driver = options.driver;

const mainFn = () => {

/**
* Returns a boolean indicating if the element has an event listener for type
* @param {HTMLElement} element
* @param {string} type Event type e.g. 'click'
* @returns {boolean}
*/

function hasEventListener(element, type) {
const eventListeners = getEventListeners(element);
return !!eventListeners[type];
}

// The tag names of the non-interactive elements which we'll check
const tagNamesToCheck = ['div', 'span'];
const selector = tagNamesToCheck.join(', ');
const elements = Array.from(document.querySelectorAll(selector));

const failingElements = elements
.filter(element => hasEventListener(element, 'click'))
.filter(element => {
// Assume that any keyboard related handlers are covering web
// accessibility for keyboard users (this could be a false negative).
const hasKeyHandler =
hasEventListener(element, 'keydown') ||
hasEventListener(element, 'keyup') ||
hasEventListener(element, 'keypress');

// Without the "tabindex" attribute, a non-interactive element won't be
// able to receive focus. It's only recommended to use the value 0 which
// means the element receives focus in the DOM order.
const hasValidTabIndex = element.tabIndex === 0;

const hasRoleAttribute = element.getAttribute('role');

return !hasKeyHandler || !hasValidTabIndex || !hasRoleAttribute;
});

const elementSummaries = failingElements.map(element => ({
// getNodeDetails is put into scope via the "deps" array
tagName: element.tagName,
node: getNodeDetails(element)
}));

/**
* @return {LH.Gatherer.PhaseResult}
*/

return elementSummaries;
}

return driver.executionContext.evaluate(mainFn, {
args: [],
deps: [
pageFunctions.getElementsInDocumentString,
pageFunctions.getNodeDetailsString,
]
});
}
}

module.exports = NonInteractiveClickHandlersGatherer;

The gatherer will look at each <div> and <span> element that has a click handler but doesn’t have any of the following:

  • Keyboard event handlers
  • The tabindex attribute with the value 0
  • The role attribute

For each element that matches, we’ll build up an array of objects with the tag name and a LH.Artifacts.NodeDetails object. This LH.Artifacts.NodeDetails object will allow us to output the element HTML in the report later when we create our audit module. The array is the data returned from our custom gatherer.


Creating the custom audit

Within the folder audits, create a new JavaScript file non-interactive-click-handlers-audit.js.

const { Audit } = require('lighthouse');

class NonInteractiveClickHandlersAudit extends Audit {
static get meta() {
/**
* @return {LH.Audit.Meta}
*/

return {
id: 'non-interactive-click-handlers',
title: 'Checks that click handlers on non-interactive elements are fully accessible',
failureTitle: 'One or more non-interactive elements has a click which is not fully accessible',
description:
`When click handlers are added to non-interactive elements like div and span,
keyboard handling should be used to ensure the element is operable via the
keyboard and the tabindex attribute used so the element can receive focus.
The element should also be given a role to assist screen reader users.
`
,

// The name of the built-in or custom gatherer class that provides data to this audit
requiredArtifacts: ['NonInteractiveClickHandlersGatherer'],
};
}

/**
* @return {LH.Audit.ScoreOptions}
*/

static get defaultOptions() {
return {
// See https://www.desmos.com/calculator/tsunbwqt3f
p10: 0.5,
median: 1
};
}

/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
*/

static audit(artifacts, context) {
// Get the data / elements collected by the gatherer
const elementSummaries = artifacts.NonInteractiveClickHandlersGatherer;

const results = elementSummaries.map(element => ({
node: Audit.makeNodeItem(element.node),
tagName: element.tagName
}));

/** @type {LH.Audit.Details.Table['headings']} */
const headings = [
{ key: 'tagName', itemType: 'text', text: 'Tag name' },
{ key: 'node', itemType: 'node', text: 'Failing element' }
];

const score = Audit.computeLogNormalScore(
{ p10: context.options.p10, median: context.options.median },
results.length
);

/**
* @return {LH.Product}
*/

return {
score, // Number between 0 and 1
numericValue: results.length,
numericUnit: 'element',
displayValue: `${results.length} elements`,
details: Audit.makeTableDetails(headings, results),
};
}
}

module.exports = NonInteractiveClickHandlersAudit;

The audit module contains the property getter meta which returns a set of metadata about the audit. The property requiredArtifacts is populated with the names of the built-in or custom gatherer classes that the audit requires data from.

The audit method reads the data from our gatherer via the artifacts.NonInteractiveClickHandlersGatherer property. The gatherer array is then mapped to an array of objects with the tag name and a LH.Audit.Details.NodeValue object. The audit method also calculates the score for the audit (which is a number between 0 and 1) and returns this along with the mapped array and some heading details.


Creating the configuration

Create a new JavaScript file config.js.

module.exports = {
// 1. Run your custom tests along with all the default Lighthouse tests
extends: 'lighthouse:default',

// 2. Add gatherer to the default Lighthouse load ('pass') of the page
passes: [
{
passName: 'defaultPass',
gatherers: [
'./gatherers/non-interactive-click-handlers-gatherer.js'
]
}
],

// 3. Add custom audit to the list of audits 'lighthouse:default' will run
audits: [
'./audits/non-interactive-click-handlers-audit.js'
],

// 4. Create a new 'Custom Audit' section in the default report for our results
categories: {
custom: {
title: 'Custom',
description: 'Custom audit for our checks on DOM element related issues',
auditRefs: [
// "weight" controls how multiple audits are averaged together
{ id: 'non-interactive-click-handlers', weight: 1 }
]
}
}
};

Running the custom audit

With the 3 JavaScript modules in place, we’re now ready to run the custom audit against our test HTML page.

npm run serve
npm run lighthouse

The command behind npm run lighthouse is:

lighthouse --config-path=config.js http://localhost:8085/ --output-path ./results.html --view

Once complete, you’ll see the new “Custom” section in the report which contains the failed element from the test HTML page.

Lighthouse report summary
Lighthouse custom section summary

The code for this article is available on GitHub