Path: blob/main/src/resources/formats/html/axe/axe-check.js
12923 views
class QuartoAxeReporter {1constructor(axeResult, options) {2this.axeResult = axeResult;3this.options = options;4}56report() {7throw new Error("report() is an abstract method");8}9}1011class QuartoAxeJsonReporter extends QuartoAxeReporter {12constructor(axeResult, options) {13super(axeResult, options);14}1516report() {17console.log(JSON.stringify(this.axeResult, null, 2));18}19}2021class QuartoAxeConsoleReporter extends QuartoAxeReporter {22constructor(axeResult, options) {23super(axeResult, options);24}2526report() {27for (const violation of this.axeResult.violations) {28console.log(violation.description);29for (const node of violation.nodes) {30for (const target of node.target) {31console.log(target);32console.log(document.querySelector(target));33}34}35}36}37}3839class QuartoAxeDocumentReporter extends QuartoAxeReporter {40constructor(axeResult, options) {41super(axeResult, options);42}4344highlightTarget(target) {45const element = document.querySelector(target);46if (!element) return null;47this.navigateToElement(element);48element.classList.add("quarto-axe-hover-highlight");49return element;50}5152unhighlightTarget(target) {53const element = document.querySelector(target);54if (element) element.classList.remove("quarto-axe-hover-highlight");55}5657navigateToElement(element) {58if (typeof Reveal !== "undefined") {59const section = element.closest("section");60if (section) {61const indices = Reveal.getIndices(section);62Reveal.slide(indices.h, indices.v);63} else {64element.scrollIntoView({ behavior: "smooth", block: "center" });65}66} else {67element.scrollIntoView({ behavior: "smooth", block: "center" });68}69}7071createViolationElement(violation) {72const violationElement = document.createElement("div");7374const descriptionElement = document.createElement("div");75descriptionElement.className = "quarto-axe-violation-description";76descriptionElement.innerText = `${violation.impact.replace(/^[a-z]/, match => match.toLocaleUpperCase())}: ${violation.description}`;77violationElement.appendChild(descriptionElement);7879const helpElement = document.createElement("div");80helpElement.className = "quarto-axe-violation-help";81helpElement.innerText = violation.help;82violationElement.appendChild(helpElement);8384const nodesElement = document.createElement("div");85nodesElement.className = "quarto-axe-violation-nodes";86violationElement.appendChild(nodesElement);87for (const node of violation.nodes) {88const nodeElement = document.createElement("div");89nodeElement.className = "quarto-axe-violation-selector";90for (const target of node.target) {91const targetElement = document.createElement("span");92targetElement.className = "quarto-axe-violation-target";93targetElement.innerText = target;94nodeElement.appendChild(targetElement);95if (typeof Reveal !== "undefined") {96// RevealJS: click navigates to the slide and highlights briefly97nodeElement.addEventListener("click", () => {98const el = this.highlightTarget(target);99if (el) setTimeout(() => el.classList.remove("quarto-axe-hover-highlight"), 3000);100});101} else {102// HTML/Dashboard: hover highlights while mouse is over the target103nodeElement.addEventListener("mouseenter", () => this.highlightTarget(target));104nodeElement.addEventListener("mouseleave", () => this.unhighlightTarget(target));105}106}107nodesElement.appendChild(nodeElement);108}109return violationElement;110}111112createReportElement() {113const violations = this.axeResult.violations;114const reportElement = document.createElement("div");115reportElement.className = "quarto-axe-report";116if (violations.length === 0) {117const noViolationsElement = document.createElement("div");118noViolationsElement.className = "quarto-axe-no-violations";119noViolationsElement.innerText = "No axe-core violations found.";120reportElement.appendChild(noViolationsElement);121}122violations.forEach((violation) => {123reportElement.appendChild(this.createViolationElement(violation));124});125return reportElement;126}127128createReportOverlay() {129const reportElement = this.createReportElement();130(document.querySelector("main") || document.body).appendChild(reportElement);131}132133createReportSlide() {134const slidesContainer = document.querySelector(".reveal .slides");135if (!slidesContainer) {136this.createReportOverlay();137return;138}139140const section = document.createElement("section");141section.className = "slide quarto-axe-report-slide scrollable";142143const title = document.createElement("h2");144title.textContent = "Accessibility Report";145section.appendChild(title);146147section.appendChild(this.createReportElement());148slidesContainer.appendChild(section);149150// sync() registers the new slide but doesn't update visibility classes.151// Re-navigating to the current slide triggers the visibility update so152// the report slide gets the correct past/present/future class.153const indices = Reveal.getIndices();154Reveal.sync();155Reveal.slide(indices.h, indices.v);156}157158createReportOffcanvas() {159const offcanvas = document.createElement("div");160offcanvas.className = "offcanvas offcanvas-end quarto-axe-offcanvas";161offcanvas.id = "quarto-axe-offcanvas";162offcanvas.tabIndex = -1;163offcanvas.setAttribute("aria-labelledby", "quarto-axe-offcanvas-label");164offcanvas.setAttribute("data-bs-scroll", "true");165offcanvas.setAttribute("data-bs-backdrop", "false");166167const header = document.createElement("div");168header.className = "offcanvas-header";169170const title = document.createElement("h5");171title.className = "offcanvas-title";172title.id = "quarto-axe-offcanvas-label";173title.textContent = "Accessibility Report";174header.appendChild(title);175176const closeBtn = document.createElement("button");177closeBtn.type = "button";178closeBtn.className = "btn-close";179closeBtn.setAttribute("data-bs-dismiss", "offcanvas");180closeBtn.setAttribute("aria-label", "Close");181header.appendChild(closeBtn);182183offcanvas.appendChild(header);184185const body = document.createElement("div");186body.className = "offcanvas-body";187body.appendChild(this.createReportElement());188offcanvas.appendChild(body);189190document.body.appendChild(offcanvas);191192const toggle = document.createElement("button");193toggle.className = "btn btn-dark quarto-axe-toggle";194toggle.type = "button";195toggle.setAttribute("data-bs-toggle", "offcanvas");196toggle.setAttribute("data-bs-target", "#quarto-axe-offcanvas");197toggle.setAttribute("aria-controls", "quarto-axe-offcanvas");198toggle.setAttribute("aria-label", "Toggle accessibility report");199200const icon = document.createElement("i");201icon.className = "bi bi-universal-access";202toggle.appendChild(icon);203204document.body.appendChild(toggle);205206new bootstrap.Offcanvas(offcanvas).show();207}208209report() {210if (typeof Reveal !== "undefined") {211if (Reveal.isReady()) {212this.createReportSlide();213} else {214return new Promise((resolve) => {215Reveal.on("ready", () => {216this.createReportSlide();217resolve();218});219});220}221} else if (document.body.classList.contains("quarto-dashboard")) {222this.createReportOffcanvas();223} else {224this.createReportOverlay();225}226}227}228229const reporters = {230json: QuartoAxeJsonReporter,231console: QuartoAxeConsoleReporter,232document: QuartoAxeDocumentReporter,233};234235class QuartoAxeChecker {236constructor(opts) {237// Normalize boolean shorthand: axe: true → {output: "console"}238this.options = opts === true ? { output: "console" } : opts;239this.axe = null;240this.scanGeneration = 0;241}242// In RevealJS, only the current slide is accessible to axe-core because243// non-visible slides have hidden and aria-hidden attributes. Temporarily244// remove these so axe can check all slides, then restore them.245revealUnhideSlides() {246const slides = document.querySelectorAll(".reveal .slides section");247if (slides.length === 0) return null;248const saved = [];249slides.forEach((s) => {250saved.push({251el: s,252hidden: s.hasAttribute("hidden"),253ariaHidden: s.getAttribute("aria-hidden"),254});255s.removeAttribute("hidden");256s.removeAttribute("aria-hidden");257});258return saved;259}260261revealRestoreSlides(saved) {262if (!saved) return;263saved.forEach(({ el, hidden, ariaHidden }) => {264if (hidden) el.setAttribute("hidden", "");265if (ariaHidden !== null) el.setAttribute("aria-hidden", ariaHidden);266});267}268269async runAxeScan() {270const saved = this.revealUnhideSlides();271try {272return await this.axe.run({273exclude: [274// https://github.com/microsoft/tabster/issues/288275// MS has claimed they won't fix this, so we need to add an exclusion to276// all tabster elements277"[data-tabster-dummy]"278],279preload: { assets: ['cssom'], timeout: 50000 }280});281} finally {282this.revealRestoreSlides(saved);283}284}285286setupDashboardRescan() {287// Page tabs and card tabsets both fire shown.bs.tab on the document288document.addEventListener("shown.bs.tab", () => this.rescanDashboard());289290// Browser back/forward — showPage() toggles classes without firing shown.bs.tab.291// The 50ms delay lets the dashboard finish toggling .active classes on tab panes292// before axe scans, so the correct page content is visible.293window.addEventListener("popstate", () => {294setTimeout(() => this.rescanDashboard(), 50);295});296297// bslib sidebar open/close — fires with bubbles:true after transition ends298document.addEventListener("bslib.sidebar", () => this.rescanDashboard());299}300301async rescanDashboard() {302const gen = ++this.scanGeneration;303304document.body.removeAttribute("data-quarto-axe-complete");305306const body = document.querySelector("#quarto-axe-offcanvas .offcanvas-body");307if (body) {308body.innerHTML = "";309const scanning = document.createElement("div");310scanning.className = "quarto-axe-scanning";311scanning.textContent = "Scanning\u2026";312body.appendChild(scanning);313}314315try {316const result = await this.runAxeScan();317318if (gen !== this.scanGeneration) return;319320const reporter = new QuartoAxeDocumentReporter(result, this.options);321const reportElement = reporter.createReportElement();322323if (body) {324body.innerHTML = "";325body.appendChild(reportElement);326}327} catch (error) {328console.error("Axe rescan failed:", error);329if (gen !== this.scanGeneration) return;330if (body) {331body.innerHTML = "";332const msg = document.createElement("div");333msg.className = "quarto-axe-scanning";334msg.setAttribute("role", "alert");335msg.textContent = "Accessibility scan failed. See console for details.";336body.appendChild(msg);337}338} finally {339if (gen === this.scanGeneration) {340document.body.setAttribute("data-quarto-axe-complete", "true");341}342}343}344345async init() {346try {347this.axe = (await import("https://cdn.skypack.dev/pin/[email protected]/mode=imports,min/optimized/axe-core.js")).default;348const result = await this.runAxeScan();349const reporter = new reporters[this.options.output](result, this.options);350await reporter.report();351352if (document.body.classList.contains("quarto-dashboard") &&353this.options.output === "document") {354this.setupDashboardRescan();355}356} catch (error) {357console.error("Axe accessibility check failed:", error);358} finally {359document.body.setAttribute('data-quarto-axe-complete', 'true');360}361}362}363364let initialized = false;365366async function init() {367if (initialized) return;368initialized = true;369const opts = document.querySelector("#quarto-axe-checker-options");370if (opts) {371const jsonOptions = JSON.parse(atob(opts.textContent));372const checker = new QuartoAxeChecker(jsonOptions);373await checker.init();374}375}376377// Self-initialize when loaded as a standalone module.378// ES modules are deferred, so the DOM is fully parsed when this runs.379init();380381382