Skip to content

Commit

Permalink
Add alt text validation rule in the accessibility checker (wagtail#11986
Browse files Browse the repository at this point in the history
)

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>
  • Loading branch information
albinazs and thibaudcolas authored Jun 27, 2024
1 parent c632832 commit 03f2618
Show file tree
Hide file tree
Showing 9 changed files with 328 additions and 23 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Changelog
* Remove reduced opacity for draft page title in listings (Inju Michorius)
* Adopt more compact representation for StreamField definitions in migrations (Matt Westcott)
* Implement a new design for locale labels in listings (Albina Starykova)
* Add alt text validation rule in the accessibility checker (Albina Starykova)
* Fix: Make `WAGTAILIMAGES_CHOOSER_PAGE_SIZE` setting functional again (Rohit Sharma)
* Fix: Enable `richtext` template tag to convert lazy translation values (Benjamin Bach)
* Fix: Ensure permission labels on group permissions page are translated where available (Matt Westcott)
Expand Down
13 changes: 4 additions & 9 deletions client/src/entrypoints/admin/preview-panel.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axe from 'axe-core';
import {
getAxeConfiguration,
getA11yReport,
renderA11yResults,
} from '../../includes/a11y-result';
import { WAGTAIL_CONFIG } from '../../config/wagtailConfig';
Expand Down Expand Up @@ -32,23 +32,18 @@ const runAccessibilityChecks = async (onClickSelector) => {
}

