Question extras
diff --git a/packages/perseus-editor/src/messages.ts b/packages/perseus-editor/src/messages.ts
index 91ddcec3e5d..beaf1e4c47c 100644
--- a/packages/perseus-editor/src/messages.ts
+++ b/packages/perseus-editor/src/messages.ts
@@ -11,6 +11,7 @@ export const WARNINGS = {
impact: "medium",
message:
"Selecting inaccessible widgets for a practice item will result in this exercise being hidden from users with 'Hide visually dependant content' setting set to true. Please select another widget or create an alternative practice item.",
+ type: "Warning",
}),
genericLinterWarning: (rule: string, message: string): Issue => ({
@@ -21,5 +22,6 @@ export const WARNINGS = {
"https://docs.google.com/document/d/1N13f4sY-7EXWDwQ04ivA9vJBVvPPd60qjBT73B4NHuM/edit?tab=t.0",
impact: "low",
message: message,
+ type: "Warning",
}),
};
diff --git a/packages/perseus-editor/src/styles/perseus-editor.css b/packages/perseus-editor/src/styles/perseus-editor.css
index 934c82536a6..bb64a97ac88 100644
--- a/packages/perseus-editor/src/styles/perseus-editor.css
+++ b/packages/perseus-editor/src/styles/perseus-editor.css
@@ -233,6 +233,8 @@ code {
}
#perseus {
margin: 20px;
+ width: calc(100% - 500px);
+ z-index: 10;
}
#perseus #problemarea #workarea {
margin: 0;
@@ -257,6 +259,9 @@ code {
float: left;
padding-bottom: 38px;
}
+#storybook-root #problemarea {
+ display: none;
+}
#problemarea a:link,
#problemarea input,
#problemarea label,
@@ -281,6 +286,8 @@ code {
width: 360px;
max-width: 360px;
min-width: 360px;
+ position: relative;
+ z-index: 10;
}
.perseus-editor-right-cell {
box-sizing: border-box;
diff --git a/packages/perseus-editor/src/util/a11y-checker.ts b/packages/perseus-editor/src/util/a11y-checker.ts
new file mode 100644
index 00000000000..5deec9ea995
--- /dev/null
+++ b/packages/perseus-editor/src/util/a11y-checker.ts
@@ -0,0 +1,159 @@
+import * as axeCore from "axe-core";
+
+import issuesList from "./a11y-issues-list";
+
+import type {Issue, IssueType} from "../components/issues-panel";
+import type axe from "axe-core";
+
+const assistanceNeededMessage =
+ "Developer assistance needed - Please send this exercise and warning info to the LEMS team for review.";
+
+const axeCoreEditorOptions = {
+ include: {
+ fromFrames: ['iframe[src^="/perseus/frame"]', "#page-container"],
+ },
+ exclude: {
+ fromFrames: [
+ 'iframe[src^="/perseus/frame"]',
+ '[target="lint-help-window"]',
+ ],
+ },
+};
+const axeCoreStorybookOptions = {
+ include: ["#preview-panel"],
+ exclude: ['[target="lint-help-window"]'],
+};
+
+const convertAxeImpactToIssueImpact = (
+ impact?: axe.ImpactValue,
+): Issue["impact"] => {
+ switch (impact) {
+ case "critical":
+ return "high";
+ case "serious":
+ return "high";
+ case "moderate":
+ return "medium";
+ case "minor":
+ return "low";
+ default:
+ return "low";
+ }
+};
+
+const getIssueMessage = (nodes: axe.NodeResult[]): string => {
+ return nodes
+ .map((node) => {
+ return node.all
+ .concat(node.any, node.none)
+ .map((result) => result.message)
+ .join(" ");
+ })
+ .join(" ");
+};
+
+const getIssueElements = (nodes: axe.NodeResult[]): Element[] => {
+ const nodeToCheck = nodes.length > 0 ? [nodes[0]] : [];
+ // @ts-expect-error TS2322: Type 'string[]' is not assignable to type 'Element[]'.
+ return nodeToCheck.flatMap((node) => {
+ // @ts-expect-error TS2769: No overload matches this call.
+ return node.target.reduce((elements: Element[], target: string) => {
+ let element: Element | null;
+ if (
+ elements.length > 0 &&
+ elements[elements.length - 1].tagName.toLowerCase() === "iframe"
+ ) {
+ element =
+ // @ts-expect-error TS2551: Property 'contentDocument' does not exist on type 'Element'
+ elements[elements.length - 1].contentDocument.querySelector(
+ target,
+ );
+ } else {
+ element = document.querySelector(target);
+ }
+ if (element) {
+ elements.push(element);
+ }
+ return elements;
+ }, []);
+ });
+};
+
+const mapResultsToIssues = (
+ results: axe.Result[],
+ type: IssueType,
+): Issue[] => {
+ return results.map((result) => {
+ const isUserFixable =
+ type === "Alert" &&
+ issuesList["axe-core"]["user-fixable"].some(
+ (testId) => testId === result.id,
+ );
+ const description = isUserFixable ? "" : assistanceNeededMessage;
+ return {
+ id: result.id,
+ description: description,
+ elements: getIssueElements(result.nodes),
+ helpUrl: result.helpUrl,
+ help: result.help,
+ impact: convertAxeImpactToIssueImpact(result.impact),
+ message: getIssueMessage(result.nodes),
+ type: type,
+ };
+ });
+};
+
+const runAxeCore = (updateIssuesFn: (issues: Issue[]) => void): void => {
+ const isInStorybook = !!document.getElementById("storybook-root");
+ if (!isInStorybook) {
+ let frameHasLoaded = false;
+ const frame = document.querySelector('iframe[src^="/perseus/frame"]');
+ if (frame) {
+ const frameDocument =
+ // @ts-expect-error TS2551: Property 'contentDocument' does not exist on type 'Element'.
+ frame.contentDocument || frame.contentWindow?.document;
+ frameHasLoaded = frameDocument?.readyState === "complete";
+ }
+ if (!frameHasLoaded) {
+ setTimeout(runAxeCore, 100, updateIssuesFn);
+ return;
+ }
+ }
+ const options = isInStorybook
+ ? axeCoreStorybookOptions
+ : axeCoreEditorOptions;
+ axeCore.configure({reporter: "v2"});
+ // @ts-expect-error TS2769: No overload matches this call.
+ axeCore.run(options).then(
+ (results) => {
+ // eslint-disable-next-line no-console
+ console.log(`Accessibility Results: `, results);
+ const violations = mapResultsToIssues(results.violations, "Alert");
+ const incompletes = mapResultsToIssues(
+ results.incomplete,
+ "Warning",
+ );
+ const issues = violations.concat(incompletes);
+ // eslint-disable-next-line no-console
+ console.log(` Issues: `, issues);
+ if (
+ violations.length === 0 &&
+ incompletes.length === 0 &&
+ results.passes.length === 0
+ ) {
+ setTimeout(runAxeCore, 1500, updateIssuesFn); // No valid results indicates that content may not be fully loaded yet
+ } else {
+ updateIssuesFn(issues);
+ }
+ },
+ (error) => {
+ // eslint-disable-next-line no-console
+ console.log(` Error: `, error);
+ },
+ );
+};
+
+export const runAxeCoreOnUpdate = (priorTimeoutId, setState): any => {
+ clearTimeout(priorTimeoutId);
+ return setTimeout(runAxeCore, 1500, setState);
+};
diff --git a/packages/perseus-editor/src/util/a11y-issues-list.ts b/packages/perseus-editor/src/util/a11y-issues-list.ts
new file mode 100644
index 00000000000..54f4d82b8de
--- /dev/null
+++ b/packages/perseus-editor/src/util/a11y-issues-list.ts
@@ -0,0 +1,19 @@
+export default {
+ "axe-core": {
+ "user-fixable": [
+ "aria-input-field-name",
+ "aria-meter-name",
+ "aria-toggle-field-name",
+ "image-alt",
+ "input-image-alt",
+ "label",
+ "link-name",
+ "list",
+ "role-img-alt",
+ "select-name",
+ "summary-name",
+ "svg-img-alt",
+ "video-caption",
+ ],
+ },
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 67c8c1e3c81..207f09f44f1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -827,6 +827,9 @@ importers:
'@khanacademy/perseus-utils':
specifier: workspace:*
version: link:../perseus-utils
+ axe-core:
+ specifier: ^4.11.0
+ version: 4.11.0
katex:
specifier: 0.11.1
version: 0.11.1
@@ -3789,6 +3792,10 @@ packages:
resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==}
engines: {node: '>=4'}
+ axe-core@4.11.0:
+ resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==}
+ engines: {node: '>=4'}
+
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -12766,6 +12773,8 @@ snapshots:
axe-core@4.10.2: {}
+ axe-core@4.11.0: {}
+
axobject-query@4.1.0: {}
babel-jest@29.7.0(@babel/core@7.26.0):
@@ -17529,7 +17538,7 @@ snapshots:
react-router-dom@5.3.4(react@18.2.0):
dependencies:
- '@babel/runtime': 7.25.4
+ '@babel/runtime': 7.27.1
history: 4.10.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -17540,7 +17549,7 @@ snapshots:
react-router@5.3.4(react@18.2.0):
dependencies:
- '@babel/runtime': 7.25.4
+ '@babel/runtime': 7.27.1
history: 4.10.1
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
@@ -17576,7 +17585,7 @@ snapshots:
react-window@1.8.10(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
- '@babel/runtime': 7.25.4
+ '@babel/runtime': 7.27.1
memoize-one: 5.2.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)