Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/resources/formats/html/axe/axe-check.js
12923 views
1
class QuartoAxeReporter {
2
constructor(axeResult, options) {
3
this.axeResult = axeResult;
4
this.options = options;
5
}
6
7
report() {
8
throw new Error("report() is an abstract method");
9
}
10
}
11
12
class QuartoAxeJsonReporter extends QuartoAxeReporter {
13
constructor(axeResult, options) {
14
super(axeResult, options);
15
}
16
17
report() {
18
console.log(JSON.stringify(this.axeResult, null, 2));
19
}
20
}
21
22
class QuartoAxeConsoleReporter extends QuartoAxeReporter {
23
constructor(axeResult, options) {
24
super(axeResult, options);
25
}
26
27
report() {
28
for (const violation of this.axeResult.violations) {
29
console.log(violation.description);
30
for (const node of violation.nodes) {
31
for (const target of node.target) {
32
console.log(target);
33
console.log(document.querySelector(target));
34
}
35
}
36
}
37
}
38
}
39
40
class QuartoAxeDocumentReporter extends QuartoAxeReporter {
41
constructor(axeResult, options) {
42
super(axeResult, options);
43
}
44
45
highlightTarget(target) {
46
const element = document.querySelector(target);
47
if (!element) return null;
48
this.navigateToElement(element);
49
element.classList.add("quarto-axe-hover-highlight");
50
return element;
51
}
52
53
unhighlightTarget(target) {
54
const element = document.querySelector(target);
55
if (element) element.classList.remove("quarto-axe-hover-highlight");
56
}
57
58
navigateToElement(element) {
59
if (typeof Reveal !== "undefined") {
60
const section = element.closest("section");
61
if (section) {
62
const indices = Reveal.getIndices(section);
63
Reveal.slide(indices.h, indices.v);
64
} else {
65
element.scrollIntoView({ behavior: "smooth", block: "center" });
66
}
67
} else {
68
element.scrollIntoView({ behavior: "smooth", block: "center" });
69
}
70
}
71
72
createViolationElement(violation) {
73
const violationElement = document.createElement("div");
74
75
const descriptionElement = document.createElement("div");
76
descriptionElement.className = "quarto-axe-violation-description";
77
descriptionElement.innerText = `${violation.impact.replace(/^[a-z]/, match => match.toLocaleUpperCase())}: ${violation.description}`;
78
violationElement.appendChild(descriptionElement);
79
80
const helpElement = document.createElement("div");
81
helpElement.className = "quarto-axe-violation-help";
82
helpElement.innerText = violation.help;
83
violationElement.appendChild(helpElement);
84
85
const nodesElement = document.createElement("div");
86
nodesElement.className = "quarto-axe-violation-nodes";
87
violationElement.appendChild(nodesElement);
88
for (const node of violation.nodes) {
89
const nodeElement = document.createElement("div");
90
nodeElement.className = "quarto-axe-violation-selector";
91
for (const target of node.target) {
92
const targetElement = document.createElement("span");
93
targetElement.className = "quarto-axe-violation-target";
94
targetElement.innerText = target;
95
nodeElement.appendChild(targetElement);
96
if (typeof Reveal !== "undefined") {
97
// RevealJS: click navigates to the slide and highlights briefly
98
nodeElement.addEventListener("click", () => {
99
const el = this.highlightTarget(target);
100
if (el) setTimeout(() => el.classList.remove("quarto-axe-hover-highlight"), 3000);
101
});
102
} else {
103
// HTML/Dashboard: hover highlights while mouse is over the target
104
nodeElement.addEventListener("mouseenter", () => this.highlightTarget(target));
105
nodeElement.addEventListener("mouseleave", () => this.unhighlightTarget(target));
106
}
107
}
108
nodesElement.appendChild(nodeElement);
109
}
110
return violationElement;
111
}
112
113
createReportElement() {
114
const violations = this.axeResult.violations;
115
const reportElement = document.createElement("div");
116
reportElement.className = "quarto-axe-report";
117
if (violations.length === 0) {
118
const noViolationsElement = document.createElement("div");
119
noViolationsElement.className = "quarto-axe-no-violations";
120
noViolationsElement.innerText = "No axe-core violations found.";
121
reportElement.appendChild(noViolationsElement);
122
}
123
violations.forEach((violation) => {
124
reportElement.appendChild(this.createViolationElement(violation));
125
});
126
return reportElement;
127
}
128
129
createReportOverlay() {
130
const reportElement = this.createReportElement();
131
(document.querySelector("main") || document.body).appendChild(reportElement);
132
}
133
134
createReportSlide() {
135
const slidesContainer = document.querySelector(".reveal .slides");
136
if (!slidesContainer) {
137
this.createReportOverlay();
138
return;
139
}
140
141
const section = document.createElement("section");
142
section.className = "slide quarto-axe-report-slide scrollable";
143
144
const title = document.createElement("h2");
145
title.textContent = "Accessibility Report";
146
section.appendChild(title);
147
148
section.appendChild(this.createReportElement());
149
slidesContainer.appendChild(section);
150
151
// sync() registers the new slide but doesn't update visibility classes.
152
// Re-navigating to the current slide triggers the visibility update so
153
// the report slide gets the correct past/present/future class.
154
const indices = Reveal.getIndices();
155
Reveal.sync();
156
Reveal.slide(indices.h, indices.v);
157
}
158
159
createReportOffcanvas() {
160
const offcanvas = document.createElement("div");
161
offcanvas.className = "offcanvas offcanvas-end quarto-axe-offcanvas";
162
offcanvas.id = "quarto-axe-offcanvas";
163
offcanvas.tabIndex = -1;
164
offcanvas.setAttribute("aria-labelledby", "quarto-axe-offcanvas-label");
165
offcanvas.setAttribute("data-bs-scroll", "true");
166
offcanvas.setAttribute("data-bs-backdrop", "false");
167
168
const header = document.createElement("div");
169
header.className = "offcanvas-header";
170
171
const title = document.createElement("h5");
172
title.className = "offcanvas-title";
173
title.id = "quarto-axe-offcanvas-label";
174
title.textContent = "Accessibility Report";
175
header.appendChild(title);
176
177
const closeBtn = document.createElement("button");
178
closeBtn.type = "button";
179
closeBtn.className = "btn-close";
180
closeBtn.setAttribute("data-bs-dismiss", "offcanvas");
181
closeBtn.setAttribute("aria-label", "Close");
182
header.appendChild(closeBtn);
183
184
offcanvas.appendChild(header);
185
186
const body = document.createElement("div");
187
body.className = "offcanvas-body";
188
body.appendChild(this.createReportElement());
189
offcanvas.appendChild(body);
190
191
document.body.appendChild(offcanvas);
192
193
const toggle = document.createElement("button");
194
toggle.className = "btn btn-dark quarto-axe-toggle";
195
toggle.type = "button";
196
toggle.setAttribute("data-bs-toggle", "offcanvas");
197
toggle.setAttribute("data-bs-target", "#quarto-axe-offcanvas");
198
toggle.setAttribute("aria-controls", "quarto-axe-offcanvas");
199
toggle.setAttribute("aria-label", "Toggle accessibility report");
200
201
const icon = document.createElement("i");
202
icon.className = "bi bi-universal-access";
203
toggle.appendChild(icon);
204
205
document.body.appendChild(toggle);
206
207
new bootstrap.Offcanvas(offcanvas).show();
208
}
209
210
report() {
211
if (typeof Reveal !== "undefined") {
212
if (Reveal.isReady()) {
213
this.createReportSlide();
214
} else {
215
return new Promise((resolve) => {
216
Reveal.on("ready", () => {
217
this.createReportSlide();
218
resolve();
219
});
220
});
221
}
222
} else if (document.body.classList.contains("quarto-dashboard")) {
223
this.createReportOffcanvas();
224
} else {
225
this.createReportOverlay();
226
}
227
}
228
}
229
230
const reporters = {
231
json: QuartoAxeJsonReporter,
232
console: QuartoAxeConsoleReporter,
233
document: QuartoAxeDocumentReporter,
234
};
235
236
class QuartoAxeChecker {
237
constructor(opts) {
238
// Normalize boolean shorthand: axe: true → {output: "console"}
239
this.options = opts === true ? { output: "console" } : opts;
240
this.axe = null;
241
this.scanGeneration = 0;
242
}
243
// In RevealJS, only the current slide is accessible to axe-core because
244
// non-visible slides have hidden and aria-hidden attributes. Temporarily
245
// remove these so axe can check all slides, then restore them.
246
revealUnhideSlides() {
247
const slides = document.querySelectorAll(".reveal .slides section");
248
if (slides.length === 0) return null;
249
const saved = [];
250
slides.forEach((s) => {
251
saved.push({
252
el: s,
253
hidden: s.hasAttribute("hidden"),
254
ariaHidden: s.getAttribute("aria-hidden"),
255
});
256
s.removeAttribute("hidden");
257
s.removeAttribute("aria-hidden");
258
});
259
return saved;
260
}
261
262
revealRestoreSlides(saved) {
263
if (!saved) return;
264
saved.forEach(({ el, hidden, ariaHidden }) => {
265
if (hidden) el.setAttribute("hidden", "");
266
if (ariaHidden !== null) el.setAttribute("aria-hidden", ariaHidden);
267
});
268
}
269
270
async runAxeScan() {
271
const saved = this.revealUnhideSlides();
272
try {
273
return await this.axe.run({
274
exclude: [
275
// https://github.com/microsoft/tabster/issues/288
276
// MS has claimed they won't fix this, so we need to add an exclusion to
277
// all tabster elements
278
"[data-tabster-dummy]"
279
],
280
preload: { assets: ['cssom'], timeout: 50000 }
281
});
282
} finally {
283
this.revealRestoreSlides(saved);
284
}
285
}
286
287
setupDashboardRescan() {
288
// Page tabs and card tabsets both fire shown.bs.tab on the document
289
document.addEventListener("shown.bs.tab", () => this.rescanDashboard());
290
291
// Browser back/forward — showPage() toggles classes without firing shown.bs.tab.
292
// The 50ms delay lets the dashboard finish toggling .active classes on tab panes
293
// before axe scans, so the correct page content is visible.
294
window.addEventListener("popstate", () => {
295
setTimeout(() => this.rescanDashboard(), 50);
296
});
297
298
// bslib sidebar open/close — fires with bubbles:true after transition ends
299
document.addEventListener("bslib.sidebar", () => this.rescanDashboard());
300
}
301
302
async rescanDashboard() {
303
const gen = ++this.scanGeneration;
304
305
document.body.removeAttribute("data-quarto-axe-complete");
306
307
const body = document.querySelector("#quarto-axe-offcanvas .offcanvas-body");
308
if (body) {
309
body.innerHTML = "";
310
const scanning = document.createElement("div");
311
scanning.className = "quarto-axe-scanning";
312
scanning.textContent = "Scanning\u2026";
313
body.appendChild(scanning);
314
}
315
316
try {
317
const result = await this.runAxeScan();
318
319
if (gen !== this.scanGeneration) return;
320
321
const reporter = new QuartoAxeDocumentReporter(result, this.options);
322
const reportElement = reporter.createReportElement();
323
324
if (body) {
325
body.innerHTML = "";
326
body.appendChild(reportElement);
327
}
328
} catch (error) {
329
console.error("Axe rescan failed:", error);
330
if (gen !== this.scanGeneration) return;
331
if (body) {
332
body.innerHTML = "";
333
const msg = document.createElement("div");
334
msg.className = "quarto-axe-scanning";
335
msg.setAttribute("role", "alert");
336
msg.textContent = "Accessibility scan failed. See console for details.";
337
body.appendChild(msg);
338
}
339
} finally {
340
if (gen === this.scanGeneration) {
341
document.body.setAttribute("data-quarto-axe-complete", "true");
342
}
343
}
344
}
345
346
async init() {
347
try {
348
this.axe = (await import("https://cdn.skypack.dev/pin/[email protected]/mode=imports,min/optimized/axe-core.js")).default;
349
const result = await this.runAxeScan();
350
const reporter = new reporters[this.options.output](result, this.options);
351
await reporter.report();
352
353
if (document.body.classList.contains("quarto-dashboard") &&
354
this.options.output === "document") {
355
this.setupDashboardRescan();
356
}
357
} catch (error) {
358
console.error("Axe accessibility check failed:", error);
359
} finally {
360
document.body.setAttribute('data-quarto-axe-complete', 'true');
361
}
362
}
363
}
364
365
let initialized = false;
366
367
async function init() {
368
if (initialized) return;
369
initialized = true;
370
const opts = document.querySelector("#quarto-axe-checker-options");
371
if (opts) {
372
const jsonOptions = JSON.parse(atob(opts.textContent));
373
const checker = new QuartoAxeChecker(jsonOptions);
374
await checker.init();
375
}
376
}
377
378
// Self-initialize when loaded as a standalone module.
379
// ES modules are deferred, so the DOM is fully parsed when this runs.
380
init();
381
382