// Ensure we only test within the preview iframe, but nonetheless with the correct selectors.
const context = {
config.context = {
include: {
fromFrames: ['#preview-iframe'].concat(config.context.include),
},
};
if (config.context.exclude?.length > 0) {
context.exclude = {
config.context.exclude = {
fromFrames: ['#preview-iframe'].concat(config.context.exclude),
};
}

const results = await axe.run(context, config.options);

const a11yErrorsNumber = results.violations.reduce(
(sum, violation) => sum + violation.nodes.length,
0,
);
const { results, a11yErrorsNumber } = await getA11yReport(config);

toggleCounter.innerText = a11yErrorsNumber.toString();
toggleCounter.hidden = a11yErrorsNumber === 0;
Expand Down
116 changes: 114 additions & 2 deletions client/src/includes/a11y-result.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { AxeResults } from 'axe-core';
import { sortAxeViolations } from './a11y-result';
import axe, { AxeResults, Spec } from 'axe-core';
import {
sortAxeViolations,
WagtailAxeConfiguration,
addCustomChecks,
checkImageAltText,
getA11yReport,
} from './a11y-result';

const mockDocument = `
<div id="a"></div>
Expand Down Expand Up @@ -55,3 +61,109 @@ describe('sortAxeViolations', () => {
]);
});
});

describe('addCustomChecks', () => {
it('should integrate custom checks into the Axe spec', () => {
const spec: Spec = {
checks: [{ id: 'check-id', evaluate: 'functionName' }],
rules: [
{
id: 'rule-id',
impact: 'serious',
any: ['check-id'],
},
],
};
const modifiedSpec = addCustomChecks(spec);
const customCheck = modifiedSpec?.checks?.find(
(check) => check.id === 'check-id',
);
expect(customCheck).toBeDefined();
expect(customCheck?.evaluate).toBe('functionName');
});

it('should return spec unchanged if no custom checks match', () => {
const spec: Spec = {
checks: [{ id: 'non-existent-check', evaluate: '' }],
};
const modifiedSpec = addCustomChecks(spec);
expect(modifiedSpec).toEqual(spec);
});
});

// Options for checkImageAltText function
const options = {
pattern: '\\.(avif|gif|jpg|jpeg|png|svg|webp)$',
};

describe.each`
text | result
${'Good alt text with words like GIFted and motif'} | ${true}
${'Bad alt text.png'} | ${false}
${''} | ${true}
`('checkImageAltText', ({ text, result }) => {
const resultText = result ? 'should not be flagged' : 'should be flagged';
test(`alt text: "${text}" ${resultText}`, () => {
const image = document.createElement('img');
image.setAttribute('alt', text);
expect(checkImageAltText(image, options)).toBe(result);
});
});

describe('checkImageAltText edge cases', () => {
test('should not flag images with no alt attribute', () => {
const image = document.createElement('img');
expect(checkImageAltText(image, options)).toBe(true);
});
});

jest.mock('axe-core', () => ({
configure: jest.fn(),
run: jest.fn(),
}));

describe('getA11yReport', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should configure Axe with custom rules and return the accessibility report', async () => {
const mockResults = {
violations: [
{
nodes: [{}, {}, {}], // 3 nodes with violations
},
],
};
(axe.run as jest.Mock).mockResolvedValue(mockResults);
const config: WagtailAxeConfiguration = {
context: 'body',
options: {},
messages: {},
spec: {
checks: [{ id: 'check-image-alt-text', evaluate: '' }],
},
};
const report = await getA11yReport(config);
expect(axe.configure).toHaveBeenCalled();
expect(axe.run).toHaveBeenCalledWith(config.context, config.options);
expect(report.results).toEqual(mockResults);
expect(report.a11yErrorsNumber).toBe(3);
});

it('should return an accessibility report with zero errors if there are no violations', async () => {
const mockResults = {
violations: [],
};
(axe.run as jest.Mock).mockResolvedValue(mockResults);
const config: WagtailAxeConfiguration = {
context: 'body',
options: {},
messages: {},
spec: {
checks: [{ id: 'check-image-alt-text', evaluate: '' }],
},
};
const report = await getA11yReport(config);
expect(report.a11yErrorsNumber).toBe(0);
});
});
77 changes: 75 additions & 2 deletions client/src/includes/a11y-result.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {
import axe, {
AxeResults,
ElementContext,
NodeResult,
Result,
RunOptions,
Spec,
} from 'axe-core';

const toSelector = (str: string | string[]) =>
Expand Down Expand Up @@ -39,10 +40,11 @@ export const sortAxeViolations = (violations: Result[]) =>
* Wagtail's Axe configuration object. This should reflect what's returned by
* `wagtail.admin.userbar.AccessibilityItem.get_axe_configuration()`.
*/
interface WagtailAxeConfiguration {
export interface WagtailAxeConfiguration {
context: ElementContext;
options: RunOptions;
messages: Record<string, string>;
spec: Spec;
}

/**
Expand Down Expand Up @@ -70,6 +72,77 @@ export const getAxeConfiguration = (
return null;
};

/**
* Custom rule for checking image alt text. This rule checks if the alt text for images
* contains poor quality text like file extensions.
* The rule will be added via the Axe.configure() API.
* https://github.com/dequelabs/axe-core/blob/master/doc/API.md#api-name-axeconfigure
*/
export const checkImageAltText = (
node: HTMLImageElement,
options: { pattern: string },
) => {
const altTextAntipatterns = new RegExp(options.pattern, 'i');
const altText = node.getAttribute('alt') || '';

const hasBadAltText = altTextAntipatterns.test(altText);
return !hasBadAltText;
};

/**
* Defines custom Axe rules, mapping each check to its corresponding JavaScript function.
* This object holds the custom checks that will be added to the Axe configuration.
*/
export const customChecks = {
'check-image-alt-text': checkImageAltText,
// Add other custom checks here
};

/**
* Configures custom Axe rules by integrating the custom checks with their corresponding
* JavaScript functions. It modifies the provided configuration to include these checks.
*/
export const addCustomChecks = (spec: Spec): Spec => {
const modifiedChecks = spec?.checks?.map((check) => {
if (customChecks[check.id]) {
return {
...check,
evaluate: customChecks[check.id],
};
}
return check;
});
return {
...spec,
checks: modifiedChecks,
};
};

interface A11yReport {
results: AxeResults;
a11yErrorsNumber: number;
}

/**
* Get accessibility testing results from Axe based on the configurable custom spec, context, and options.
* It integrates custom rules into the Axe configuration before running the tests.
*/
export const getA11yReport = async (
config: WagtailAxeConfiguration,
): Promise<A11yReport> => {
axe.configure(addCustomChecks(config.spec));
// Initialise Axe based on the context and options defined in Python.
const results = await axe.run(config.context, config.options);
const a11yErrorsNumber = results.violations.reduce(
(sum, violation) => sum + violation.nodes.length,
0,
);
return {
results,
a11yErrorsNumber,
};
};

/**
* Render A11y results based on template elements.
*/
Expand Down
16 changes: 6 additions & 10 deletions client/src/includes/userbar.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import axe from 'axe-core';

import A11yDialog from 'a11y-dialog';
import { Application } from '@hotwired/stimulus';
import { getAxeConfiguration, renderA11yResults } from './a11y-result';
import {
getAxeConfiguration,
getA11yReport,
renderA11yResults,
} from './a11y-result';
import { DialogController } from '../controllers/DialogController';
import { TeleportController } from '../controllers/TeleportController';

Expand Down Expand Up @@ -311,13 +313,7 @@ export class Userbar extends HTMLElement {

if (!this.shadowRoot || !accessibilityTrigger || !config) return;

// Initialise Axe based on the configurable context (whole page body by default) and options ('empty-heading', 'p-as-heading' and 'heading-order' rules by default)
const results = await axe.run(config.context, config.options);

const a11yErrorsNumber = results.violations.reduce(
(sum, violation) => sum + violation.nodes.length,
0,
);
const { results, a11yErrorsNumber } = await getA11yReport(config);

if (results.violations.length) {
const a11yErrorBadge = document.createElement('span');
Expand Down
8 changes: 8 additions & 0 deletions docs/advanced_topics/accessibility_considerations.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ By default, the checker includes the following rules to find common accessibilit
- `input-button-name`: `<input>` button elements must always have a text label.
- `link-name`: `<a>` link elements must always have a text label.
- `p-as-heading`: This rule checks for paragraphs that are styled as headings. Paragraphs should not be styled as headings, as they don’t help users who rely on headings to navigate content.
- `alt-text-quality`: A custom rule ensures that image alt texts don't contain anti-patterns like file extensions.

To customize how the checker is run (such as what rules to test), you can define a custom subclass of {class}`~wagtail.admin.userbar.AccessibilityItem` and override the attributes to your liking. Then, swap the instance of the default `AccessibilityItem` with an instance of your custom class via the [`construct_wagtail_userbar`](construct_wagtail_userbar) hook.

Expand All @@ -155,6 +156,10 @@ The following is the reference documentation for the `AccessibilityItem` class:
.. autoattribute:: axe_run_only
:no-value:
.. autoattribute:: axe_rules
.. autoattribute:: axe_custom_rules
:no-value:
.. autoattribute:: axe_custom_checks
:no-value:
.. autoattribute:: axe_messages
:no-value:
Expand All @@ -167,12 +172,15 @@ The following is the reference documentation for the `AccessibilityItem` class:
.. method:: get_axe_exclude(request)
.. method:: get_axe_run_only(request)
.. method:: get_axe_rules(request)
.. method:: get_axe_custom_rules(request)
.. method:: get_axe_custom_checks(request)
.. method:: get_axe_messages(request)
For more advanced customization, you can also override the following methods:
.. automethod:: get_axe_context
.. automethod:: get_axe_options
.. automethod:: get_axe_spec
```

Here is an example of a custom `AccessibilityItem` subclass that enables more rules:
Expand Down
5 changes: 5 additions & 0 deletions docs/releases/6.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ depth: 1

## What's new

### Alt text accessibility check

The [built-in accessibility checker](authoring_accessible_content) now enforces a new `alt-text-quality` rule, which tests alt text for the presence of known bad patterns such as file extensions. This rule is enabled by default, but can be disabled if necessary.

This feature was implemented by Albina Starykova, with support from the Wagtail accessibility team.

### Other features

Expand Down
Loading

0 comments on commit 03f2618

Please sign in to comment.