Skip to content

Commit

Permalink
Add headless support via Axe Plugin cross-frame comm
Browse files Browse the repository at this point in the history
  • Loading branch information
albinazs committed Jun 19, 2024
1 parent 481f0b4 commit 91fe3d4
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 53 deletions.
21 changes: 19 additions & 2 deletions client/src/entrypoints/admin/preview-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,28 @@ import {
getAxeConfiguration,
renderA11yResults,
} from '../../includes/a11y-result';
import { runContentCheck } from '../../includes/contentMetrics';
import { wagtailPreviewPlugin } from '../../includes/previewPlugin';
import {
getPreviewContentMetrics,
renderContentMetrics,
} from '../../includes/contentMetrics';
import { WAGTAIL_CONFIG } from '../../config/wagtailConfig';
import { debounce } from '../../utils/debounce';
import { gettext } from '../../utils/gettext';

const runContentChecks = async () => {
axe.registerPlugin(wagtailPreviewPlugin);

const contentMetrics = await getPreviewContentMetrics({
targetElement: 'main',
});

renderContentMetrics({
wordCount: contentMetrics.wordCount,
readingTime: contentMetrics.readingTime,
});
};

const runAccessibilityChecks = async (onClickSelector) => {
const a11yRowTemplate = document.querySelector('#w-a11y-result-row-template');
const a11ySelectorTemplate = document.querySelector(
Expand Down Expand Up @@ -207,7 +224,7 @@ function initPreview() {
// Remove the load event listener so it doesn't fire when switching modes
newIframe.removeEventListener('load', handleLoad);

runContentCheck();
runContentChecks();

const onClickSelector = () => newTabButton.click();
runAccessibilityChecks(onClickSelector);
Expand Down
98 changes: 57 additions & 41 deletions client/src/includes/contentMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
interface ContentMetrics {
wordCount: number;
readingTime: number;
}
import axe from 'axe-core';
import { ngettext } from '../utils/gettext';

export const getWordCount = (lang: string, text: string): number => {
const segmenter = new Intl.Segmenter(lang, { granularity: 'word' });
Expand Down Expand Up @@ -43,50 +41,68 @@ export const getReadingTime = (lang: string, wordCount: number): number => {
return readingTime;
};

const renderContentMetrics = ({ wordCount, readingTime }: ContentMetrics) => {
interface ContentMetricsOptions {
targetElement: string;
}

interface ContentMetrics {
wordCount: number;
readingTime: number;
}

export const contentMetricsPluginInstance = {
id: 'metrics',
getMetrics(
options: ContentMetricsOptions,
done: (metrics: ContentMetrics) => void,
) {
const main = document.querySelector<HTMLElement>(options.targetElement);
const text = main?.innerText || '';
const lang = document.documentElement.lang || 'en';
const wordCount = getWordCount(lang, text);
const readingTime = getReadingTime(lang, wordCount);
done({
wordCount,
readingTime,
});
},
};

/**
* Calls the `getMetrics` method in the `metrics` plugin instance of the `wagtailPreview` registry.
* Wrapped in a promise so we can use async/await syntax instead of callbacks
*/
export const getPreviewContentMetrics = (
options: ContentMetricsOptions,
): Promise<ContentMetrics> =>
new Promise((resolve) => {
axe.plugins.wagtailPreview.run(
'metrics',
'getMetrics',
options,
(metrics: ContentMetrics) => {
resolve(metrics);
},
);
});

export const renderContentMetrics = ({
wordCount,
readingTime,
}: ContentMetrics) => {
const wordCountContainer = document.querySelector<HTMLElement>(
'[data-content-word-count]',
);
const readingTimeContainer = document.querySelector<HTMLElement>(
'[data-content-reading-time]',
);
const readingTimeSingleUnit = document.querySelector<HTMLElement>(
'[data-content-reading-time-single]',
);
const readingTimeUnitPluralUnit = document.querySelector<HTMLElement>(
'[data-content-reading-time-plural]',
);

if (
!wordCountContainer ||
!readingTimeContainer ||
!readingTimeSingleUnit ||
!readingTimeUnitPluralUnit
)
return;
if (!wordCountContainer || !readingTimeContainer) return;

if (readingTime === 1) {
readingTimeSingleUnit.hidden = false;
readingTimeUnitPluralUnit.hidden = true;
}
wordCountContainer.textContent = wordCount.toString();
readingTimeContainer.textContent = readingTime.toString();
};

export const runContentCheck = () => {
const iframe = document.querySelector<HTMLIFrameElement>(
'[data-preview-iframe]',
);
const iframeDocument =
iframe?.contentDocument || iframe?.contentWindow?.document;
const text = iframeDocument?.querySelector('main')?.innerText;
if (!iframe || !iframeDocument || !text) {
return;
}
const lang = iframeDocument.documentElement.lang || 'en';

const wordCount = getWordCount(lang, text);
const readingTime = getReadingTime(lang, wordCount);

renderContentMetrics({ wordCount, readingTime });
readingTimeContainer.textContent = ngettext(
'%(num)s min',
'%(num)s mins',
readingTime,
).replace('%(num)s', `${readingTime}`);
};
53 changes: 53 additions & 0 deletions client/src/includes/previewPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import axe, { AxePlugin } from 'axe-core';

/**
* Axe plugin registry for interaction between the page editor and the live preview.
* Compared to other aspects of Axe and other plugins,
* - The parent frame only triggers execution of the plugin’s logic in the one frame.
* - The preview frame only executes the plugin’s logic, it doesn’t go through its own frames.
* See https://github.com/dequelabs/axe-core/blob/master/doc/plugins.md.
*/
export const wagtailPreviewPlugin: AxePlugin = {
id: 'wagtailPreview',
run(id, action, options, callback) {
// Outside the preview frame, we need to send the command to the preview iframe.
const preview = document.querySelector<HTMLIFrameElement>(
'[data-preview-iframe]',
);

if (preview) {
// @ts-expect-error Not declared in the official Axe Utils API.
axe.utils.sendCommandToFrame(
preview,
{
command: 'run-wagtailPreview',
parameter: id,
action: action,
options: options,
},
(results) => {
// Pass the results from the preview iframe to the callback.
callback(results);
},
);
} else {
// Inside the preview frame, only call the expected plugin instance method.
// eslint-disable-next-line no-underscore-dangle
const pluginInstance = this._registry[id];
pluginInstance[action].call(pluginInstance, options, callback);
}
},
commands: [
{
id: 'run-wagtailPreview',
callback(data, callback) {
return axe.plugins.wagtailPreview.run(
data.parameter,
data.action,
data.options,
callback,
);
},
},
],
};
12 changes: 8 additions & 4 deletions client/src/includes/userbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import axe from 'axe-core';
import A11yDialog from 'a11y-dialog';
import { Application } from '@hotwired/stimulus';
import { getAxeConfiguration, renderA11yResults } from './a11y-result';
import { wagtailPreviewPlugin } from './previewPlugin';
import { contentMetricsPluginInstance } from './contentMetrics';
import { DialogController } from '../controllers/DialogController';
import { TeleportController } from '../controllers/TeleportController';

Expand Down Expand Up @@ -301,17 +303,19 @@ export class Userbar extends HTMLElement {
See documentation: https://github.com/dequelabs/axe-core/tree/develop/doc
*/

// Initialise axe accessibility checker
// Initialise Axe
async initialiseAxe() {
// Collect content data from the live preview via Axe plugin for content metrics calculation
axe.registerPlugin(wagtailPreviewPlugin);
axe.plugins.wagtailPreview.add(contentMetricsPluginInstance);

const accessibilityTrigger = this.shadowRoot?.getElementById(
'accessibility-trigger',
);

const config = getAxeConfiguration(this.shadowRoot);

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)
// Run accessibility checks 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,11 @@ <h2 class="w-my-5 w-text-16 w-font-bold w-text-text-label">
<div class="w-flex w-gap-10">
<div>
<h3 class="w-my-2 w-text-14 w-text-text-placeholder">{% trans 'Words' %}</h3>
<p class="w-font-semibold w-text-text-label" data-content-word-count>0</p>
<p class="w-font-semibold w-text-text-label" data-content-word-count>-</p>
</div>
<div>
<h3 class="w-my-2 w-text-14 w-text-text-placeholder">{% trans 'Reading time' %}</h3>
<p class="w-font-semibold w-text-text-label">
<span data-content-reading-time>0</span>
<span hidden data-content-reading-time-single>{% trans " min" %}</span>
<span data-content-reading-time-plural>{% trans " mins" %}</span>
</p>
<p class="w-font-semibold w-text-text-label" data-content-reading-time>-</p>
</div>
</div>
</div>
Expand Down

0 comments on commit 91fe3d4

Please sign in to comment.